30 May 2020

How to combine Servant and React Admin, Part 2 : The React Web App

11 minutes reading time
Table of contents

Overview

In the first episode of our blogs we ended by having a working Haskell REST server serving our Servant WebApi type. The use case we are working towards is serving comments in a web app. A comment is currently just a string of text but we can expand on it in the future. In this part we will try to write a UI for it, exploring some React-Admin specifics as we go.

React Admin - Overview

React Admin is a React framework that allows you to scaffold a CRUD web UI fast and easy as long as you stick to the defaults it has chosen for you. For me, it’s more convenient than React. It allows me to hammer out a full website with little code. Later on, it can always be customized by a front end dev as much as you like.

One property of React Admin is that it assumes your REST server has a very specific layout.

  • It needs to handle and return JSON objects
  • When deleting, the deleted object needs to be returned
  • The JSON attributes have to be camel cased
  • Listing is done by a GET request on the REST resource, e.g. listing the comments is assumed to be done through a GET on /comments
  • Retrieving a specific element is done by launching a GET on the REST topic plus the id e.g. a specific comment is retrieved through /comments/{id}
  • Deletes should have the deleted item returned from the call
  • Inserts should have the created item returned from the call
  • All JSON objects returned should contain an ‘id’ field

While you can work around most of these properties, it is better not to fight the framework. It is really easy to define a server that adheres to React-admin’s expectations with the Servant library, which is makes this a great combination.

We’ll start off by building towards a react-admin front end that consumes our Servant application. We will choose a Servant API type that is friendly towards React admin.

React webapp

Generate initial project

We will start our application by creating a React app through yarn

yarn create react-app webui

By default there is no dependency on react-admin so we will also have to add it to the package.json file by running

yarn add react-admin

Add necessary dependencies

We’ve now set up a yarn app where App.js is the entry point.

There is a default REST client we can use for React Admin. We’ll add it to the dependencies:

yarn add ra-data-json-server

You might have noticed that the REST client has server in its name. I wonder why myself as well.

Let’s add the data provider to App.js For demo purposes, we have just hard coded the port to 8082.

import jsonServerProvider from "ra-data-json-server";
const dataProvider = jsonServerProvider('http://localhost:8082');

The list comments component

We are ready to retrieve comments from the backend. We won’t go into a deep explanation concerning React-admin, this should be a blog and not a full blown book. Most of the concepts are straightforward and the tutorial is excellent if you’d like more info.

We need to create a React component which can be used for the listing of the comments. We’ll create a comments.js file that contains:

import React from 'react';
import {Datagrid, List, TextField} from 'react-admin';

export const CommentsList = props => (
   <List {...props}>
       <Datagrid rowClick="show">
           <TextField source="content"/>
       </Datagrid>
   </List>
);

Simple and straightforward, our comments list component. Alright, let’s put it to use!

Retrieve the comments

This will be our app :

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

This little snippet already shows React-Admins defaults. The core of the application is the Admin component, where we pass a default data provider. Inside the Admin component you can specify multiple Resource components, which will all correspond to a REST endpoint, in our case ‘comments’. This means all calls will be done to /comments. ‘List’ will perform a GET on /comments, ‘read comment with {id}’ will perform a GET on /comments/{id} and so on. These defaults make writing a basic UI very simple but of course depend on the REST server adhering to React-Admin’s expectations. Luckily we have foreseen our REST server to serve the list part correctly in the blog post where we setup the API type.

Run the server

Let’s launch our application by running ‘stack run’ in the source folder and then go for ‘yarn start’ to launch the web UI.

Bang, an error, NetworkResource could not be found. If we open up the developer’s console we will notice this error message

Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at 
http://localhost:8082/comments?_end=10&_order=ASC&_sort=id&_start=0. 
(Reason: CORS header ‘Access-Control-Allow-Origin’ missing)

CORS

CORS basics

CORS stands for Cross Origin Resource Sharing. It is a way to relax the Same Origin Policy (SOP), which is the standard. The Same Origin Policy specifies that scripts can only call resources on the same site. This means you are by default protected from other sites consuming the REST resources you expose. It is not a security feature as such, you can think of it more as a privacy feature.

CORS as such is a way to only open up the resources you want to be opened to the rest - or a small part - of the world.

A good explanation can be found in this Mozilla article.

TLDR we can use headers in the HTTP request to humbly ask a remote server whether we are allowed to use one of their resources. You can look at it as a little protocol on top of HTTP to allow for CORS requests.

Note that CORS is something of an honor-based system in the sense that the client should behave nicely. Most browser environments, acting as clients in the case of a web UI will do so. Curl, for instance, won’t.

CORS and Servant/WAI

As you can read in the article, CORS’ main playground are the Access-Control-* headers. We could of course handle all this manually and start setting headers and so on, but that’s a bit tedious.

Recall that our web server serves the Haskell Application type from WAI (Web Application Interface). Well, you’ll be relieved to learn that WAI offers a package that lets us declare which requests we want to make available through CORS. This package is called wai-cors.

For our current simple api we can use the simple CORS functionality as mentioned on the WAI site. This simple CORS policy is also described in the Mozilla article mentioned earlier as well.

Code-wise, not a lot will change, just wrap the simpleCorsResourcePolicy middleware around the server and the CORS dance will be done.

First of all, let’s add our dependencies :

- wai == 3.2.2.1
- wai-cors == 0.2.7

Let’s also create the CORS module :

module Cors where

