Alexey's blog

Ballast, a library for talking to Shipwire

3 minute read Published: 2017-01-17

After finishing the wonderful Haskellbook, the first "real world" project I've started writing is a Haskell API wrapper for Shipwire called Ballast. While doing that, I was following the general architecture of dmjio's Stripe API wrapper. In this post I will try to describe an overview of how both the stripe library and Ballast are built.

Making an easy to use library

A goal of Ballast was to make it easy to use. Here's some example code:

main :: IO ()
main = do
  let config <- sandboxEnvConfig
  result <- shipwire config $ getReceivings -&- (ExpandReceivingsParam [ExpandAll])
  return result

A couple of things:

sandboxEnvConfig gets environment variables and is needed for authentication with Shipwire. getReceivings is a function defined in the Client module that corresponds to a specific endpoint and accepts optional query parameters via (-&-).

You can find similar functions for all the other endpoints.

Simplicity through type families

I wanted to be able to vary return type based on what sort of request I made. To do this I used type families.

shipwire
  :: (FromJSON (ShipwireReturn a))
  => ShipwireConfig
  -> ShipwireRequest a TupleBS8 BSL.ByteString
  -> IO (Either ShipwireError (ShipwireReturn a)) -- This part right here
shipwire config request = do
  response <- shipwire' config request
  let result = eitherDecode $ responseBody response
  case result of
    Left s -> return (Left (ShipwireError s response))
    (Right r) -> return (Right r)

shipwire needs to return ShipwireReturn a where a is dependent on the type of ShipwireRequest we have submitted.

Because of that restriction I define the following type family:

type family ShipwireReturn a :: *

This lets us specify a particular request and response type for each endpoint. We can generate a real-time shipping quote with the Rate endpoint like so:

data ShipwireRequest a b c = ShipwireRequest
  { rMethod  :: Method -- ^ Method of ShipwireRequest
  , endpoint :: Text -- ^ Endpoint of ShipwireRequest
  , params   :: [Params TupleBS8 BSL.ByteString] -- ^ Request params of ShipwireRequest
  }

data RateRequest
type instance ShipwireReturn RateRequest = RateResponse

With that in place, I can now define our GetRate datatype, whose JSON representation will be sent to this endpoint:

data GetRate = GetRate
  { rateOptions :: RateOptions
  , rateOrder   :: RateOrder
  } deriving (Eq, Show)

instance ToJSON GetRate where
  toJSON GetRate {..} = object ["options" .= rateOptions
                               ,"order"   .= rateOrder]

Ballast uses the following function to perform the HTTP request:

createRateRequest :: GetRate -> ShipwireRequest RateRequest TupleBS8 BSL.ByteString
createRateRequest getRate = mkShipwireRequest NHTM.methodPost url params
  where
    url = "/rate"
    params = [Body (encode getRate)]

mkShipwirerequest is a constructor that creates our request, NHTM.methodPost is http-client's POST method.

Handling optional query parameters

You might be wondering what TupleBS8 BSL.ByteString is. That's how we pass optional parameters to an endpoint.

Here's how I set that up:

-- | Parameters for each request which include both the query and the body of a
-- request
data Params b c
  = Query TupleBS8
  | Body BSL.ByteString
  deriving (Show)

-- | Type alias for query parameters
type TupleBS8 = (BS8.ByteString, BS8.ByteString)

-- | Convert a parameter to a key/value
class ToShipwireParam param where
  toShipwireParam :: param -> [Params TupleBS8 c] -> [Params TupleBS8 c]

class (ToShipwireParam param) => ShipwireHasParam request param where

-- | Add an optional query parameter
(-&-)
  :: ShipwireHasParam request param
  => ShipwireRequest request b c -> param -> ShipwireRequest request b c
stripeRequest -&- param =
  stripeRequest
  { params = toShipwireParam param (params stripeRequest)
  }

That allows us to specify which endpoint might have optional query parameters like so:

instance ShipwireHasParam StockRequest SKU

instance ToShipwireParam SKU where
  toShipwireParam (SKU i) =
    (Query ("sku", TE.encodeUtf8 i) :)

You can chain multiple parameters with (-&-):

result <- shipwire config $ getReceivings -&- (ExpandReceivingsParam [ExpandAll])
                                          -&- (ReceivingStatusParams [StatusCanceled])
                                          -&- (WarehouseIdParam ["TEST 1"])
                                          -&- (UpdatedAfter $ (read "2017-11-19 18:28:52 UTC" :: UTCTime)) -- Note: using `read` for UTCTime is not a good idea, this code exists in tests only.

Conclusion

This proved to be a pleasant way to structure a client API wrapper. It's straightforward and flexible and I believe I will reuse this in my future projects.

Further reading material:

Type Families and Pokemon

type family vs data family, in brief?

Type Classes with Functional Dependencies