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 resultA 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 = RateResponseWith 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: