15 Jun 2020

How to combine Servant and React Admin, Part 3 : Display a single comment

7 minutes reading time
Table of contents

Overview

Our last blog ended with a very simple REST server and a UI that could list comments, and that was it. Let’s expand it a bit with GET requests to fetch single comments!

GET resource in Servant

The GET request itself is reasonably close to what we already have. Let’s recall our current type :

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

We will add a second REST route first of all. Secondly, a GET request must specify one single comment it wants to retrieve so it needs some kind of unique id. Also, if that comment cannot be found, we should be able to indicate failure.

On the Servant side it will look like this :

type WebApi
   = "comments" :> Get '[ JSON] (ListHeaders [Comment]) 
   :<|> "comments" :> Capture "id" Int :> Get '[ JSON] Comment

There’s already some stuff to unpack here. Even though it is likely rather clear what our intention is, there are some new elements.

The most glaring one is the union operator, :<|> . It expresses that our two defined routes should be lumped together in one API type. You can think of it as the ( ,)-operator on type level. In fact, when you forget to implement part of the API you will see a compile error that resembles a nested tuple structure trying to tell you what you did wrong.

The next new thing is the Capture type. Capture tries to parse one subroute of the url, into the type you specify, in this case an Int. The “id” string here is just for information, can be used when doing documentation generation for Servant Api types. We will talk about that later.

Implement the server

