title | toc |
---|---|
A web API as a type |
true |
Consider the following informal specification of an API:
The endpoint at
/users
expects a GET request with query string parametersortby
whose value can be one ofage
orname
and returns a list/array of JSON objects describing users, with fieldsage
,name
,registration_date
".
You should be able to formalize that. And then use the formalized version to get you much of the way towards writing a web app. And all the way towards getting some client libraries, and documentation (and in the future, who knows
- tests, HATEOAS, ...).
How would we describe it with servant? As mentioned earlier, an endpoint description is a good old Haskell type:
type UserAPI = "users" :> QueryParam "sortby" SortBy :> Get '[JSON] [User]
Let's break that down:
"users"
says that our endpoint will be accessible under/users
;QueryParam "sortby" SortBy
, whereSortBy
is defined bydata SortBy = Age | Name
, says that the endpoint has a query string parameter namedsortby
whose value will be extracted as a value of typeSortBy
.Get '[JSON] [User]
says that the endpoint will be accessible through HTTP GET requests, returning a list of users encoded as JSON. You will see later how you can make use of this to make your data available under different formats, the choice being made depending on the Accept header specified in the client's request.- the
:>
operator that separates the various "combinators" just lets you sequence static path fragments, URL captures and other combinators. The ordering only matters for static path fragments and URL captures."users" :> "list-all" :> Get '[JSON] [User]
, equivalent to/users/list-all
, is obviously not the same as"list-all" :> "users" :> Get '[JSON] [User]
, which is equivalent to/list-all/users
. This means that sometimes:>
is somehow equivalent to/
, but sometimes it just lets you chain another combinator.
We can also describe APIs with multiple endpoints, of course, using the :<|>
combinators. Here's an example:
type UserAPI = "users" :> "list-all" :> Get '[JSON] [User]
:<|> "list-all" :> "users" :> Get '[JSON] [User]
servant provides a fair amount of combinators out-of-the-box, but you can always write your own when you need it. Here's a quick overview of all the combinators that servant comes with.
As you've already seen, you can use type-level strings (enabled with the
DataKinds
language extension) for static path fragments. Chaining
them amounts to /
-separating them in an URL.
type UserAPI = "users" :> "list-all" :> "now" :> Get '[JSON] [User]
-- describes an endpoint reachable at:
-- /users/list-all/now
These 5 combinators are very similar except that they obviously each describe a different HTTP method. This is how they're declared
data Delete (contentTypes :: [*]) a
data Get (contentTypes :: [*]) a
data Patch (contentTypes :: [*]) a
data Post (contentTypes :: [*]) a
data Put (contentTypes :: [*]) a
An endpoint ends with one of the 5 combinators above (unless you write your own). Examples:
type UserAPI = "users" :> Get '[JSON] [User]
:<|> "admins" :> Get '[JSON] [User]
URL captures are parts of the URL that are variable and whose actual value is
captured and passed to the request handlers. In many web frameworks, you'll see
it written as in /users/:userid
, with that leading :
denoting that userid
is just some kind of variable name or placeholder. For instance, if userid
is
supposed to range over all integers greater or equal to 1, our endpoint will
match requests made to /users/1
, /users/143
and so on.
The Capture
combinator in servant takes a (type-level) string representing
the "name of the variable" and a type, which indicates the type we want to
decode the "captured value" to.
data Capture (s :: Symbol) a
-- s :: Symbol just says that 's' must be a type-level string.
In some web frameworks, you use regexes for captures. We use a FromText
class
which the captured value must be an instance of.
Examples:
type UserAPI = "user" :> Capture "userid" Integer :> Get '[JSON] User
-- equivalent to 'GET /user/:userid'
-- except that we explicitly say that "userid"
-- must be an integer
:<|> "user" :> Capture "userid" Integer :> Delete '[] ()
-- equivalent to 'DELETE /user/:userid'
QueryParam
, QueryParams
and QueryFlag
are about query string parameters,
i.e those parameters that come after the question mark (?
) in URLs, like
sortby
in /users?sortby=age
, whose value is here set to age
. The
difference is that QueryParams
lets you specify that the query parameter
is actually a list of values, which can be specified using
?param[]=value1¶m[]=value2
. This represents a list of values composed
of value1
and value2
. QueryFlag
lets you specify a boolean-like query
parameter where a client isn't forced to specify a value. The absence or
presence of the parameter's name in the query string determines whether the
parameter is considered to have value True
or False
. /users?active
would list only active users whereas /users
would list them all.
Here are the corresponding data type declarations.
data QueryParam (sym :: Symbol) a
data QueryParams (sym :: Symbol) a
data QueryFlag (sym :: Symbol)
Matrix parameters, on the
other hand, are like query string parameters that can appear anywhere in the
paths (click the link for more details). An URL with matrix parameters in it
looks like /users;sortby=age
, as opposed to /users?sortby=age
with query
string parameters. The big advantage is that they are not necessarily at the
end of the URL. You could have
/users;active=true;registered_after=2005-01-01/locations
to get geolocation
data about your users that are still active and who registered after January
1st, 2005.
Corresponding data type declarations below.
data MatrixParam (sym :: Symbol) a
data MatrixParams (sym :: Symbol) a
data MatrixFlag (sym :: Symbol)
Examples:
type UserAPI = "users" :> QueryParam "sortby" SortBy :> Get '[JSON] [User]
-- equivalent to 'GET /users?sortby={age, name}'
:<|> "users" :> MatrixParam "sortby" SortBy :> Get '[JSON] [User]
-- equivalent to 'GET /users;sortby={age, name}'
Again, your handlers don't have to deserialize these things (into, for example,
a SortBy
). servant takes care of it.
Each HTTP request can carry some additional data that the server can use in its body and the said data can be encoded in any format -- as long as the server understands it. This can be used for example for an endpoint for creating new users: instead of passing each field of the user as a separate query string parameter or anything dirty like that, we can group all the data into a JSON object. This has the advantage of supporting nested objects.
servant's ReqBody
combinator takes a list of content types in which the
data encoded in the request body can be represented and the type of that data.
And, as you might have guessed, you don't have to check the content-type
header, and do the deserialization yourself. We do it for you. And return Bad Request
or Unsupported Content Type
as appropriate.
Here's the data type declaration for it.
data ReqBody (contentTypes :: [*]) a
Examples:
type UserAPI = "users" :> ReqBody '[JSON] User :> Post '[JSON] User
-- - equivalent to 'POST /users' with a JSON object
-- describing an User in the request body
-- - returns an User encoded in JSON
:<|> "users" :> Capture "userid" Integer
:> ReqBody '[JSON] User
:> Put '[JSON] User
-- - equivalent to 'PUT /users/:userid' with a JSON
-- object describing an User in the request body
-- - returns an User encoded in JSON
Request headers are used for various purposes, from caching to carrying
auth-related data. They consist of a header name and an associated value. An
example would be Accept: application/json
.
The Header
combinator in servant takes a type-level string for the header
name and the type to which we want to decode the header's value (from some
textual representation), as illustrated below.
data Header (sym :: Symbol) a
Here's an example where we declare that an endpoint makes use of the
User-Agent
header which specifies the name of the software/library used by
the client to send the request.
type UserAPI = "users" :> Header "User-Agent" Text :> Get '[JSON] [User]
So far, whenever we have used a combinator that carries a list of content
types, we've always specified '[JSON]
. servant however lets you use several
content types and define your owns.
Four content-types are provided out-of-the-box by the core servant package:
JSON
, PlainText
, FormUrlEncoded
and OctetStream
. If for some obscure
reason you wanted one of your endpoints to make your user data available under
those 4 formats, you would write the API type as below.
type UserAPI = "users" :> Get '[JSON, PlainText, FormUrlEncoded, OctetStream] [User]
We obviously provide an HTML content-type, but since there's no single library that everyone uses, we decided to release 2 packages, servant-lucid and servant-blaze, to provide HTML encoding of your data.
We will further explain how these content types and your data types can play together in the section about serving an API.
Just like an HTTP request, the response generated by a webserver can carry
headers too. servant provides a Headers
combinator that carries a list of
Header
and can be used by simply wrapping the "return type" of an endpoint
with it.
data Headers (ls :: [*]) a
If you want to describe an endpoint that returns a "User-Count" header in each response, you could write it as below.
type UserAPI = "users" :> Get '[JSON] (Headers [Header "User-Count" Integer] [User])
Finally, we include a combinator named Raw
for handing control to the underlying
framework. Raw
endpoints trade away the type safety of normal Servant endpoints
in return for direct access to the HTTP request and response. This can be useful for
two reasons:
-
You want to serve static files from a given directory. In that case you can just say:
type UserAPI = "users" :> Get '[JSON] [User] -- a /users endpoint :<|> Raw IO Application -- requests to anything else than /users -- go here, where the server will try to -- find a file with the right name -- at the right path
-
You more generally want to plug a WAI
Application
into your webservice. Static file serving is a specific example of that. The API type would look the same as above though. (You can even combine servant with other web frameworks this way!)
Raw
takes two type arguments m
and a
corresponding to the underlying monad your
web handler will run in and the handler's return type. This information is often needed
by an API's server but ignored by other interpretations. As we will see later, it can be
useful to have a version of your API that is free of Raw
combinators.