The Tao of Servant
Servant is one of many high-quality Haskell packages available for writing web applications. I cannot hope to best the official Servant docs. However, while the docs do a great job of explaining the mechanics of Servant, in this post, I want to impart my conceptual model of Servant.
A Formalization of an Endpoint as a Type
The big idea behind Servant is that we can describe an endpoint in a web application formally, as a Haskell type. Once we have a formal description of the endpoint, Servant can do many “nice” things, such as generate documentation, generation client functions, etc. However, those features are tangential to serving the actual web application. Where Servant’s true benefits lie is that, once it has this formal specification of the endpoint, Servant can ensure that the request adheres to that specification. It can ensure that the data our request handlers require — query params, request headers, request bodies / content — is available. What if the request doesn’t adhere to the specification? Well, Servant will then respond with a Bad Request, Unsupported Content-Type, or other error as appropriate. (Please read the caveat at the end of this post).
A Quick Example of an Endpoint Specification
Let’s take a look at a small formal specification of an endpoint:
type MyEndpoint = "users" :> ReqBody '[JSON] User :> Post '[JSON] User
MyEndPoint is a formal specification of an endpoint. The specification specifies where the endpoint is available;
"/users". It specifies the contract that the request must adhere to — i.e., the request must contain a request body in JSON format that is deserializable into a value of type
User. Finally, the specification specifies the contract that the response will adhere to; it will return a
User in JSON format. (The
Post type doesn’t fit nicely into this conceptual model, but for completeness’s sake, it is simply the HTTP Verb under which that endpoint is available).
The fact that, given a specification, Servant can generate client function is huge. But it is not the most important thing.
The fact that, given a specification, Servant can generate documentation is huge. But it is not the most important thing.
The fact that, given a specification, Servan will ensure the request conforms to that specification IS huge. A whole swath of un-happy paths are now being dealt with before they even reach our request handlers. Want to specify that the request include a certain
type MyEndpoint = "users" :> Header "Authentication" Text :> ReqBody '[JSON] User :> Post '[JSON] User
We’ll talk more about how to build an endpoint specification below, but given the above specification, if a request to “/users” comes in without an
Authentication header, Servant will deal with it.
Note: This is still under construction.
Frankly, at this point, you should go read the Servant docs. They do a great job of explaining the mechanics. The list below is more of a reference for myself, but if it helps you too, great.
- First of all, we want to specify our endpoints. To do that, we create a type alias and use the
:>combinator to chain combinators and create an endpoint specification.
- Most people don’t build websites with a single endpoint (this ain’t GraphQL), so we want to use the
:<|>combinator to chain endpoints together.
- With our endpoint(s) fully specified, we have to create handlers to handle the requests that do meet specification. We combine each handler using the
:<|>operator, taking special precaution to combine the handlers in the same order in which we combined the endpoints.
I didn’t want to litter the post with this caveat everywhere, so I have saved it for the end. While Servant ensures that the requests received by our handlers adhere to our specification, it doesn’t break with the semantics of the web. For example, if we add the specification
QueryParam "name" String, our handlers will receive a value of type
Maybe String, because “an endpoint can technically still be accessed without specifying any query string”. The end of the section From combinators to handler arguments in the official docs has all the details.