|
| 1 | +# MultiVerb: Powerful endpoint types |
| 2 | + |
| 3 | +`MultiVerb` allows you to represent an API endpoint with multiple response types, status codes and headers. |
| 4 | + |
| 5 | +## Preliminaries |
| 6 | + |
| 7 | +```haskell |
| 8 | +{-# LANGUAGE GHC2021 #-} |
| 9 | +{-# LANGUAGE DataKinds #-} |
| 10 | +{-# LANGUAGE DerivingStrategies #-} |
| 11 | +{-# LANGUAGE DerivingVia #-} |
| 12 | +
|
| 13 | +import GHC.Generics |
| 14 | +import Generics.SOP qualified as GSOP |
| 15 | +import Network.Wai.Handler.Warp as Warp |
| 16 | +
|
| 17 | +import Servant.API |
| 18 | +import Servant.API.MultiVerb |
| 19 | +import Servant.Server |
| 20 | +import Servant.Server.Generic |
| 21 | +``` |
| 22 | +
|
| 23 | +## Writing an endpoint |
| 24 | +
|
| 25 | +Let us create an endpoint that captures an 'Int' and has the following logic: |
| 26 | +
|
| 27 | +* If the number is negative, we return status code 400 and an empty body; |
| 28 | +* If the number is even, we return a 'Bool' in the response body; |
| 29 | +* If the number is odd, we return another 'Int' in the response body. |
| 30 | +
|
| 31 | +Let us list all possible HTTP responses: |
| 32 | +```haskell |
| 33 | +
|
| 34 | +type Responses = |
| 35 | + '[ RespondEmpty 400 "Negative" |
| 36 | + , Respond 200 "Odd number" Int |
| 37 | + , Respond 200 "Even number" Bool |
| 38 | + ] |
| 39 | +``` |
| 40 | +
|
| 41 | +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. |
| 42 | +In order to tie the two types together, we will use a mechanism called `AsUnion` to create a correspondance between the two: |
| 43 | +
|
| 44 | +```haskell |
| 45 | +data Result |
| 46 | + = NegativeNumber |
| 47 | + | Odd Int |
| 48 | + | Even Bool |
| 49 | + deriving stock (Generic) |
| 50 | + deriving (AsUnion Responses) |
| 51 | + via GenericAsUnion Responses Result |
| 52 | +
|
| 53 | +instance GSOP.Generic Result |
| 54 | +``` |
| 55 | +
|
| 56 | +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: |
| 57 | +
|
| 58 | +``` |
| 59 | +• No instance for ‘AsConstructor |
| 60 | + ((:) @Type Int ('[] @Type)) (Respond 200 "Even number" Bool)’ |
| 61 | + arising from the 'deriving' clause of a data type declaration |
| 62 | +``` |
| 63 | +
|
| 64 | +(_If you would prefer to write an intance of 'AsUnion' by yourself, read more in Annex 1 “Implementing AsUnion manually” section._) |
| 65 | +
|
| 66 | +Finally, let us write our endpoint description: |
| 67 | +
|
| 68 | +```haskell |
| 69 | +type MultipleChoicesInt = |
| 70 | + Capture "int" Int |
| 71 | + :> MultiVerb |
| 72 | + 'GET |
| 73 | + '[JSON] |
| 74 | + Responses |
| 75 | + Result |
| 76 | +``` |
| 77 | +
|
| 78 | +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, |
| 79 | +and can send one of the responses and associated result value." |
| 80 | +
|
| 81 | +### Implementing AsUnion manually |
| 82 | +
|
| 83 | +In the above example, the `AsUnion` typeclass is derived through the help of the `DerivingVia` mechanism, |
| 84 | +and the `GenericAsUnion` wrapper. |
| 85 | +
|
| 86 | +If you would prefer implementing it yourself, you need to encode your responses as [Peano numbers](https://wiki.haskell.org/Peano_numbers), |
| 87 | +augmented with the `I`(identity) combinator. |
| 88 | +
|
| 89 | +See how three options can be encoded as the Z (zero), S Z (successor to zero, so one), |
| 90 | +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 |
| 91 | +Haskell datatypes. See the instance below for the encoding/decoding process: |
| 92 | +
|
| 93 | +``` |
| 94 | +instance AsUnion MultipleChoicesIntResponses MultipleChoicesIntResult where |
| 95 | + toUnion NegativeNumber = Z (I ()) |
| 96 | + toUnion (Even b) = S (Z (I b)) |
| 97 | + toUnion (Odd i) = S (S (Z (I i))) |
| 98 | +
|
| 99 | + fromUnion (Z (I ())) = NegativeNumber |
| 100 | + fromUnion (S (Z (I b))) = Even b |
| 101 | + fromUnion (S (S (Z (I i)))) = Odd i |
| 102 | + fromUnion (S (S (S x))) = case x of {} |
| 103 | +``` |
| 104 | +
|
| 105 | +## Integration in a routing table |
| 106 | +
|
| 107 | +We want to integrate our endpoint into a wider routing table with another |
| 108 | +endpoint: `version`, which returns the version of the API |
| 109 | +
|
| 110 | +```haskell |
| 111 | +data Routes mode = Routes |
| 112 | + { choicesRoutes :: mode :- "choices" :> Choices |
| 113 | + , version :: mode :- "version" :> Get '[JSON] Int |
| 114 | + } |
| 115 | + deriving stock (Generic) |
| 116 | +``` |
| 117 | +
|
| 118 | +```haskell |
| 119 | +type Choices = NamedRoutes Choices' |
| 120 | +data Choices' mode = Choices' |
| 121 | + { choices :: mode :- MultipleChoicesInt |
| 122 | + } |
| 123 | + deriving stock (Generic) |
| 124 | +
|
| 125 | +choicesServer :: Choices' AsServer |
| 126 | +choicesServer = |
| 127 | + Choices' |
| 128 | + { choices = choicesHandler |
| 129 | + } |
| 130 | +
|
| 131 | +routesServer :: Routes AsServer |
| 132 | +routesServer = |
| 133 | + Routes |
| 134 | + { choicesRoutes = choicesServer |
| 135 | + , version = versionHandler |
| 136 | + } |
| 137 | +
|
| 138 | +choicesHandler :: Int -> Handler Result |
| 139 | +choicesHandler parameter = |
| 140 | + if parameter < 0 |
| 141 | + then pure NegativeNumber |
| 142 | + else |
| 143 | + if even parameter |
| 144 | + then pure $ Odd 3 |
| 145 | + else pure $ Even True |
| 146 | +
|
| 147 | +versionHandler :: Handler Int |
| 148 | +versionHandler = pure 1 |
| 149 | +``` |
| 150 | +
|
| 151 | +We can now plug everything together: |
| 152 | +
|
| 153 | +
|
| 154 | +```haskell |
| 155 | +main :: IO () |
| 156 | +main = do |
| 157 | + putStrLn "Starting server on http://localhost:5000" |
| 158 | + let server = genericServe routesServer |
| 159 | + Warp.run 5000 server |
| 160 | +``` |
| 161 | +
|
| 162 | +Now let us run the server and observe how it behaves: |
| 163 | +
|
| 164 | +``` |
| 165 | +$ http http://localhost:5000/version |
| 166 | +HTTP/1.1 200 OK |
| 167 | +Content-Type: application/json;charset=utf-8 |
| 168 | +Date: Thu, 29 Aug 2024 14:22:20 GMT |
| 169 | +Server: Warp/3.4.1 |
| 170 | +Transfer-Encoding: chunked |
| 171 | +
|
| 172 | +1 |
| 173 | +``` |
| 174 | +
|
| 175 | +
|
| 176 | +``` |
| 177 | +$ http http://localhost:5000/choices/3 |
| 178 | +HTTP/1.1 200 OK |
| 179 | +Content-Type: application/json;charset=utf-8 |
| 180 | +Date: Thu, 29 Aug 2024 14:22:30 GMT |
| 181 | +Server: Warp/3.4.1 |
| 182 | +Transfer-Encoding: chunked |
| 183 | +
|
| 184 | +true |
| 185 | +``` |
| 186 | +
|
| 187 | +``` |
| 188 | +$ http http://localhost:5000/choices/2 |
| 189 | +HTTP/1.1 200 OK |
| 190 | +Content-Type: application/json;charset=utf-8 |
| 191 | +Date: Thu, 29 Aug 2024 14:22:33 GMT |
| 192 | +Server: Warp/3.4.1 |
| 193 | +Transfer-Encoding: chunked |
| 194 | +
|
| 195 | +3 |
| 196 | +``` |
| 197 | +
|
| 198 | +``` |
| 199 | +$ http http://localhost:5000/choices/-432 |
| 200 | +HTTP/1.1 400 Bad Request |
| 201 | +Date: Thu, 29 Aug 2024 14:22:41 GMT |
| 202 | +Server: Warp/3.4.1 |
| 203 | +Transfer-Encoding: chunked |
| 204 | +``` |
| 205 | +
|
| 206 | +You have now learned how to use the MultiVerb feature of Servant. |
| 207 | +
|
| 208 | +## Annex 1: Implementing AsUnion manually |
| 209 | +
|
| 210 | +Should you need to implement `AsUnion` manually, here is how to do it. `AsUnion` relies on |
| 211 | +two methods, `toUnion` and `fromUnion`. They respectively encode your response type to, and decode it from, an inductive type that resembles a [Peano number](https://wiki.haskell.org/Peano_numbers). |
| 212 | +
|
| 213 | +Let's see it in action, with explanations below: |
| 214 | +
|
| 215 | +```haskell |
| 216 | +instance => AsUnion MultipleChoicesIntResponses MultipleChoicesIntResult where |
| 217 | + toUnion NegativeNumber = Z (I ()) |
| 218 | + toUnion (Even b) = S (Z (I b)) |
| 219 | + toUnion (Odd i) = S (S (Z (I i))) |
| 220 | +
|
| 221 | + fromUnion (Z (I ())) = NegativeNumber |
| 222 | + fromUnion (S (Z (I b))) = Even b |
| 223 | + fromUnion (S (S (Z (I i)))) = Odd i |
| 224 | + fromUnion (S (S (S x))) = case x of {} |
| 225 | +``` |
| 226 | +
|
| 227 | +### Encoding our data to a Union |
| 228 | +
|
| 229 | +Let's see how the implementation of `toUnion` works: |
| 230 | +
|
| 231 | +In the first equation for `toUnion`, `NegativeNumber` gets translated by `toUnion` into `Z (I ())`. |
| 232 | +`I` is the constructor that holds a value. Here it is holds no meaningful value, because `NegativeNumber` does not have any argument. |
| 233 | +In the tradition of Peano numbers, we start with the `Z`, for Zero. |
| 234 | +
|
| 235 | +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. |
| 236 | +Since it has one argument, we can store it in the `I` constructor. |
| 237 | +
|
| 238 | +The pattern repeats with `Odd`, which hole a value (`i`) too. We add a `S`uccessor constructor to the previous encoding, |
| 239 | +and we store the value inside `I`. |
| 240 | +
|
| 241 | +### Decoding the Union |
| 242 | +
|
| 243 | +Since every member of our sum type was encoded to a unique form as an inductive data structure, we can decode them quite easily: |
| 244 | +
|
| 245 | +* `Z (I ())` is our `NegativeNumber` constructor; |
| 246 | +* `(S (Z (I b)))` is `Even` with `b`; |
| 247 | +* `(S (S (Z (I i))))` is `Odd` with `i`. |
| 248 | +
|
| 249 | +Finally, the last equation of `fromUnion` is here to satisfy GHC's pattern checker. It does not serve any functional purpose. |
0 commit comments