From 92a724fe4648dacc781f1217eb077e42786f6fde Mon Sep 17 00:00:00 2001 From: akhesaCaro Date: Mon, 21 Feb 2022 11:39:07 +0100 Subject: [PATCH 01/11] adding namedRoutes cookbook to cabal --- cabal.project | 1 + 1 file changed, 1 insertion(+) diff --git a/cabal.project b/cabal.project index 5c8c2a70f..d8f0b19ae 100644 --- a/cabal.project +++ b/cabal.project @@ -37,6 +37,7 @@ packages: doc/cookbook/db-sqlite-simple doc/cookbook/file-upload doc/cookbook/generic + doc/cookbook/namedRoutes doc/cookbook/hoist-server-with-context doc/cookbook/https doc/cookbook/jwt-and-basic-auth From 461da7cfe7848ec8fb062e3ebe84ab2763f23ad1 Mon Sep 17 00:00:00 2001 From: akhesaCaro Date: Mon, 21 Feb 2022 11:39:37 +0100 Subject: [PATCH 02/11] Introducing NamedRoutes cookbook --- doc/cookbook/index.rst | 1 + doc/cookbook/namedRoutes/NamedRoutes.lhs | 375 +++++++++++++++++++++ doc/cookbook/namedRoutes/namedRoutes.cabal | 27 ++ 3 files changed, 403 insertions(+) create mode 100644 doc/cookbook/namedRoutes/NamedRoutes.lhs create mode 100644 doc/cookbook/namedRoutes/namedRoutes.cabal diff --git a/doc/cookbook/index.rst b/doc/cookbook/index.rst index 79a0179bf..4d416e5f4 100644 --- a/doc/cookbook/index.rst +++ b/doc/cookbook/index.rst @@ -20,6 +20,7 @@ you name it! structuring-apis/StructuringApis.lhs generic/Generic.lhs openapi3/OpenAPI.lhs + namedRoutes/NamedRoutes.lhs https/Https.lhs db-mysql-basics/MysqlBasics.lhs db-sqlite-simple/DBConnection.lhs diff --git a/doc/cookbook/namedRoutes/NamedRoutes.lhs b/doc/cookbook/namedRoutes/NamedRoutes.lhs new file mode 100644 index 000000000..4302ad5b5 --- /dev/null +++ b/doc/cookbook/namedRoutes/NamedRoutes.lhs @@ -0,0 +1,375 @@ +# 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 [Structuring APIs +](<../structuring-apis/StructuringApis.html>) 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](../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/namedRoutes/namedRoutes.cabal b/doc/cookbook/namedRoutes/namedRoutes.cabal new file mode 100644 index 000000000..d8221ec28 --- /dev/null +++ b/doc/cookbook/namedRoutes/namedRoutes.cabal @@ -0,0 +1,27 @@ +name: namedRoutes +version: 0.1 +synopsis: NamedRoutes - Generic servant API implementation cookbook example +homepage: http://docs.servant.dev/ +license: BSD3 +license-file: ../../../servant/LICENSE +author: Servant Contributors +maintainer: haskell-servant-maintainers@googlegroups.com +build-type: Simple +cabal-version: >=1.10 +tested-with: GHC==8.6.5, GHC==8.8.3, GHC ==8.10.1 + +executable namedRoutes + main-is: NamedRoutes.lhs + build-depends: base == 4.* + , aeson >= 1.2 + , text + , servant + , servant-client + , servant-client-core + , servant-server + , wai >= 3.2 + , warp >= 3.2 + + default-language: Haskell2010 + ghc-options: -Wall -pgmL markdown-unlit + build-tool-depends: markdown-unlit:markdown-unlit From 87fb7c8fea53307eade8207dfe8254166ddac7f5 Mon Sep 17 00:00:00 2001 From: akhesaCaro Date: Mon, 21 Feb 2022 11:48:38 +0100 Subject: [PATCH 03/11] renaming route into mode (for clarity and homogenization) --- doc/cookbook/generic/Generic.lhs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/cookbook/generic/Generic.lhs b/doc/cookbook/generic/Generic.lhs index 45180230a..e057cad62 100644 --- a/doc/cookbook/generic/Generic.lhs +++ b/doc/cookbook/generic/Generic.lhs @@ -22,12 +22,12 @@ 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`: +First you define a record with field types prefixed by a parameter `mode`: ```haskell -data Routes route = Routes - { _get :: route :- Capture "id" Int :> Get '[JSON] String - , _put :: route :- ReqBody '[JSON] Int :> Put '[JSON] Bool +data Routes mode = Routes + { _get :: mode :- Capture "id" Int :> Get '[JSON] String + , _put :: mode :- ReqBody '[JSON] Int :> Put '[JSON] Bool } deriving (Generic) ``` From 464aebc7e6ac7ebb4fbb140a2b237e830ebabdf4 Mon Sep 17 00:00:00 2001 From: akhesaCaro Date: Tue, 22 Feb 2022 09:31:08 +0100 Subject: [PATCH 04/11] put some consistency between NamedRoutes cookbook and Generic cookbook --- doc/cookbook/generic/Generic.lhs | 18 +++++++++++++----- doc/cookbook/namedRoutes/NamedRoutes.lhs | 10 ++++++++-- .../structuring-apis/StructuringApis.lhs | 16 ++++++++++++++++ 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/doc/cookbook/generic/Generic.lhs b/doc/cookbook/generic/Generic.lhs index e057cad62..24463c207 100644 --- a/doc/cookbook/generic/Generic.lhs +++ b/doc/cookbook/generic/Generic.lhs @@ -1,4 +1,14 @@ -# Using generics +# Record-based APIs: the simple case + +This cookbook explains how to implement an API with a simple record-based +structure. We only deal with non-nested APIs in which every endpoint is on the same +level. + +If a you need nesting because you have different branches in your API tree, you +might want to jump directly to the [Record-based APIs: the nested records +case](../namedRoutes/NamedRoutes.html) cookbook that broaches the subject. + +Shall we begin? ```haskell {-# LANGUAGE DataKinds #-} @@ -110,7 +120,7 @@ main = do _ -> putStrLn "To run, pass 'run' argument: cabal new-run cookbook-generic run" ``` -## Using generics together with a custom monad +## Using record-based APIs together with a custom monad If your app uses a custom monad, here's how you can combine it with generics. @@ -121,9 +131,6 @@ data AppCustomState = type AppM = ReaderT AppCustomState Handler -apiMyMonad :: Proxy (ToServantApi Routes) -apiMyMonad = genericApi (Proxy :: Proxy Routes) - getRouteMyMonad :: Int -> AppM String getRouteMyMonad = return . show @@ -139,3 +146,4 @@ 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 index 4302ad5b5..17a45a4c0 100644 --- a/doc/cookbook/namedRoutes/NamedRoutes.lhs +++ b/doc/cookbook/namedRoutes/NamedRoutes.lhs @@ -1,10 +1,16 @@ -# NamedRoutes - Using records to define APIs +# Record-based APIs: the nested records case *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. +This cookbook explains how to implement such nested-record-based-APIs using +`NamedRoutes` through the example of a Movie Catalog. +If you don't need the nested aspect of the record-based API, you might want to look at [Record-based +APIs: the simple +case](../generic/Generic.html) cookbook +which covers a simpler implementation in which every endpoint is on the same +level. 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. diff --git a/doc/cookbook/structuring-apis/StructuringApis.lhs b/doc/cookbook/structuring-apis/StructuringApis.lhs index a8a822587..a0e603f0a 100644 --- a/doc/cookbook/structuring-apis/StructuringApis.lhs +++ b/doc/cookbook/structuring-apis/StructuringApis.lhs @@ -35,6 +35,11 @@ The first part, `FactoringAPI`, shows how we can endpoints, just like we turn `a * b + a * c` into `a * (b + c)` in algebra. +(It should be noted that the `(:<|>)` operator is not the only way of combining +endpoints with Servant. Other techniques are shown in subsequent cookbooks. See +[record-based alternative for implementing APIs](StructuringApis.html#record-based-alternative-for-implementing-apis)) + + ``` haskell -- Two endpoints: -- - GET /x/[?y=] @@ -203,3 +208,14 @@ main = run 8080 . serve api $ This program is available as a cabal project [here](https://github.com/haskell-servant/servant/tree/master/doc/cookbook/structuring-apis). + +## Record-based alternative for implementing APIs + +It should be noted that the `(:<|>)` is not the only way of combining endpoints. +Servant offers a convenient way to design APIs with records avoiding the ordering constraint of the operator. + +A simple case is approached in the [Record-based APIs: the simple +case](../generic/Generic.html) +cookbook, which deals with flat APIs where every endpoint is on the same level. +Also, a more complex example with nested record is discussed in [Record-based APIs: the nested +records case](../namedRoutes/NamedRoutes.html) in which we implement an API tree with many branches. From 8a52da9ebcc19d9bc09844fe5e021984a44750ac Mon Sep 17 00:00:00 2001 From: akhesaCaro Date: Wed, 23 Feb 2022 07:42:17 +0100 Subject: [PATCH 05/11] alp comments --- doc/cookbook/namedRoutes/NamedRoutes.lhs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/doc/cookbook/namedRoutes/NamedRoutes.lhs b/doc/cookbook/namedRoutes/NamedRoutes.lhs index 17a45a4c0..96421378c 100644 --- a/doc/cookbook/namedRoutes/NamedRoutes.lhs +++ b/doc/cookbook/namedRoutes/NamedRoutes.lhs @@ -133,7 +133,7 @@ import Servant.Client ( AsClientT, ClientM, client import Servant.Client.Generic () import Servant.Server ( Application, ServerT ) -import Servant.Server.Generic ( AsServerT ) +import Servant.Server.Generic ( AsServer ) ``` @@ -298,21 +298,21 @@ deleteMovieHandler _ = And assemble them together with the record structure, which is the glue here. ```haskell -server :: ServerT MovieCatalogAPI Handler +server :: API AsServer server = API { version = versionHandler , movies = moviesHandler } -moviesHandler :: MoviesAPI (AsServerT Handler) +moviesHandler :: MoviesAPI AsServer moviesHandler = MoviesAPI { list = movieListHandler , movie = movieHandler } -movieHandler :: MovieId -> MovieAPI (AsServerT Handler) +movieHandler :: MovieId -> MovieAPI AsServer movieHandler movieId = MovieAPI { get = getMovieHandler movieId , update = updateMovieHandler movieId @@ -323,6 +323,8 @@ As you might have noticed, we build our handlers out of the same record types we 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. +You can alternatively use the AsServer (= AsServerT Handler) type alias. If you need to define handlers in some specific App monad from your codebase, the mode would simply be changed to AsServerT App. + Finally, we can run the server and connect the API routes to the handlers as usual: ``` haskell @@ -344,7 +346,7 @@ The client, so to speak, is very easy to implement: ``` haskell movieCatalogClient :: API (AsClientT ClientM) -movieCatalogClient = client api -- remember: api: Proxy MovieCatalogAPI +movieCatalogClient = client api -- remember: api :: Proxy MovieCatalogAPI ``` Have you noticed the `mode` `AsClient ClientM`? @@ -374,7 +376,7 @@ 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. +We hope that you found this cookbook helpful, and that you now feel more confident using the record-based APIs, nested or not. If you are interested in further understanding the built-in Servant combinators, see [Structuring APIs](../structuring-apis/StructuringApis.html). From 2adf97d351234a4d250456cc4718af28635269bd Mon Sep 17 00:00:00 2001 From: akhesaCaro Date: Thu, 24 Feb 2022 12:08:57 +0100 Subject: [PATCH 06/11] why paragraph, gone --- doc/cookbook/namedRoutes/NamedRoutes.lhs | 81 ------------------------ 1 file changed, 81 deletions(-) diff --git a/doc/cookbook/namedRoutes/NamedRoutes.lhs b/doc/cookbook/namedRoutes/NamedRoutes.lhs index 96421378c..5c5a08750 100644 --- a/doc/cookbook/namedRoutes/NamedRoutes.lhs +++ b/doc/cookbook/namedRoutes/NamedRoutes.lhs @@ -20,87 +20,6 @@ However, it should be understood that this cookbook does _not_ dwell on the built-in servant combinators as the [Structuring APIs ](<../structuring-apis/StructuringApis.html>) 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! From cefb0bbd1ef21b6d66a1957a186a17254de41165 Mon Sep 17 00:00:00 2001 From: akhesaCaro Date: Thu, 24 Feb 2022 12:35:33 +0100 Subject: [PATCH 07/11] Adding paragraph to generic cookbook --- doc/cookbook/generic/Generic.lhs | 73 ++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/doc/cookbook/generic/Generic.lhs b/doc/cookbook/generic/Generic.lhs index 24463c207..4ea1c7470 100644 --- a/doc/cookbook/generic/Generic.lhs +++ b/doc/cookbook/generic/Generic.lhs @@ -10,6 +10,79 @@ case](../namedRoutes/NamedRoutes.html) cookbook that broaches the subject. Shall we begin? +## Why would I want to use `Records` over the alternative `:<|>` operator? + +With a record-based API, 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 record-based 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, records are more readable for a human, and GHC gives you more accurate error messages. + +What are we waiting for? + ```haskell {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveGeneric #-} From 3ce7244740a5a6dea859017e63497d020c2f5655 Mon Sep 17 00:00:00 2001 From: akhesaCaro Date: Thu, 24 Feb 2022 15:10:05 +0100 Subject: [PATCH 08/11] removing mode explaination --- doc/cookbook/namedRoutes/NamedRoutes.lhs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/doc/cookbook/namedRoutes/NamedRoutes.lhs b/doc/cookbook/namedRoutes/NamedRoutes.lhs index 5c5a08750..e19c9df8e 100644 --- a/doc/cookbook/namedRoutes/NamedRoutes.lhs +++ b/doc/cookbook/namedRoutes/NamedRoutes.lhs @@ -240,10 +240,6 @@ movieHandler movieId = MovieAPI ``` 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. - -You can alternatively use the AsServer (= AsServerT Handler) type alias. If you need to define handlers in some specific App monad from your codebase, the mode would simply be changed to AsServerT App. - Finally, we can run the server and connect the API routes to the handlers as usual: ``` haskell @@ -268,8 +264,6 @@ 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. From df31b862c7883ecc73cfe48f2b3528b7a73d08b4 Mon Sep 17 00:00:00 2001 From: mangoiv Date: Thu, 7 Mar 2024 21:54:21 +0100 Subject: [PATCH 09/11] [chore] some changes as requested by PR reviewers --- doc/cookbook/generic/Generic.lhs | 9 ++-- doc/cookbook/namedRoutes/NamedRoutes.lhs | 56 ++++++++++++------------ 2 files changed, 33 insertions(+), 32 deletions(-) diff --git a/doc/cookbook/generic/Generic.lhs b/doc/cookbook/generic/Generic.lhs index 4ea1c7470..0c80930d0 100644 --- a/doc/cookbook/generic/Generic.lhs +++ b/doc/cookbook/generic/Generic.lhs @@ -4,7 +4,7 @@ This cookbook explains how to implement an API with a simple record-based structure. We only deal with non-nested APIs in which every endpoint is on the same level. -If a you need nesting because you have different branches in your API tree, you +If you need nesting because you have different branches in your API tree, you might want to jump directly to the [Record-based APIs: the nested records case](../namedRoutes/NamedRoutes.html) cookbook that broaches the subject. @@ -25,7 +25,7 @@ apiHandler :: ServerT API1 Handler apiHandler = getMovies :<|> getVersion ``` -GHC could scold you with a very tedious message such as : +GHC can and will scold you with a very tedious message such as : ```console • Couldn't match type 'Handler NoContent' with 'Movie -> Handler NoContent' @@ -50,6 +50,7 @@ GHC could scold you with a very tedious message such as : getMovieHandler :<|> updateMovieHandler :<|> deleteMovieHandler | 226 | server = versionHandler + | ``` On the contrary, with the record-based technique, we refer to the routes by their name: ```haskell,ignore @@ -58,7 +59,7 @@ data API mode = API , delete :: "delete" :> ... } ``` -and GHC follows the lead : +and GHC follows the lead: ```console • Couldn't match type 'NoContent' with 'Movie' Expected type: AsServerT Handler :- Delete '[JSON] Movie @@ -77,6 +78,7 @@ and GHC follows the lead : delete = deleteMovieHandler movieId} | 252 | , delete = deleteMovieHandler movieId + | ``` So, records are more readable for a human, and GHC gives you more accurate error messages. @@ -92,7 +94,6 @@ 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) diff --git a/doc/cookbook/namedRoutes/NamedRoutes.lhs b/doc/cookbook/namedRoutes/NamedRoutes.lhs index e19c9df8e..a97eef832 100644 --- a/doc/cookbook/namedRoutes/NamedRoutes.lhs +++ b/doc/cookbook/namedRoutes/NamedRoutes.lhs @@ -1,4 +1,4 @@ -# Record-based APIs: the nested records case +# Record-based APIs: the nested case *Available in Servant 0.19 or higher* @@ -7,8 +7,7 @@ Servant offers a very natural way of constructing APIs with nested records, call This cookbook explains how to implement such nested-record-based-APIs using `NamedRoutes` through the example of a Movie Catalog. If you don't need the nested aspect of the record-based API, you might want to look at [Record-based -APIs: the simple -case](../generic/Generic.html) cookbook +APIs: the simple case](../generic/Generic.html) cookbook which covers a simpler implementation in which every endpoint is on the same level. @@ -17,13 +16,13 @@ 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 [Structuring APIs -](<../structuring-apis/StructuringApis.html>) cookbook already covers that angle. +built-in servant combinators as the [Structuring APIs](<../structuring-apis/StructuringApis.html>) +cookbook already covers that angle. ## Boilerplate time! -First, let’s get rid of the the extensions and imports boilerplate in order to focus on our new technique: +First, let’s get rid of the extensions and imports boilerplate in order to focus on our new technique: ```haskell @@ -51,7 +50,7 @@ import Servant.Client ( AsClientT, ClientM, client , (//), (/:) ) import Servant.Client.Generic () -import Servant.Server ( Application, ServerT ) +import Servant.Server ( Application ) import Servant.Server.Generic ( AsServer ) ``` @@ -62,7 +61,7 @@ Now that we’ve handled the boilerplate, we can dive into our Movie Catalog dom Consider a `Movie` constructed from a `Title` and a `Year` of publication. -``` haskell +```haskell data Movie = Movie { movieId :: MovieId , title :: Title @@ -107,7 +106,7 @@ So, the URLs would look like 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 +```haskell data API mode = API { version :: mode :- "version" :> Get '[JSON] Version @@ -129,7 +128,7 @@ The `mode` type parameter indicates into which implementation the record’s `Ge Let's jump into the "movies" subtree node: -``` haskell +```haskell data MoviesAPI mode = MoviesAPI { list :: mode :- "list" :> QueryParam "SortBy" SortBy :> Get '[JSON] [Movie] @@ -154,7 +153,7 @@ In this subtree, we illustrated both an endpoint with a **query param** and also So, let's go deeper into our API tree. -``` haskell +```haskell data MovieAPI mode = MovieAPI { get :: mode :- Get '[JSON] (Maybe Movie) , update :: mode :- ReqBody '[JSON] Movie :> Put '[JSON] NoContent @@ -167,7 +166,7 @@ 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 +```haskell type MovieCatalogAPI = NamedRoutes API ``` @@ -199,8 +198,8 @@ 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 + go (m : _ms) | movieId m == requestMovieId = pure $ Just m + go (_m : ms) = go ms updateMovieHandler :: MovieId -> Movie -> Handler NoContent updateMovieHandler requestedMovieId newMovie = @@ -211,7 +210,6 @@ 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. @@ -232,17 +230,17 @@ moviesHandler = } movieHandler :: MovieId -> MovieAPI AsServer -movieHandler movieId = MovieAPI - { get = getMovieHandler movieId - , update = updateMovieHandler movieId - , delete = deleteMovieHandler movieId +movieHandler mId = MovieAPI + { get = getMovieHandler mId + , update = updateMovieHandler mId + , delete = deleteMovieHandler mId } ``` 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 ? Finally, we can run the server and connect the API routes to the handlers as usual: -``` haskell +```haskell api :: Proxy MovieCatalogAPI api = Proxy @@ -251,7 +249,6 @@ main = run 8081 app app :: Application app = serve api server - ``` Yay! That’s done and we’ve got our server! @@ -259,7 +256,7 @@ Yay! That’s done and we’ve got our server! The client, so to speak, is very easy to implement: -``` haskell +```haskell movieCatalogClient :: API (AsClientT ClientM) movieCatalogClient = client api -- remember: api :: Proxy MovieCatalogAPI ``` @@ -276,21 +273,24 @@ listMovies :: Maybe SortBy -> ClientM [Movie] listMovies sortBy = movieCatalogClient // movies // list /: sortBy getMovie :: MovieId -> ClientM (Maybe Movie) -getMovie movieId = movieCatalogClient // movies // movie /: movieId // get +getMovie mId = movieCatalogClient // movies // movie /: mId // get updateMovie :: MovieId -> Movie -> ClientM NoContent -updateMovie movieId newMovie = movieCatalogClient // movies // movie /: movieId // update /: newMovie +updateMovie mId newMovie = movieCatalogClient // movies // movie /: mId // update /: newMovie deleteMovie :: MovieId -> ClientM NoContent -deleteMovie movieId = movieCatalogClient // movies // movie /: movieId // delete +deleteMovie mId = movieCatalogClient // movies // movie /: mId // delete ``` Done! We’ve got our client! ## Conclusion -We hope that you found this cookbook helpful, and that you now feel more confident using the record-based APIs, nested or not. +We hope that you found this cookbook helpful, and that you now feel more confident +using the record-based APIs, nested or not. -If you are interested in further understanding the built-in Servant combinators, see [Structuring APIs](../structuring-apis/StructuringApis.html). +If you are interested in further understanding the built-in Servant combinators, see +[Structuring APIs](../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). +Since `NamedRoutes` is based on the Generic mechanism, you might want to have a look at +[Sandy Maguire’s _Thinking with Types_ book](https://thinkingwithtypes.com/). From 23f6c3997ace625182740b1bb280cf92790d6c6a Mon Sep 17 00:00:00 2001 From: mangoiv Date: Fri, 26 Apr 2024 12:13:13 +0200 Subject: [PATCH 10/11] [chore] rename some things --- .gitignore | 2 ++ cabal.project | 2 +- doc/cookbook/generic/Generic.lhs | 2 +- doc/cookbook/index.rst | 2 +- .../NamedRoutes.lhs | 0 .../named-routes/cookbook-named-routes.cabal | 30 +++++++++++++++++++ doc/cookbook/namedRoutes/namedRoutes.cabal | 27 ----------------- .../structuring-apis/StructuringApis.lhs | 2 +- 8 files changed, 36 insertions(+), 31 deletions(-) rename doc/cookbook/{namedRoutes => named-routes}/NamedRoutes.lhs (100%) create mode 100644 doc/cookbook/named-routes/cookbook-named-routes.cabal delete mode 100644 doc/cookbook/namedRoutes/namedRoutes.cabal diff --git a/.gitignore b/.gitignore index 3b2084ae6..0a0ae1d1d 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,8 @@ shell.nix # nix result* +.direnv +.pre-commit-config.yaml # local versions of things servant-multipart diff --git a/cabal.project b/cabal.project index d8f0b19ae..f4a2a105c 100644 --- a/cabal.project +++ b/cabal.project @@ -37,7 +37,7 @@ packages: doc/cookbook/db-sqlite-simple doc/cookbook/file-upload doc/cookbook/generic - doc/cookbook/namedRoutes + doc/cookbook/named-routes doc/cookbook/hoist-server-with-context doc/cookbook/https doc/cookbook/jwt-and-basic-auth diff --git a/doc/cookbook/generic/Generic.lhs b/doc/cookbook/generic/Generic.lhs index 0c80930d0..4f9f3a38b 100644 --- a/doc/cookbook/generic/Generic.lhs +++ b/doc/cookbook/generic/Generic.lhs @@ -6,7 +6,7 @@ level. If you need nesting because you have different branches in your API tree, you might want to jump directly to the [Record-based APIs: the nested records -case](../namedRoutes/NamedRoutes.html) cookbook that broaches the subject. +case](../named-routes/NamedRoutes.html) cookbook that broaches the subject. Shall we begin? diff --git a/doc/cookbook/index.rst b/doc/cookbook/index.rst index 4d416e5f4..6a8b12041 100644 --- a/doc/cookbook/index.rst +++ b/doc/cookbook/index.rst @@ -20,7 +20,7 @@ you name it! structuring-apis/StructuringApis.lhs generic/Generic.lhs openapi3/OpenAPI.lhs - namedRoutes/NamedRoutes.lhs + named-routes/NamedRoutes.lhs https/Https.lhs db-mysql-basics/MysqlBasics.lhs db-sqlite-simple/DBConnection.lhs diff --git a/doc/cookbook/namedRoutes/NamedRoutes.lhs b/doc/cookbook/named-routes/NamedRoutes.lhs similarity index 100% rename from doc/cookbook/namedRoutes/NamedRoutes.lhs rename to doc/cookbook/named-routes/NamedRoutes.lhs diff --git a/doc/cookbook/named-routes/cookbook-named-routes.cabal b/doc/cookbook/named-routes/cookbook-named-routes.cabal new file mode 100644 index 000000000..13d95d6e9 --- /dev/null +++ b/doc/cookbook/named-routes/cookbook-named-routes.cabal @@ -0,0 +1,30 @@ +name: cookbook-named-routes +version: 0.1 +synopsis: + NamedRoutes - Generic servant API implementation cookbook example + +homepage: http://docs.servant.dev/ +license: BSD3 +license-file: ../../../servant/LICENSE +author: Servant Contributors +maintainer: haskell-servant-maintainers@googlegroups.com +build-type: Simple +cabal-version: >=1.10 +tested-with: GHC ==8.6.5 || ==8.8.3 || ==8.10.1 + +executable cookbook-named-routes + main-is: NamedRoutes.lhs + build-depends: + aeson >=1.2 + , base >=4 && <5 + , servant + , servant-client + , servant-client-core + , servant-server + , text + , wai >=3.2 + , warp >=3.2 + + default-language: Haskell2010 + ghc-options: -Wall -pgmL markdown-unlit + build-tool-depends: markdown-unlit:markdown-unlit diff --git a/doc/cookbook/namedRoutes/namedRoutes.cabal b/doc/cookbook/namedRoutes/namedRoutes.cabal deleted file mode 100644 index d8221ec28..000000000 --- a/doc/cookbook/namedRoutes/namedRoutes.cabal +++ /dev/null @@ -1,27 +0,0 @@ -name: namedRoutes -version: 0.1 -synopsis: NamedRoutes - Generic servant API implementation cookbook example -homepage: http://docs.servant.dev/ -license: BSD3 -license-file: ../../../servant/LICENSE -author: Servant Contributors -maintainer: haskell-servant-maintainers@googlegroups.com -build-type: Simple -cabal-version: >=1.10 -tested-with: GHC==8.6.5, GHC==8.8.3, GHC ==8.10.1 - -executable namedRoutes - main-is: NamedRoutes.lhs - build-depends: base == 4.* - , aeson >= 1.2 - , text - , servant - , servant-client - , servant-client-core - , servant-server - , wai >= 3.2 - , warp >= 3.2 - - default-language: Haskell2010 - ghc-options: -Wall -pgmL markdown-unlit - build-tool-depends: markdown-unlit:markdown-unlit diff --git a/doc/cookbook/structuring-apis/StructuringApis.lhs b/doc/cookbook/structuring-apis/StructuringApis.lhs index a0e603f0a..406147a5c 100644 --- a/doc/cookbook/structuring-apis/StructuringApis.lhs +++ b/doc/cookbook/structuring-apis/StructuringApis.lhs @@ -218,4 +218,4 @@ A simple case is approached in the [Record-based APIs: the simple case](../generic/Generic.html) cookbook, which deals with flat APIs where every endpoint is on the same level. Also, a more complex example with nested record is discussed in [Record-based APIs: the nested -records case](../namedRoutes/NamedRoutes.html) in which we implement an API tree with many branches. +records case](../named-routes/NamedRoutes.html) in which we implement an API tree with many branches. From 812dcf452a6f58bd0bbd7fad4c96fa7788d25639 Mon Sep 17 00:00:00 2001 From: mangoiv Date: Mon, 20 May 2024 17:55:27 +0200 Subject: [PATCH 11/11] [feat] move generic cookbook into named-routes --- cabal.project | 1 - doc/cookbook/generic/Generic.lhs | 223 -------------- doc/cookbook/generic/generic.cabal | 25 -- doc/cookbook/named-routes/NamedRoutes.lhs | 277 ++++++++++++------ .../named-routes/cookbook-named-routes.cabal | 3 +- 5 files changed, 189 insertions(+), 340 deletions(-) delete mode 100644 doc/cookbook/generic/Generic.lhs delete mode 100644 doc/cookbook/generic/generic.cabal diff --git a/cabal.project b/cabal.project index f4a2a105c..206f6b5d3 100644 --- a/cabal.project +++ b/cabal.project @@ -36,7 +36,6 @@ packages: doc/cookbook/db-postgres-pool doc/cookbook/db-sqlite-simple doc/cookbook/file-upload - doc/cookbook/generic doc/cookbook/named-routes doc/cookbook/hoist-server-with-context doc/cookbook/https diff --git a/doc/cookbook/generic/Generic.lhs b/doc/cookbook/generic/Generic.lhs deleted file mode 100644 index 4f9f3a38b..000000000 --- a/doc/cookbook/generic/Generic.lhs +++ /dev/null @@ -1,223 +0,0 @@ -# Record-based APIs: the simple case - -This cookbook explains how to implement an API with a simple record-based -structure. We only deal with non-nested APIs in which every endpoint is on the same -level. - -If you need nesting because you have different branches in your API tree, you -might want to jump directly to the [Record-based APIs: the nested records -case](../named-routes/NamedRoutes.html) cookbook that broaches the subject. - -Shall we begin? - -## Why would I want to use `Records` over the alternative `:<|>` operator? - -With a record-based API, 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 can and will 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 record-based 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, records are more readable for a human, and GHC gives you more accurate error messages. - -What are we waiting for? - -```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 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 `mode`: - -```haskell -data Routes mode = Routes - { _get :: mode :- Capture "id" Int :> Get '[JSON] String - , _put :: mode :- 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 record-based APIs 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 - -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/generic/generic.cabal b/doc/cookbook/generic/generic.cabal deleted file mode 100644 index 725f70c97..000000000 --- a/doc/cookbook/generic/generic.cabal +++ /dev/null @@ -1,25 +0,0 @@ -cabal-version: 2.2 -name: cookbook-generic -version: 0.1 -synopsis: Using custom monad to pass a state between handlers -homepage: http://docs.servant.dev/ -license: BSD-3-Clause -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 - -executable cookbook-using-custom-monad - main-is: Generic.lhs - build-depends: base == 4.* - , servant - , servant-client - , servant-client-core - , servant-server - , base-compat - , warp >= 3.2 - , transformers >= 0.3 - default-language: Haskell2010 - ghc-options: -Wall -pgmL markdown-unlit - build-tool-depends: markdown-unlit:markdown-unlit >= 0.4 diff --git a/doc/cookbook/named-routes/NamedRoutes.lhs b/doc/cookbook/named-routes/NamedRoutes.lhs index a97eef832..0226fefa5 100644 --- a/doc/cookbook/named-routes/NamedRoutes.lhs +++ b/doc/cookbook/named-routes/NamedRoutes.lhs @@ -1,15 +1,10 @@ -# Record-based APIs: the nested case +# Record-based APIs *Available in Servant 0.19 or higher* -Servant offers a very natural way of constructing APIs with nested records, called `NamedRoutes`. +Servant offers a very natural way of constructing APIs with records and nested records. -This cookbook explains how to implement such nested-record-based-APIs using -`NamedRoutes` through the example of a Movie Catalog. -If you don't need the nested aspect of the record-based API, you might want to look at [Record-based -APIs: the simple case](../generic/Generic.html) cookbook -which covers a simpler implementation in which every endpoint is on the same -level. +This cookbook explains how to implement APIs using records. 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. @@ -19,45 +14,113 @@ However, it should be understood that this cookbook does _not_ dwell on the built-in servant combinators as the [Structuring APIs](<../structuring-apis/StructuringApis.html>) cookbook already covers that angle. +## Motivation: Why would I want to use records over the `:<|>` operator? -## Boilerplate time! +With a record-based API, 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 -First, let’s get rid of the extensions and imports boilerplate in order to focus on our new technique: +```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 can and will 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 record-based 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, records are more readable for a human, and GHC gives you more accurate error messages, so +why ever look back? Let's get started! -```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 ) -import Servant.Server.Generic ( AsServer ) +
+ The boilerplate required for both the nested and flat case + +```haskell +{-# LANGUAGE GHC2021 #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DerivingVia #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE OverloadedRecordDot #-} + +import Control.Exception (throwIO) +import Control.Monad.Trans.Reader (ReaderT, runReaderT) +import Network.Wai.Handler.Warp (run) +import System.Environment (getArgs) +import Data.Aeson (FromJSON (..), ToJSON (..)) +import GHC.Generics (Generic, Generically (..)) +import Data.List (sortOn) +import Data.Text (Text) +import Data.Foldable (find) + +import Servant +import Servant.Client +import Servant.Client.Generic +import Servant.Server +import Servant.Server.Generic ``` -## Domain context +
-Now that we’ve handled the boilerplate, we can dive into our Movie Catalog domain. +## Domain context Consider a `Movie` constructed from a `Title` and a `Year` of publication. @@ -68,58 +131,60 @@ data Movie = Movie , year :: Year } deriving stock Generic - deriving anyclass (FromJSON, ToJSON) + deriving (FromJSON, ToJSON) via Generically Movie -type MovieId = String -type Title = String +type MovieId = Text +type Title = Text type Year = Int - ``` - -Let’s forget about the deriving stuff for now and think about the API that we want to make. +To proceed, let us think about the API we want to build: ``` - "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 +"version" ───► Get Version + +"movies" ──┬─► "list" ────────────► Get [Movie] ?sortBy=(Title|Year) + │ + └─► Capture MovieId ─┬─► Get Movie + │ + ├─► Put Movie + │ + └─► Delete Movie ``` 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 +So, flattening this out, the URLs a client may call are: -- GET …/version -- GET …/movies/list?sortby=Title -- GET …/movies// -- PUT …/movies// -- DELETE …/movies/ +- `GET /version` +- `GET /movies/list?sortby=` +- `GET /movies/:MovieId` +- `PUT /movies/:MovieId` +- `DELETE /movies/:MovieId` ### 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: +Now that we have a clear idea of the API we want to make, we need to transform it into usable Haskell code, for that, let us first +create a `Generic` record type that will hold our Api: ```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. - +type Version = Text -- 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. +The “version” branch reads as follows: "instantiated ad a `mode`, the record field with the name `version` holds a route that needs +to match the prefix `"version"` and returns a `Version`, serialized as a `JSON` upon issing a `Get` request. + +The “movies” branch will contain another node, represented by another record (see above). That is why we need the `NameRoutes` helper, +`:>` would normally expect another "standard" `API` tree (the ones with the `:<|>` operator) and `NamedRoutes` gets us one of these +when passing a record. Note: @@ -129,14 +194,15 @@ 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 + deriving stock (Eq, Ord, Show) +-- ... and the boilerplate to allow for usage as a query param: instance ToHttpApiData SortBy where toQueryParam Year = "year" toQueryParam Title = "title" @@ -144,14 +210,14 @@ instance ToHttpApiData SortBy where instance FromHttpApiData SortBy where parseQueryParam "year" = Right Year parseQueryParam "title" = Right Title - parseQueryParam param = Left $ param <> " is not a valid value" - + parseQueryParam param = Left $ param <> " is not a valid SortBy" ``` + 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. +The first branch is done, now, let's also implement the second one as follows: ```haskell data MovieAPI mode = MovieAPI @@ -161,9 +227,7 @@ data MovieAPI mode = MovieAPI } 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. +Small detail: as our main API tree is also a record, we need the `NamedRoutes` helper (to obtain the API proper) To improve readability, we suggest you create a type alias: ```haskell @@ -178,14 +242,18 @@ Let's make a server and a client out of it! As you know, we can’t talk about a server, without addressing the handlers. -First, we take our handlers… +First, we build our handlers (mind that we're cheating a bit, obviously, the `moviesDB` in reality +would have to be part of some state, such that we can modify it, additionally, making it a list is not +very wise in terms of performance) ```haskell versionHandler :: Handler Version versionHandler = pure "0.0.1" movieListHandler :: Maybe SortBy -> Handler [Movie] -movieListHandler _ = pure moviesDB +movieListHandler _ = + -- depending on sortBy, do a different sorting + pure moviesDB moviesDB :: [Movie] moviesDB = @@ -195,11 +263,7 @@ moviesDB = ] getMovieHandler :: MovieId -> Handler (Maybe Movie) -getMovieHandler requestMovieId = go moviesDB - where - go [] = pure Nothing - go (m : _ms) | movieId m == requestMovieId = pure $ Just m - go (_m : ms) = go ms +getMovieHandler requestMovieId = find (\movie -> movie.movieId == requestMovieId) moviesDB updateMovieHandler :: MovieId -> Movie -> Handler NoContent updateMovieHandler requestedMovieId newMovie = @@ -236,29 +300,28 @@ movieHandler mId = MovieAPI , delete = deleteMovieHandler mId } ``` -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 ? -Finally, we can run the server and connect the API routes to the handlers as usual: +Finally, we can run the server and connect the API routes to the handlers, using the convenient `genericServe` function ```haskell -api :: Proxy MovieCatalogAPI -api = Proxy - main :: IO () main = run 8081 app app :: Application -app = serve api server +app = genericServe server ``` + Yay! That’s done and we’ve got our server! -## The Client +## Clients and Links -The client, so to speak, is very easy to implement: +The clear advantage of record-based generics approach, is that +we can get safe links and clients very conveniently. We don't need to define endpoint types, +as field accessors work as proxies - let's demonstrate that with a client: ```haskell movieCatalogClient :: API (AsClientT ClientM) -movieCatalogClient = client api -- remember: api :: Proxy MovieCatalogAPI +movieCatalogClient = genericClient -- this also works with other Monads than ClientM by using `genericClientHoist` ``` We’ve also introduced some operators that help navigate through the nested records. @@ -282,7 +345,41 @@ deleteMovie :: MovieId -> ClientM NoContent deleteMovie mId = movieCatalogClient // movies // movie /: mId // delete ``` -Done! We’ve got our client! +Done! We’ve got our client, now let's turn to the links: + +```haskell +-- a single link: +versionLink :: Link +versionLink = fieldLink version + +-- links for the entire route: +routesLinks :: API (AsLink Link) +routesLinks = allFieldLinks +``` + +## Using record-based APIs together with a custom monad + +If your app uses a custom monad, here's how you can combine it with +generics. + +```haskell +-- for some custom Environment +data HandlerEnv = MkHandlerEnv + +type AppM = ReaderT HandlerEnv Handler + +-- we need to provide a natural transformation +appToHandler :: HandlerEnv -> (forall a. AppM a -> Handler a) +appToHandler env act = runReaderT act env + +-- which we can then use in `genericServeT` +appMyMonad :: HandlerEnv -> Application +appMyMonad env = genericServeT (appToHandler env) appMapi + where + appMapi = undefined +``` + +There is also a combinator for serving with a `Context`, `genericServeWithContextT`. ## Conclusion diff --git a/doc/cookbook/named-routes/cookbook-named-routes.cabal b/doc/cookbook/named-routes/cookbook-named-routes.cabal index 13d95d6e9..187e920a3 100644 --- a/doc/cookbook/named-routes/cookbook-named-routes.cabal +++ b/doc/cookbook/named-routes/cookbook-named-routes.cabal @@ -20,7 +20,8 @@ executable cookbook-named-routes , servant , servant-client , servant-client-core - , servant-server + , servant-server + , transformers , text , wai >=3.2 , warp >=3.2