diff --git a/cabal.project b/cabal.project index 95187ef0..092e12a4 100644 --- a/cabal.project +++ b/cabal.project @@ -34,7 +34,6 @@ packages: doc/cookbook/db-postgres-pool doc/cookbook/db-sqlite-simple doc/cookbook/file-upload - doc/cookbook/generic doc/cookbook/hoist-server-with-context doc/cookbook/https doc/cookbook/jwt-and-basic-auth @@ -47,6 +46,7 @@ packages: doc/cookbook/using-custom-monad doc/cookbook/using-free-client -- doc/cookbook/open-id-connect + doc/cookbook/namedRoutes tests: True optimization: False diff --git a/doc/cookbook/generic/Generic.lhs b/doc/cookbook/generic/Generic.lhs deleted file mode 100644 index 45180230..00000000 --- a/doc/cookbook/generic/Generic.lhs +++ /dev/null @@ -1,141 +0,0 @@ -# Using generics - -```haskell -{-# LANGUAGE DataKinds #-} -{-# LANGUAGE DeriveGeneric #-} -{-# LANGUAGE RankNTypes #-} -{-# LANGUAGE TypeOperators #-} -module Main (main, api, getLink, routesLinks, cliGet) where - -import Control.Exception (throwIO) -import Control.Monad.Trans.Reader (ReaderT, runReaderT) -import Data.Proxy (Proxy (..)) -import Network.Wai.Handler.Warp (run) -import System.Environment (getArgs) - -import Servant -import Servant.Client - -import Servant.API.Generic -import Servant.Client.Generic -import Servant.Server.Generic -``` - -The usage is simple, if you only need a collection of routes. -First you define a record with field types prefixed by a parameter `route`: - -```haskell -data Routes route = Routes - { _get :: route :- Capture "id" Int :> Get '[JSON] String - , _put :: route :- ReqBody '[JSON] Int :> Put '[JSON] Bool - } - deriving (Generic) -``` - -Then we'll use this data type to define API, links, server and client. - -## API - -You can get a `Proxy` of the API using `genericApi`: - -```haskell -api :: Proxy (ToServantApi Routes) -api = genericApi (Proxy :: Proxy Routes) -``` - -It's recommended to use `genericApi` function, as then you'll get -better error message, for example if you forget to `derive Generic`. - -## Links - -The clear advantage of record-based generics approach, is that -we can get safe links very conveniently. We don't need to define endpoint types, -as field accessors work as proxies: - -```haskell -getLink :: Int -> Link -getLink = fieldLink _get -``` - -We can also get all links at once, as a record: - -```haskell -routesLinks :: Routes (AsLink Link) -routesLinks = allFieldLinks -``` - -## Client - -Even more power starts to show when we generate a record of client functions. -Here we use `genericClientHoist` function, which lets us simultaneously -hoist the monad, in this case from `ClientM` to `IO`. - -```haskell -cliRoutes :: Routes (AsClientT IO) -cliRoutes = genericClientHoist - (\x -> runClientM x env >>= either throwIO return) - where - env = error "undefined environment" - -cliGet :: Int -> IO String -cliGet = _get cliRoutes -``` - -## Server - -Finally, probably the most handy usage: we can convert record of handlers into -the server implementation: - -```haskell -record :: Routes AsServer -record = Routes - { _get = return . show - , _put = return . odd - } - -app :: Application -app = genericServe record - -main :: IO () -main = do - args <- getArgs - case args of - ("run":_) -> do - putStrLn "Starting cookbook-generic at http://localhost:8000" - run 8000 app - -- see this cookbook below for custom-monad explanation - ("run-custom-monad":_) -> do - putStrLn "Starting cookbook-generic with a custom monad at http://localhost:8000" - run 8000 (appMyMonad AppCustomState) - _ -> putStrLn "To run, pass 'run' argument: cabal new-run cookbook-generic run" -``` - -## Using generics together with a custom monad - -If your app uses a custom monad, here's how you can combine it with -generics. - -```haskell -data AppCustomState = - AppCustomState - -type AppM = ReaderT AppCustomState Handler - -apiMyMonad :: Proxy (ToServantApi Routes) -apiMyMonad = genericApi (Proxy :: Proxy Routes) - -getRouteMyMonad :: Int -> AppM String -getRouteMyMonad = return . show - -putRouteMyMonad :: Int -> AppM Bool -putRouteMyMonad = return . odd - -recordMyMonad :: Routes (AsServerT AppM) -recordMyMonad = Routes {_get = getRouteMyMonad, _put = putRouteMyMonad} - --- natural transformation -nt :: AppCustomState -> AppM a -> Handler a -nt s x = runReaderT x s - -appMyMonad :: AppCustomState -> Application -appMyMonad state = genericServeT (nt state) recordMyMonad diff --git a/doc/cookbook/namedRoutes/NamedRoutes.lhs b/doc/cookbook/namedRoutes/NamedRoutes.lhs new file mode 100644 index 00000000..701d4371 --- /dev/null +++ b/doc/cookbook/namedRoutes/NamedRoutes.lhs @@ -0,0 +1,373 @@ +# NamedRoutes - Using records to define APIs + +*Available in Servant 0.19 or higher* + +Servant offers a very natural way of constructing APIs with nested records, called `NamedRoutes`. + +This cookbook explains how to implement such nested-record-APIs using `NamedRoutes` through the example of a Movie Catalog. + +First, we start by constructing the domain types of our Movie Catalog. +After, we show you how to implement the API type with the NamedRoutes records. +Lastly, we make a Server and a Client out of the API type. + +However, it should be understood that this cookbook does _not_ dwell on the built-in servant combinators as the [ cookbook ]() already covers that angle. + +## Why would I want to use `NamedRoutes` over the alternative `:<|>` operator? + +With `NamedRoutes`, we don’t need to care about the declaration order of the endpoints. +For example, with the `:<|>` operator there’s room for error when the order of the API type + +```haskell,ignore +type API1 = "version" :> Get '[JSON] Version + :<|> "movies" :> Get '[JSON] [Movie] +``` + +does not follow the `Handler` implementation order + +```haskell,ignore +apiHandler :: ServerT API1 Handler +apiHandler = getMovies + :<|> getVersion +``` + +GHC could scold you with a very tedious message such as : + +```console + • Couldn't match type 'Handler NoContent' + with 'Movie -> Handler NoContent' + Expected type: ServerT MovieCatalogAPI Handler + Actual type: Handler Version + :<|> ((Maybe SortBy -> Handler [Movie]) + :<|> ((MovieId -> Handler (Maybe Movie)) + :<|> ((MovieId -> Movie -> Handler NoContent) + :<|> (MovieId -> Handler NoContent)))) + • In the expression: + versionHandler + :<|> + movieListHandler + :<|> + getMovieHandler :<|> updateMovieHandler :<|> deleteMovieHandler + In an equation for 'server': + server + = versionHandler + :<|> + movieListHandler + :<|> + getMovieHandler :<|> updateMovieHandler :<|> deleteMovieHandler + | +226 | server = versionHandler +``` + +On the contrary, with the `NamedRoutes` technique, we refer to the routes by their name: + +```haskell,ignore +data API mode = API + { list :: "list" :> ... + , delete :: "delete" :> ... + } +``` + +and GHC follows the lead : + +```console + • Couldn't match type 'NoContent' with 'Movie' + Expected type: AsServerT Handler :- Delete '[JSON] Movie + Actual type: Handler NoContent + • In the 'delete' field of a record + In the expression: + MovieAPI + {get = getMovieHandler movieId, + update = updateMovieHandler movieId, + delete = deleteMovieHandler movieId} + In an equation for 'movieHandler': + movieHandler movieId + = MovieAPI + {get = getMovieHandler movieId, + update = updateMovieHandler movieId, + delete = deleteMovieHandler movieId} + | +252 | , delete = deleteMovieHandler movieId +``` + +So, NamedRoutes is more readable for a human, and GHC gives you more accurate error messages. + +What are we waiting for? + + +## Boilerplate time! + +First, let’s get rid of the the extensions and imports boilerplate in order to focus on our new technique: + + +```haskell +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DerivingStrategies #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TypeOperators #-} + +import GHC.Generics ( Generic ) +import Data.Aeson ( FromJSON, ToJSON ) +import Data.Proxy ( Proxy(..) ) +import Network.Wai.Handler.Warp ( run ) + +import Servant ( NamedRoutes + , Handler, serve ) +import Servant.API (Capture, Delete, Get, Put, QueryParam, ReqBody + , JSON, NoContent (..) + , FromHttpApiData (..),ToHttpApiData(..) + , (:>) ) +import Servant.API.Generic ( (:-) ) + +import Servant.Client ( AsClientT, ClientM, client + , (//), (/:) ) +import Servant.Client.Generic () + +import Servant.Server ( Application, ServerT ) +import Servant.Server.Generic ( AsServerT ) + +``` + +## Domain context + +Now that we’ve handled the boilerplate, we can dive into our Movie Catalog domain. + +Consider a `Movie` constructed from a `Title` and a `Year` of publication. + +``` haskell +data Movie = Movie + { movieId :: MovieId + , title :: Title + , year :: Year + } + deriving stock Generic + deriving anyclass (FromJSON, ToJSON) + +type MovieId = String +type Title = String +type Year = Int + +``` + + +Let’s forget about the deriving stuff for now and think about the API that we want to make. + +``` + "version" -> Get Version + / +api "list" -> Get [Movie] ?sortBy= Title | Year (sort by the Title or the Year) + \ / + "movies" Get Movie + \ / + Capture MovieId - Put Movie + \ + Delete MovieId +``` + +In this example, we create a very simple endpoint for the Version, +and several complex endpoints that use nested records for the CRUD part of the movie. + +So, the URLs would look like + +- GET …/version +- GET …/movies/list?sortby=Title +- GET …/movies// +- PUT …/movies// +- DELETE …/movies/ + +### API Type + +Now that we have a very clear idea of the API we want to make, we need to transform it into usable Haskell code: + +``` haskell + +data API mode = API + { version :: mode :- "version" :> Get '[JSON] Version + , movies :: mode :- "movies" :> NamedRoutes MoviesAPI + } deriving stock Generic + +type Version = String -- This will do for the sake of example. + +``` +Here, we see the first node of our tree. It contains the two branches “version” and “movies” respectively: + +The “version” branch is very simple and self-explanatory. +The “movies” branch will contain another node, represented by another record (see above). That is why we need the `NameRoutes` helper. + +Note: + +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. + +Let's jump into the "movies" subtree node: + + +``` haskell + +data MoviesAPI mode = MoviesAPI + { list :: mode :- "list" :> QueryParam "SortBy" SortBy :> Get '[JSON] [Movie] + , movie :: mode :- Capture "movieId" MovieId :> NamedRoutes MovieAPI + } deriving stock Generic + +data SortBy = Year | Title + +instance ToHttpApiData SortBy where + toQueryParam Year = "year" + toQueryParam Title = "title" + +instance FromHttpApiData SortBy where + parseQueryParam "year" = Right Year + parseQueryParam "title" = Right Title + parseQueryParam param = Left $ param <> " is not a valid value" + +``` +So, remember, this type represents the `MoviesAPI` node that we’ve connected earlier to the main `API` tree. + +In this subtree, we illustrated both an endpoint with a **query param** and also, a **capture** with a subtree underneath it. + +So, let's go deeper into our API tree. + +``` haskell +data MovieAPI mode = MovieAPI + { get :: mode :- Get '[JSON] (Maybe Movie) + , update :: mode :- ReqBody '[JSON] Movie :> Put '[JSON] NoContent + , delete :: mode :- Delete '[JSON] NoContent + } deriving stock Generic +``` + +As you can see, we end up implementing the deepest routes of our API. + +Small detail: as our main API tree is also a record, we need the `NamedRoutes` helper. +To improve readability, we suggest you create a type alias: + +``` haskell +type MovieCatalogAPI = NamedRoutes API +``` + +That's it, we have our `MovieCatalogAPI` type! + +Let's make a server and a client out of it! + +### The Server + +As you know, we can’t talk about a server, without addressing the handlers. + +First, we take our handlers… + +```haskell +versionHandler :: Handler Version +versionHandler = pure "0.0.1" + +movieListHandler :: Maybe SortBy -> Handler [Movie] +movieListHandler _ = pure moviesDB + +moviesDB :: [Movie] +moviesDB = + [ Movie "1" "Se7en" 1995 + , Movie "2" "Minority Report" 2002 + , Movie "3" "The Godfather" 1972 + ] + +getMovieHandler :: MovieId -> Handler (Maybe Movie) +getMovieHandler requestMovieId = go moviesDB + where + go [] = pure Nothing + go (movie:ms) | movieId movie == requestMovieId = pure $ Just movie + go (m:ms) = go ms + +updateMovieHandler :: MovieId -> Movie -> Handler NoContent +updateMovieHandler requestedMovieId newMovie = + -- update the movie list in the database... + pure NoContent + +deleteMovieHandler :: MovieId -> Handler NoContent +deleteMovieHandler _ = + -- delete the movie from the database... + pure NoContent + +``` + +And assemble them together with the record structure, which is the glue here. + +```haskell +server :: ServerT MovieCatalogAPI Handler +server = + API + { version = versionHandler + , movies = moviesHandler + } + +moviesHandler :: MoviesAPI (AsServerT Handler) +moviesHandler = + MoviesAPI + { list = movieListHandler + , movie = movieHandler + } + +movieHandler :: MovieId -> MovieAPI (AsServerT Handler) +movieHandler movieId = MovieAPI + { get = getMovieHandler movieId + , update = updateMovieHandler movieId + , delete = deleteMovieHandler movieId + } +``` +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 ? + +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. + +Finally, we can run the server and connect the API routes to the handlers as usual: + +``` haskell +api :: Proxy MovieCatalogAPI +api = Proxy + +main :: IO () +main = run 8081 app + +app :: Application +app = serve api server + +``` +Yay! That’s done and we’ve got our server! + +## The Client + +The client, so to speak, is very easy to implement: + +``` haskell +movieCatalogClient :: API (AsClientT ClientM) +movieCatalogClient = client api -- remember: api: Proxy MovieCatalogAPI +``` + +Have you noticed the `mode` `AsClient ClientM`? + +We’ve also introduced some operators that help navigate through the nested records. + +`(//)` is used to jump from one record to another. +`(/:)` is used to provide a parameter, whether it be a query param or a capture. + +Let’s use those nice helpers for our movie catalog: + +```haskell +listMovies :: Maybe SortBy -> ClientM [Movie] +listMovies sortBy = movieCatalogClient // movies // list /: sortBy + +getMovie :: MovieId -> ClientM (Maybe Movie) +getMovie movieId = movieCatalogClient // movies // movie /: movieId // get + +updateMovie :: MovieId -> Movie -> ClientM NoContent +updateMovie movieId newMovie = movieCatalogClient // movies // movie /: movieId // update /: newMovie + +deleteMovie :: MovieId -> ClientM NoContent +deleteMovie movieId = movieCatalogClient // movies // movie /: movieId // delete +``` + +Done! We’ve got our client! + +## Conclusion + +We hope that you found this workbook helpful, and that you now feel more confident using the `NamedRoutes` technique. + +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). + +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). diff --git a/doc/cookbook/generic/generic.cabal b/doc/cookbook/namedRoutes/namedRoutes.cabal similarity index 55% rename from doc/cookbook/generic/generic.cabal rename to doc/cookbook/namedRoutes/namedRoutes.cabal index 725f70c9..d8221ec2 100644 --- a/doc/cookbook/generic/generic.cabal +++ b/doc/cookbook/namedRoutes/namedRoutes.cabal @@ -1,25 +1,27 @@ -cabal-version: 2.2 -name: cookbook-generic +name: namedRoutes version: 0.1 -synopsis: Using custom monad to pass a state between handlers +synopsis: NamedRoutes - Generic servant API implementation cookbook example homepage: http://docs.servant.dev/ -license: BSD-3-Clause +license: BSD3 license-file: ../../../servant/LICENSE author: Servant Contributors maintainer: haskell-servant-maintainers@googlegroups.com build-type: Simple -tested-with: GHC==8.6.5, GHC==8.8.3, GHC ==8.10.7 +cabal-version: >=1.10 +tested-with: GHC==8.6.5, GHC==8.8.3, GHC ==8.10.1 -executable cookbook-using-custom-monad - main-is: Generic.lhs +executable namedRoutes + main-is: NamedRoutes.lhs build-depends: base == 4.* + , aeson >= 1.2 + , text , servant , servant-client , servant-client-core , servant-server - , base-compat + , wai >= 3.2 , warp >= 3.2 - , transformers >= 0.3 + default-language: Haskell2010 ghc-options: -Wall -pgmL markdown-unlit - build-tool-depends: markdown-unlit:markdown-unlit >= 0.4 + build-tool-depends: markdown-unlit:markdown-unlit