If we would try to compile now we would see this error :

    • Couldn't match type ‘Handler (ListHeaders [Comment])’
                     with ‘Handler (Headers '[TotalCountHeader] [Comment])
                           :<|> (Int -> Handler Comment)’
      Expected type: Server WebApi
        Actual type: Handler (ListHeaders [Comment])

You can already see the effect of the union operator. It will become even more outspoken when we add a third route. This error is effectively telling us to please implement the server by providing a function from Int to Comment, inside the Handler Monad.

Note that you do not need to bother with parsing the captured subroute anymore.

So, here we go :

module Server where

import Servant
import ApiType
import Network.Wai.Handler.Warp (run)
import Comment
import Cors
import qualified Data.List as L
import Data.Maybe (fromJust)

fixedComments :: [Comment]
fixedComments = [Comment 1 "A comment", Comment 2 "Another comment"]

listComments :: Handler (ListHeaders [Comment])
listComments = return $ addHeader 2 fixedComments

getComment :: Int -> Handler Comment
getComment requestedId = 
  let maybeComment = L.find (\comment -> Comment.id comment == requestedId) fixedComments in
  return $ fromJust maybeComment

server ::  Server WebApi
server = listComments :<|> getComment

We changed quite a few things.

First of all, we extracted all the comments in a variable, fixedComments so it can be used in listComments as well as getComment.

Secondly, there is a getComment function that implements the get functionality. It takes an id, looks it up and if it does not find it the fromJust function will throw an exception.

The most interesting part is the fact that you see our union operator coming back in the server method.

Note that, in case you do not pass a ‘key’ that can be parsed to an Int, Servant will already have answered with a 400 and there is no need for you to handle that error anymore inside the getComment function, let’s demonstrate :

curl -i  http://localhost:8082/comments/asdf

HTTP/1.1 400 Bad Request
Transfer-Encoding: chunked
Date: Sat, 30 May 2020 09:46:36 GMT
Server: Warp/3.3.10

The union operator

This server snippet above boils down to implementing the server api we have defined with the union operator by applying that same union operator on functions that have the correct signature (corresponding to the one we used in the definition).

I thought it remarkable that the same operator we use to create the API type, on type level, can also be used on functions, but instead of defining the server type we provide functions to implement it.

Error handling

Currently, we will return a 500 Internal Server error in case the GET request cannot retrieve our comment. This is because the fromJust function we apply will throw an exception, that will be translated by the server loop into a 500.

That is of course far from optimal, but let’s recall from our first blog in this series that the Handler monad can be used to perform IO as well as return a ServantError.

The easiest way to go about this is by using the pre-made error types from the servant-server library and returns those from the getComment function by using throwError from MonadError. The only reason we can do this is because the Handler monad implements this type class, of course.

We change our getComment function like this:

getComment :: Int -> Handler Comment
getComment requestedId = 
  let maybeComment = L.find (\comment -> Comment.id comment == requestedId) fixedComments in
  maybe (throwError err404) return maybeComment

The maybe function does not need a lot of explaining, it will return the comment if present or return the error type if not.

Now, these errXXX functions all return pretty standard responses, the err404 implementation for instance is this :

err404 :: ServerError
err404 = ServerError { errHTTPCode = 404
                    , errReasonPhrase = "Not Found"
                    , errBody = ""
                    , errHeaders = []
                    }

This serves the purpose, but it might be better to specify what exactly hasn’t been found. For a more descriptive error we will change our getComment function to this:

getComment :: Int -> Handler Comment
getComment requestedId = 
  let maybeComment = L.find (\comment -> Comment.id comment == requestedId) fixedComments in
  maybe (throwError err404 {errBody = BSLazy.pack $ "Could not retrieve comment with id " ++ show requestedId}) return maybeComment

We need to import Data.ByteString.Lazy.Char8 as BSLazy for this to work of course. We could also change the reason phrase if we would want, in the same way as we edit the errBody.

Note that the errHeaders do not change the type of ServerError like the Headers in the Api type do. Here, they boil down to a tuple of strings.

Let’s demonstrate, again by using curl :

 curl -i  http://localhost:8082/comments/2

HTTP/1.1 200 OK
Transfer-Encoding: chunked
Date: Sat, 30 May 2020 09:46:25 GMT
Server: Warp/3.3.10
Content-Type: application/json;charset=utf-8

{"content":"Another comment","id":2}

and in case of a non existing id :

curl -i  http://localhost:8082/comments/3

HTTP/1.1 404 Not Found
Transfer-Encoding: chunked
Date: Sat, 30 May 2020 09:46:26 GMT
Server: Warp/3.3.10

Could not retrieve comment with id 3

GET from React Admin

Let’s head back to React Admin now. We want to show our comment, and React Admin has a handy feature where it can guess the layout of the show component based on the JSON being returned.

We change our App component to :

const App = () => (
    <Admin dataProvider={dataProvider}>
        <Resource name="comments" list={CommentsList} show={ShowGuesser}/>
    </Admin>
);

using the ShowGuesser as the component that handles the ‘show’ functionality of a single Comment.

This component will not just guess the layout but will also print it in the console in the browser, so open up the developer tools and witness :

Guessed Show:

export const CommentShow = props => (
    <Show {...props}>
        <SimpleShowLayout>
            <TextField source="content" />
            <TextField source="id" />
        </SimpleShowLayout>
    </Show>
);

It’s a very practical way to effortlessly start with a decent component. We can always change it afterwards. Currently, this suits us fine, the only thing we will change right now is switching the order of the text fields to display the id first. Note that in reality of course we will likely have our zoomed in version contain more information than is already present on the list view.

So, our React component to show our comment will look like this :

export const CommentsShow = props => (
    <Show {...props}>
        <SimpleShowLayout>
            <TextField source="id"/>
            <TextField source="content"/>
        </SimpleShowLayout>
    </Show>
);

SimpleShowLayout is one of the most used show layouts. Another option is the TabbedShowLayout, more info in the react admin tutorial. We need to use one of the existing show layouts for our component to be usable in the ‘show’ property in the Resource component.

If we now update this property to :

<Resource name="comments" list={CommentsList} show={CommentsShow}/>

we will be able to click on a comment in the list overview and zoom in on a single comment. Single comment

The source code for this blog can be found here.

Conclusion

Okay, this wraps up a single GET request. On to POST in the next blog.