Servant 0.20.3.0 and Scrive
The Servant web framework version 0.20.3.0 has been released, with contributions from Scrive. We are very proud to contribute our time to Servant, which is one of the finest pieces of software for industrial users of Haskell
The Servant web framework version 0.20.3.0 has been released, with contributions from Scrive.
We are very proud to contribute our time to Servant, which is one of the finest pieces of software for industrial users of Haskell.
MultiVerb
Servant v0.20.3.0 brings the new MultiVerb type to better express complex API routes.
The inclusion of MultiVerb in the servant code base is the product of a collaboration between Scrive and Wire.
This was a great opportunity for two actors of different industry to combine their strength and make battle-tested solutions available to the public.
This is hopefully only the beginning of more partnerships with Wire, whose open-source code base demonstrates the power of Haskell in production.
Opaque route types
The problem with the traditional way of declaring a route, using the type Verb
, is that we can only represent one status code and one HTTP method:
data Verb (method :: k1)
(statusCode :: Nat)
(contentTypes :: [Type])
(a :: Type)
Which means that endpoint definitions using Verb can only represent the happy path of an endpoint. Failures must be expressed out-of-band, by throwing unchecked exceptions.
Ironically, this goes against what we are used to when working with Servant: Getting as much useful information as possible from the route types. Moreover, generated documentation – like OpenAPI specifications – from these types will miss the very real endpoint responses.
Union Verb, or UVerb
Servant already has a mechanism called UVerb, with the U standing for Union.
However, this mechanism suffers from several disadvantages, the most important being that there is a fundamental incompatibilty between Verb
and UVerb
,
which forces users into using servant-specific functions in their handlers.
For instance, let us consider a route for handling sign-ins:
type CreateSession =
"new"
:> ReqBody '[FormUrlEncoded] LoginForm
:> UVerb 'POST '[HTML] CreateSessionResponses
type CreateSessionResponses =
'[ -- Failure, send login page back
WithStatus 403 (Html ())
, -- Success, redirected to home page
WithStatus 301 (Headers '[Header "Location" Text, Header "Set-Cookie" SetCookie] NoContent)
]
This route declaration reads as
In the
/new
path, we expect a POST request with a body (LoginForm
). If the authentication fails we reply with status 403 and HTML. If the authentication is successful we reply with a 301 and redirect to another page while also giving the user a cookie to keep them connected.
And the handler will look something like:
createSessionHandler
:: LoginForm
-> m (Union CreateSessionResponses)
createSessionHandler LoginForm{email, password} = do
mUser <- Query.getUserByEmail email
case mUser of
Nothing ->
respond $ WithStatus @403 $ render Sessions.newSession
Just user ->
if Sel.verifyText user.password password
then do
sessionId <- persistSession session.sessionId user.userId
let cookie = craftSessionCookie sessionId True
respond $ WithStatus @301 $ redirectWithCookie "/" cookie
else do
respond $ WithStatus @403 $ render Sessions.newSession
redirectWithCookie
:: Text
-> SetCookie
-> Headers '[Header "Location" Text, Header "Set-Cookie" SetCookie]
NoContent
redirectWithCookie destination cookie =
addHeader destination (addHeader cookie NoContent)
UVerb's list of HTTP responses is used both for the type signatures of the route handlers, and as the return values within the handlers.
We see that the WithStatus construct is indeed reflected in the handler and we have to disambiguate the status code as a Type Application, which is rather redundant and prone to error.
A function like redirectWithCookie is also needed in order to bring HTTP response concerns in the code of the handler, which is quite invasive.
Better ergonomics with MultiVerb
In contrast, here is the equivalent route declaration with MultiVerb:
type CreateSessionResponses =
'[ -- Failure, send login page back
Respond 403 "Authentication failed" (Html ())
, -- Success, redirected to home page
WithHeaders
'[Header "Location" Text, Header "Set-Cookie" SetCookie]
(Text, SetCookie)
(RespondEmpty 301 "Authentication succeeded")
]
We have here a type-level list of HTTP responses, with details about the status code and the headers.
These details do not leak into the handler, because we define the values that the handler will return:
data CreateSessionResult
= AuthenticationFailure (Html ())
| AuthenticationSuccess (Text, SetCookie)
deriving stock (Generic)
deriving
(AsUnion CreateSessionResponses)
via GenericAsUnion CreateSessionResponses CreateSessionResult
This is a simple sum type, which we bind to the route responses to ensure consistency. Each member of this union holds a return type that will get serialised on the network: No need for status codes.
Finally, we declare the endpoint as such:
type CreateSession =
"new"
:> ReqBody '[FormUrlEncoded] LoginForm
:> MultiVerb
'POST
'[HTML]
CreateSessionResponses
CreateSessionResult
And the handler will look as such:
createSessionHandler
:: LoginForm
-> m CreateSessionResult
createSessionHandler LoginForm{email, password} = do
mUser <- Query.getUserByEmail email
case mUser of
Nothing -> do
body <- render Sessions.newSession
pure $ AuthenticationFailure body
Just user ->
if Sel.verifyText user.password password
then do
sessionId <- persistSession session.sessionId user.userId
let sessionCookie = craftSessionCookie sessionId True
pure $ AuthenticationSuccess ("/", sessionCookie)
else do
body <- render templateEnv Sessions.newSession
pure $ AuthenticationFailure body
We are now using good old pure
without having to use type applications, which makes the code much more readable and lets us focus on the important things
This is a good step forward for more expressive and ergonomic endpoint types within Servant, which has always been at the forefront of innovation when
it comes to using type-level programming to bring safety and generated documentation to web development.
Servant.API.Range - Bounded Type-Level Values
Another powerful feature in Servant 0.20.3.0 is the extended use of the Range
type, which can enforce compile-time bounds on numeric values.
Strongly-Typed Bounded Values
Flavio Corpa worked on the Range
type, that allows you to define values that must fall within specific bounds at the type level:
type MaxAttempts = Range 1 5
This declares a type that can only contain integers between 1 and 5 (inclusive). If you try to construct a value outside these bounds, you'll get a compile-time error.
Usage in API Definitions
This is particularly useful for API parameters that have logical constraints:
type API =
"limited-query"
:> QueryParam "attempts" MaxAttempts
:> Get '[JSON] Response
By using Range
, we ensure that handlers only ever receive valid values, eliminating an entire class of runtime checks and potential errors.
Implementation Example
Here's how you might use this in a handler:
handler :: Maybe MaxAttempts -> Handler Response
handler Nothing = -- Default case, perhaps using 3 attempts
performOperation (fromJust $ mkRange @1 @5 3)
handler (Just attempts) = -- We know attempts is between 1-5
performOperation attempts
performOperation :: MaxAttempts -> Handler Response
performOperation attempts = do
-- We can safely use the value knowing it's within bounds
let attemptCount = unRange attempts
-- ... rest of implementation
The mkRange
smart constructor returns a Maybe
that's only Nothing
if the value is out of bounds.
Benefits of Type-Level Constraints
Using Range
for bounded values provides several advantages:
- Compile-time safety: Invalid values are caught early in the development cycle
- Self-documenting code: The constraints are visible in the type signature
- Elimination of validation boilerplate: No need for runtime checks in handler code
- Clear API contracts: Users of your API know exactly what values are acceptable
Combined with the other features like MultiVerb, Servant continues to leverage Haskell's type system to provide safe, expressive, and robust APIs.