A tale of servant clients
by Clement Delafargue on December 27, 2018
This blog post is about an outdated servant version
Please have a look a this updated post instead
At Fretlink, we use Servant a lot. All of our haskell webservices are written using servant-server, which is a pure joy to use (see my post on macaroon-based auth).
In addition to servant-server, we also use servant-client, to query the services implemented with servant-server as well as external services (currently, PipeDrive).
While just using servant-server is quite pleasant, raw servant-client use can get complicated.
Let’s consider a small user management API, protected by basic auth
:
/
└─ users/
├─• GET
├─• POST
└─ :user_id /
├─• GET
├─• PUT
└─• DELETE
Transcribed in the servant DSL, we get:
type API = BasicAuth "user-management" User :> UsersAPI
type UsersAPI = "users" :>
Get '[JSON] [User]
:<|> ReqBody '[JSON] UserData :> Post '[JSON] NoContent
:<|> Capture "user_id" UserId :> UserAPI
type UserAPI =
Get '[JSON] User
:<|> ReqBody '[JSON] UserData :> Put '[JSON] NoContent
:<|> Delete '[JSON] NoContent
Extracting clients for this API can look like that:
listUsersClient :: BasicAuthData
-> ClientM [User]
=
listUsersClient auth let lu :<|> _ = client api auth
in lu
editUserClient :: BasicAuthData
-> UserId
-> UserData
-> ClientM [User]
=
editUserClient auth userId let _ :<|> _ :<|> ue = client api auth
:<|> eu :<|> _ = ue userId
_ in eu
This not very pleasant to read or write: the pattern matching needed to extract endpoints generates quite a lot of syntactic noise, and you need to apply parameters here and there to get access to the inner values.
Fortunately, servant supports generic derivation for clients.
For the API we’ve seen above, we can declare the accompanying records, with some generic magic sprinkled on top of it:
type APIClient = BasicAuthData
-> UserClient
data UsersAPIClient = UserClient
listUsers :: ClientM [User]
{ addUser :: UserData -> ClientM NoContent
, withUser :: UserId -> UsersAPIClient
,
}deriving stock GHC.Generic -- (from GHC.Generics)
deriving anyclass SOP.Generic -- (from Generics.SOP)
instance (Client ClientM UsersAPI ~ client)
=> ClientLike client UsersAPIClient
data UserAPIClient = UserAPIClient
getUser :: ClientM User
{ editUser :: UserData -> ClientM NoContent
,
}deriving stock GHC.Generic -- (from GHC.Generics)
deriving anyclass SOP.Generic -- (from Generics.SOP)
instance (Client ClientM UserAPI ~ client)
=> ClientLike client UserAPIClient
With all that done, you can turn our first client into records, with
mkClient
. Instead of extracting calls through pattern matching, we
can use record accessors.
newClient :: APIClient
= mkClient $ client api
newClient
listUsersClient' :: BasicAuthData -> ClientM [User]
= listUsers $ newClient auth
listUsersClient' auth
editUserClient' :: BasicAuthData
-> UserId -> UserData
-> ClientM NoContent
=
editUserClient' auth userId userData editUser (withUser (newClient auth) userId) userData
Well… it’s a bit better than previously, but not way
better. listUserClient'
is not too bad, but for editUserClient'
, it’s
quite hard to read (and come up with). See how userId
and userData
are far from withUser
and editUser
? It only gets worse with bigger routes.
With a little trick, we can fix that:
editUserClient'' :: BasicAuthData
-> UserId -> UserData
-> ClientM NoContent
=
editUserClient'' auth userId userData $ userData) . editUser . ($ userId) . withUser $ newClient auth (
You can also use a lambda if you’re allergic to sections (replace ($ userId)
with (\e -> e userId)
). It’s still tedious, but at least it keeps parameters
application close to where they’re defined.
Another solution is to use RecordWildCards
.
editUserClientWithWildCards :: BasicAuthData
-> UserId -> UserData
-> ClientM NoContent
=
editUserClientWithWildCards auth userId userData let UsersAPIClient{..} = newClient auth
UserAPIClient{..} = withUser userId
in editUser userData
This also prevents the parameters from going too far, but I find it quite verbose. There is little syntactic noise, but I think it obscures the URL structure.
Thankfully, we can still improve on building routes with function application.
While right-to-left composition is a great choice in many cases 1, in
this context, following the URL more closely seems better. Also, as much as
I love abusing sections in the pursuit of η-reduction, ($ userId)
is not
particularly readable. That being said, using lambdas is a definite no-no,
so we’ll add a few helpers.
Since manually passing BasicAuthData
everywhere is tedious, we’ll apply
Entreprise FP Patterns and use a Reader
to handle this. While we’re at
it, we can also get a ClientEnv
from the reader and actually run the request.
data ApplicationEnv = ApplicationEnv
auth :: BasicAuth
{ env :: ClientEnv
,
}
-- | Instead of passing BasicAuthData explicitly, we're
-- taking it as a dependency, as well as the HTTP client environment
-- (base URL, HTTP client manager, cookies, …)
type Application m = (MonadReader ApplicationEnv m, MonadIO m)
-- | Helper managing the client creation for us, as well
runClient :: Application m
=> (UsersClient -> ClientM a)
-> m (Either ServantError a)
= do
runClient f ApplicationEnv{..} <- ask
$ runClientM (f $ newClient auth) clientEnv
liftIO
-- | This helper is there purely to improve readability.
-- We could also define other versions with different arities
-- (even though chaining multiple calls to @WithParam@ would
-- work as well
withParam :: a -> (a -> b) -> b
= flip ($) withParam
With all this done, and thanks to (>>>)
from Control.Category
,
we can improve readability:
editUserIO :: Application m => UserId -> UserData -> m User
= runWithAuth $
editUserIO userId userData >>> withParam userId >>> editUser >>> withParam userData
withUser
-- We could also completely forego @withParam@ with the backquote trick.
-- This is definitely shorter, but arguably less readable
-- (`withUser` userId) >>> (`editUser` userData)
-- If you're not allergic to dollar sections, it reads quite well.
-- withUser >>> ($ userId) >>> editUser >>> ($ userData)
In the codebase I am working on, I went with a >>>
/ withParam
combo,
and I am quite happy with the result. Maybe the same could be achieved with
lenses, but I have not tried it yet.
This article uses servant clients as an example, but it could have been the same with any library. In the end, we’re composing functions, no more, no less.
I really like the way everything comes together nicely when you have found the right way to combine values. The experience of refactoring haskell code, especially with hlint by my side hasn’t been matched in any other language for now.
Special thanks to @haitlah and @am_i_tom_ for their feedback, to @raveline for pairing with me, and to @ptit_fred for making all of this possible.
A complete code example is available.
Don’t get me started on why everyone uses
>>=
instead of the clearly superior=<<
↩︎