import           Network.Wai                 (Middleware)
import           Network.Wai.Middleware.Cors (cors, simpleCorsResourcePolicy)

corsConfig :: Middleware
corsConfig = cors (const $ Just simpleCorsResourcePolicy)

This definition is rather simple. The cors function allows you to return different policies based on the info in the request. We will go for one-size-fits all for the sake of the blog, but if you want to do more, the corsConfig function is the place. By the way, returning a Nothing here would mean no CORS policy (and, as such, total SOP).

Right, that’s it, now just wrap the middleware around the WAI server and we’re done, we go stack run and yarn start and we should be rid of the error message!

Surprise!

Hmm, no error messages in the UI, indeed, but also, no comments. Checking the console shows us a weird error message:

The X-Total-Count header is missing in the HTTP Response. The jsonServer Data Provider expects responses for lists of resources to contain this header with the total number of results to build the pagination. If you are using CORS, did you declare X-Total-Count in the Access-Control-Expose-Headers header?

Okay, some stuff to unpack here.

What we can deduce is that React-Admin wants us to report the number of elements returned in a list request. More specifically, it wants to have an X-Total-Count header where it can find that info. We did not provide that header, so we should start there.

The last sentence tells us that we should also declare in our CORS policy that this header can be passed in the return message, or more precisely that we allow a remote server to consume the info in this header.

Let’s start with adding headers to our Servant type.

Adding Headers to the Servant API type

Adding headers to a Servant type is a bit more involved than you would expect. The headers change the API type itself, which is a good thing as we’ll see in the next paragraphs.

In our ApiType we will define the total count header like this :

type TotalCountHeader = Header "X-Total-Count" Int

Here, we have specified the name of the header and the fact that we can only fill in integers in Haskell. If you want to add more headers for some reason, they also need to be defined in this way.

Alright, let’s use this header in our WebApi definition. Because we only have a single header we want to add, this type constructor becomes rather straightforward. We know that we will want to use this header for every list request as React-Admin forces us to, so we shall call this set of headers ListHeaders.

type ListHeaders retType = Headers '[ TotalCountHeader] retType

Then it is just a matter of wrapping the ListHeaders type constructor around the return type of list like this. The compiler will now point out all the places where we need to think about headers, so it’s a matter of following the compiler complaints to to a working program, taking into account headers.

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

After making this change, the compiler will complain about the implementation of the list function, because it does not return the header. We will fix it like this:

listComments :: Handler (ListHeaders [Comment])
listComments = return $ addHeader 2 [Comment "A comment", Comment "Another comment"]

So, because adding a header changes the types, the compiler can force you to actually add the header or to explicitly decide not to add it. The latter can be done by calling the noHeader function.

Note that there is no need to specify which header you are adding here, the type inference figures that out for you. We will work out an example with more headers in a later blog post.

Ok, so this nails part one of the error message. Recall that it also mentioned that the CORS policy did not specifically allow returning the X-Total-Count header in the call. So we have to fix that up as well.

Expand our simple cors policy

To make changes or additions to the simple cors policy we need to update the record which is returned by the simpleCorsResourcePolicy smart constructor. We’ll have to allow the header to be passed in there by expanding the corsExposedHeaders field.

Note that we indeed have some duplication in the code, specifying the header name in two places (the Cors module and the ApiType module). That will become annoying to maintain in the future if we would have multiple headers but for the purpose of this blog it’s good enough.

The code for the cors config then becomes

corsConfig :: Middleware
corsConfig = cors (const $ Just policy)
  where
    policy = simpleCorsResourcePolicy {corsExposedHeaders = Just ["X-Total-Count"]}

If you want to see what else you can configure, just check out the CORS record :

simpleCorsResourcePolicy  CorsResourcePolicy
simpleCorsResourcePolicy = CorsResourcePolicy
   { corsOrigins = Nothing
   , corsMethods = simpleMethods
   , corsRequestHeaders = []
   , corsExposedHeaders = Nothing
   , corsMaxAge = Nothing
   , corsVaryOrigin = False
   , corsRequireOrigin = False
   , corsIgnoreFailures = False
   }

We will need to change some of these parameters as well later on.

Booting up the server and listing the comments will show two comments, but there is still an annoying warning message in the console :

Warning: Each child in a list should have a unique "key" prop.

Currently a warning, but will become an issue pretty soon.

Next stop, adding a key property to our JSON object so it can uniquely identify the elements in the list and can request and update the correct element later on.

Add a unique field to our records

Let’s go for an Int key for now.

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

Let’s also add a key to each of our Comments:

module Comments where 

listComments :: Handler (ListHeaders [Comment])
listComments = return $ addHeader 2 [Comment 1 "A comment", Comment 2 "Another comment"]

Running the application will now show two comments, but they’re the same! And the warning is still present in the console!

Comments

When React-Admin shows the same element multiple times in a list, it nearly always means there is an issue with the uniqueness of the key-attribute. But we provided a key and it is unique, so how did we get here? Well, that’s because React-Admin complains about wanting a key field but in reality it looks for an id field, which is a bit unexpected of course. Changing key to id in our Haskell Comment record as well as the listing will now show both comments.

Note that it is not necessary to display the id field in the list for it to be used, we just did that for the purpose of our blog.

Conclusion

Alright, this concludes the first part of our series of having fun with Haskell Servant and React Admin. There are still a lot of interesting topics left untouched which we will explore in our next blogs. The source code for this first simple set up can be found here.