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: