Intro
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: