Skip to content

Commit 6af3835

Browse files
authored
Merge pull request #946 from KtorZ/servant-pagination
add cookbook recipe introducing servant-pagination
2 parents 680820c + 93838ae commit 6af3835

File tree

4 files changed

+276
-0
lines changed

4 files changed

+276
-0
lines changed

doc/cookbook/cabal.project

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ packages:
77
file-upload/
88
structuring-apis/
99
https/
10+
pagination/
1011
../../servant
1112
../../servant-server
1213
../../servant-client-core

doc/cookbook/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ you name it!
2525
basic-auth/BasicAuth.lhs
2626
jwt-and-basic-auth/JWTAndBasicAuth.lhs
2727
file-upload/FileUpload.lhs
28+
pagination/Pagination.lhs
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
# Pagination
2+
3+
## Overview
4+
5+
Let's see an approach to typed pagination with *Servant* using [servant-pagination](https://hackage.haskell.org/package/servant-pagination).
6+
7+
This module offers opinionated helpers to declare a type-safe and a flexible pagination
8+
mechanism for Servant APIs. This design, inspired by [Heroku's API](https://devcenter.heroku.com/articles/platform-api-reference#ranges),
9+
provides a small framework to communicate about a possible pagination feature of an endpoint,
10+
enabling a client to consume the API in different fashions (pagination with offset / limit,
11+
endless scroll using last referenced resources, ascending and descending ordering, etc.)
12+
13+
Therefore, client can provide a `Range` header with their request with the following format:
14+
15+
- `Range: <field> [<value>][; offset <o>][; limit <l>][; order <asc|desc>]`
16+
17+
For example: `Range: createdAt 2017-01-15T23:14:67.000Z; offset 5; order desc` indicates that
18+
the client is willing to retrieve the next batch of document in descending order that were
19+
created after the fifteenth of January, skipping the first 5.
20+
21+
As a response, the server may return the list of corresponding document, and augment the
22+
response with 3 headers:
23+
24+
- `Accept-Ranges`: A comma-separated list of fields upon which a range can be defined
25+
- `Content-Range`: Actual range corresponding to the content being returned
26+
- `Next-Range`: Indicate what should be the next `Range` header in order to retrieve the next range
27+
28+
For example:
29+
30+
- `Accept-Ranges: createdAt, modifiedAt`
31+
- `Content-Range: createdAt 2017-01-15T23:14:51.000Z..2017-02-18T06:10:23.000Z`
32+
- `Next-Range: createdAt 2017-02-19T12:56:28.000Z; offset 0; limit 100; order desc`
33+
34+
35+
## Getting Started
36+
37+
Code-wise the integration is quite seamless and unobtrusive. `servant-pagination` provides a
38+
`Ranges (fields :: [Symbol]) (resource :: *) -> *` data-type for declaring available ranges
39+
on a group of _fields_ and a target _resource_. To each combination (resource + field) is
40+
associated a given type `RangeType (resource :: *) (field :: Symbol) -> *` as described by
41+
the type-family in the `HasPagination` type-class.
42+
43+
So, let's start with some imports and extensions to get this out of the way:
44+
45+
``` haskell
46+
{-# LANGUAGE DataKinds #-}
47+
{-# LANGUAGE DeriveGeneric #-}
48+
{-# LANGUAGE FlexibleInstances #-}
49+
{-# LANGUAGE MultiParamTypeClasses #-}
50+
{-# LANGUAGE TypeApplications #-}
51+
{-# LANGUAGE TypeFamilies #-}
52+
{-# LANGUAGE TypeOperators #-}
53+
54+
import Data.Aeson
55+
(ToJSON, genericToJSON)
56+
import Data.Maybe
57+
(fromMaybe)
58+
import Data.Proxy
59+
(Proxy (..))
60+
import GHC.Generics
61+
(Generic)
62+
import Servant
63+
((:>), GetPartialContent, Handler, Header, Headers, JSON, Server, addHeader)
64+
import Servant.Pagination
65+
(HasPagination (..), PageHeaders, Range (..), Ranges, RangeOptions(..),
66+
applyRange, extractRange, returnRange)
67+
68+
import qualified Data.Aeson as Aeson
69+
import qualified Network.Wai.Handler.Warp as Warp
70+
import qualified Servant
71+
import qualified Servant.Pagination as Pagination
72+
```
73+
74+
75+
#### Declaring the Resource
76+
77+
Servant APIs are rather resource-oriented, and so is `servant-pagination`. This
78+
guide shows a basic example working with `JSON` (as you could tell from the
79+
import list already). To make the world a <span style='text-decoration:
80+
line-through'>better</span> colored place, let's create an API to retrieve
81+
colors -- with pagination.
82+
83+
``` haskell
84+
data Color = Color
85+
{ name :: String
86+
, rgb :: [Int]
87+
, hex :: String
88+
} deriving (Eq, Show, Generic)
89+
90+
instance ToJSON Color where
91+
toJSON =
92+
genericToJSON Aeson.defaultOptions
93+
94+
colors :: [Color]
95+
colors =
96+
[ Color "Black" [0, 0, 0] "#000000"
97+
, Color "Blue" [0, 0, 255] "#0000ff"
98+
, Color "Green" [0, 128, 0] "#008000"
99+
, Color "Grey" [128, 128, 128] "#808080"
100+
, Color "Purple" [128, 0, 128] "#800080"
101+
, Color "Red" [255, 0, 0] "#ff0000"
102+
, Color "Yellow" [255, 255, 0] "#ffff00"
103+
]
104+
```
105+
106+
#### Declaring the Ranges
107+
108+
Now that we have defined our _resource_ (a.k.a `Color`), we are ready to declare a new `Range`
109+
that will operate on a "name" field (genuinely named after the `name` fields from the `Color`
110+
record).
111+
For that, we need to tell `servant-pagination` two things:
112+
113+
- What is the type of the corresponding `Range` values
114+
- How do we get one of these values from our resource
115+
116+
This is done via defining an instance of `HasPagination` as follows:
117+
118+
``` haskell
119+
instance HasPagination Color "name" where
120+
type RangeType Color "name" = String
121+
getFieldValue _ = name
122+
-- getRangeOptions :: Proxy "name" -> Proxy Color -> RangeOptions
123+
-- getDefaultRange :: Proxy Color -> Range "name" String
124+
125+
defaultRange :: Range "name" String
126+
defaultRange =
127+
getDefaultRange (Proxy @Color)
128+
```
129+
130+
Note that `getFieldValue :: Proxy "name" -> Color -> String` is the minimal complete definintion
131+
of the class. Yet, you can define `getRangeOptions` to provide different parsing options (see
132+
the last section of this guide). In the meantime, we've also defined a `defaultRange` as it will
133+
come in handy when defining our handler.
134+
135+
#### API
136+
137+
Good, we have a resource, we have a `Range` working on that resource, we can now declare our
138+
API using other Servant combinators we already know:
139+
140+
``` haskell
141+
type API =
142+
"colors"
143+
:> Header "Range" (Ranges '["name"] Color)
144+
:> GetPartialContent '[JSON] (Headers MyHeaders [Color])
145+
146+
type MyHeaders =
147+
Header "Total-Count" Int ': PageHeaders '["name"] Color
148+
```
149+
150+
`PageHeaders` is a type alias provided by the library to declare the necessary response headers
151+
we mentionned in introduction. Expanding the alias boils down to the following:
152+
153+
``` haskell
154+
-- type MyHeaders =
155+
-- '[ Header "Total-Count" Int
156+
-- , Header "Accept-Ranges" (AcceptRanges '["name"])
157+
-- , Header "Content-Range" (ContentRange '["name"] Color)
158+
-- , Header "Next-Range" (Ranges '["name"] Color)
159+
-- ]
160+
```
161+
162+
As a result, we will need to provide all those headers with the response in our handler. Worry
163+
not, _servant-pagination_ provides an easy way to lift a collection of resources into such handler.
164+
165+
#### Server
166+
167+
Time to connect the last bits by defining the server implementation of our colorful API. The `Ranges`
168+
type we've defined above (tight to the `Range` HTTP header) indicates the server to parse any `Range`
169+
header, looking for the format defined in introduction with fields and target types we have just declared.
170+
If no such header is provided, we will end up receiving `Nothing`. Otherwise, it will be possible
171+
to _extract_ a `Range` from our `Ranges`.
172+
173+
``` haskell
174+
server :: Server API
175+
server = handler
176+
where
177+
handler :: Maybe (Ranges '["name"] Color) -> Handler (Headers MyHeaders [Color])
178+
handler mrange = do
179+
let range =
180+
fromMaybe defaultRange (mrange >>= extractRange)
181+
182+
addHeader (length colors) <$> returnRange range (applyRange range colors)
183+
184+
main :: IO ()
185+
main =
186+
Warp.run 1442 $ Servant.serve (Proxy @API) server
187+
```
188+
189+
Let's try it out using different ranges to observe the server's behavior. As a reminder, here's
190+
the format we defined, where `<field>` here can only be `name` and `<value>` must parse to a `String`:
191+
192+
- `Range: <field> [<value>][; offset <o>][; limit <l>][; order <asc|desc>]`
193+
194+
Beside the target field, everything is pretty much optional in the `Range` HTTP header. Missing parts
195+
are deducted from the `RangeOptions` that are part of the `HasPagination` instance. Therefore, all
196+
following examples are valid requests to send to our server:
197+
198+
- 1 - `curl http://localhost:1442/colors -vH 'Range: name'`
199+
- 2 - `curl http://localhost:1442/colors -vH 'Range: name; limit 2'`
200+
- 3 - `curl http://localhost:1442/colors -vH 'Range: name Green; order asc; offset 1'`
201+
202+
Considering the following default options:
203+
204+
- `defaultRangeLimit: 100`
205+
- `defaultRangeOffset: 0`
206+
- `defaultRangeOrder: RangeDesc`
207+
208+
The previous ranges reads as follows:
209+
210+
- 1 - The first 100 colors, ordered by descending names
211+
- 2 - The first 2 colors, ordered by descending names
212+
- 3 - The 100 colors after `Green` (not included), ordered by ascending names.
213+
214+
215+
## Going Forward
216+
217+
#### Multiple Ranges
218+
219+
Note that in the simple above scenario, there's no ambiguity with `extractRange` and `returnRange`
220+
because there's only one possible `Range` defined on our resource. Yet, as you've most probably
221+
noticed, the `Ranges` combinator accepts a list of fields, each of which must declare a `HasPagination`
222+
instance. Doing so will make the other helper functions more ambiguous and type annotation are
223+
highly likely to be needed.
224+
225+
226+
``` haskell
227+
instance HasPagination Color "hex" where
228+
type RangeType Color "hex" = String
229+
getFieldValue _ = hex
230+
231+
-- to then define: Ranges '["name", "hex"] Color
232+
```
233+
234+
235+
#### Parsing Options
236+
237+
By default, `servant-pagination` provides an implementation of `getRangeOptions` for each
238+
`HasPagination` instance. However, this can be overwritten when defining the instance to provide
239+
your own options. This options come into play when a `Range` header is received and isn't fully
240+
specified (`limit`, `offset`, `order` are all optional) to provide default fallback values for those.
241+
242+
For instance, let's say we wanted to change the default limit to `5` in a new range on
243+
`"rgb"`, we could tweak the corresponding `HasPagination` instance as follows:
244+
245+
``` haskell
246+
instance HasPagination Color "rgb" where
247+
type RangeType Color "rgb" = Int
248+
getFieldValue _ = sum . rgb
249+
getRangeOptions _ _ = Pagination.defaultOptions { defaultRangeLimit = 5 }
250+
```
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: cookbook-pagination
2+
version: 2.1
3+
synopsis: Pagination with Servant example
4+
homepage: http://haskell-servant.readthedocs.org/
5+
license: BSD3
6+
license-file: ../../../servant/LICENSE
7+
author: Servant Contributors
8+
maintainer: [email protected]
9+
build-type: Simple
10+
cabal-version: >=1.10
11+
12+
executable cookbook-pagination
13+
if impl(ghc < 7.10.1)
14+
buildable: False
15+
main-is: Pagination.lhs
16+
build-depends: base == 4.*
17+
, aeson
18+
, servant
19+
, servant-server
20+
, servant-pagination >= 2.1.0 && < 3.0.0
21+
, warp
22+
default-language: Haskell2010
23+
ghc-options: -Wall -pgmL markdown-unlit
24+
build-tool-depends: markdown-unlit:markdown-unlit

0 commit comments

Comments
 (0)