How to combine Servant and React Admin, Part 3 : Display a single comment
7 minutes reading timeOverview
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
.
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.