MultiVerb: Powerful endpoint types
MultiVerb allows you to represent an API endpoint with multiple response types, status codes and headers.
Preliminaries
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE DerivingVia #-}
import GHC.Generics
import Generics.SOP qualified as GSOP
import Network.Wai.Handler.Warp as Warp
import Servant.API
import Servant.API.MultiVerb
import Servant.Server
import Servant.Server.Generic
Writing an endpoint
Let us create an endpoint that captures an ‘Int’ and has the following logic:
If the number is negative, we return status code 400 and an empty body;
If the number is even, we return a ‘Bool’ in the response body;
If the number is odd, we return another ‘Int’ in the response body.
Let us list all possible HTTP responses:
type Responses =
'[ RespondEmpty 400 "Negative"
, Respond 200 "Odd number" Int
, Respond 200 "Even number" Bool
]
Let us create the return type. We will create a sum type that lists the values on the Haskell side that correspond to our HTTP responses.
In order to tie the two types together, we will use a mechanism called AsUnion to create a correspondance between the two:
data Result
= NegativeNumber
| Odd Int
| Even Bool
deriving stock (Generic)
deriving (AsUnion Responses)
via GenericAsUnion Responses Result
instance GSOP.Generic Result
These deriving statements above tie together the responses and the return values, and the order in which they are defined matters. For instance, if Even and Odd had switched places in the definition of Result, this would provoke an error:
• No instance for ‘AsConstructor
((:) @Type Int ('[] @Type)) (Respond 200 "Even number" Bool)’
arising from the 'deriving' clause of a data type declaration
(If you would prefer to write an intance of ‘AsUnion’ by yourself, read more in Annex 1 “Implementing AsUnion manually” section.)
Finally, let us write our endpoint description:
type MultipleChoicesInt =
Capture "int" Int
:> MultiVerb
'GET
'[JSON]
Responses
Result
This piece of code is to be read as “Create an endpoint that captures an integer, and accepts a GET request with the application/json MIME type,
and can send one of the responses and associated result value.”
Implementing AsUnion manually
In the above example, the AsUnion typeclass is derived through the help of the DerivingVia mechanism,
and the GenericAsUnion wrapper.
If you would prefer implementing it yourself, you need to encode your responses as Peano numbers,
augmented with the I(identity) combinator.
See how three options can be encoded as the Z (zero), S Z (successor to zero, so one), and S (S Z) (the sucessor to the successor to zero, so two). This encoding is static, so we know in advance how to decode them to Haskell datatypes. See the instance below for the encoding/decoding process:
instance AsUnion MultipleChoicesIntResponses MultipleChoicesIntResult where
toUnion NegativeNumber = Z (I ())
toUnion (Even b) = S (Z (I b))
toUnion (Odd i) = S (S (Z (I i)))
fromUnion (Z (I ())) = NegativeNumber
fromUnion (S (Z (I b))) = Even b
fromUnion (S (S (Z (I i)))) = Odd i
fromUnion (S (S (S x))) = case x of {}
Integration in a routing table
We want to integrate our endpoint into a wider routing table with another
endpoint: version, which returns the version of the API
data Routes mode = Routes
{ choicesRoutes :: mode :- "choices" :> Choices
, version :: mode :- "version" :> Get '[JSON] Int
}
deriving stock (Generic)
type Choices = NamedRoutes Choices'
data Choices' mode = Choices'
{ choices :: mode :- MultipleChoicesInt
}
deriving stock (Generic)
choicesServer :: Choices' AsServer
choicesServer =
Choices'
{ choices = choicesHandler
}
routesServer :: Routes AsServer
routesServer =
Routes
{ choicesRoutes = choicesServer
, version = versionHandler
}
choicesHandler :: Int -> Handler Result
choicesHandler parameter =
if parameter < 0
then pure NegativeNumber
else
if even parameter
then pure $ Odd 3
else pure $ Even True
versionHandler :: Handler Int
versionHandler = pure 1
We can now plug everything together:
main :: IO ()
main = do
putStrLn "Starting server on http://localhost:5000"
let server = genericServe routesServer
Warp.run 5000 server
Now let us run the server and observe how it behaves:
$ http http://localhost:5000/version
HTTP/1.1 200 OK
Content-Type: application/json;charset=utf-8
Date: Thu, 29 Aug 2024 14:22:20 GMT
Server: Warp/3.4.1
Transfer-Encoding: chunked
1
$ http http://localhost:5000/choices/3
HTTP/1.1 200 OK
Content-Type: application/json;charset=utf-8
Date: Thu, 29 Aug 2024 14:22:30 GMT
Server: Warp/3.4.1
Transfer-Encoding: chunked
true
$ http http://localhost:5000/choices/2
HTTP/1.1 200 OK
Content-Type: application/json;charset=utf-8
Date: Thu, 29 Aug 2024 14:22:33 GMT
Server: Warp/3.4.1
Transfer-Encoding: chunked
3
$ http http://localhost:5000/choices/-432
HTTP/1.1 400 Bad Request
Date: Thu, 29 Aug 2024 14:22:41 GMT
Server: Warp/3.4.1
Transfer-Encoding: chunked
You have now learned how to use the MultiVerb feature of Servant.
Annex 1: Implementing AsUnion manually
Should you need to implement AsUnion manually, here is how to do it. AsUnion relies on
two methods, toUnion and fromUnion. They respectively encode your response type to, and decode it from, an inductive type that resembles a Peano number.
Let’s see it in action, with explanations below:
instance => AsUnion MultipleChoicesIntResponses MultipleChoicesIntResult where
toUnion NegativeNumber = Z (I ())
toUnion (Even b) = S (Z (I b))
toUnion (Odd i) = S (S (Z (I i)))
fromUnion (Z (I ())) = NegativeNumber
fromUnion (S (Z (I b))) = Even b
fromUnion (S (S (Z (I i)))) = Odd i
fromUnion (S (S (S x))) = case x of {}
Encoding our data to a Union
Let’s see how the implementation of toUnion works:
In the first equation for toUnion, NegativeNumber gets translated by toUnion into Z (I ()).
I is the constructor that holds a value. Here it is holds no meaningful value, because NegativeNumber does not have any argument.
In the tradition of Peano numbers, we start with the Z, for Zero.
Then Even, which holds a value, b, must then be encoded. Following Zero is its Successor, so we wrap the Z within a S constructor.
Since it has one argument, we can store it in the I constructor.
The pattern repeats with Odd, which hole a value (i) too. We add a Successor constructor to the previous encoding,
and we store the value inside I.
Decoding the Union
Since every member of our sum type was encoded to a unique form as an inductive data structure, we can decode them quite easily:
Z (I ())is ourNegativeNumberconstructor;(S (Z (I b)))isEvenwithb;(S (S (Z (I i))))isOddwithi.
Finally, the last equation of fromUnion is here to satisfy GHC’s pattern checker. It does not serve any functional purpose.