|
| 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 | +``` |
0 commit comments