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 Generic
This 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 record
While 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 Int
Clients 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 /: 42
Design
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 theAlternative
typeclass — this is very much intentional). The(:<|>)
combinator lets us describe APIs with multiple endpoints, and structure them into trees.
data a :<|> b = a :<|> b
The 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 n
Its 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 -> Application
servant-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 Generic
Until 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 p
GServantProduct
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 = K1
Now, 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 . gfromServant
We 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 = ToServantApi 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 n
In 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 . toServant
However, 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 . toServant
Conclusion
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!