git.iko.soy/typeable/servant
on master help sign in
Merge pull request #1534 from akhesaCaro/cookbook_namedRoutes

Cookbook named routes

commit50355d0125697acbd8ea84c829712c6c45c1e43a authorCaroline GAUDREAU <caroline.gaudreau@gmail.com> dateFri 18 Feb 2022, 11:41 (5 years ago) parent009dc0634aed1d
4 files changed+386−152
cabal.project+1−1 doc/cookbook/generic/Generic.lhs−141 doc/cookbook/namedRoutes/NamedRoutes.lhs+373 doc/cookbook/generic/generic.cabal → doc/cookbook/namedRoutes/namedRoutes.cabal+12−10
cabal.project+1−1
@@ -34,7 +34,6 @@ packages:
3434 doc/cookbook/db-postgres-pool
3535 doc/cookbook/db-sqlite-simple
3636 doc/cookbook/file-upload
37− doc/cookbook/generic
3837 doc/cookbook/hoist-server-with-context
3938 doc/cookbook/https
4039 doc/cookbook/jwt-and-basic-auth
@@ -47,6 +46,7 @@ packages:
4746 doc/cookbook/using-custom-monad
4847 doc/cookbook/using-free-client
4948 -- doc/cookbook/open-id-connect
49+ doc/cookbook/namedRoutes
5050
5151tests: True
5252optimization: False
doc/cookbook/generic/Generic.lhsdeleted−141
@@ -1,141 +0,0 @@
1−# Using generics
2−
3−```haskell
4−{-# LANGUAGE DataKinds #-}
5−{-# LANGUAGE DeriveGeneric #-}
6−{-# LANGUAGE RankNTypes #-}
7−{-# LANGUAGE TypeOperators #-}
8−module Main (main, api, getLink, routesLinks, cliGet) where
9−
10−import Control.Exception (throwIO)
11−import Control.Monad.Trans.Reader (ReaderT, runReaderT)
12−import Data.Proxy (Proxy (..))
13−import Network.Wai.Handler.Warp (run)
14−import System.Environment (getArgs)
15−
16−import Servant
17−import Servant.Client
18−
19−import Servant.API.Generic
20−import Servant.Client.Generic
21−import Servant.Server.Generic
22−```
23−
24−The usage is simple, if you only need a collection of routes.
25−First you define a record with field types prefixed by a parameter `route`:
26−
27−```haskell
28−data Routes route = Routes
29− { _get :: route :- Capture "id" Int :> Get '[JSON] String
30− , _put :: route :- ReqBody '[JSON] Int :> Put '[JSON] Bool
31− }
32− deriving (Generic)
33−```
34−
35−Then we'll use this data type to define API, links, server and client.
36−
37−## API
38−
39−You can get a `Proxy` of the API using `genericApi`:
40−
41−```haskell
42−api :: Proxy (ToServantApi Routes)
43−api = genericApi (Proxy :: Proxy Routes)
44−```
45−
46−It's recommended to use `genericApi` function, as then you'll get
47−better error message, for example if you forget to `derive Generic`.
48−
49−## Links
50−
51−The clear advantage of record-based generics approach, is that
52−we can get safe links very conveniently. We don't need to define endpoint types,
53−as field accessors work as proxies:
54−
55−```haskell
56−getLink :: Int -> Link
57−getLink = fieldLink _get
58−```
59−
60−We can also get all links at once, as a record:
61−
62−```haskell
63−routesLinks :: Routes (AsLink Link)
64−routesLinks = allFieldLinks
65−```
66−
67−## Client
68−
69−Even more power starts to show when we generate a record of client functions.
70−Here we use `genericClientHoist` function, which lets us simultaneously
71−hoist the monad, in this case from `ClientM` to `IO`.
72−
73−```haskell
74−cliRoutes :: Routes (AsClientT IO)
75−cliRoutes = genericClientHoist
76− (\x -> runClientM x env >>= either throwIO return)
77− where
78− env = error "undefined environment"
79−
80−cliGet :: Int -> IO String
81−cliGet = _get cliRoutes
82−```
83−
84−## Server
85−
86−Finally, probably the most handy usage: we can convert record of handlers into
87−the server implementation:
88−
89−```haskell
90−record :: Routes AsServer
91−record = Routes
92− { _get = return . show
93− , _put = return . odd
94− }
95−
96−app :: Application
97−app = genericServe record
98−
99−main :: IO ()
100−main = do
101− args <- getArgs
102− case args of
103− ("run":_) -> do
104− putStrLn "Starting cookbook-generic at http://localhost:8000"
105− run 8000 app
106− -- see this cookbook below for custom-monad explanation
107− ("run-custom-monad":_) -> do
108− putStrLn "Starting cookbook-generic with a custom monad at http://localhost:8000"
109− run 8000 (appMyMonad AppCustomState)
110− _ -> putStrLn "To run, pass 'run' argument: cabal new-run cookbook-generic run"
111−```
112−
113−## Using generics together with a custom monad
114−
115−If your app uses a custom monad, here's how you can combine it with
116−generics.
117−
118−```haskell
119−data AppCustomState =
120− AppCustomState
121−
122−type AppM = ReaderT AppCustomState Handler
123−
124−apiMyMonad :: Proxy (ToServantApi Routes)
125−apiMyMonad = genericApi (Proxy :: Proxy Routes)
126−
127−getRouteMyMonad :: Int -> AppM String
128−getRouteMyMonad = return . show
129−
130−putRouteMyMonad :: Int -> AppM Bool
131−putRouteMyMonad = return . odd
132−
133−recordMyMonad :: Routes (AsServerT AppM)
134−recordMyMonad = Routes {_get = getRouteMyMonad, _put = putRouteMyMonad}
135−
136−-- natural transformation
137−nt :: AppCustomState -> AppM a -> Handler a
138−nt s x = runReaderT x s
139−
140−appMyMonad :: AppCustomState -> Application
141−appMyMonad state = genericServeT (nt state) recordMyMonad
doc/cookbook/namedRoutes/NamedRoutes.lhsnew file+373
@@ -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).
doc/cookbook/generic/generic.cabal → doc/cookbook/namedRoutes/namedRoutes.cabalrenamed+12−10
@@ -1,25 +1,27 @@
1−cabal-version: 2.2
2−name: cookbook-generic
1+name: namedRoutes
32version: 0.1
4−synopsis: Using custom monad to pass a state between handlers
3+synopsis: NamedRoutes - Generic servant API implementation cookbook example
54homepage: http://docs.servant.dev/
6−license: BSD-3-Clause
5+license: BSD3
76license-file: ../../../servant/LICENSE
87author: Servant Contributors
98maintainer: haskell-servant-maintainers@googlegroups.com
109build-type: Simple
11−tested-with: GHC==8.6.5, GHC==8.8.3, GHC ==8.10.7
10+cabal-version: >=1.10
11+tested-with: GHC==8.6.5, GHC==8.8.3, GHC ==8.10.1
1212
13−executable cookbook-using-custom-monad
14− main-is: Generic.lhs
13+executable namedRoutes
14+ main-is: NamedRoutes.lhs
1515 build-depends: base == 4.*
16+ , aeson >= 1.2
17+ , text
1618 , servant
1719 , servant-client
1820 , servant-client-core
1921 , servant-server
20− , base-compat
22+ , wai >= 3.2
2123 , warp >= 3.2
22− , transformers >= 0.3
24+
2325 default-language: Haskell2010
2426 ghc-options: -Wall -pgmL markdown-unlit
25− build-tool-depends: markdown-unlit:markdown-unlit >= 0.4
27+ build-tool-depends: markdown-unlit:markdown-unlit