From 93838ae9e76ffa18dcf4ff344b52decccd5a6db2 Mon Sep 17 00:00:00 2001 From: KtorZ Date: Mon, 16 Apr 2018 15:37:20 +0200 Subject: [PATCH] Add recipe introducing servant-pagination --- doc/cookbook/cabal.project | 1 + doc/cookbook/index.rst | 1 + doc/cookbook/pagination/Pagination.lhs | 250 +++++++++++++++++++++++ doc/cookbook/pagination/pagination.cabal | 24 +++ 4 files changed, 276 insertions(+) create mode 100644 doc/cookbook/pagination/Pagination.lhs create mode 100644 doc/cookbook/pagination/pagination.cabal diff --git a/doc/cookbook/cabal.project b/doc/cookbook/cabal.project index 5a459079..4f2a4255 100644 --- a/doc/cookbook/cabal.project +++ b/doc/cookbook/cabal.project @@ -7,6 +7,7 @@ packages: file-upload/ structuring-apis/ https/ + pagination/ ../../servant ../../servant-server ../../servant-client-core diff --git a/doc/cookbook/index.rst b/doc/cookbook/index.rst index 2c5603ba..93322c91 100644 --- a/doc/cookbook/index.rst +++ b/doc/cookbook/index.rst @@ -25,3 +25,4 @@ you name it! basic-auth/BasicAuth.lhs jwt-and-basic-auth/JWTAndBasicAuth.lhs file-upload/FileUpload.lhs + pagination/Pagination.lhs diff --git a/doc/cookbook/pagination/Pagination.lhs b/doc/cookbook/pagination/Pagination.lhs new file mode 100644 index 00000000..7c2c0e70 --- /dev/null +++ b/doc/cookbook/pagination/Pagination.lhs @@ -0,0 +1,250 @@ +# Pagination + +## Overview + +Let's see an approach to typed pagination with *Servant* using [servant-pagination](https://hackage.haskell.org/package/servant-pagination). + +This module offers opinionated helpers to declare a type-safe and a flexible pagination +mechanism for Servant APIs. This design, inspired by [Heroku's API](https://devcenter.heroku.com/articles/platform-api-reference#ranges), +provides a small framework to communicate about a possible pagination feature of an endpoint, +enabling a client to consume the API in different fashions (pagination with offset / limit, +endless scroll using last referenced resources, ascending and descending ordering, etc.) + +Therefore, client can provide a `Range` header with their request with the following format: + +- `Range: [][; offset ][; limit ][; order ]` + +For example: `Range: createdAt 2017-01-15T23:14:67.000Z; offset 5; order desc` indicates that +the client is willing to retrieve the next batch of document in descending order that were +created after the fifteenth of January, skipping the first 5. + +As a response, the server may return the list of corresponding document, and augment the +response with 3 headers: + +- `Accept-Ranges`: A comma-separated list of fields upon which a range can be defined +- `Content-Range`: Actual range corresponding to the content being returned +- `Next-Range`: Indicate what should be the next `Range` header in order to retrieve the next range + +For example: + +- `Accept-Ranges: createdAt, modifiedAt` +- `Content-Range: createdAt 2017-01-15T23:14:51.000Z..2017-02-18T06:10:23.000Z` +- `Next-Range: createdAt 2017-02-19T12:56:28.000Z; offset 0; limit 100; order desc` + + +## Getting Started + +Code-wise the integration is quite seamless and unobtrusive. `servant-pagination` provides a +`Ranges (fields :: [Symbol]) (resource :: *) -> *` data-type for declaring available ranges +on a group of _fields_ and a target _resource_. To each combination (resource + field) is +associated a given type `RangeType (resource :: *) (field :: Symbol) -> *` as described by +the type-family in the `HasPagination` type-class. + +So, let's start with some imports and extensions to get this out of the way: + +``` haskell +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE MultiParamTypeClasses #-} +{-# LANGUAGE TypeApplications #-} +{-# LANGUAGE TypeFamilies #-} +{-# LANGUAGE TypeOperators #-} + +import Data.Aeson + (ToJSON, genericToJSON) +import Data.Maybe + (fromMaybe) +import Data.Proxy + (Proxy (..)) +import GHC.Generics + (Generic) +import Servant + ((:>), GetPartialContent, Handler, Header, Headers, JSON, Server, addHeader) +import Servant.Pagination + (HasPagination (..), PageHeaders, Range (..), Ranges, RangeOptions(..), + applyRange, extractRange, returnRange) + +import qualified Data.Aeson as Aeson +import qualified Network.Wai.Handler.Warp as Warp +import qualified Servant +import qualified Servant.Pagination as Pagination +``` + + +#### Declaring the Resource + +Servant APIs are rather resource-oriented, and so is `servant-pagination`. This +guide shows a basic example working with `JSON` (as you could tell from the +import list already). To make the world a better colored place, let's create an API to retrieve +colors -- with pagination. + +``` haskell +data Color = Color + { name :: String + , rgb :: [Int] + , hex :: String + } deriving (Eq, Show, Generic) + +instance ToJSON Color where + toJSON = + genericToJSON Aeson.defaultOptions + +colors :: [Color] +colors = + [ Color "Black" [0, 0, 0] "#000000" + , Color "Blue" [0, 0, 255] "#0000ff" + , Color "Green" [0, 128, 0] "#008000" + , Color "Grey" [128, 128, 128] "#808080" + , Color "Purple" [128, 0, 128] "#800080" + , Color "Red" [255, 0, 0] "#ff0000" + , Color "Yellow" [255, 255, 0] "#ffff00" + ] +``` + +#### Declaring the Ranges + +Now that we have defined our _resource_ (a.k.a `Color`), we are ready to declare a new `Range` +that will operate on a "name" field (genuinely named after the `name` fields from the `Color` +record). +For that, we need to tell `servant-pagination` two things: + +- What is the type of the corresponding `Range` values +- How do we get one of these values from our resource + +This is done via defining an instance of `HasPagination` as follows: + +``` haskell +instance HasPagination Color "name" where + type RangeType Color "name" = String + getFieldValue _ = name + -- getRangeOptions :: Proxy "name" -> Proxy Color -> RangeOptions + -- getDefaultRange :: Proxy Color -> Range "name" String + +defaultRange :: Range "name" String +defaultRange = + getDefaultRange (Proxy @Color) +``` + +Note that `getFieldValue :: Proxy "name" -> Color -> String` is the minimal complete definintion +of the class. Yet, you can define `getRangeOptions` to provide different parsing options (see +the last section of this guide). In the meantime, we've also defined a `defaultRange` as it will +come in handy when defining our handler. + +#### API + +Good, we have a resource, we have a `Range` working on that resource, we can now declare our +API using other Servant combinators we already know: + +``` haskell +type API = + "colors" + :> Header "Range" (Ranges '["name"] Color) + :> GetPartialContent '[JSON] (Headers MyHeaders [Color]) + +type MyHeaders = + Header "Total-Count" Int ': PageHeaders '["name"] Color +``` + +`PageHeaders` is a type alias provided by the library to declare the necessary response headers +we mentionned in introduction. Expanding the alias boils down to the following: + +``` haskell +-- type MyHeaders = +-- '[ Header "Total-Count" Int +-- , Header "Accept-Ranges" (AcceptRanges '["name"]) +-- , Header "Content-Range" (ContentRange '["name"] Color) +-- , Header "Next-Range" (Ranges '["name"] Color) +-- ] +``` + +As a result, we will need to provide all those headers with the response in our handler. Worry +not, _servant-pagination_ provides an easy way to lift a collection of resources into such handler. + +#### Server + +Time to connect the last bits by defining the server implementation of our colorful API. The `Ranges` +type we've defined above (tight to the `Range` HTTP header) indicates the server to parse any `Range` +header, looking for the format defined in introduction with fields and target types we have just declared. +If no such header is provided, we will end up receiving `Nothing`. Otherwise, it will be possible +to _extract_ a `Range` from our `Ranges`. + +``` haskell +server :: Server API +server = handler + where + handler :: Maybe (Ranges '["name"] Color) -> Handler (Headers MyHeaders [Color]) + handler mrange = do + let range = + fromMaybe defaultRange (mrange >>= extractRange) + + addHeader (length colors) <$> returnRange range (applyRange range colors) + +main :: IO () +main = + Warp.run 1442 $ Servant.serve (Proxy @API) server +``` + +Let's try it out using different ranges to observe the server's behavior. As a reminder, here's +the format we defined, where `` here can only be `name` and `` must parse to a `String`: + +- `Range: [][; offset ][; limit ][; order ]` + +Beside the target field, everything is pretty much optional in the `Range` HTTP header. Missing parts +are deducted from the `RangeOptions` that are part of the `HasPagination` instance. Therefore, all +following examples are valid requests to send to our server: + +- 1 - `curl http://localhost:1442/colors -vH 'Range: name'` +- 2 - `curl http://localhost:1442/colors -vH 'Range: name; limit 2'` +- 3 - `curl http://localhost:1442/colors -vH 'Range: name Green; order asc; offset 1'` + +Considering the following default options: + +- `defaultRangeLimit: 100` +- `defaultRangeOffset: 0` +- `defaultRangeOrder: RangeDesc` + +The previous ranges reads as follows: + +- 1 - The first 100 colors, ordered by descending names +- 2 - The first 2 colors, ordered by descending names +- 3 - The 100 colors after `Green` (not included), ordered by ascending names. + + +## Going Forward + +#### Multiple Ranges + +Note that in the simple above scenario, there's no ambiguity with `extractRange` and `returnRange` +because there's only one possible `Range` defined on our resource. Yet, as you've most probably +noticed, the `Ranges` combinator accepts a list of fields, each of which must declare a `HasPagination` +instance. Doing so will make the other helper functions more ambiguous and type annotation are +highly likely to be needed. + + +``` haskell +instance HasPagination Color "hex" where + type RangeType Color "hex" = String + getFieldValue _ = hex + +-- to then define: Ranges '["name", "hex"] Color +``` + + +#### Parsing Options + +By default, `servant-pagination` provides an implementation of `getRangeOptions` for each +`HasPagination` instance. However, this can be overwritten when defining the instance to provide +your own options. This options come into play when a `Range` header is received and isn't fully +specified (`limit`, `offset`, `order` are all optional) to provide default fallback values for those. + +For instance, let's say we wanted to change the default limit to `5` in a new range on +`"rgb"`, we could tweak the corresponding `HasPagination` instance as follows: + +``` haskell +instance HasPagination Color "rgb" where + type RangeType Color "rgb" = Int + getFieldValue _ = sum . rgb + getRangeOptions _ _ = Pagination.defaultOptions { defaultRangeLimit = 5 } +``` diff --git a/doc/cookbook/pagination/pagination.cabal b/doc/cookbook/pagination/pagination.cabal new file mode 100644 index 00000000..e05d7401 --- /dev/null +++ b/doc/cookbook/pagination/pagination.cabal @@ -0,0 +1,24 @@ +name: cookbook-pagination +version: 2.1 +synopsis: Pagination with Servant example +homepage: http://haskell-servant.readthedocs.org/ +license: BSD3 +license-file: ../../../servant/LICENSE +author: Servant Contributors +maintainer: haskell-servant-maintainers@googlegroups.com +build-type: Simple +cabal-version: >=1.10 + +executable cookbook-pagination + if impl(ghc < 7.10.1) + buildable: False + main-is: Pagination.lhs + build-depends: base == 4.* + , aeson + , servant + , servant-server + , servant-pagination >= 2.1.0 && < 3.0.0 + , warp + default-language: Haskell2010 + ghc-options: -Wall -pgmL markdown-unlit + build-tool-depends: markdown-unlit:markdown-unlit