Servant 0.19 was
released
earlier this month. It features a new combinator, called NamedRoutes, which
allows you to structure your APIs with records. Concretely, instead of building
up a tree of anonymous routes with (:<|>):
type API =
PublicRoutes
:<|> "admin" :> Auth '[BasicAuth] User :> AdminRoutes
type PublicRoutes =
"version" :> Get '[JSON] Version
:<|> …
type AdminRoutes =
"do_stuff" :> ReqBody '[JSON] Request :> Post '[JSON] Response
:<|> …We can now write:
type API = NamedRoutes NamedAPI
data NamedAPI mode = NamedAPI
{ publicRoutes :: mode :- NamedRoutes PublicRoutes
, adminRoutes :: mode :- "admin" :> Auth '[BasicAuth] User :> NamedRoutes AdminRoutes
}
deriving Generic
data PublicRoutes mode = PublicRoutes
{ version :: mode :- "version" :> Get '[JSON] Version
, …
}
deriving Generic
data AdminRoutes mode = AdminRoutes
{ doStuff :: mode :- "do_stuff" :> ReqBody '[JSON] Request :> Post '[JSON] Response
, …
}
deriving GenericThis is slightly more verbose, but for good reason: named routes are much nicer to work with than anonymous routes, especially when dealing with complex route hierarchies exposing dozens of endpoints. To serve the API above, all we need to do is provide nested records of handlers:
app :: Application
app = serveWithContext (Proxy @API) ctx NamedAPI
{ publicRoutes = PublicRoutes
{ version = pure "0.1.0"
, …
}
, adminRoutes = \case
Authenticated usr -> AdminRoutes
{ doStuff = …
, …
}
_ -> throwAll err401
}
where ctx = …Since matching between handlers and servant types is now done by name instead of position, order of handlers becomes irrelevant, which eliminates an entire source of frustration for servant users.
But it doesn’t stop here: GHC can now generate helpful, more focused error messages than it could before. For example, if we were to add an endpoint to the public routes of the anonymous API, like this:
type PublicRoutes =
"version" :> Get '[JSON] Version
:<|> "give_me_an_int" :> Capture "someInt" Int :> Get '[JSON] Int
:<|> …
But forgot to implement a handler for it, GHC would produce the following error message:
• Couldn't match type ‘Handler Int’ with ‘[Char]’
Expected type: Server
((("version" :> Get '[JSON] Version)
:<|> ("give_me_an_int" :> Capture "someInt" Int :> Get '[JSON] Int))
:<|> ("admin"
:> (Auth '[BasicAuth] User
:> ("do_stuff"
:> (ReqBody '[JSON] Request :> Post '[JSON] Response)))))
Actual type: (Handler [Char] :<|> [Char])
:<|> (AuthResult User -> Request -> Handler Response)
• In the third argument of ‘serveWithContext’, namely
‘(public :<|> admin)’The best GHC can do here is to report the discrepancy between the expected type
of the server (computed from the entire API type) and the effective type of the
server provided to serveWithContext. This is already not great with small APIs like
this one, but it quickly becomes unmanageable in real-world applications. In
contrast, the same situation with NamedRoutes will lead to a simple message
about a missing field, no matter the size of our API:
• Fields of ‘PublicRoutes’ not initialised: giveMeAnInt
• In the ‘publicRoutes’ field of a record
In the third argument of ‘serveWithContext’, namely
‘NamedAPI
{publicRoutes = PublicRoutes {version = pure "0.1.0", …},Similarly, if we implement a handler of the wrong type returning — say — a
String instead of an Int, the compiler will mention the infringing handler
in the error message:
• Couldn't match type ‘[Char]’ with ‘Int’
Expected type: Servant.Server.Internal.AsServerT Handler
:- ("give_me_an_int" :> (Capture "someInt" Int :> Get '[JSON] Int))
Actual type: Int -> Handler String
• Possible cause: ‘(.)’ is applied to too many arguments
In the ‘giveMeAnInt’ field of a record
In the ‘publicRoutes’ field of a recordWhile GHC could me slightly more helpful by expanding the :- type family for
us here, we can easily confirm with GHCi what type it is expecting:
> :kind! Servant.Server.Internal.AsServerT Handler :- ("give_me_an_int" :> (Capture "someInt" Int :> Get '[JSON] Int))
Servant.Server.Internal.AsServerT Handler :- ("give_me_an_int" :> (Capture "someInt" Int :> Get '[JSON] Int)) :: *
= Int -> Handler IntClients are also significantly nicer to work with when using NamedRoutes: the
client function will generate nested records out-of-the-box, so we no longer
need to pattern-match on its output to retrieve the functions. We can just do:
apiClient :: Client ClientM API
apiClient = client (Proxy @API)And use normal field accessors. As a bonus, some operators have been added to make client usage feel more natural:
someClientComputation :: ClientM Int
someClientComputation = do
version <- apiClient // publicRoutes // version
_ <- apiClient // adminRoutes /: Token "auth_token" // doStuff /: Request
…
apiClient // publicRoutes // giveMeAnInt /: 42Design
What I just showed isn’t entirely new. Servant has had some form of support
for records since Patrick Chilton’s early work on
servant-generic (itself based
on solga) in 2017. The servant-generic
library was merged into servant itself the next year. So, what’s the news,
really ?
For starters, servant-generic isn’t going anywhere ; it is actually the
foundation for NamedRoutes. But NamedRoutes improves upon it by making
records truly first-class citizens in Servant.
Inside Servant
Servant is a type-level DSL: an API is input as a type and not as a value. APIs are defined using a variety of combinators. The most central ones are:
Verb, which figures at the tip of each endpoint:
data Verb (method :: StdMethod) (statusCode :: Nat) (contentTypes :: [*]) (a :: *)
data StdMethod = GET | POST | HEAD | PUT | DELETE | …
type Get = Verb 'GET 200
type Post = Verb 'POST 200
…(:>), used to add path prefixes and request parameters to a route:
data (path :: k) :> (a :: *)(:<|>), used to combine APIs into larger ones (it may remind you of(<|>)from theAlternativetypeclass — this is very much intentional). The(:<|>)combinator lets us describe APIs with multiple endpoints, and structure them into trees.
data a :<|> b = a :<|> bThe Servant DSL is interpreted through various type classes (HasServer,
HasClient, HasLink, …).
As a concrete example, consider the definition of the HasServer type class
(simplified for the sake of exposition):
class HasServer api where
-- | Associated type family computing the type of the server of a given API in the `m` monad
type ServerT api (m :: * -> *) :: *
-- | Route a request
route ::
Proxy api
-> Delayed (ServerT api Handler)
-- ^ Server representation with delayed request checks.
-> Router
-- | Hoist a server from one monad to another
hoistServer
:: Proxy api
-> (forall x. m x -> n x)
-> ServerT api m
-> ServerT api nIts implementation for a :<|> b defines the server for such an API as a pair
of servers ((:<|>) is used both as a type and a data constructor here), and
piggy-backs on the instances of HasServer for a and b to implement
hoisting and routing:
instance (HasServer a, HasServer b) => HasServer (a :<|> b) where
type ServerT (a :<|> b) m = ServerT a m :<|> ServerT b m
route Proxy server = choice (route pa ((\ (a :<|> _) -> a) <$> server))
(route pb ((\ (_ :<|> b) -> b) <$> server))
where pa = Proxy :: Proxy a
pb = Proxy :: Proxy b
hoistServer _ nat (a :<|> b) =
hoistServer (Proxy :: Proxy a) nt a :<|>
hoistServer (Proxy :: Proxy b) nt b
When serving an API, the serve function then ensures that we give it a server
of the appropriate type, by applying the ServerT type family to the API type:
serve :: HasServer api => Proxy api -> ServerT api Handler -> Applicationservant-generic Primer
The records of routes one must define with servant-generic are exactly the
same as those expected by NamedRoutes, e.g.:
data BookCRUD mode
= BookCRUD
{ createBook :: mode :- ReqBody '[JSON] BookData :> Post '[JSON] BookId
, getBook :: mode :- Capture "book_id" BookId :> Get '[JSON] Book
, updateBook :: mode :- Capture "book_id" BookId :> ReqBody '[JSON] BookData :> Put '[JSON] NoContent
, deleteBook :: mode :- Capture "book_id" BookId :> Delete '[JSON] NoContent
}
deriving GenericUntil now, we have glossed over the mode parameter and the :- operator. The
explanation is in the GenericMode type class, and its instances:
class GenericMode mode where
type mode :- api :: *
instance GenericMode (AsServerT m) where
type (AsServerT m) :- api = ServerT api m
instance GenericMode AsApi where
type AsApi :- api = api
…So, BookCRUD (AsServerT Handler) is a record of handlers, and BookCRUD AsAPI
is an uninhabited record whose fields have Servant API types.
But servant doesn’t know how deal with named products of routes such as
BookCRUD AsAPI: it only knows about anonymous (:<|>)-trees. So
servant-generic introduces a way of converting from vanilla (:<|>) products
to records, and vice versa, via yet another type class:
class GServantProduct f where
type GToServant f
gtoServant :: f p -> GToServant f
gfromServant :: GToServant f -> f pGServantProduct operates on the Generic representation of the datatype. The
associated type family, GToServant, essentially maps :*: to :<|> and
implements the straightforward conversion:
-- Map products (:*:) to (:<|>:)
instance (GServantProduct l, GServantProduct r) => GServantProduct (l :*: r) where
type GToServant (l :*: r) = GToServant l :<|> GToServant r
gtoServant (l :*: r) = gtoServant l :<|> gtoServant r
gfromServant (l :<|> r) = gfromServant l :*: gfromServant r
-- Do not transform leaves
instance GServantProduct (K1 i c) where
type GToServant (K1 i c) = c
gtoServant = unK1
gfromServant = K1Now, the following two functions allow us to convert between records and
:<|>-products:
-- | Record to :<|>
toServant
:: GenericServant routes mode
=> routes mode -> ToServant routes mode
toServant = gtoServant . from
-- | :<|> to record
fromServant
:: GenericServant routes mode
=> ToServant routes mode -> routes mode
fromServant = to . gfromServantWe now have all the pieces required to understand how BooksCRUD can be served:
- The vanilla servant type can be computed as
GToServant (Rep (BooksCRUD AsApi)). - The record of handlers can be converted to a vanilla servant product using toServant.
type ToServant routes mode = GToServant (Rep (routes mode))
type ToServantApi routes = ToServant routes AsApi
app :: Application
app = serve (Proxy @(ToServantApi BooksCRUD) (toServant server)
where server = BooksCRUD { … }The Problem with servant-generic
While servant-generic does allow us to implement servers as records (and
similarly, to generate records of client functions) it does not really integrate
into Servant itself: at no point is any naming information available to the core
mechanisms of Servant, as it is all erased using (via GToServant /
gToServant).
This is not problematic when dealing with non-nested records of routes such as
our BooksCRUD example above. But it is much more painful when dealing with
nested APIs such as our original example. At first glance, our API definition
does not change so much, as we just replaced every occurrence of NamedRoutes
by ToServantApi:
type API = ToServantApi NamedAPI
data NamedAPI mode = NamedAPI
{ publicRoutes :: mode :- ToServantApi PublicRoutes
, adminRoutes :: mode :- "admin" :> Auth '[BasicAuth] User :> ToServantApi AdminRoutes
}
deriving Generic
…To serve this API, though, we must call the toServant function at each level
of the route hierarchy:
app :: Application
app = serveWithContext (Proxy @API) ctx $ toServant NamedAPI
{ publicRoutes = toServant PublicRoutes
{ version = pure "0.1.0"
, …
}
, adminRoutes = \case
Authenticated usr -> toServant AdminRoutes
{ doStuff = …
, …
}
_ -> throwAll err401
}
where ctx = …It is cumbersome, and the error messages in case of a forgotten
toServant call can be quite confusing and unhelpful.
To put it succinctly, the
servant-generic machinery is very demanding: it is the
responsibility of the user
to understand it well enough to perform the necessary conversions. It has been a
recurring source of
confusion for users.
Here Comes NamedRoutes
The idea of NamedRoutes is to embed records into Servant APIs using a bona
fide combinator:
data NamedRoutes (api :: * -> *)And define:
instance (HasServer (ToServantApi api)) => HasServer (NamedRoutes api)
type ServerT (NamedRoutes api) m = api (AsServerT m)
…This instance declares the server for NamedRoutes api in monad m to be
a record of handlers in the same monad (using AsServerT m as the supplied
mode parameter).
It’s trickier that it may sound at first, otherwise servant-generic
would have probably done just that to begin with. Our troubles come in
the form of the hoistServer method of the HasServer
class. Its type is:
hoistServer
:: Proxy api
-> (forall x. m x -> n x)
-> ServerT api m
-> ServerT api nIn the case of NamedRoutes its type unrolls to:
hoistServer
:: Proxy (NamedRoutes api)
-> (forall x. m x -> n x)
-> api (AsServerT m)
-> api (AsServerT n)So, we have a record of ServerT _ m and we need to change it into a record of
ServerT _ n. To do so, we convert to the record to its :<|>-based equivalent
with toServant, hoist it to the new monad, and convert back to a record type
with fromServant:
hoistServer _ nat = fromServant . hoistServer nat . toServantHowever, we have a problem. Indeed toServant has type api (AsServerT m) -> ToServant api (AsServerT m). But hoistServer
expects an argument whose type is of the form ServerT _ m. These
types don’t align!
But we know that servant-generic is designed so that ToServant api (AsServerT m) is indeed of the form ServerT _ m (specifically, it
is ServerT (ToServantAPI api) m). It’s just that GHC cannot see that
in general: it can only be verified for concrete API types. So we need
to give GHC a little bit of help and provide this identity (as well as
the corresponding one for n needed for fromServant):
instance
( HasServer (ToServantApi api)
, ToServant api (AsServerT m) ~ ServerT (ToServantApi api) m
, ToServant api (AsServerT n) ~ ServerT (ToServantApi api) n
) => HasServer (NamedRoutes api) where
…This doesn’t work, though, since neither m nor n are bound in the
instance definition: they only make sense in the type of
hoistServer. But the truth is that ToServant api (AsServerT m) ~ ServerT (ToServantApi api) m holds independently of the choice of
m. So really, what we want to write is
instance
( HasServer (ToServantApi api)
, forall m. ToServant api (AsServerT m) ~ ServerT (ToServantApi api) m
) => HasServer (NamedRoutes api) where
…This is a quantified
constraint. Quantified
constraints are a recent feature, only available since GHC 8.6
(September 2018). Therefore, at the time servant-generic was
conceived, having a NamedRoutes combinator was therefore not an
option. In fact, Servant 0.19 is not compatible with GHC versions prior
to 8.6 for this very reason.
Unfortunately, our troubles don’t end here. Indeed GHC rejects our quantified constraint with this error message
• Illegal type synonym family application ‘ToServant api (AsServerT m)’ in instance:
ToServant api (AsServerT m) ~ ServerT (ToServantApi api) m
• In the quantified constraint ‘forall m. ToServant api (AsServerT m) ~ ServerT (ToServantApi api) m’GHC complains that ToServant (and ServerT) is a type family. In
quantified constraints, the usage of type families obeys similar
restrictions as in type-class instances (see this
discussion for
more details).
Yet, we’re almost there: we need one more trick to work around GHC’s restriction. Specifically, we introduce the following type class:
class GServer (api :: * -> *) (m :: * -> *) where
gServerProof :: Dict (ToServant api (AsServerT m) ~ ServerT (ToServantApi api) m)Where the Dict type lets us manipulate type classes and identities
as values (as described, for instance, on this wiki
page). Having
the identity be a value requires more work from the programmer, but
it’s much more flexible as well. With this we can complete our definition.
In the instance context, we then require forall m. GServer api m (which is a
valid quantified constraint) and pattern-match on the Dict produced by
gServerProof @api @n as needed:
instance
( HasServer (ToServantApi api)
, forall m. GServer api m
) => HasServer (NamedRoutes api) where
…
hoistServer
:: forall m n. Proxy (NamedRoutes api)
-> (forall x. m x -> n x)
-> api (AsServerT m)
-> api (AsServerT n)
hoistServer _ nat =
case (gServerProof @api @m, gServerProof @api @n) of
(Dict, Dict) -> fromServant . hoistServer nat . toServantConclusion
Using records to define Servant routes has been a tantalising option
since servant-generic was introduced. Unfortunately it was
finicky to use and most reverted to use anonymous routes.
There was a missing piece to the servant-generic puzzle which was
completed with NamedRoutes, the implementation of which is
deceptively subtle.
Hopefully, I convinced you that using records to declare Servant APIs has become a very pragmatic choice in Servant 0.19.
Should you have any kind of feedback, don’t hesitate to open a ticket on our issue tracker!
If you enjoyed this article, you might be interested in joining the Tweag team.