Given the following Servant API definition:
type API =
"single-content" :> Get '[JSON] Int
:<|> "contents" :> QueryParam "page" Int :> Get '[JSON] (Headers '[Header "Link" String] [Int])
The second endpoint is paginated, and contains a next
Link header in the response if there are more elements.
I can generate client functions using servant-client:
paginatedClient :: Maybe Int -> ClientM (Headers '[Header "Link" String] [Int])
singleClient :: ClientM Int
singleClient :<|> paginatedClient = client (Proxy :: Proxy API)
I'm looking for a way to extend the client function for the paginated endpoint so that it automatically picks up the link from the response headers, call the next page, and accumulate the results.
Ideally, the type signature wouldn't change compared to the default client. It would be fine if the request would live in a different monad than ClientM
.
I found some prior art and had some ideas, but nothing that brings me closer to my goal:
a
inside the monad.Besides the usual Servant packages and imports, this answer also depends on http-client.
This is a function that takes a URL string and a Servant client action, and overwrites the path (and query string) of all HTTP requests performed by the action with the URL parameter.
import Network.HTTP.Client.Internal qualified as Http
overrideUrl :: String -> ClientM a -> ClientM a
overrideUrl url action = do
request <- Http.parseRequest url
let transformClientRequest original =
original {
Http.path = request.path,
Http.queryString = request.queryString
}
transformMakeClientRequest f baseUrl servantReq = do
httpReq <- f baseUrl servantReq
pure $ transformClientRequest httpReq
transformClientEnv clientEnv =
clientEnv {
makeClientRequest =
transformMakeClientRequest clientEnv.makeClientRequest
}
local transformClientEnv action
It works by tweaking the values in the ClientEnv
using local
.
This is a function that takes a Servant client action that returns a monoidal value along with a "next page" link, and returns another action that collects all the results while following the links:
paginated ::
forall (s :: Symbol) rest a . Monoid a =>
ClientM (Headers (Header s String ': rest) a) ->
ClientM (Headers (Header s String ': rest) a)
paginated initial = do
let go action acc = do
r <- action
let acc' = acc <> getResponse r
HCons header _ = getHeadersHList r
case header of
UndecodableHeader {} -> do
liftIO $ throwIO $ userError "undecodable header"
MissingHeader -> do
pure $ r { getResponse = acc' }
Header next -> do
go (overrideUrl next initial) acc'
go initial mempty
paginated
makes use of overrideUrl
to go to a different link each time, while keeping the same request headers and other configuration.
The question now is how to apply the paginated
decorator to your client. It's not done at the type level. Instead, you have to take your API client value, go into the particular client function that you want to paginate, and transform its ClientM
action with the decorator in order to obtain a new API client. (If the client function has parameters, you'll need a bit more busywork to reach the ClientM
action.)
Decorating the API client value is much easier if you use NamedRoutes
(video) because then the client functions become name fields in a record, instead of being anonymous slots in and positional struture.
An example with named routes:
type PaginatedApi = NamedRoutes Foo
data Foo mode = Foo {
firstContent ::
mode
:- "contents"
:> Get '[JSON] (Headers '[Header "Link" String] [Int]),
extraContent ::
mode
:- "contents-extra"
:> Capture "page" Int
:> Get '[JSON] (Headers '[Header "Link" String] [Int])
} deriving stock (Generic)
fooClient :: Client ClientM PaginatedApi
fooClient = client (Proxy @PaginatedApi)
fooClientDecorated :: Client ClientM PaginatedApi
fooClientDecorated = fooClient { firstContent = paginated fooClient.firstContent}