15 Jul 2020

How to combine Servant and React Admin, Part 5 : Deleting comments

4 minutes reading time
Table of contents

Overview

We will add a delete method to the REST api and explain how to connect it to React Admin.

The DELETE method

We could start defining the API type like this :

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

but by now I am likely fooling nobody anymore, you know that React Admin will want the deleted record to be returned to it else it will tell us JSON not defined.

So let’s immediately define it correctly :

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

It’s nothing unexpected, it resembles the GET resource, except for the REST method being Delete instead of Get.

The next id property

The real fun comes in the implementation, where, as you recall from our last blog, we took a shortcut to define the unique id that is going to bite us now.

So, let’s first try to rewrite the insert method. The easiest way to keep track of the next id is by actually keeping track of it in the State, which is what we will do. To keep it clean we will extract a data type that contains everything related to our in memory database.

type InMemDb = ([Comment], Int)

After which our state becomes a TVar wrapped around the InMemDb type:

newtype State
  = State
      { commentsInMemDb :: TVar InMemDb
      }

Now, last time during the POST blog, we were leaning heavily on the readTVar and writeTVar methods, mainly to show what is going on. The code became complicated because of that, when you know what’s going on behind the scenes it is clearer to use the methods that work on state directly, like for instance stateTVar, which looks like it was made for our use case!. The insert code then becomes a lot clearer than before :

insertInternal :: NewComment -> InMemDb -> (Comment, InMemDb)
insertInternal newComment curState@(comments, nextId) =
  let comment = fromNewComment nextId newComment in
  let updatedComments = List.insert comment comments in
  (comment, (updatedComments, nextId + 1))

insertComment :: NewComment -> AppM Comment
insertComment newComment = do
  commentsDbTVar <- asks commentsInMemDb
  liftIO $ atomically $ stateTVar commentsDbTVar $ insertInternal newComment

Makes for a lot more readable code. The insertComment function handles the state stuff and the insertInternal becomes a very straightforward, testable function.

Deleting, with this way of coding, becomes easy to write, although a little bit more complicated due to the fact that we have to take failure into account in our code:

deleteInternal :: Int -> InMemDb -> (Maybe Comment, InMemDb)
deleteInternal idToDelete curState@(comments, nextId) =
  let maybeCommentToDelete = List.find (\comment -> Comment.id comment == idToDelete) comments
   in maybe
        (Nothing, curState)
        ( \commentToDelete ->
            let prunedComments = List.delete commentToDelete comments
             in (Just commentToDelete, (prunedComments, nextId))
        )
        maybeCommentToDelete

deleteComment :: Int -> AppM Comment
deleteComment idToDelete = do
  commentsDbTVar <- asks commentsInMemDb
  maybeDeletedComment <-
    liftIO $ atomically $
      stateTVar commentsDbTVar $ deleteInternal idToDelete
  maybe (throwError err404) return maybeDeletedComment

In case no comment has been found we should of course not update any state. In case it has been found we should remove the comment and update the state. We will return a 404 in case the id supplied hasn’t been found.

That concludes our backend, now for the front end part.

React Admin and delete

The great thing about React Admin is that the UI is already totally ready to perform DELETEs. You can click on one of the checkboxes next to a comment in the list overview and a Delete icon will appear. Clicking that will send the delete request to our REST server and all will be fine.

Of course it won’t, in our console we see our now-familiar NetworkError, related to CORS again. Who would have thought.

The problem is easy to spot :

Method requested in Access-Control-Request-Method of CORS request is not supported; requested: DELETE; supported are GET, POST, HEAD, OPTIONS.

We could have known. Alright, let’s also allow the DELETE method to be used, we expand the CORS config with HttpTypes.methodDelete:

corsConfig :: Middleware
corsConfig = cors (const $ Just policy)
  where
    policy =
      simpleCorsResourcePolicy
        { corsExposedHeaders = Just ["X-Total-Count"],
          corsMethods = [HttpTypes.methodGet, HttpTypes.methodPost, HttpTypes.methodHead, HttpTypes.methodOptions, HttpTypes.methodDelete],
          corsRequestHeaders = [HttpTypes.hContentType]
        }

and hooray, we can delete. So now we can list comments, we can read a single comment, we can add one and we can delete one. The one thing missing now is updating comments. Before we continue with that, we will first take a deeper dive into Servant and try to explain away some of the black magic that we took for granted up until now. The code can be found here.