HTTP APIs with WebGear
26 Sep 2020First of all, you should check out the user guide if you are not familiar with WebGear. This is a short blog post and I won’t be covering everything in detail.
Haskell for HTTP APIs
Most APIs accept some input from the HTTP request, perform a bunch of validations and operations, and then respond with some data retrieved from a backend such as a database or other services. Haskell has some unique strengths that makes this whole process robust.
I strongly believe in making illegal states unrepresentable in the types and techniques like “parse, don’t validate”. Haskell is well-suited for this style of programming and WebGear implements some of these ideas in building HTTP APIs.
Type Safety
Haskell’s type system makes it possible to encode concepts at type level and WebGear makes good use of this. For example, here is a handler:
-- Use this handler for a route such as: PUT /api/widget/<widgetId>
putWidgetHandler :: Have [ Method PUT
, PathVar "widgetId" Int
, JSONRequestBody Widget
, BasicAuth
] req
=> Handler req Widget
putWidgetHandler = ...
This handler can only be used with requests with a PUT method and a URL path variable named widgetId
with an Int
value. It is a compile time error otherwise, here is an example:
• The request doesn't have the trait ‘Method 'PUT’.
Did you use a wrong trait type?
For e.g., ‘PathVar "foo" Int’ instead of ‘PathVar "foo" String’?
Or did you forget to apply an appropriate middleware?
For e.g. The trait ‘JSONRequestBody Foo’ can be used with ‘jsonRequestBody @Foo’ middleware.
Middlewares “parse” the request and express traits such as HTTP method as a type level list. The trait attributes can be extracted from the request in a type safe manner:
type WidgetId = PathVar "widgetId" Int
putWidgetHandler :: Has WidgetId req => Handler req Widget
putWidgetHandler = Kleisli $ \request -> do
-- wid has type Int
let wid = get (Proxy @WidgetId) request
...
You cannot access the widget ID unless you have a Has WidgetId
constraint in the type signature. And to satisfy this
constraint, you must use the pathVar
middleware which verifies the presence of such a path variable in the request.
You don’t need to know a lot of advanced haskell features to use WebGear. Using Has
or Have
constraints and the
get
function with a Proxy
is enough to get you going.
Composable Handlers
Composition is a key tool to manage complexity. The ability to split code into reusable pieces and combine them at will is the foundation on which we build all our software. WebGear shines really well in this. Handlers are regular functions and you can just use function composition.
For example, if you have a number of routes that use the same basic authentication, you can extract that as a common authentication middleware.
allRoutes :: Handler req Widget
allRoutes = basicAuth "realm" checkCredentials
$ getWidget <|> putWidget
getWidget :: Has BasicAuth req => Handler req Widget
getWidget = ...
putWidget :: Has BasicAuth req => Handler req Widget
putWidget = ...
This also shows a prominent difference between WebGear and Servant. Servant uses type level combinators to implement such functionality. This helps to derive a server, client, documentation etc from the same type definition. But composition at type level is not as easy to deal with as value level composition. Error messages are often cryptic and this is a big barrier to entry for newcomers. You also have to resort to techniques like servant-flatten to workaround problems caused by nested types.
WebGear on the other hand uses functions which can be composed trivially. The trade-off is that you cannot generate client and documentation from WebGear middlewares and handlers, they only build a server.
Building Middlewares
Often you would want to build your own traits and middlewares. This is straightforward in WebGear.
Let us take an example. Many microservices use Correlation IDs to trace a transaction across many services. This is done
by sending a special HTTP header - Correlation-ID
- in every request and response. All services include this
correlation ID in their logs. The first request in the transaction obviously will not receive the header and should
generate a random ID instead.
How do we implement this in WebGear? We start by defining a data type for correlation IDs and implement the Trait
type
class for it.
data CorrelationId = CorrelationId ByteString
instance MonadIO m => Trait CorrelationId Request m where
type Attribute CorrelationId Request = CorrelationId
type Absence CorrelationId Request = Void
toAttribute :: Request -> m (Result CorrelationId Request)
toAttribute req = do
let h = requestHeader "Correlation-ID" req
Found . CorrelationId <$> maybe randomUUID pure h
The Trait
type class has two associated types - Attribute
and Absence
. Attribute
is the type of the trait
attribute and Absence
is an error type for the case when correlation ID is missing. In this case, we always have a
correlation ID, either from the request or a randomly generated one. So we use CorrelationId
as the Attribute
and
Void
as Absence
.
The toAttribute
function returns a CorrelationId
either from the Correlation-ID
header or generating a random UUID.
Next, let us build a middleware that uses CorrelationId
:
withCorrelationId :: MonadIO m
=> RequestMiddleware' m req (CorrelationId : req) a
withCorrelationId handler = Kleisli $ \request -> do
result <- probe @CorrelationId request
either absurd runHandler result
where
runHandler request = do
let CorrelationId cid = get (Proxy @CorrelationId) request
response <- runKleisli handler request
pure $ setResponseHeader "Correlation-ID" cid response
The probe
function checks the presence of CorrelationId
and the result is an Either
value with the Left
indicating Absence
and the Right
indicating an Attribute
.
We know that CorrelationId
is always present and will not have a Left
case indicating absence of the trait. But most
other traits are “optional” and have to deal with a case where they are absent from the request. So probe
returns an
Either
value to indicate this.
The absurd
function from Data.Void
module is used to handle the Left
case that will never occur.
For the positive case where we have a correlation ID, runHandler
invokes the handler
and then adds the correlation
ID to the response.
We can use this middleware with any of the handlers:
allRoutes :: Handler req ByteString
allRoutes = withCorrelationId
$ putWidget <|> deleteWidget
putWidget :: Has CorrelationId req => Handler req ByteString
putWidget = method @PUT $ Kleisli $ \request -> do
let cid = get (Proxy @CorrelationId) request
...
Summary
Hopefully this post has convinced you that WebGear can build type safe APIs without requiring a PhD in type theory. The user-facing parts of the library is built with care to not require many advanced Haskell concepts. You can find code, examples, and much more about WebGear in the Github repo.