22 May 2020

How to combine Servant and React Admin, Part 1 : Servant API type

12 minutes reading time
Table of contents

Overview

I prefer using Haskell for my day-to-day programming challenges: the expressiveness and the type system make me a lot more productive. A typical project consists of a back-end and front-end part, and while Haskell has a clear track record for backend work, its application to frontend is far less common. There are of course front end frameworks in Haskell, but it is usually difficult to convince front end devs or designers to learn the shakespearean templating languages from Yesod for instance.

In practice, I use React with a backend REST server which allows me to write my server code in Haskell and have a front end developer be productive without learning a new language. For fast scaffolding I prefer to use something like react-admin : it allows me to check whether my server offers the necessary resources. Later I can hand it over to a designer who can sort out the lay-out, most of the times ditching react admin for something tailor made in basic React.

Goal

This blog post is part of a trilogy that will show a really simple Servant REST server with a React-admin web app in front. Further blog posts will flesh out the example and will explore some more advanced Servant topics and extra libraries.

Servant - Overview

Servant is a Haskell library that lets you generate REST clients and servers based on a Haskell type definition. It captures the ease of use of defining a binary RPC in a tool like gRPC and brings it to Haskell REST servers and clients.

Ergonomically it is superior to Swagger/OpenAPI, which does have the advantage of providing bindings for other languages than just Haskell: you cannot create a server in Servant and consume it with an automatically generated client in a different language. We’ll see in a later post how we can create a Swagger definition from a Haskell Servant type to work around this problem.

Servant

Set up initial project

The first thing we will do is set up a new Haskell project. We will use stack.


stack new servant-blog

This will set us up with src, app and test folders. We will currently skip the test specs but it is rather easy to test Servant servers by e.g. creating a Servant client. This is out of scope for this post but we will revisit it later.

Servant itself is a Domain Specific Language or DSL at the type level, meaning heavy use of type level operators and the data kinds extension. We will have to activate these language extensions. We will start by defining a simple GET request but we need a little bit of scaffolding first.

Add the necessary dependencies

First of all, we will add ‘servant’ and ‘servant-server’ as packages to the package.yml . The version depends on your stack version This example uses:


- servant == 0.16.2
- servant-server == 0.16.2

Our next part is setting up our api type. I think it is easiest to separate the definition of the REST server from its implementation. We create an ApiType haskell module like this (note the language extensions required by Servant ):

{-# LANGUAGE DataKinds     #-}
{-# LANGUAGE TypeOperators #-}

module ApiType where

import Servant


type WebApi =  "comments" :> Get '[ JSON] [String]

It’s a very limited example and one that can easily be understood even if you do not have experience with Servant. It defines a REST api where you can perform a GET on /comments. The return type of that GET request will be of the JSON content type (which will translate to application/json MIME type). The function that implements this call will have to return a list of strings, those strings will be translated to JSON and returned to the caller.

We will go a bit deeper into the exact meaning of the content types later in the blog.

The Proxy type

Next, the proxy.


api :: Proxy WebApi
api = Proxy

The Proxy type here is a little trick to go back from type-level definition to data-level definition. We would not be able to use WebApi-the-type as a function argument. We can use this api record in function arguments.

Server implementation

Now that we have the type, we have the REST API nailed down but we still need to implement this GET request. We will do this in a new module, Server. We will add a dependency on a web server for this as Servant does not include a web server. We use Warp here.

module Server where

import Servant
import ApiType
import Network.Wai.Handler.Warp (run)

listComments :: Handler [String]
listComments = return ["A comment", "Another comment"]

server ::  Server WebApi
server = listComments

The implementation is rather straightforward: we define a server, which is as far as Servant goes. We will also need to wrap a real http server around this REST definition.

The server itself has to provide an implementation for all endpoints specified in the WebApi. Currently there is only one (i.e. GET on /comments) so it is rather straightforward. The signature of the function serving the list endpoint is also rather straightforward, there are no arguments necessary and it will just return some dummy comments. The default monad here is the Handler monad and for now this is the one we will use. We will have need of a different monad later on and you will be able to find an example of that in a later blog post.

Handler monad

The default monad in Servant is the Handler monad and for now this is the one we will use. We will need a different monad later on and you will be able to find an example of that in a later blog post.

The Handler monad provides two properties :

  • It can run IO
  • We can indicate failure

It achieves this by leveraging the ExceptT monad transformer from the mtl package. A more detailed explanation can be found here.

The HTTP server

As mentioned earlier, we also need a real HTTP server. We define and run it like this:

app ::  Application
app =  serve api server 

start :: Int -> IO ()
start port = run port app

The app function defines the application. Its type signature corresponds to the Application type from Wai . In this function we can do some initialization and then start serving the api. We will not go a lot deeper into Wai but it might be interesting to know that initialization code should be run inside the app function before serving the api. If we would run code inside the server function, this code would be executed for every request and that is usually not the objective for initialization code.

The last part, the start function, is just us telling Warp to run our Servant app on this specific port and is again Warp-specific.

That’s basically it, we now have a running server we can invoke by putting this in our Main.hs in the app folder.

main :: IO ()
main = start 8082

Invoking ‘stack run’ on the command line will start the server.

JSON MIME type

We did not go deeper into the JSON MIME type when we were talking about the Servant api type. Let’s recap the api type:

type WebApi =  "comments" :> Get '[ JSON] [String]

We tell Servant that we will return a JSON object, or, to be more precise, a list of Strings that will be turned into JSON objects through aeson. We have been very quiet about the type-level list (the list with the backtick) that appears here, and we will remain quiet about it a little bit longer for now. Suffice to say that this indicates all possible content types that can be requested from (and thus returned by) this GET function, by use of the Accept headers. In our case this is limited to only JSON objects.

This works because String and [] provide an instance for the ToJSON and FromJSON type classes from Data.Aeson but it might not achieve exactly what we want. In practice we often want to return a JSON object with a specific structure.

We can check by calling the start function from our binary and running it on e.g. port 8080. If we then launch curl we will see this

curl http://localhost:8080/comments
["A comment","Another comment"]

So the default ToJSON instance returns a JSON array. To change this into a nicer JSON record, we will create a data type Comment with custom FromJSON and ToJSON instances

{-# LANGUAGE DeriveGeneric #-}

module Comment where

import           Data.Aeson.Types (FromJSON, ToJSON)
import           GHC.Generics     (Generic)

data Comment =
  Comment
    {  content :: String
    }
  deriving (Generic, Show)

instance ToJSON Comment

instance FromJSON Comment

We then change the api type to use our fresh comment object and end up with:

type WebApi = "comments" :> Get '[ JSON] [Comment]

We also need to change our implementation to use the Comment type.

listComments :: Handler [Comment]
listComments = return [Comment "A comment", Comment "Another comment"]

If we then run our server and try a curl request again we see:

[
  {
    "content": "A comment"
  },
  {
    "content": "Another comment"
  }
]

Content types list

Let’s briefly revisit the Servant type before moving on to the GUI. As mentioned, the type currently is this :

type WebApi = "comments" :> Get '[ JSON] [Comment]

As explained a bit higher up, ‘[JSON] specifies the type of content that can be returned and as we have seen forces us ar the type level to make sure our Comment data type can be translated to JSON.

The JSON type here is nothing more than a token type, with only one possible value, defined like this :

data JSON deriving Typeable

The list itself is technically a list at the type level, just like for example ‘[String, Int, Bool]. It would take us too far to go deeper into the technical details, but suffice to say it is activated by using the DataKinds language extension and it promotes all definitions from type/value level to kind/type level. An in depth discussion can be found here.

Important for us here is to understand what it means in the type definition. What we tell Servant by defining our api like this is that a GET request on /comments can only return the JSON content type. This means that the only allowed value for the Accept header of the request is application/json and the content-type of the answer will be application/json. Curl, by default, will add an Accept header to be

Accept : */*

so it works with anything. If we would rerun our curl statement we did before like this :


curl -i  -H "Accept: text/plain" http://localhost:8082/comments

Servant would tell us it can’t serve our request :

HTTP/1.1 406 Not Acceptable
Transfer-Encoding: chunked

We can solve this by telling our server type that we will also accept plain text by adding the PlainText marker type

type WebApi = "comments" :> Get '[ JSON, PlainText] [Comment]

The compiler will now complain:

No instance for (MimeRender PlainText [Comment]).

We can solve this by making our Comment instance of the correct type class.

So, let’s provide an instance for the type class and try again :

toBytestring :: Comment -> ByteString
toBytestring comment =
 BSLazy.concat
   [ "I made a comment with key "
   , BSLazyChar8.pack . show . key $ comment
   , " and content "
   , BSLazyChar8.pack . content $ comment
   ]

instance MimeRender PlainText [Comment] where
 mimeRender :: Proxy PlainText -> [Comment] -> BSLazy.ByteString
 mimeRender _ comments = intercalate ";" $ fmap toBytestring comments

Note, we need to activate quite a few language extensions for this to work :


{-# LANGUAGE FlexibleInstances     #-}
{-# LANGUAGE InstanceSigs          #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings     #-}

If we now run our curl command again we no longer get a 406 error and notice this output :

I made a comment with key 1 and content A comment;I made a comment with key 2 and content Another comment

Conclusion

So, to summarise, the list of supported content types we pass to the type api determines which Accept headers we can support. The Accept headers themselves also determine which type conversion (from Comment to JSON or from Comment to ByteString) will be taken. This is done by providing an instance for the Accept type class, which is done for JSON and PlainText in the library, and which can be done by any other extra library that wants to provide Servant with a new supported content type. The type that you return from your functions that implement that server function will need to provide instances for certain type classes. These are determined by the content types you support. In our case, before we only needed to provide the JSON instances, but after adding PlainText, we also need to provide the MimeRender PlaintText instance.

As another small side-remark before we conclude this part, we actually did not have to provide an instance of the FromJSON type class for our current implementation, as we only return Comment types from our server, the only thing we need to do is ToJSON. We already provided the FromJSON instance as well cause it will come in handy later on when we start sending Comment records in JSON format to the server. For the PlainText instance, the equivalent of FromJSON is MimeUnrender, and we could also provide an instance for that type class if we want but this is out of scope for this blog.

Finally, when we would run our original curl request, the one without specific Accept header and for which curl itself fills out

Accept : */*

the content type will be chosen by Servant. It will take the first element it encounters, so a

type WebApi = "comments" :> Get '[ JSON, PlainText] [Comment]

definition would return JSON content, but

type WebApi = "comments" :> Get '[ PlainText, JSON] [Comment]

would return plain text content.

Okay, now we are ready to try our hand at a React-admin gui that uses this REST server. We’ll continue this in the next post.

The source code for this first simple set up can be found here. This code covers the first three blog posts, so it’s a bit wider than what we explained here but contains a working UI and REST server.