Skip to content

Instantly share code, notes, and snippets.

@TotallyNotChase
Last active October 1, 2022 11:07
Show Gist options
  • Save TotallyNotChase/f31a8e12a0e4424b0167adf91da27f28 to your computer and use it in GitHub Desktop.
Save TotallyNotChase/f31a8e12a0e4424b0167adf91da27f28 to your computer and use it in GitHub Desktop.
Servant woes
Servant woes for future me to figure out
========================================
```sh
stack ghci Woes.lhs \
--package servant \
--package servant-client-core \
--package servant-client \
--package text
```
> {-# LANGUAGE DataKinds #-}
> {-# LANGUAGE FlexibleContexts #-}
> {-# LANGUAGE OverloadedStrings #-}
> {-# LANGUAGE ScopedTypeVariables #-}
> {-# LANGUAGE TypeApplications #-}
> {-# LANGUAGE TypeOperators #-}
> {-# LANGUAGE TypeFamilyDependencies #-}
> {-# LANGUAGE StandaloneKindSignatures #-}
> module Servant.Woes where
Note: I'm sure many of these have idiomatic solutions; I'm just noting them down so that I can find said solutions later.
Servant is easily the best web framework I've ever used, so these are hardly woes, just nitpicks.
> import Data.Proxy (Proxy (Proxy))
> import Data.Kind (Type)
> import Data.Text (Text)
>
> import Servant.API
> import Servant.Client.Core
> import Servant.Client
Hoisting out common API prefix
------------------------------
Here's the thing, you have a bunch of API endpoints:
> type Api
> = "example1" :> Get '[JSON] ()
> :<|> "example2" :> ReqBody '[JSON] Text :> Post '[JSON] Int
You can now obtain the client endpoints at the term level, for free. As an example:
> example1 :: ClientM ()
> example2 :: Text -> ClientM Int
> example1 :<|> example2 = client @Api Proxy
This is all great, but what if you now needed an API token to be set in a header across all the endpoints?
Could you do:
> type Api' = Header' '[Required, Strict] "X-API-Token" Text :> Api
What would one expect the endpoints types to be after that change?
You could expect it to be added as the first arg to every endpoint:
@
example1 :: Text -> ClientM ()
example2 :: Text -> Text -> ClientM Int
@
But that won't work, since `:>` is right associative. So `Api'` looks more like:
@Text -> Client ClientM Api@
Which resolves to:
@Text -> (ClientM () :<|> (Text -> ClientM Int))@
So it would end up looking more like:
> mkApi' :: Text -> ClientM () :<|> (Text -> ClientM Int)
> mkApi' = client @Api' Proxy
Which means you have to re-obtain the endpoints each time by passing the header value.
Here's what calling the new "example1" endpoint would look like:
> foo :: ClientM ()
> foo = example1'
> where
> example1' :<|> _ = mkApi' "ApiToken"
With more headers/common prefixes, hoisting becomes rather annoying. In production, you'll want to use
[NamedRoutes](https://www.tweag.io/blog/2022-02-24-named-routes/) instead. Which is effectively designed
to make this less annoying, at the cost of more code.
Bonus: Why not use a type family?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
You can indeed do:
> type MapPrefix :: Type -> Type -> Type
> type family MapPrefix prefx api = r | r -> prefx where
> MapPrefix prefx (route1 :<|> routeRest) = prefx :> route1 :<|> MapPrefix prefx routeRest
> MapPrefix prefx routeLast = prefx :> routeLast
>
> type MappedApi = MapPrefix (Header' '[Required, Strict] "X-API-Token" Text) Api
>
> mappedExample1 :: Text -> ClientM ()
> mappedExample2 :: Text -> Text -> ClientM Int
> mappedExample1 :<|> mappedExample2 = client @MappedApi Proxy
However, in all honesty, this isn't particularly viable all the time. The more mileage you try to get out of
type families in Haskell, the more you realize that they solve one problem while creating another.
Once you start adding more headers (or indeed other prefixes), you might try to generalize this type family.
Which may open another can of worms due to type families not being first class (among other things)!
Composition, where?
-------------------
Ever thought about composing two HTTP APIs at the type level to obtain the consolidated _and_ **correct**
endpoints at the term level for free?
No? Well, consider this: there is a standardized API for a particular process, but such an API is provided by several
different companies/providers, each with their own prefixes - i.e different headers, tokens etc.
The core, standardized part looks like:
> type SubmitCore = ReqBody '[JSON] Text :> Post '[JSON] Int
Indeed, it simply accepts a string and yields an identifying int.
Now, there are two providers, Foo and Bar:
- Foo mounted the corresponding endpoint on 'api/submit/sample'.
It doesn't take anything extra other than the standard request body.
- Bar mounted the corresponding endpoint on 'submit/example'.
However, it also takes a header: 'X-API-Token' with a textual token.
Fundamentally, the _core_ endpoint should look the same for both of them. Say you have a custom type:
> data CustomData
>
> serializeCustomData :: CustomData -> Text
> serializeCustomData = undefined -- Assume this is a real serialization function :)
Now, you want to serialize and submit this data type using the above standardized API.
You don't particularly care about the provider. What you want is a function of effectively this type:
> type SubmissionF = CustomData -> ClientM Int
However, to implement this you need that aforementioned _core_ endpoint, that can be somehow composed with any
provider. You need a function of type:
> type SubmitCoreF = Text -> ClientM Int
OK, but how? The two providers will have two different client handler functions:
> type FooApi = "api" :> "submit" :> "sample" :> SubmitCore
> type BarApi = Header' '[Required, Strict] "X-API-Token" Text :> "submit" :> "example" :> SubmitCore
>
> fooSubmit :: SubmitCoreF
> fooSubmit = client @FooApi Proxy
>
> barSubmit :: Text -> SubmitCoreF
> barSubmit = client @BarApi Proxy
This means that you cannot have a "free" core function of type `SubmissionF`:
> coreSubmit :: SubmissionF
> coreSubmit d = undefined -- ???
> where
> payload = serializeCustomData d
Instead, you have to make it so `coreSubmit` is parameterized over a function of type `SubmitCoreF`:
> coreSubmit' :: SubmitCoreF -> SubmissionF
> coreSubmit' f d = f payload
> where
> payload = serializeCustomData d
Alas, there is no clean way to ad-hoc obtain `coreSubmit` by somehow type-applying a specific provider's API type.
Bonus: Why not use a type family? (part deuce)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~-------------
Here we go again.
OK, my idea of doing this involves a type function that is effectively the inverse of `:>`.
In essence, the first step towards an ad-hoc `coreSubmit` would look like:
> helpme ::
> ( HasClient ClientM api
> , Client ClientM api ~ SubmitCoreF
> )
> => Proxy api
> -> SubmissionF
> helpme prox d = f payload
> where
> f = client prox
> payload = serializeCustomData d
The issue here is that it's too specific. The 'api' type being accepted _must_ have a single endpoint, and
it must be of type `SubmitCoreF`.
This means that `FooApi` will be accepted here, but `BarApi` will not be - it takes an extra header!
This is why I suggest a `:>` inverse, which would acknowledge and delegate extra arguments.... without
losing composability.
That still won't be enough to accomodate for multi-endpoint APIs where the interesting endpoint is only a part of
the whole thing.
I need to experiment with this a lot more.
Identity of :>
--------------
This hasn't been as big of an issue. But sometimes I have a higher kinded API builder:
> type MkApi prefx coreApi = prefx :> coreApi
It's hard to articulate the specific issue here since it's oversimplified, but just pretend that `MkApi` is used somewhere
within a complex hierarchy of cross-composing servant machinery - so you can't just take it out and use `:>` yourself. You **have
to** pass a prefix.
That's all great when you do indeed have a prefix. But what happens when you don't have a prefix, and you just want to use some
"core API" raw?
This sort of pattern is quite common in the Haskell world, where everything is expected to compose with each other. Usually, you'd
use `mempty` when you are forced to combine something with another thing, but you want the result to just be that "other thing". You
need an idendity element for a given binary function.
Unfortunately, there is no clean way to do this with `:>`. I think both `:>` and `:<|>` should have an idendity. However, I realize
that they aren't actually monoids as they violate the associativity law.
Bonus: Why ~~not~~ use a type family?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Stop.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment