I wanted to have a simple way to test all possible scenario of an http responses.
There are tools like Mountebank but it is an extra thing that requires to be learn/maintained/installed/etc by all team members so I was looking for a simpler solution that could be leveraged from inside Elm.
So I created a folder with a bunch of json files that contain the body of a typical successful api response, then when the application run with a certain environment (I called it “Local”) the app would access these files instead sending an ajax request to the outside world.
We use elm-live server for our development environment.
This system works well for successful http responses (response status == 200) but as it is, it doesn’t support other status codes.
So I tried to encapsulate and entire http response inside the body of an http response and let Elm do the magic.
A response in Elm is defined in Http.Response as:
type alias Response body =
{ url : String
, status :
{ code : Int
, message : String
}
, headers : Dict String String
, body : body
}
so, let suppose that, upon sending username and password, we have a successful answer form our server-side API like
{
"token": "abc",
"userName": "Miyuki"
}
so we can just save this file into login.json and, regardless username and password (that are ignored by our “dumb” API), we will always get a successful login.
How can we simulate the situation that the username or password are wrong without contaminating too much our code?
Let’s put an entire http response in a file called login-401.json:
{
"url": "",
"status": {
"code": 401,
"message": "Unauthorised"
},
"headers": {},
"body": "{\"error\": \"Username or Password are not valid\"}"
}
So now we can create as many different responses as we want.
How can we tell Elm to verify if the response is actually another response or just data?
This is where the magic happen:
unboxResponse outerResponse =
case Json.Decode.decodeString decodeResponse outerResponse.body of
Ok innerResponse ->
Ok innerResponse Err _ ->
Ok outerResponse
where decodeResponse
is a decoder for the Http.Response type. This function try to decode the body of the response as if it would contain another response. If it succeeds then return the inner response, otherwise it returns the outer response.
Implementation details
First of all we need the decoder for the response:
decodeResponse : Json.Decode.Decoder (Http.Response String)
decodeResponse =
Json.Decode.succeed Http.Response
|> Json.Decode.Pipeline.required "url" Json.Decode.string
|> Json.Decode.Pipeline.required "status"
(Json.Decode.succeed HttpStatus
|> Json.Decode.Pipeline.required "code" Json.Decode.int
|> Json.Decode.Pipeline.required "message" Json.Decode.string
)
|> Json.Decode.Pipeline.required "headers" (Json.Decode.dict Json.Decode.string)
|> Json.Decode.Pipeline.required "body" Json.Decode.string
type alias HttpStatus =
{ code : Int
, message : String
}
Now we need to deal with the entire response, so we need to use:
Http.expectStringResponse : (Http.Response String -> Result String a) -> Expect a
and we give our magic unboxResponse
as first parameter that has the type signature:
unboxResponse : Http.Response String -> Result String (Http.Response String)
Note that while expectStringResponse
accept a generic type a
, we are constrained to use String
as a
because unboxResponse
need to be consistent between the type that come in (Http.Response String
) and the type that goes out.
So we need to decode the response in two steps. First time we decode considering the body as type String
. The second time we decode the body.
Let’s have a look of what the http request looks like (using lukewestby/elm-http-builder
):
cmdApiRequest =
"login-401.json"
|> HttpBuilder.get
|> HttpBuilder.withExpect Http.expectStringResponse unboxResponse
|> HttpBuilder.send MsgApiResponse
when we hit the real API, it should be a POST request, so we can add a condition there based on the environment
cmdApiRequest environment bodyData =
case environment of
Local ->
"login-401.json"
|> HttpBuilder.get
|> HttpBuilder.withExpect Http.expectStringResponse unboxResponse
|> HttpBuilder.send MsgApiResponse _ ->
"https://apiserver/login"
|> HttpBuilder.post
|> HttpBuilder.withBody (Http.stringBody "application/json" <| bodyRequestToString bodyData)
|> HttpBuilder.withExpect Http.expectStringResponse unboxResponse
|> HttpBuilder.send MsgApiResponse
Note that in the Local
request we don’t even bother to send the body to the request because we are talking to a dummy server that will completely ignore the body.
If you want to get fancier, you can use part of the bodyData
to influence with API response you want to text, for example:
"login-" ++ String.left 3 data.username ++ ".json"
|> HttpBuilder.get
|> HttpBuilder.withExpect Http.expectStringResponse unboxResponse
|> HttpBuilder.send MsgApiResponse
So now typing 200abcd
as user name you will get a successful response, typing 401abcd
you will get an unauthorised response. All this without an API server!
At this point, when the response is coming back from the http server we will probably save it in the model with. Before saving it we should decode the body as well because it is still just a string. Assuming we have a decoder for the body (decoderBody
):
decodeBodyInsideResult :
Result Http.Error (Http.Response String)
-> Json.Decode.Decoder body
-> Result Http.Error (Http.Response body)
decodeBodyInsideResult responseWithBodyAsString decoderBody =
case responseWithBodyAsString of
Err err ->
Err err Ok response ->
case Json.Decode.decodeString decoderBody response.body of
Err err ->
Err <| Http.BadPayload (Json.Decode.errorToString err) response Ok body ->
if response.status.code < 200 || 300 <= response.status.code then
Err <| Http.BadStatus response else
Ok <|
{ url = response.url
, status = response.status
, headers = response.headers
, body = body
}
Note the type signature here, we get a Http.Response String
as input and return a Http.Response body
as output, we decoded the body part!
We also took care of the case of the response status code, something that before Elm was doing for us when we were using Http.expectJson
.
Now we can proceed saving this in the model from our update
:
MsgApiResponse responseWithBodyAsString ->
{ model | apiResponse = decodeBodyInsideResult responseWithBodyAsString decoderBody }
Or wrap it in some other type, for example:
type State body
= NotRequested
| Fetching
| Success (Http.Response body)
| Error Http.Error
Conclusion
This system allow our front-end developers to consider all possible API errors remaining inside Elm without the aid of any external resource.