Skip to content

Commit 5c80214

Browse files
committed
introducing NamedRoutes cookbook
1 parent 009dc06 commit 5c80214

File tree

3 files changed

+401
-0
lines changed

3 files changed

+401
-0
lines changed

cabal.project

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ packages:
4747
doc/cookbook/using-custom-monad
4848
doc/cookbook/using-free-client
4949
-- doc/cookbook/open-id-connect
50+
doc/cookbook/namedRoutes
5051

5152
tests: True
5253
optimization: False
Lines changed: 373 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,373 @@
1+
# NamedRoutes - Using records to define APIs
2+
3+
*Available in Servant 0.19 or higher*
4+
5+
Servant offers a very natural way of constructing APIs with nested records, called `NamedRoutes`.
6+
7+
This cookbook explains how to implement such nested-record-APIs using `NamedRoutes` through the example of a Movie Catalog.
8+
9+
First, we start by constructing the domain types of our Movie Catalog.
10+
After, we show you how to implement the API type with the NamedRoutes records.
11+
Lastly, we make a Server and a Client out of the API type.
12+
13+
However, it should be understood that this cookbook does _not_ dwell on the built-in servant combinators as the [<Structuring APIs> cookbook ](<https://docs.servant.dev/en/stable/cookbook/structuring-apis/StructuringApis.html>) already covers that angle.
14+
15+
## Why would I want to use `NamedRoutes` over the alternative `:<|>` operator?
16+
17+
With `NamedRoutes`, we don’t need to care about the declaration order of the endpoints.
18+
For example, with the `:<|>` operator there’s room for error when the order of the API type
19+
20+
```haskell,ignore
21+
type API1 = "version" :> Get '[JSON] Version
22+
:<|> "movies" :> Get '[JSON] [Movie]
23+
```
24+
25+
does not follow the `Handler` implementation order
26+
27+
```haskell,ignore
28+
apiHandler :: ServerT API1 Handler
29+
apiHandler = getMovies
30+
:<|> getVersion
31+
```
32+
33+
GHC could scold you with a very tedious message such as :
34+
35+
```console
36+
• Couldn't match type 'Handler NoContent'
37+
with 'Movie -> Handler NoContent'
38+
Expected type: ServerT MovieCatalogAPI Handler
39+
Actual type: Handler Version
40+
:<|> ((Maybe SortBy -> Handler [Movie])
41+
:<|> ((MovieId -> Handler (Maybe Movie))
42+
:<|> ((MovieId -> Movie -> Handler NoContent)
43+
:<|> (MovieId -> Handler NoContent))))
44+
• In the expression:
45+
versionHandler
46+
:<|>
47+
movieListHandler
48+
:<|>
49+
getMovieHandler :<|> updateMovieHandler :<|> deleteMovieHandler
50+
In an equation for 'server':
51+
server
52+
= versionHandler
53+
:<|>
54+
movieListHandler
55+
:<|>
56+
getMovieHandler :<|> updateMovieHandler :<|> deleteMovieHandler
57+
|
58+
226 | server = versionHandler
59+
```
60+
61+
On the contrary, with the `NamedRoutes` technique, we refer to the routes by their name:
62+
63+
```haskell,ignore
64+
data API mode = API
65+
{ list :: "list" :> ...
66+
, delete :: "delete" :> ...
67+
}
68+
```
69+
70+
and GHC follows the lead :
71+
72+
```console
73+
• Couldn't match type 'NoContent' with 'Movie'
74+
Expected type: AsServerT Handler :- Delete '[JSON] Movie
75+
Actual type: Handler NoContent
76+
• In the 'delete' field of a record
77+
In the expression:
78+
MovieAPI
79+
{get = getMovieHandler movieId,
80+
update = updateMovieHandler movieId,
81+
delete = deleteMovieHandler movieId}
82+
In an equation for 'movieHandler':
83+
movieHandler movieId
84+
= MovieAPI
85+
{get = getMovieHandler movieId,
86+
update = updateMovieHandler movieId,
87+
delete = deleteMovieHandler movieId}
88+
|
89+
252 | , delete = deleteMovieHandler movieId
90+
```
91+
92+
So, NamedRoutes is more readable for a human, and GHC gives you more accurate error messages.
93+
94+
What are we waiting for?
95+
96+
97+
## Boilerplate time!
98+
99+
First, let’s get rid of the the extensions and imports boilerplate in order to focus on our new technique:
100+
101+
102+
```haskell
103+
{-# LANGUAGE DataKinds #-}
104+
{-# LANGUAGE DeriveAnyClass #-}
105+
{-# LANGUAGE DerivingStrategies #-}
106+
{-# LANGUAGE DeriveGeneric #-}
107+
{-# LANGUAGE OverloadedStrings #-}
108+
{-# LANGUAGE TypeOperators #-}
109+
110+
import GHC.Generics ( Generic )
111+
import Data.Aeson ( FromJSON, ToJSON )
112+
import Data.Proxy ( Proxy(..) )
113+
import Network.Wai.Handler.Warp ( run )
114+
115+
import Servant ( NamedRoutes
116+
, Handler, serve )
117+
import Servant.API (Capture, Delete, Get, Put, QueryParam, ReqBody
118+
, JSON, NoContent (..)
119+
, FromHttpApiData (..),ToHttpApiData(..)
120+
, (:>) )
121+
import Servant.API.Generic ( (:-) )
122+
123+
import Servant.Client ( AsClientT, ClientM, client
124+
, (//), (/:) )
125+
import Servant.Client.Generic ()
126+
127+
import Servant.Server ( Application, ServerT )
128+
import Servant.Server.Generic ( AsServerT )
129+
130+
```
131+
132+
## Domain context
133+
134+
Now that we’ve handled the boilerplate, we can dive into our Movie Catalog domain.
135+
136+
Consider a `Movie` constructed from a `Title` and a `Year` of publication.
137+
138+
``` haskell
139+
data Movie = Movie
140+
{ movieId :: MovieId
141+
, title :: Title
142+
, year :: Year
143+
}
144+
deriving stock Generic
145+
deriving anyclass (FromJSON, ToJSON)
146+
147+
type MovieId = String
148+
type Title = String
149+
type Year = Int
150+
151+
```
152+
153+
154+
Let’s forget about the deriving stuff for now and think about the API that we want to make.
155+
156+
```
157+
"version" -> Get Version
158+
/
159+
api "list" -> Get [Movie] ?sortBy= Title | Year (sort by the Title or the Year)
160+
\ /
161+
"movies" Get Movie
162+
\ /
163+
Capture MovieId - Put Movie
164+
\
165+
Delete MovieId
166+
```
167+
168+
In this example, we create a very simple endpoint for the Version,
169+
and several complex endpoints that use nested records for the CRUD part of the movie.
170+
171+
So, the URLs would look like
172+
173+
- GET …/version
174+
- GET …/movies/list?sortby=Title
175+
- GET …/movies/<MovieId>/
176+
- PUT …/movies/<MovieId>/
177+
- DELETE …/movies/<MovieId>
178+
179+
### API Type
180+
181+
Now that we have a very clear idea of the API we want to make, we need to transform it into usable Haskell code:
182+
183+
``` haskell
184+
185+
data API mode = API
186+
{ version :: mode :- "version" :> Get '[JSON] Version
187+
, movies :: mode :- "movies" :> NamedRoutes MoviesAPI
188+
} deriving stock Generic
189+
190+
type Version = String -- This will do for the sake of example.
191+
192+
```
193+
Here, we see the first node of our tree. It contains the two branches “version” and “movies” respectively:
194+
195+
The “version” branch is very simple and self-explanatory.
196+
The “movies” branch will contain another node, represented by another record (see above). That is why we need the `NameRoutes` helper.
197+
198+
Note:
199+
200+
The `mode` type parameter indicates into which implementation the record’s `Generic` representation will be transformed—as a client or as a server. We will discuss that later.
201+
202+
Let's jump into the "movies" subtree node:
203+
204+
205+
``` haskell
206+
207+
data MoviesAPI mode = MoviesAPI
208+
{ list :: mode :- "list" :> QueryParam "SortBy" SortBy :> Get '[JSON] [Movie]
209+
, movie :: mode :- Capture "movieId" MovieId :> NamedRoutes MovieAPI
210+
} deriving stock Generic
211+
212+
data SortBy = Year | Title
213+
214+
instance ToHttpApiData SortBy where
215+
toQueryParam Year = "year"
216+
toQueryParam Title = "title"
217+
218+
instance FromHttpApiData SortBy where
219+
parseQueryParam "year" = Right Year
220+
parseQueryParam "title" = Right Title
221+
parseQueryParam param = Left $ param <> " is not a valid value"
222+
223+
```
224+
So, remember, this type represents the `MoviesAPI` node that we’ve connected earlier to the main `API` tree.
225+
226+
In this subtree, we illustrated both an endpoint with a **query param** and also, a **capture** with a subtree underneath it.
227+
228+
So, let's go deeper into our API tree.
229+
230+
``` haskell
231+
data MovieAPI mode = MovieAPI
232+
{ get :: mode :- Get '[JSON] (Maybe Movie)
233+
, update :: mode :- ReqBody '[JSON] Movie :> Put '[JSON] NoContent
234+
, delete :: mode :- Delete '[JSON] NoContent
235+
} deriving stock Generic
236+
```
237+
238+
As you can see, we end up implementing the deepest routes of our API.
239+
240+
Small detail: as our main API tree is also a record, we need the `NamedRoutes` helper.
241+
To improve readability, we suggest you create a type alias:
242+
243+
``` haskell
244+
type MovieCatalogAPI = NamedRoutes API
245+
```
246+
247+
That's it, we have our `MovieCatalogAPI` type!
248+
249+
Let's make a server and a client out of it!
250+
251+
### The Server
252+
253+
As you know, we can’t talk about a server, without addressing the handlers.
254+
255+
First, we take our handlers…
256+
257+
```haskell
258+
versionHandler :: Handler Version
259+
versionHandler = pure "0.0.1"
260+
261+
movieListHandler :: Maybe SortBy -> Handler [Movie]
262+
movieListHandler _ = pure moviesDB
263+
264+
moviesDB :: [Movie]
265+
moviesDB =
266+
[ Movie "1" "Se7en" 1995
267+
, Movie "2" "Minority Report" 2002
268+
, Movie "3" "The Godfather" 1972
269+
]
270+
271+
getMovieHandler :: MovieId -> Handler (Maybe Movie)
272+
getMovieHandler requestMovieId = go moviesDB
273+
where
274+
go [] = pure Nothing
275+
go (movie:ms) | movieId movie == requestMovieId = pure $ Just movie
276+
go (m:ms) = go ms
277+
278+
updateMovieHandler :: MovieId -> Movie -> Handler NoContent
279+
updateMovieHandler requestedMovieId newMovie =
280+
-- update the movie list in the database...
281+
pure NoContent
282+
283+
deleteMovieHandler :: MovieId -> Handler NoContent
284+
deleteMovieHandler _ =
285+
-- delete the movie from the database...
286+
pure NoContent
287+
288+
```
289+
290+
And assemble them together with the record structure, which is the glue here.
291+
292+
```haskell
293+
server :: ServerT MovieCatalogAPI Handler
294+
server =
295+
API
296+
{ version = versionHandler
297+
, movies = moviesHandler
298+
}
299+
300+
moviesHandler :: MoviesAPI (AsServerT Handler)
301+
moviesHandler =
302+
MoviesAPI
303+
{ list = movieListHandler
304+
, movie = movieHandler
305+
}
306+
307+
movieHandler :: MovieId -> MovieAPI (AsServerT Handler)
308+
movieHandler movieId = MovieAPI
309+
{ get = getMovieHandler movieId
310+
, update = updateMovieHandler movieId
311+
, delete = deleteMovieHandler movieId
312+
}
313+
```
314+
As you might have noticed, we build our handlers out of the same record types we used to define our API: `MoviesAPI` and `MovieAPI`. What kind of magic is this ?
315+
316+
Remember the `mode` type parameter we saw earlier? Since we need to transform our API type into a _server_, we need to provide a server `mode`, which is `AsServerT Handler` here.
317+
318+
Finally, we can run the server and connect the API routes to the handlers as usual:
319+
320+
``` haskell
321+
api :: Proxy MovieCatalogAPI
322+
api = Proxy
323+
324+
main :: IO ()
325+
main = run 8081 app
326+
327+
app :: Application
328+
app = serve api server
329+
330+
```
331+
Yay! That’s done and we’ve got our server!
332+
333+
## The Client
334+
335+
The client, so to speak, is very easy to implement:
336+
337+
``` haskell
338+
movieCatalogClient :: API (AsClientT ClientM)
339+
movieCatalogClient = client api -- remember: api: Proxy MovieCatalogAPI
340+
```
341+
342+
Have you noticed the `mode` `AsClient ClientM`?
343+
344+
We’ve also introduced some operators that help navigate through the nested records.
345+
346+
`(//)` is used to jump from one record to another.
347+
`(/:)` is used to provide a parameter, whether it be a query param or a capture.
348+
349+
Let’s use those nice helpers for our movie catalog:
350+
351+
```haskell
352+
listMovies :: Maybe SortBy -> ClientM [Movie]
353+
listMovies sortBy = movieCatalogClient // movies // list /: sortBy
354+
355+
getMovie :: MovieId -> ClientM (Maybe Movie)
356+
getMovie movieId = movieCatalogClient // movies // movie /: movieId // get
357+
358+
updateMovie :: MovieId -> Movie -> ClientM NoContent
359+
updateMovie movieId newMovie = movieCatalogClient // movies // movie /: movieId // update /: newMovie
360+
361+
deleteMovie :: MovieId -> ClientM NoContent
362+
deleteMovie movieId = movieCatalogClient // movies // movie /: movieId // delete
363+
```
364+
365+
Done! We’ve got our client!
366+
367+
## Conclusion
368+
369+
We hope that you found this workbook helpful, and that you now feel more confident using the `NamedRoutes` technique.
370+
371+
If you are interested in further understanding the built-in Servant combinators, see [Structuring APIs](https://docs.servant.dev/en/stable/cookbook/structuring-apis/StructuringApis.html).
372+
373+
Since `NamedRoutes` is based on the Generic mechanism, you might want to have a look at [Sandy Maguire’s _Thinking with Types_ book](https://doku.pub/download/sandy-maguire-thinking-with-typesz-liborgpdf-4lo5ne7kdj0x).

0 commit comments

Comments
 (0)