Merge pull request #946 from KtorZ/servant-pagination
add cookbook recipe introducing servant-pagination
This commit is contained in:
commit
6af38354d0
4 changed files with 276 additions and 0 deletions
|
@ -7,6 +7,7 @@ packages:
|
|||
file-upload/
|
||||
structuring-apis/
|
||||
https/
|
||||
pagination/
|
||||
../../servant
|
||||
../../servant-server
|
||||
../../servant-client-core
|
||||
|
|
|
@ -25,3 +25,4 @@ you name it!
|
|||
basic-auth/BasicAuth.lhs
|
||||
jwt-and-basic-auth/JWTAndBasicAuth.lhs
|
||||
file-upload/FileUpload.lhs
|
||||
pagination/Pagination.lhs
|
||||
|
|
250
doc/cookbook/pagination/Pagination.lhs
Normal file
250
doc/cookbook/pagination/Pagination.lhs
Normal file
|
@ -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: <field> [<value>][; offset <o>][; limit <l>][; order <asc|desc>]`
|
||||
|
||||
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 <span style='text-decoration:
|
||||
line-through'>better</span> 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 `<field>` here can only be `name` and `<value>` must parse to a `String`:
|
||||
|
||||
- `Range: <field> [<value>][; offset <o>][; limit <l>][; order <asc|desc>]`
|
||||
|
||||
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 }
|
||||
```
|
24
doc/cookbook/pagination/pagination.cabal
Normal file
24
doc/cookbook/pagination/pagination.cabal
Normal file
|
@ -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
|
Loading…
Reference in a new issue