|
| 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