packages feed

servant-pagination 1.0.0 → 2.0.0

raw patch · 10 files changed

+628/−429 lines, 10 filesdep ~servantdep ~servant-paginationdep ~servant-serverPVP ok

version bump matches the API change (PVP)

Dependency ranges changed: servant, servant-pagination, servant-server

API changes (from Hackage documentation)

- Servant.Pagination: FromRangeOptions :: Int -> Int -> RangeOrder -> FromRangeOptions
- Servant.Pagination: InL :: a -> (:|:) a b
- Servant.Pagination: InR :: b -> (:|:) a b
- Servant.Pagination: NextRange :: range -> NextRange range
- Servant.Pagination: class FromHttpApiData a
- Servant.Pagination: class FromRange a
- Servant.Pagination: data FromRangeOptions
- Servant.Pagination: data NextRange range
- Servant.Pagination: data a (:|:) b
- Servant.Pagination: defaultRange :: Maybe a -> FromRangeOptions -> Range field a
- Servant.Pagination: getRangeField :: HasPagination resource field => Proxy field -> resource -> RangeType resource field
- Servant.Pagination: instance (Web.Internal.HttpApiData.FromHttpApiData typ, GHC.TypeLits.KnownSymbol field) => Servant.Pagination.FromRange (Servant.Pagination.Range field typ)
- Servant.Pagination: instance (Web.Internal.HttpApiData.FromHttpApiData typ, GHC.TypeLits.KnownSymbol field) => Web.Internal.HttpApiData.FromHttpApiData (Servant.Pagination.Range field typ)
- Servant.Pagination: instance (Web.Internal.HttpApiData.ToHttpApiData (Servant.Pagination.ContentRange a), Web.Internal.HttpApiData.ToHttpApiData (Servant.Pagination.ContentRange b)) => Web.Internal.HttpApiData.ToHttpApiData (Servant.Pagination.ContentRange (a Servant.Pagination.Internal.:|: b))
- Servant.Pagination: instance (Web.Internal.HttpApiData.ToHttpApiData (Servant.Pagination.NextRange a), Web.Internal.HttpApiData.ToHttpApiData (Servant.Pagination.NextRange b)) => Web.Internal.HttpApiData.ToHttpApiData (Servant.Pagination.NextRange (a Servant.Pagination.Internal.:|: b))
- Servant.Pagination: instance (Web.Internal.HttpApiData.ToHttpApiData typ, GHC.TypeLits.KnownSymbol field) => Web.Internal.HttpApiData.ToHttpApiData (Servant.Pagination.ContentRange (Servant.Pagination.Range field typ))
- Servant.Pagination: instance (Web.Internal.HttpApiData.ToHttpApiData typ, GHC.TypeLits.KnownSymbol field) => Web.Internal.HttpApiData.ToHttpApiData (Servant.Pagination.NextRange (Servant.Pagination.Range field typ))
- Servant.Pagination: instance (Web.Internal.HttpApiData.ToHttpApiData typ, GHC.TypeLits.KnownSymbol field) => Web.Internal.HttpApiData.ToHttpApiData (Servant.Pagination.Range field typ)
- Servant.Pagination: instance GHC.Base.Functor (Servant.Pagination.Range field)
- Servant.Pagination: instance GHC.Classes.Eq Servant.Pagination.FromRangeOptions
- Servant.Pagination: instance GHC.Classes.Eq typ => GHC.Classes.Eq (Servant.Pagination.Range field typ)
- Servant.Pagination: instance GHC.Generics.Generic (Servant.Pagination.Range field typ)
- Servant.Pagination: instance GHC.Show.Show Servant.Pagination.FromRangeOptions
- Servant.Pagination: instance GHC.Show.Show typ => GHC.Show.Show (Servant.Pagination.Range field typ)
- Servant.Pagination: instance GHC.TypeLits.KnownSymbol field => Servant.Pagination.Internal.ToAcceptRanges (Servant.Pagination.Range field typ)
- Servant.Pagination: instance Servant.Pagination.Internal.ToAcceptRanges a => Web.Internal.HttpApiData.ToHttpApiData (Servant.Pagination.AcceptRanges a)
- Servant.Pagination: parseHeader :: FromHttpApiData a => ByteString -> Either Text a
- Servant.Pagination: parseQueryParam :: FromHttpApiData a => Text -> Either Text a
- Servant.Pagination: parseRange :: FromRange a => FromRangeOptions -> Text -> Either Text a
- Servant.Pagination: parseUrlPiece :: FromHttpApiData a => Text -> Either Text a
- Servant.Pagination: returnPage :: forall m ranges. (HasPagination resource field, Monad m, (Range field (RangeType resource field)) :<: ranges, ToAcceptRanges ranges, ToHttpApiData (ContentRange ranges), ToHttpApiData (NextRange ranges), Ord (RangeType resource field)) => TotalCount -> (Range field (RangeType resource field)) -> [resource] -> m (Headers (PageHeaders ranges) [resource])
- Servant.Pagination: returnPage_ :: forall m ranges. (HasPagination resource field, Monad m, (Range field (RangeType resource field)) :<: ranges, ToAcceptRanges ranges, ToHttpApiData (ContentRange ranges), ToHttpApiData (NextRange ranges), Ord (RangeType resource field)) => (Range field (RangeType resource field)) -> [resource] -> m (Headers (PageHeaders ranges) [resource])
- Servant.Pagination: type TotalCount = Maybe Natural
- Servant.Pagination.Internal: InL :: a -> (:|:) a b
- Servant.Pagination.Internal: InR :: b -> (:|:) a b
- Servant.Pagination.Internal: class ToAcceptRanges r
- Servant.Pagination.Internal: class range :<: ranges
- Servant.Pagination.Internal: data a (:|:) b
- Servant.Pagination.Internal: instance (Servant.Pagination.Internal.ToAcceptRanges a, Servant.Pagination.Internal.ToAcceptRanges b) => Servant.Pagination.Internal.ToAcceptRanges (a Servant.Pagination.Internal.:|: b)
- Servant.Pagination.Internal: instance (Web.Internal.HttpApiData.FromHttpApiData a, Web.Internal.HttpApiData.FromHttpApiData b) => Web.Internal.HttpApiData.FromHttpApiData (a Servant.Pagination.Internal.:|: b)
- Servant.Pagination.Internal: instance (Web.Internal.HttpApiData.ToHttpApiData a, Web.Internal.HttpApiData.ToHttpApiData b) => Web.Internal.HttpApiData.ToHttpApiData (a Servant.Pagination.Internal.:|: b)
- Servant.Pagination.Internal: instance r Servant.Pagination.Internal.:<: ((((((((r Servant.Pagination.Internal.:|: r2) Servant.Pagination.Internal.:|: r3) Servant.Pagination.Internal.:|: r4) Servant.Pagination.Internal.:|: r5) Servant.Pagination.Internal.:|: r6) Servant.Pagination.Internal.:|: r7) Servant.Pagination.Internal.:|: r8) Servant.Pagination.Internal.:|: r9)
- Servant.Pagination.Internal: instance r Servant.Pagination.Internal.:<: ((((((((r1 Servant.Pagination.Internal.:|: r) Servant.Pagination.Internal.:|: r3) Servant.Pagination.Internal.:|: r4) Servant.Pagination.Internal.:|: r5) Servant.Pagination.Internal.:|: r6) Servant.Pagination.Internal.:|: r7) Servant.Pagination.Internal.:|: r8) Servant.Pagination.Internal.:|: r9)
- Servant.Pagination.Internal: instance r Servant.Pagination.Internal.:<: (((((((r Servant.Pagination.Internal.:|: r2) Servant.Pagination.Internal.:|: r3) Servant.Pagination.Internal.:|: r4) Servant.Pagination.Internal.:|: r5) Servant.Pagination.Internal.:|: r6) Servant.Pagination.Internal.:|: r7) Servant.Pagination.Internal.:|: r8)
- Servant.Pagination.Internal: instance r Servant.Pagination.Internal.:<: (((((((r1 Servant.Pagination.Internal.:|: r) Servant.Pagination.Internal.:|: r3) Servant.Pagination.Internal.:|: r4) Servant.Pagination.Internal.:|: r5) Servant.Pagination.Internal.:|: r6) Servant.Pagination.Internal.:|: r7) Servant.Pagination.Internal.:|: r8)
- Servant.Pagination.Internal: instance r Servant.Pagination.Internal.:<: ((((((r Servant.Pagination.Internal.:|: r2) Servant.Pagination.Internal.:|: r3) Servant.Pagination.Internal.:|: r4) Servant.Pagination.Internal.:|: r5) Servant.Pagination.Internal.:|: r6) Servant.Pagination.Internal.:|: r7)
- Servant.Pagination.Internal: instance r Servant.Pagination.Internal.:<: ((((((r1 Servant.Pagination.Internal.:|: r) Servant.Pagination.Internal.:|: r3) Servant.Pagination.Internal.:|: r4) Servant.Pagination.Internal.:|: r5) Servant.Pagination.Internal.:|: r6) Servant.Pagination.Internal.:|: r7)
- Servant.Pagination.Internal: instance r Servant.Pagination.Internal.:<: (((((r Servant.Pagination.Internal.:|: r2) Servant.Pagination.Internal.:|: r3) Servant.Pagination.Internal.:|: r4) Servant.Pagination.Internal.:|: r5) Servant.Pagination.Internal.:|: r6)
- Servant.Pagination.Internal: instance r Servant.Pagination.Internal.:<: (((((r1 Servant.Pagination.Internal.:|: r) Servant.Pagination.Internal.:|: r3) Servant.Pagination.Internal.:|: r4) Servant.Pagination.Internal.:|: r5) Servant.Pagination.Internal.:|: r6)
- Servant.Pagination.Internal: instance r Servant.Pagination.Internal.:<: ((((r Servant.Pagination.Internal.:|: r2) Servant.Pagination.Internal.:|: r3) Servant.Pagination.Internal.:|: r4) Servant.Pagination.Internal.:|: r5)
- Servant.Pagination.Internal: instance r Servant.Pagination.Internal.:<: ((((r1 Servant.Pagination.Internal.:|: r) Servant.Pagination.Internal.:|: r3) Servant.Pagination.Internal.:|: r4) Servant.Pagination.Internal.:|: r5)
- Servant.Pagination.Internal: instance r Servant.Pagination.Internal.:<: (((r Servant.Pagination.Internal.:|: r2) Servant.Pagination.Internal.:|: r3) Servant.Pagination.Internal.:|: r4)
- Servant.Pagination.Internal: instance r Servant.Pagination.Internal.:<: (((r1 Servant.Pagination.Internal.:|: r) Servant.Pagination.Internal.:|: r3) Servant.Pagination.Internal.:|: r4)
- Servant.Pagination.Internal: instance r Servant.Pagination.Internal.:<: ((r Servant.Pagination.Internal.:|: r2) Servant.Pagination.Internal.:|: r3)
- Servant.Pagination.Internal: instance r Servant.Pagination.Internal.:<: ((r1 Servant.Pagination.Internal.:|: r) Servant.Pagination.Internal.:|: r3)
- Servant.Pagination.Internal: instance r Servant.Pagination.Internal.:<: (r Servant.Pagination.Internal.:|: r2)
- Servant.Pagination.Internal: instance r Servant.Pagination.Internal.:<: (r1 Servant.Pagination.Internal.:|: r)
- Servant.Pagination.Internal: instance r Servant.Pagination.Internal.:<: r
- Servant.Pagination.Internal: liftRange :: (:<:) range ranges => range -> ranges
- Servant.Pagination.Internal: orElse :: Either a b -> Either a b -> Either a b
- Servant.Pagination.Internal: toAcceptRanges :: ToAcceptRanges r => Proxy r -> Text
+ Servant.Pagination: RangeOptions :: Int -> Int -> RangeOrder -> RangeOptions
+ Servant.Pagination: [contentRangeField] :: ContentRange resource -> Proxy field
+ Servant.Pagination: [rangeField] :: Range -> Proxy field
+ Servant.Pagination: data RangeOptions
+ Servant.Pagination: data Ranges :: [Symbol] -> * -> *
+ Servant.Pagination: extractRange :: (ExtractRange fields field, HasPagination resource field) => Ranges fields resource -> Maybe (Range field (RangeType resource field))
+ Servant.Pagination: getDefaultRange :: (HasPagination resource field, IsRangeType (RangeType resource field)) => Proxy resource -> Maybe (RangeType resource field) -> Range field (RangeType resource field)
+ Servant.Pagination: getFieldValue :: HasPagination resource field => Proxy field -> resource -> RangeType resource field
+ Servant.Pagination: getRangeOptions :: HasPagination resource field => Proxy resource -> Proxy field -> RangeOptions
+ Servant.Pagination: instance (Web.Internal.HttpApiData.FromHttpApiData (Servant.Pagination.Ranges fields resource), Servant.Pagination.HasPagination resource field, GHC.TypeLits.KnownSymbol field, Servant.Pagination.IsRangeType (Servant.Pagination.RangeType resource field)) => Web.Internal.HttpApiData.FromHttpApiData (Servant.Pagination.Ranges (field : fields) resource)
+ Servant.Pagination: instance (Web.Internal.HttpApiData.ToHttpApiData (Servant.Pagination.AcceptRanges (f : fs)), GHC.TypeLits.KnownSymbol field) => Web.Internal.HttpApiData.ToHttpApiData (Servant.Pagination.AcceptRanges (field : f : fs))
+ Servant.Pagination: instance GHC.Classes.Eq Servant.Pagination.RangeOptions
+ Servant.Pagination: instance GHC.Show.Show Servant.Pagination.RangeOptions
+ Servant.Pagination: instance GHC.TypeLits.KnownSymbol field => Web.Internal.HttpApiData.ToHttpApiData (Servant.Pagination.AcceptRanges '[field])
+ Servant.Pagination: instance Servant.Pagination.ExtractRange (field : fields) field
+ Servant.Pagination: instance Servant.Pagination.ExtractRange fields field => Servant.Pagination.ExtractRange (y : fields) field
+ Servant.Pagination: instance Servant.Pagination.PutRange (field : fields) field
+ Servant.Pagination: instance Servant.Pagination.PutRange fields field => Servant.Pagination.PutRange (y : fields) field
+ Servant.Pagination: instance Web.Internal.HttpApiData.FromHttpApiData (Servant.Pagination.Ranges '[] resource)
+ Servant.Pagination: instance Web.Internal.HttpApiData.ToHttpApiData (Servant.Pagination.ContentRange fields res)
+ Servant.Pagination: instance Web.Internal.HttpApiData.ToHttpApiData (Servant.Pagination.Ranges fields resource)
+ Servant.Pagination: returnRange :: (Monad m, ToHttpApiData (AcceptRanges fields), KnownSymbol field, HasPagination resource field, IsRangeType (RangeType resource field), PutRange fields field) => Range field (RangeType resource field) -> [resource] -> m (Headers (PageHeaders fields resource) [resource])
+ Servant.Pagination: type IsRangeType a = (Show a, Ord a, Eq a, FromHttpApiData a, ToHttpApiData a)
- Servant.Pagination: AcceptRanges :: AcceptRanges range
+ Servant.Pagination: AcceptRanges :: AcceptRanges
- Servant.Pagination: ContentRange :: range -> range -> ContentRange range
+ Servant.Pagination: ContentRange :: RangeType resource field -> RangeType resource field -> Proxy field -> ContentRange resource
- Servant.Pagination: Range :: Maybe typ -> Int -> Int -> RangeOrder -> Range typ
+ Servant.Pagination: Range :: Maybe a -> Int -> Int -> RangeOrder -> Proxy field -> Range
- Servant.Pagination: [contentRangeEnd] :: ContentRange range -> range
+ Servant.Pagination: [contentRangeEnd] :: ContentRange resource -> RangeType resource field
- Servant.Pagination: [contentRangeStart] :: ContentRange range -> range
+ Servant.Pagination: [contentRangeStart] :: ContentRange resource -> RangeType resource field
- Servant.Pagination: [defaultRangeLimit] :: FromRangeOptions -> Int
+ Servant.Pagination: [defaultRangeLimit] :: RangeOptions -> Int
- Servant.Pagination: [defaultRangeOffset] :: FromRangeOptions -> Int
+ Servant.Pagination: [defaultRangeOffset] :: RangeOptions -> Int
- Servant.Pagination: [defaultRangeOrder] :: FromRangeOptions -> RangeOrder
+ Servant.Pagination: [defaultRangeOrder] :: RangeOptions -> RangeOrder
- Servant.Pagination: [rangeLimit] :: Range typ -> Int
+ Servant.Pagination: [rangeLimit] :: Range -> Int
- Servant.Pagination: [rangeOffset] :: Range typ -> Int
+ Servant.Pagination: [rangeOffset] :: Range -> Int
- Servant.Pagination: [rangeOrder] :: Range typ -> RangeOrder
+ Servant.Pagination: [rangeOrder] :: Range -> RangeOrder
- Servant.Pagination: [rangeValue] :: Range typ -> Maybe typ
+ Servant.Pagination: [rangeValue] :: Range -> Maybe a
- Servant.Pagination: applyRange :: forall b field. (HasPagination b field, Ord (RangeType b field)) => Range field (RangeType b field) -> [b] -> [b]
+ Servant.Pagination: applyRange :: HasPagination resource field => Range field (RangeType resource field) -> [resource] -> [resource]
- Servant.Pagination: data AcceptRanges range
+ Servant.Pagination: data AcceptRanges (fields :: [Symbol])
- Servant.Pagination: data ContentRange range
+ Servant.Pagination: data ContentRange (fields :: [Symbol]) resource
- Servant.Pagination: data Range (field :: Symbol) typ
+ Servant.Pagination: data Range (field :: Symbol) (a :: *)
- Servant.Pagination: defaultOptions :: FromRangeOptions
+ Servant.Pagination: defaultOptions :: RangeOptions
- Servant.Pagination: type PageHeaders range = '[Header "Accept-Ranges" (AcceptRanges range), Header "Content-Range" (ContentRange range), Header "Next-Range" (NextRange range), Header "Total-Count" Natural]
+ Servant.Pagination: type PageHeaders (fields :: [Symbol]) (resource :: *) = '[Header "Accept-Ranges" (AcceptRanges fields), Header "Content-Range" (ContentRange fields resource), Header "Next-Range" (Ranges fields resource)]

Files

+ .stylish-haskell.yaml view
@@ -0,0 +1,41 @@+---+columns: 100+language_extensions:+  - BangPatterns+  - DefaultSignatures+  - DataKinds+  - DeriveDataTypeable+  - DeriveFunctor+  - DeriveGeneric+  - ExistentialQuantification+  - FlexibleContexts+  - GADTs+  - GeneralizedNewtypeDeriving+  - MultiParamTypeClasses+  - OverloadedStrings+  - RecordWildCards+  - ScopedTypeVariables+  - TupleSections+  - TypeOperators+steps:+  - simple_align:+      cases: true+      top_level_patterns: true+      records: true++  - imports:+      align: global+      list_align: after_alias+      pad_module_names: true+      long_list_align: inline+      empty_list_align: inherit+      list_padding: 4+      separate_lists: true+      space_surround: false++  - language_pragmas:+      style: vertical+      align: true+      remove_redundant: true++  - trailing_whitespace: {}
+ CHANGELOG.md view
@@ -0,0 +1,16 @@+# Changelog++## v2.0.0 (2018-04-06)++- Review internal implementation and public API (ditch Range combinator to favor type-level+  list and more discrete footprint). ++- Remove 'Total-Count' header, can still be added on top of the range headers but isn't a Range+  header so to speak. ++- Extend haddock documentation to be more user-friendly+++## v1.0.0 (2018-02-06)++- Initial release
+ README.md view
@@ -0,0 +1,131 @@+# servant-pagination [![](https://img.shields.io/hackage/v/servant-pagination.svg)](https://hackage.haskell.org/package/servant-pagination)++## 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](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 may 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 or 4 headers:++- `Accept-Ranges`: A comma-separated list of field 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 Starting++Code-wise, the integration is rather seamless and requires to declare a `Range` type on+on a given field and to provide an instance of `HasPagination` and `FromHttpApiData`. +The `getRangeField` method from `HasPagination` is merely a getter to retrieve+a range's field value from a resource. ++```hs+data Color = Color+  { name :: String+  , rgb  :: [Int]+  , hex  :: String+  } deriving (Eq, Show, Generic)++instance ToJSON Color where+  toJSON = genericToJSON defaultOptions++instance HasPagination Color "name" where+  type RangeType Color "name" = String+  getFieldValue _ = name+```++That's it, the range is ready to use and to be declared in the Servant API. Additionally,+this library provides a small type alias helper `PageHeaders` to derive response headers from+a range. For example:++```hs+type API =+  "colors"+  :> Header "Range" (Ranges '["name"] Color)+  :> GetPartialContent '[JSON] (Headers (PageHeaders '["name"] Color) [Color])+```++The range is then provided to the corresponding handler as a `Maybe NameRange` (for Servant+<0.13) type and can be used by the backend service to actually apply the given range and +fetch the resources demanded by the client. To send the response, one can leverage the+`returnPage` to lift a collection of resources into a Servant Handler:++```hs+defaultRange :: Range "name" String+defaultRange =+  getDefaultRange (Proxy @Color) Nothing++server :: Maybe (Ranges '["name"] Color) -> Handler (Headers (PageHeaders '["name"] Color) [Color])+server mrange = do+  let range =+        fromMaybe defaultRange (mrange >>= extractRange)++  returnRange range (applyRange range colors)+```++> See `examples/Simple.hs` for a running version of this guide.+++## Multiple Ranges++As you've probably noticed, the 'Ranges' type takes a list of 'Symbol' of accepted fields. For+each of those 'Symbol', there must be a instance of `HasPagination` tighting the 'Symbol' to a+'Resource' and a given type. This enables you to define as many ranges as you want on a given+resource type. For instance, one could go for:++```hs+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 for our range on+`"name"`, we could tweak the corresponding `HasPagination` instance as follows:++```hs+instance HasPagination Color "name" where+  type RangeType Color "name" = String+  getFieldValue _ = name+  getRangeOptions _ _ = defaultOptions { defaultRangeLimit = 5 }+```+++## Changelog++[CHANGELOG.md](CHANGELOG.md)+++## License++[LGPL-3 © 2018 Chordify](LICENSE)
examples/Color.hs view
@@ -1,10 +1,9 @@ module Color where -import           Data.Aeson      (ToJSON)-import           GHC.Generics    (Generic)-import           Numeric.Natural (Natural)+import           Data.Aeson   (ToJSON)+import           GHC.Generics (Generic) -import qualified Data.Aeson      as Aeson+import qualified Data.Aeson   as Aeson   data Color = Color@@ -17,11 +16,6 @@ instance ToJSON Color where   toJSON =     Aeson.genericToJSON Aeson.defaultOptions---nColors :: Natural-nColors =-  fromIntegral $ length colors   colors :: [Color]
examples/Complex.hs view
@@ -1,12 +1,16 @@ {-# OPTIONS_GHC -fno-warn-orphans #-}-{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE TypeFamilies     #-}  module Main where +import           Control.Applicative      ((<|>))+import           Data.Maybe               (fromMaybe) import           Data.Proxy               (Proxy (..)) import           Servant import           Servant.Pagination +import qualified Data.Char                as Char import qualified Network.Wai.Handler.Warp as Warp  import           Color@@ -14,71 +18,72 @@  --  Ranges definitions --- | Custom options for ranges-myOpts :: FromRangeOptions-myOpts =-  defaultOptions { defaultRangeLimit = 5, defaultRangeOrder = RangeAsc }---- | A range on the colors' name-type NameRange =-  Range "name" String--instance FromHttpApiData NameRange where-  parseUrlPiece =-    parseRange myOpts-+-- By default, a Range relies on `defaultOptions` but any instance can define its own options instance HasPagination Color "name" where   type RangeType Color "name" = String-  getRangeField _ =-    name---- | A range on the sum of the rgb components of a color-type RGBRange =-  Range "rgb" Int+  getFieldValue _  = name+  getRangeOptions _ _ = defaultOptions+    { defaultRangeLimit = 5+    , defaultRangeOrder = RangeAsc+    } -instance FromHttpApiData RGBRange where-  parseUrlPiece =-    parseRange myOpts+-- We can declare more than one range on a given type if they use different symbol field+instance HasPagination Color "hex" where+  type RangeType Color "hex" = String+  getFieldValue _ = map Char.toUpper . hex  instance HasPagination Color "rgb" where   type RangeType Color "rgb" = Int--  getRangeField _ =-    sum . rgb+  getFieldValue _ = sum . rgb   -- API  type API =   "colors"-    :> Header "Range" (NameRange :|: RGBRange)+    :> Header "Range" (Ranges '["name", "rgb", "hex"] Color)     :> GetPartialContent '[JSON] (Headers MyHeaders [Color]) +-- PageHeaders fields resource ~ '[Header h typ], thus we can add extra headers+-- as we desire. type MyHeaders =-  PageHeaders (NameRange :|: RGBRange)+  Header "Total-Count" Int ': PageHeaders '["name", "rgb", "hex"] Color   -- Application +defaultRange :: Range "name" String+defaultRange =+  getDefaultRange (Proxy @Color) Nothing+ server :: Server API-server mrange = do-  let range =-        defaultRange Nothing myOpts :: NameRange+server mrange =+  addHeader (length colors) <$> handler mrange+ where+  -- 'extractRange' tries to extract a range if it has the right type, and yields 'Nothing'+  -- otherwise. We can use the '<|>' alternative combinator to try handlers one after+  -- the other+  handler r =+    fromMaybe (returnNameRange defaultRange) $+          fmap returnNameRange (r >>= extractRange)+      <|> fmap returnRGBRange  (r >>= extractRange)+      <|> fmap returnHexRange  (r >>= extractRange) -  case mrange of-    Nothing ->-      returnPage (Just nColors) range (applyRange range colors)+  -- Handlers below are very simple, in practice, they're likely to trigger different functions+  -- or database calls.+  returnNameRange (range :: Range "name" String) =+    returnRange range (applyRange range colors) -    Just (InL nameRange) ->-      returnPage (Just nColors) nameRange (applyRange nameRange colors)+  returnRGBRange (range :: Range "rgb" Int) =+    returnRange range (applyRange range colors) -    Just (InR rgbRange) ->-      returnPage (Just nColors) rgbRange (applyRange rgbRange colors)+  returnHexRange (range :: Range "hex" String) =+    returnRange range (applyRange range colors)   main :: IO () main =-  Warp.run 1337 (serve (Proxy :: Proxy API) server)+  Warp.run 1337 (serve (Proxy @API) server)   -- Examples@@ -91,7 +96,7 @@ -- < Content-Type: application/json;charset=utf-8 -- < Accept-Ranges: name,rgb -- < Content-Range: name Aqua..CadetBlue--- < Next-Range: name CadetBlue;limit 5;offset 0;order asc+-- < Next-Range: name CadetBlue;limit 5;offset 1;order asc -- < Total-Count: 59  @@ -103,8 +108,8 @@ -- < HTTP/1.1 206 Partial Content -- < Content-Type: application/json;charset=utf-8 -- < Accept-Ranges: name,rgb--- < Content-Range: rgb 0..128--- < Next-Range: rgb 128;limit 5;offset 0;order asc+-- < Content-Range: rgb 765..0+-- < Next-Range: rgb 0;limit 100;offset 1;order desc -- < Total-Count: 59  @@ -116,6 +121,6 @@ -- < HTTP/1.1 206 Partial Content -- < Content-Type: application/json;charset=utf-8 -- < Accept-Ranges: name,rgb--- < Content-Range: name Fuchsia..DarkMagenta--- < Next-Range: name DarkMagenta;limit 10;offset 0;order desc+-- < Content-Range: name Green..DarkMagenta+-- < Next-Range: name DarkMagenta;limit 10;offset 1;order desc -- < Total-Count: 59
examples/Simple.hs view
@@ -1,5 +1,6 @@ {-# OPTIONS_GHC -fno-warn-orphans #-}-{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE TypeFamilies     #-}  module Main where @@ -15,32 +16,30 @@  --  Ranges definitions --- | A range on the colors' name-type NameRange =-  Range "name" String- instance HasPagination Color "name" where   type RangeType Color "name" = String--  getRangeField _ =-    name+  getFieldValue _ = name  -- API  type API =   "colors"-    :> Header "Range" NameRange-    :> GetPartialContent '[JSON] (Headers (PageHeaders NameRange) [Color])+    :> Header "Range" (Ranges '["name"] Color)+    :> GetPartialContent '[JSON] (Headers (PageHeaders '["name"] Color) [Color])   -- Application +defaultRange :: Range "name" String+defaultRange =+  getDefaultRange (Proxy @Color) Nothing+ server :: Server API server mrange = do   let range =-        fromMaybe (defaultRange Nothing defaultOptions) mrange+        fromMaybe defaultRange (mrange >>= extractRange) -  returnPage (Just nColors) range (applyRange range colors)+  returnRange range (applyRange range colors)  main :: IO () main =@@ -63,8 +62,7 @@ -- < Content-Type: application/json;charset=utf-8 -- < Accept-Ranges: name -- < Content-Range: name Yellow..Aqua--- < Next-Range: name Aqua;limit 100;offset 0;order desc--- < Total-Count: 59+-- < Next-Range: name Aqua;limit 100;offset 1;order desc   -- $ curl -v http://localhost:1337/colors --header 'Range: name; offset 59'@@ -75,4 +73,3 @@ -- < HTTP/1.1 206 Partial Content -- < Content-Type: application/json;charset=utf-8 -- < Accept-Ranges: name--- < Total-Count: 59
servant-pagination.cabal view
@@ -5,7 +5,7 @@                      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.)-version:             1.0.0+version:             2.0.0 homepage:            https://github.com/chordify/haskell-servant-pagination bug-reports:         https://github.com/chordify/haskell-servant-pagination/issues license:             LGPL-3@@ -17,6 +17,12 @@ build-type:          Simple cabal-version:       >=1.20 +extra-source-files:  README.md+                     CHANGELOG.md+                     stack.yaml+                     Setup.hs+                     .stylish-haskell.yaml+ source-repository head   type: git   location: git://github.com/chordify/haskell-servant-pagination.git@@ -51,12 +57,11 @@    build-depends:       base >= 4 && < 5                      , text >= 1.2 && < 2-                     , servant >= 0.11 && < 1-                     , servant-server >= 0.11 && < 1+                     , servant >= 0.11 && <= 0.13+                     , servant-server >= 0.11 && <= 0.13                      , safe >= 0.3 && < 1    exposed-modules:     Servant.Pagination-                     , Servant.Pagination.Internal  executable servant-pagination-simple   if !flag(examples)@@ -87,9 +92,9 @@    build-depends:       base >= 4 && < 5                      , aeson >= 1.2 && < 2-                     , servant >= 0.11 && < 1-                     , servant-pagination >= 1 && < 2-                     , servant-server >= 0.11 && < 1+                     , servant >= 0.11 && <= 0.13+                     , servant-pagination+                     , servant-server >= 0.11 && <= 0.13                      , warp >= 3.2 && < 4    other-modules:       Color@@ -123,9 +128,9 @@    build-depends:       base >= 4 && < 5                      , aeson >= 1.2 && < 2-                     , servant >= 0.11 && < 1-                     , servant-pagination >= 1 && < 2-                     , servant-server >= 0.11 && < 1+                     , servant >= 0.11 && <= 0.13+                     , servant-pagination+                     , servant-server >= 0.11 && <= 0.13                      , warp >= 3.2 && < 4    other-modules:       Color
src/Servant/Pagination.hs view
@@ -1,196 +1,240 @@-{-# LANGUAGE TypeFamilies #-}+-- | Opinionated Pagination Helpers for Servant APIs+--+--+-- Client can provide a `Range` header with their request with the following format+--+-- > Range: <field> [<value>][; offset <o>][; limit <l>][; order <asc|desc>]+--+-- Available ranges are declared using type-level list of accepted fields, bound to a given+-- resource and type using the 'HasPagination' type-class. The library provides unobtrusive+-- types and abstract away all the plumbing to hook that on an existing Servant API.+--+-- The 'IsRangeType' constraints summarize all constraints that must apply to a possible field+-- and heavily rely on the 'Web.Internal.FromHttpApiData' and 'Web.Internal.ToHttpApiData'.+--+-- > $ curl -v http://localhost:1337/colors -H 'Range: name; limit 10'+-- >+-- > > GET /colors HTTP/1.1+-- > > Host: localhost:1337+-- > > User-Agent: curl/7.47.0+-- > > Accept: */*+-- > >+-- > < HTTP/1.1 206 Partial Content+-- > < Transfer-Encoding: chunked+-- > < Date: Tue, 30 Jan 2018 12:45:17 GMT+-- > < Server: Warp/3.2.13+-- > < Content-Type: application/json;charset=utf-8+-- > < Accept-Ranges: name+-- > < Content-Range: name Yellow..Purple+-- > < Next-Range: name Purple;limit 10;offset 1;order desc+--+-- The 'Range' header is totally optional, but when provided, it indicates to the server what+-- parts of the collection is requested. As a reponse and in addition to the data, the server may+-- provide 3 headers to the client:+--+-- - @Accept-Ranges@: A comma-separated list of field 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+--+-- This allows the client to work in a very _dumb_ mode where it simply consumes data from the server+-- using the value of the 'Next-Range' header to fetch each new batch of data. The 'Accept-Ranges'+-- comes in handy to self-document the API telling the client about the available filtering and sorting options+-- of a resource.+--+-- Here's a minimal example used to obtained the previous behavior; Most of the magic happens in the+-- 'returnRange' function which lift a collection of resources into a Servant handler, computing the+-- corresponding ranges from the range used to retrieve the resources.+--+-- @+-- -- Resource Type+--+-- data Color = Color+--   { name :: 'String'+--   , rgb  :: ['Int']+--   , hex  :: 'String'+--   } deriving ('Eq', 'Show', 'GHC.Generics.Generic')+--+-- colors :: [Color]+-- colors = [ {- ... -} ]+--+-- -- Ranges definitions+--+-- instance 'HasPagination' Color "name" where+--   type 'RangeType' Color "name" = 'String'+--   'getFieldValue' _ = name+--+--+-- -- API+--+-- type API =+--   "colors"+--     :> 'Servant.Header' \"Range\" ('Ranges' '["name"] Color)+--     :> 'Servant.GetPartialContent' '['Servant.JSON'] ('Servant.Headers' ('PageHeaders' '["name"] Color) [Color])+--+--+-- -- Application+--+-- defaultRange :: 'Range' "name" 'String'+-- defaultRange =+--   'getDefaultRange' ('Data.Proxy.Proxy' @Color) 'Data.Maybe.Nothing'+--+-- server :: 'Servant.Server.Server' API+-- server mrange = do+--   let range =+--         'Data.Maybe.fromMaybe' defaultRange (mrange >>= 'extractRange')+--+--   'returnRange' range ('applyRange' range colors)+--+-- main :: 'IO' ()+-- main =+--   'Network.Wai.Handler.Warp.run' 1337 ('Servant.Server.serve' ('Data.Proxy.Proxy' @API) server)+-- @ +{-# LANGUAGE ConstraintKinds     #-}+{-# LANGUAGE GADTs               #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications    #-}+{-# LANGUAGE TypeFamilies        #-}+ module Servant.Pagination   (   -- * Types-    Range(..)+  Ranges+  , Range(..)   , RangeOrder(..)   , AcceptRanges (..)   , ContentRange (..)-  , NextRange (..)   , PageHeaders-  , TotalCount+  , IsRangeType    -- * Declare Ranges-  , FromRange(..)-  , FromHttpApiData(..)-  , FromRangeOptions(..)+  , HasPagination(..)+  , RangeOptions(..)   , defaultOptions-  , defaultRange    -- * Use Ranges-  , HasPagination(..)+  , extractRange+  , returnRange   , applyRange--  -- * Combine Ranges-  , (:|:)(..)   ) where -import           Data.List                   (filter, find)-import           Data.Maybe                  (fromMaybe, listToMaybe)-import           Data.Proxy                  (Proxy (..))-import           Data.Semigroup              ((<>))-import           Data.Text                   (Text)-import           GHC.Generics                (Generic)-import           GHC.TypeLits                (KnownSymbol, Symbol, symbolVal)-import           Numeric.Natural             (Natural)+import           Data.List      (filter, find)+import           Data.Maybe     (listToMaybe)+import           Data.Proxy     (Proxy (..))+import           Data.Semigroup ((<>))+import           Data.Text      (Text)+import           GHC.Generics   (Generic)+import           GHC.TypeLits   (KnownSymbol, Symbol, symbolVal) import           Servant -import qualified Data.List                   as List-import qualified Data.Text                   as Text+import qualified Data.List      as List+import qualified Data.Text      as Text import qualified Safe -import           Servant.Pagination.Internal - -- -- TYPES -- --- An actual Range parsed from a `Range` header. A Range-data Range (field :: Symbol) typ = Range-  { rangeValue  :: Maybe typ   -- ^ The value of that field, beginning of the range+-- | Set of constraints that must apply to every type target of a Range+type IsRangeType a =+  ( Show a+  , Ord a+  , Eq a+  , FromHttpApiData a+  , ToHttpApiData a+  )++-- | A type to specify accepted Ranges via the @Range@ HTTP Header. For example:+--+-- @+-- type API = "resources"+--   :> 'Servant.Header' \"Range\" ('Ranges' '["created_at"] Resource)+--   :> 'Servant.Get' '['Servant.JSON'] [Resource]+-- @+data Ranges :: [Symbol] -> * -> * where+  Lift   :: Ranges fields resource -> Ranges (y ': fields) resource+  Ranges+    :: HasPagination resource field+    => Range field (RangeType resource field)+    -> Ranges (field ': fields) resource++-- | An actual 'Range' instance obtained from parsing / to generate a @Range@ HTTP Header.+data Range (field :: Symbol) (a :: *) =+  (KnownSymbol field, IsRangeType a) => Range+  { rangeValue  :: Maybe a     -- ^ The value of that field, beginning of the range   , rangeLimit  :: Int         -- ^ Maximum number of resources to return   , rangeOffset :: Int         -- ^ Offset, number of resources to skip after the starting value   , rangeOrder  :: RangeOrder  -- ^ The order of sorting (ascending or descending)-  } deriving (Eq, Show, Generic)--instance Functor (Range field) where-  fmap f r =-    r { rangeValue = f <$> rangeValue r }--instance (ToHttpApiData typ, KnownSymbol field) => ToHttpApiData (Range field typ) where-  toUrlPiece Range{..} =-    Text.pack (symbolVal (Proxy :: Proxy field))-    <> maybe "" (\v -> " " <> toUrlPiece v) rangeValue-    <> ";limit "  <> toUrlPiece rangeLimit-    <> ";offset " <> toUrlPiece rangeOffset-    <> ";order "  <> toUrlPiece rangeOrder--instance (KnownSymbol field) => ToAcceptRanges (Range field typ) where-  toAcceptRanges _ =-    Text.pack (symbolVal (Proxy :: Proxy field))----- | Define the sorting order of the paginated resources (ascending or descending)-data RangeOrder-  = RangeAsc-  | RangeDesc-  deriving (Eq, Show, Ord, Generic)--instance ToHttpApiData RangeOrder where-  toUrlPiece order =-    case order of-      RangeAsc  -> "asc"-      RangeDesc -> "desc"--instance FromHttpApiData RangeOrder where-  parseUrlPiece txt =-    case txt of-      "asc"  -> pure RangeAsc-      "desc" -> pure RangeDesc-      _      -> Left "Invalid Range Order"----- | Accepted Ranges in the `Accept-Ranges` response's header-data AcceptRanges range = AcceptRanges--instance (ToAcceptRanges a) => ToHttpApiData (AcceptRanges a) where-  toUrlPiece _ =-    toAcceptRanges (Proxy :: Proxy a)----- | Actual range returned, in the `Content-Range` response's header-data ContentRange range = ContentRange-  { contentRangeStart :: range-  , contentRangeEnd   :: range+  , rangeField  :: Proxy field -- ^ Actual field this Range actually refers to   } -instance (ToHttpApiData typ, KnownSymbol field) => ToHttpApiData (ContentRange (Range field typ)) where-  toUrlPiece (ContentRange start end) =-    Text.pack (symbolVal (Proxy :: Proxy field))-    <> " "  <> (fromMaybe "" (toUrlPiece <$> rangeValue start))-    <> ".." <> (fromMaybe "" (toUrlPiece <$> rangeValue end)) -instance (ToHttpApiData (ContentRange a), ToHttpApiData (ContentRange b)) => ToHttpApiData (ContentRange (a :|: b)) where-  toUrlPiece (ContentRange (InL sa) (InL ea)) =-    toUrlPiece (ContentRange sa ea)--  toUrlPiece (ContentRange (InR sb) (InR eb)) =-    toUrlPiece (ContentRange sb eb)--  toUrlPiece _ =-    error "impossible"----- | Range to provide to retrieve the next batch of resource, in the `Next-Range` response's header-data NextRange range = NextRange range--instance (ToHttpApiData typ, KnownSymbol field) => ToHttpApiData (NextRange (Range field typ)) where-  toUrlPiece (NextRange r) =-    toUrlPiece r+-- | Extract a 'Range' from a 'Ranges'+class ExtractRange (fields :: [Symbol]) (field :: Symbol) where+  -- | Extract a 'Range' from a 'Ranges'. Works like a safe 'read', trying to coerce a 'Range' instance to+  -- an expected type. Type annotation are most likely necessary to remove ambiguity. Note that a 'Range'+  -- can only be extrated to a type bound by the allowed 'fields' on a given 'resource'.+  --+  -- @+  -- extractDateRange :: 'Ranges' '["created_at", "name"] Resource -> 'Range' "created_at" 'Data.Time.Clock.UTCTime'+  -- extractDateRange =+  --   'extractRange'+  -- @+  extractRange+    :: HasPagination resource field+    => Ranges fields resource                         -- ^ A list of accepted Ranges for the API+    -> Maybe (Range field (RangeType resource field)) -- ^ A Range instance of the expected type, if it matches -instance (ToHttpApiData (NextRange a), ToHttpApiData (NextRange b)) => ToHttpApiData (NextRange (a :|: b)) where-  toUrlPiece (NextRange (InL a)) =-    toUrlPiece (NextRange a)+instance ExtractRange (field ': fields) field where+  extractRange (Ranges r) = Just r+  extractRange (Lift _)   = Nothing -  toUrlPiece (NextRange (InR b)) =-    toUrlPiece (NextRange b)+instance {-# OVERLAPPABLE #-} ExtractRange fields field => ExtractRange (y ': fields) field where+  extractRange (Ranges _) = Nothing+  extractRange (Lift r)   = extractRange r  --- | Type alias to declare response headers related to pagination-type PageHeaders range =-  '[ Header "Accept-Ranges" (AcceptRanges range)-   , Header "Content-Range" (ContentRange range)-   , Header "Next-Range"    (NextRange range)-   , Header "Total-Count"   Natural-   ]-+-- | Put a 'Range' in a 'Ranges'+class PutRange (fields :: [Symbol]) (field :: Symbol) where+  putRange+    :: HasPagination resource field+    => Range field (RangeType resource field)+    -> Ranges fields resource ------ DECLARE RANGES---+instance PutRange (field ': fields) field where+  putRange = Ranges --- | Default values to apply when parsing a Range-data FromRangeOptions  = FromRangeOptions-  { defaultRangeLimit  :: Int-  , defaultRangeOffset :: Int-  , defaultRangeOrder  :: RangeOrder-  } deriving (Eq, Show)+instance {-# OVERLAPPABLE #-} (PutRange fields field) => PutRange (y ': fields) field where+  putRange = Lift . putRange  --- | Some default options of default values for a Range (limit 100; offset 0; order desc)-defaultOptions :: FromRangeOptions-defaultOptions =-  FromRangeOptions 100 0 RangeDesc-+instance ToHttpApiData (Ranges fields resource) where+  toUrlPiece (Lift range) =+    toUrlPiece range --- | Some default range based on the default options-defaultRange :: Maybe a -> FromRangeOptions -> Range field a-defaultRange val opts =-  let-    (FromRangeOptions lim off ord) =-      opts-  in-    Range val lim off ord+  toUrlPiece (Ranges Range{..}) =+    Text.pack (symbolVal rangeField)+    <> maybe "" (\v -> " " <> toUrlPiece v) rangeValue+    <> ";limit "  <> toUrlPiece rangeLimit+    <> ";offset " <> toUrlPiece rangeOffset+    <> ";order "  <> toUrlPiece rangeOrder  --- | Parse a Range object from a `Range` request's header. Any `Range field typ` and combinations--- of any `Range field typ` provide instance of this class. It is a signature similar to--- `parseUrlPiece` from the `FromHttpApiData` class and can be used as a drop-in replacement to--- define instance of this class.------ > type MyRange = Range "created_at" UTCTime--- >--- > instance FromHttpApiData UTCTime => FromHttpApiData MyRange where--- >     parseUrlPiece =--- >         fromRange defaultOptions-class FromRange a where-  parseRange :: FromRangeOptions -> Text -> Either Text a+instance FromHttpApiData (Ranges '[] resource) where+  parseUrlPiece _ =+    Left "Invalid Range" -instance (FromHttpApiData typ, KnownSymbol field) => FromRange (Range field typ) where-  parseRange FromRangeOptions{..} txt =+instance+  ( FromHttpApiData (Ranges fields resource)+  , HasPagination resource field+  , KnownSymbol field+  , IsRangeType (RangeType resource field)+  ) => FromHttpApiData (Ranges (field ': fields) resource) where+  parseUrlPiece txt =     let+      RangeOptions{..} = getRangeOptions (Proxy @resource) (Proxy @field)+       toTuples =         filter (/= "") . Text.splitOn (Text.singleton ' ') @@ -198,21 +242,24 @@         map toTuples $ Text.splitOn (Text.singleton ';') txt        field =-        Text.pack $ symbolVal (Proxy :: Proxy field)+        Text.pack $ symbolVal (Proxy @field)     in       case args of         (field' : value) : rest | field == field' -> do           opts <-             traverse parseOpt rest -          Range+          range <- Range             <$> sequence (fmap parseQueryParam (listToMaybe value))-            <*> ifOpt "limit" defaultRangeLimit opts-            <*> ifOpt "offset"  defaultRangeOffset opts-            <*> ifOpt "order" defaultRangeOrder opts+            <*> ifOpt "limit"  defaultRangeLimit opts+            <*> ifOpt "offset" defaultRangeOffset opts+            <*> ifOpt "order"  defaultRangeOrder opts+            <*> pure (Proxy @field) +          pure $ Ranges range+         _ ->-          Left "Invalid Range"+          Lift <$> (parseUrlPiece txt :: Either Text (Ranges fields resource))     where       parseOpt :: [Text] -> Either Text (Text, Text)       parseOpt piece =@@ -227,107 +274,188 @@       ifOpt opt def =         maybe (pure def) (parseQueryParam . snd) . find ((== opt) . fst) -instance {-# Overlappable #-} (FromHttpApiData typ, KnownSymbol field) => FromHttpApiData (Range field typ) where-  parseUrlPiece =-    parseRange defaultOptions +-- | Define the sorting order of the paginated resources (ascending or descending)+data RangeOrder+  = RangeAsc+  | RangeDesc+  deriving (Eq, Show, Ord, Generic) -type TotalCount =-  Maybe Natural+instance ToHttpApiData RangeOrder where+  toUrlPiece order =+    case order of+      RangeAsc  -> "asc"+      RangeDesc -> "desc" +instance FromHttpApiData RangeOrder where+  parseUrlPiece txt =+    case txt of+      "asc"  -> pure RangeAsc+      "desc" -> pure RangeDesc+      _      -> Left "Invalid Range Order"+++-- | Type alias to declare response headers related to pagination --+-- @+-- type MyHeaders =+--   'PageHeaders' '["created_at"] Resource+--+-- type API = "resources"+--   :> 'Servant.Header' \"Range\" ('Ranges' '["created_at"] Resource)+--   :> 'Servant.Get' '['Servant.JSON'] ('Servant.Headers' MyHeaders [Resource])+-- @+type PageHeaders (fields :: [Symbol]) (resource :: *) =+  '[ Header "Accept-Ranges" (AcceptRanges fields)+   , Header "Content-Range" (ContentRange fields resource)+   , Header "Next-Range"    (Ranges fields resource)+   ]++-- | Accepted Ranges in the `Accept-Ranges` response's header+data AcceptRanges (fields :: [Symbol]) = AcceptRanges++instance (KnownSymbol field) => ToHttpApiData (AcceptRanges '[field]) where+  toUrlPiece AcceptRanges =+    Text.pack (symbolVal (Proxy @field))++instance (ToHttpApiData (AcceptRanges (f ': fs)), KnownSymbol field) => ToHttpApiData (AcceptRanges (field ': f ': fs)) where+  toUrlPiece AcceptRanges =+    Text.pack (symbolVal (Proxy @field)) <> "," <> toUrlPiece (AcceptRanges @(f ': fs))+++-- | Actual range returned, in the `Content-Range` response's header+data ContentRange (fields :: [Symbol]) resource =+  forall field. (KnownSymbol field, ToHttpApiData (RangeType resource field)) => ContentRange+  { contentRangeStart :: RangeType resource field+  , contentRangeEnd   :: RangeType resource field+  , contentRangeField :: Proxy field+  }++instance ToHttpApiData (ContentRange fields res) where+  toUrlPiece (ContentRange start end field) =+    Text.pack (symbolVal field) <> " " <> toUrlPiece start <> ".." <> toUrlPiece end+++-- -- USE RANGES -- --- | In addition to the `FromHttpApiData` instance, one can provide an instance for this--- type-class to easily lift a list of response to a Servant handler.--- By providing a getter to retrieve the value of an actual range from a resource, the--- `HasPagination` class provides `returnPage` to handle the plumbering of declaring--- response headers related to pagination.+-- | Available 'Range' on a given @resource@ must implements the 'HasPagination' type-class.+-- This class defines how the library can interact with a given @resource@ to access the value+-- to which a @field@ refers. class KnownSymbol field => HasPagination resource field where   type RangeType resource field :: * -  getRangeField :: Proxy field -> resource -> RangeType resource field+  -- | Get the corressponding value of a Resource+  getFieldValue :: Proxy field -> resource -> RangeType resource field -  returnPage_ :: forall m ranges.-    ( Monad m-    , (Range field (RangeType resource field)) :<: ranges-    , ToAcceptRanges ranges-    , ToHttpApiData (ContentRange ranges)-    , ToHttpApiData (NextRange ranges)-    , Ord (RangeType resource field)-    ) => (Range field (RangeType resource field)) -> [resource] -> m (Headers (PageHeaders ranges) [resource])-  returnPage_ =-    returnPage Nothing-  {-# INLINE returnPage_ #-}+  -- | Get parsing options for the 'Range' defined on this 'field'+  getRangeOptions :: Proxy resource -> Proxy field -> RangeOptions+  getRangeOptions _ _ = defaultOptions -  returnPage :: forall m ranges.-    ( Monad m-    , (Range field (RangeType resource field)) :<: ranges-    , ToAcceptRanges ranges-    , ToHttpApiData (ContentRange ranges)-    , ToHttpApiData (NextRange ranges)-    , Ord (RangeType resource field)-    ) => TotalCount -> (Range field (RangeType resource field)) -> [resource] -> m (Headers (PageHeaders ranges) [resource])-  returnPage count range rs = do-    let field =-          Proxy :: Proxy field+  -- | Create a default 'Range' from a value and default 'RangeOptions'. Typical use-case+  -- is for when no or an invalid 'Range' header was provided.+  getDefaultRange+    :: IsRangeType (RangeType resource field)+    => Proxy resource+    -> Maybe (RangeType resource field)+    -> Range field (RangeType resource field)+  getDefaultRange _ val =+    let+      RangeOptions{..} = getRangeOptions (Proxy @resource) (Proxy @field)+    in Range+      { rangeValue  = val+      , rangeLimit  = defaultRangeLimit+      , rangeOffset = defaultRangeOffset+      , rangeOrder  = defaultRangeOrder+      , rangeField  = Proxy @field+      } -    let boundaries = (,)-          <$> fmap (getRangeField field) (Safe.headMay rs)-          <*> fmap (getRangeField field) (Safe.lastMay rs)+-- | Lift an API response in a 'Monad', typically a 'Servant.Server.Handler'. 'Ranges' headers can be quite cumbersome to+-- declare and can be deduced from the resources returned and the previous 'Range'. This is exactly what this function+-- does.+--+-- @+-- myHandler+--  :: 'Maybe' ('Ranges' '["created_at"] Resource)+--  -> 'Servant.Server.Handler' ('Servant.Headers' ('PageHeaders' '["created_at"] Resource) [Resource])+-- myHandler mrange =+--  let range =+--        'Data.Maybe.fromMaybe' ('getDefaultRange' ('Data.Proxy.Proxy' @Resource)) (mrange >>= 'extractRange')+--+--  'returnRange' range ('applyRange' range resources)+-- @+returnRange+  :: ( Monad m+     , ToHttpApiData (AcceptRanges fields)+     , KnownSymbol field+     , HasPagination resource field+     , IsRangeType (RangeType resource field)+     , PutRange fields field+     )+  => Range field (RangeType resource field)               -- ^ Actual 'Range' used to retrieve the results+  -> [resource]                                           -- ^ Resources to returned, fetched from a db or a local store+  -> m (Headers (PageHeaders fields resource) [resource]) -- ^ Resources embedded in a given 'Monad' (typically a 'Servant.Server.Handler', with pagination headers)+returnRange Range{..} rs = do+  let boundaries = (,)+        <$> fmap (getFieldValue rangeField) (Safe.headMay rs)+        <*> fmap (getFieldValue rangeField) (Safe.lastMay rs) -    let acceptRanges =-          addHeader (AcceptRanges :: AcceptRanges ranges)+  case boundaries of+    Nothing ->+      return $ addHeader AcceptRanges $ noHeader $ noHeader rs -    let totalCount =-          maybe noHeader addHeader count+    Just (start, end) -> do+      let nextOffset | rangeValue == Just end = rangeOffset + length rs+                     | otherwise              = length $ takeWhile ((==) end . getFieldValue rangeField) (reverse rs) -    case boundaries of-      Nothing ->-        return $-          acceptRanges $ noHeader $ noHeader $ totalCount rs+      let nextRange = putRange Range+            { rangeValue  = Just end+            , rangeLimit  = rangeLimit+            , rangeOffset = nextOffset+            , rangeOrder  = rangeOrder+            , rangeField  = rangeField+            } -      Just (start, end) -> do-        let rangeStart =-              liftRange $ (range { rangeValue = Just start } :: Range field (RangeType resource field))+      let contentRange = ContentRange+            { contentRangeStart = start+            , contentRangeEnd   = end+            , contentRangeField = rangeField+            } -        let rangeEnd =-              liftRange $ (range { rangeValue = Just end } :: Range field (RangeType resource field))+      return $ addHeader AcceptRanges $ addHeader contentRange $ addHeader nextRange rs -        let nextOffset | rangeValue range `compare` Just end == EQ = rangeOffset range + length rs-                       | otherwise = length $ takeWhile (\r -> getRangeField field r `compare` end == EQ) $ reverse rs-        -        let rangeNext =-              liftRange $ (range { rangeValue = Just end, rangeOffset = nextOffset } :: Range field (RangeType resource field))+-- | Default values to apply when parsing a 'Range'+data RangeOptions  = RangeOptions+  { defaultRangeLimit  :: Int         -- ^ Default limit if not provided, default to @100@+  , defaultRangeOffset :: Int         -- ^ Default offset if not provided, default to @0@+  , defaultRangeOrder  :: RangeOrder  -- ^ Default order if not provided, default to 'RangeDesc'+  } deriving (Eq, Show) -        let contentRange =-              addHeader $ ContentRange-                { contentRangeStart = rangeStart-                , contentRangeEnd   = rangeEnd-                } -        let nextRange =-              addHeader $ NextRange $ rangeNext--        return-          $ acceptRanges $ contentRange $ nextRange $ totalCount rs+-- | Some default options of default values for a Range (limit 100; offset 0; order desc)+defaultOptions :: RangeOptions+defaultOptions =+  RangeOptions 100 0 RangeDesc  --- | Apply a range to a list of element-applyRange :: forall b field. (HasPagination b field, Ord (RangeType b field)) => Range field (RangeType b field) -> [b] -> [b]+-- | Helper to apply a 'Range' to a list of values. Most likely useless in practice+-- as results may come more realistically from a database, but useful for debugging or+-- testing.+applyRange+  :: HasPagination resource field+  => Range field (RangeType resource field) -- ^ A 'Range' instance on a given @resource@+  -> [resource]                             -- ^ A full-list of @resource@+  -> [resource]                             -- ^ The sublist obtained by applying the 'Range' applyRange Range{..} =   let-    field =-      Proxy :: Proxy (field :: Symbol)-     sortRel =       case rangeOrder of         RangeDesc ->-          \a b -> compare (getRangeField field b) (getRangeField field a)+          \a b -> compare (getFieldValue rangeField b) (getFieldValue rangeField a)          RangeAsc ->-          \a b -> compare (getRangeField field a) (getRangeField field b)+          \a b -> compare (getFieldValue rangeField a) (getFieldValue rangeField b)      dropRel =       case (rangeValue, rangeOrder) of@@ -335,10 +463,10 @@           const False          (Just a, RangeDesc) ->-          (> a) . (getRangeField field)+          (> a) . getFieldValue rangeField          (Just a, RangeAsc) ->-          (< a) . (getRangeField field)+          (< a) . getFieldValue rangeField   in       List.take rangeLimit     . List.drop rangeOffset
− src/Servant/Pagination/Internal.hs
@@ -1,123 +0,0 @@-{-# LANGUAGE TypeFamilies #-}--module Servant.Pagination.Internal where--import           Data.Kind      (Constraint)-import           Data.Proxy     (Proxy (..))-import           Data.Semigroup ((<>))-import           Data.Text      (Text)-import           Servant        (FromHttpApiData (..), ToHttpApiData (..))----- | Helper to execute two `Either a b` successively-orElse :: Either a b -> Either a b -> Either a b-orElse a b =-  either (const b) (const a) a-{-# INLINE orElse #-}----- | Representation of AcceptRanges as a list of comma-separated text fields from the type--- of a Range only (value isn't needed here since only the 'field' matters)-class ToAcceptRanges r where-  toAcceptRanges :: Proxy r -> Text----- | Combine two ranges in a new range, parsing is done left-first-data a :|: b = InL a | InR b-infixl 7 :|:--instance (ToHttpApiData a, ToHttpApiData b) => ToHttpApiData (a :|: b) where-  toUrlPiece (InL a) =-    toUrlPiece a--  toUrlPiece (InR b) =-    toUrlPiece b--instance (FromHttpApiData a, FromHttpApiData b) => FromHttpApiData (a :|: b) where-  parseUrlPiece txt =-             (liftRange <$> (parseUrlPiece txt :: Either Text a))-    `orElse` (liftRange <$> (parseUrlPiece txt :: Either Text b))--instance (ToAcceptRanges a, ToAcceptRanges b) => ToAcceptRanges (a :|: b) where-  toAcceptRanges _ =-    toAcceptRanges (Proxy :: Proxy a) <> "," <>  toAcceptRanges (Proxy :: Proxy b)----- | Type family helper to define a constraint about ranges-type family InRanges r rs :: Constraint where-  InRanges r r           = ()-  InRanges r (rs :|: r)  = ()-  InRanges r (rs :|: r') = InRanges r rs----- | Relation for lifting range into a combination of ranges-class range :<: ranges where-  liftRange :: range -> ranges--instance r :<: r where-  liftRange = id-  {-# INLINE liftRange #-}--instance r :<: (r :|: r2) where-  liftRange = InL-  {-# INLINE liftRange #-}--instance r :<: (r1 :|: r) where-  liftRange = InR-  {-# INLINE liftRange #-}--instance r :<: (r1 :|: r :|: r3) where-  liftRange = InL . InR-  {-# INLINE liftRange #-}--instance r :<: (r :|: r2 :|: r3) where-  liftRange = InL . InL-  {-# INLINE liftRange #-}--instance r :<: (r1 :|: r :|: r3 :|: r4) where-  liftRange = InL . InL . InR-  {-# INLINE liftRange #-}--instance r :<: (r :|: r2 :|: r3 :|: r4) where-  liftRange = InL . InL . InL-  {-# INLINE liftRange #-}--instance r :<: (r1 :|: r :|: r3 :|: r4 :|: r5) where-  liftRange = InL . InL . InL . InR-  {-# INLINE liftRange #-}--instance r :<: (r :|: r2 :|: r3 :|: r4 :|: r5) where-  liftRange = InL . InL . InL . InL-  {-# INLINE liftRange #-}--instance r :<: (r1 :|: r :|: r3 :|: r4 :|: r5 :|: r6) where-  liftRange = InL . InL . InL . InL . InR-  {-# INLINE liftRange #-}--instance r :<: (r :|: r2 :|: r3 :|: r4 :|: r5 :|: r6) where-  liftRange = InL . InL . InL . InL . InL-  {-# INLINE liftRange #-}--instance r :<: (r1 :|: r :|: r3 :|: r4 :|: r5 :|: r6 :|: r7) where-  liftRange = InL . InL . InL . InL . InL . InR-  {-# INLINE liftRange #-}--instance r :<: (r :|: r2 :|: r3 :|: r4 :|: r5 :|: r6 :|: r7) where-  liftRange = InL . InL . InL . InL . InL . InL-  {-# INLINE liftRange #-}--instance r :<: (r1 :|: r :|: r3 :|: r4 :|: r5 :|: r6 :|: r7 :|: r8) where-  liftRange = InL . InL . InL . InL . InL . InL . InR-  {-# INLINE liftRange #-}--instance r :<: (r :|: r2 :|: r3 :|: r4 :|: r5 :|: r6 :|: r7 :|: r8) where-  liftRange = InL . InL . InL . InL . InL . InL . InL-  {-# INLINE liftRange #-}--instance r :<: (r1 :|: r :|: r3 :|: r4 :|: r5 :|: r6 :|: r7 :|: r8 :|: r9) where-  liftRange = InL . InL . InL . InL . InL . InL . InL . InR-  {-# INLINE liftRange #-}--instance r :<: (r :|: r2 :|: r3 :|: r4 :|: r5 :|: r6 :|: r7 :|: r8 :|: r9) where-  liftRange = InL . InL . InL . InL . InL . InL . InL . InL-  {-# INLINE liftRange #-}
+ stack.yaml view
@@ -0,0 +1,5 @@+resolver: lts-10.3+packages:+- .+extra-deps: []+extra-package-dbs: []