Skip to content

pinata-llc/haskell-servant-pagination

 
 

Repository files navigation

servant-pagination Build Status

Overview

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, 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%3A14%3A67.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%3A14%3A51.000Z..2017-02-18T06%3A10%3A23.000Z
  • Next-Range: createdAt 2017-02-19T12%3A56%3A28.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:

{-# 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)
import           Servant.Pagination (HasPagination (..), PageHeaders, Range (..), Ranges, applyRange)

import qualified Data.Aeson               as Aeson
import qualified Network.Wai.Handler.Warp as Warp
import qualified Servant.Pagination       as Pagination
import qualified Servant

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.

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:

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:

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:

-- 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.

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.

See examples/Simple.hs for a running version of this guide.

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.

instance HasPagination Color "hex" where
  type RangeType Color "hex" = String
  getFieldValue _ = hex

-- to then define: Ranges '["name", "hex"] Color

See examples/Complex.hs for more complex examples.

Parsing Options

By default, servant-pagination provides an implementation of getRangeOptions for each HasPagination type-class. However, this can be overwritten when defining a instance of that class 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:

instance HasPagination Color "rgb" where
  type RangeType Color "rgb" = String
  getFieldValue _ = sum . rgb
  getRangeOptions _ _ = defaultOptions { defaultRangeLimit = 5 }

Changelog

CHANGELOG.md

License

LGPL-3 © 2018 Chordify

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages

  • Haskell 100.0%