on master help sign in
Merge pull request #946 from KtorZ/servant-pagination

add cookbook recipe introducing servant-pagination

commit6af38354d0e5e58c744aa0ff96e47a0dfe4d37a3 authorAlp Mestanogullari <alpmestan@gmail.com> dateTue 17 Apr 2018, 10:33 (9 years ago) parent680820c93838ae
4 files changed+276
doc/cookbook/cabal.project+1 doc/cookbook/index.rst+1 doc/cookbook/pagination/Pagination.lhs+250 doc/cookbook/pagination/pagination.cabal+24
doc/cookbook/cabal.project+1
@@ -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+1
@@ -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
doc/cookbook/pagination/Pagination.lhsnew file+250
@@ -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+```
doc/cookbook/pagination/pagination.cabalnew file+24
@@ -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: haskell-servant-maintainers@googlegroups.com
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