Modelling state in Elm to reflect business logic

Ritesh Pillai

Ritesh Pillai

June 4, 2018

We recently made ApiSnapshot open source. As mentioned in that blog we ported code from React.js to Elm.

One of the features of ApiSnapshot is support for Basic Authentication.

ApiSnapshot with basic authentication

While we were rebuilding the whole application in Elm, we had to port the "Add Basic Authentication" feature. This feature can be accessed from the "More" drop-down on the right-hand side of the app and it lets user add username and password to the request.

Let's see how the Model of our Elm app looks.

1type alias Model =
2{ request : Request.MainRequest.Model
3, response : Response.MainResponse.Model
4, route : Route
5}

Here is the Model in Request.MainRequest module.

1type alias APISnapshotRequest =
2{ url : String
3, httpMethod : HttpMethod
4, requestParameters : RequestParameters
5, requestHeaders : RequestHeaders
6, username : Maybe String
7, password : Maybe String
8, requestBody : Maybe RequestBody
9}
10
11type alias Model =
12{ request : APISnapshotRequest
13, showErrors : Bool
14}

username and password fields are optional for the users so we kept them as Maybe types.

Note that API always responds with username and password whether user clicked to add Basic Authentication or not. The API would respond with a null for both username and password when a user tries to retrieve a snapshot for which user did not fill username and password.

Here is a sample API response.

1{
2  "url": "http://dog.ceo/api/breed/affenpinscher/images/random",
3  "httpMethod": "GET",
4  "requestParams": {},
5  "requestHeaders": {},
6  "requestBody": null,
7  "username": "alanturning",
8  "password": "welcome",
9  "assertions": [],
10  "response": {
11    "response_headers": {
12      "age": "0",
13      "via": "1.1 varnish (Varnish/6.0), 1.1 varnish (Varnish/6.0)",
14      "date": "Thu, 03 May 2018 09:43:11 GMT",
15      "vary": "",
16      "cf_ray": "4151c826ac834704-EWR",
17      "server": "cloudflare"
18    },
19    "response_body": "{\"status\":\"success\",\"message\":\"https:\\/\\/images.dog.ceo\\/breeds\\/affenpinscher\\/n02110627_13221.jpg\"}",
20    "response_code": "200"
21  }
22}

Let's look at the view code which renders the data received from the API.

1view : (Maybe String, Maybe String) -> Html Msg
2view usernameAndPassword =
3case usernameAndPassword of
4(Nothing, Nothing) -> text ""
5(Just username, Nothing) -> basicAuthenticationView username ""
6(Nothing, Just password) -> basicAuthenticationView "" password
7(Just username, Just password) -> basicAuthenticationView username password
8
9basicAuthenticationView : String -> String -> Html Msg
10basicAuthenticationView username password =
11[ div [ class "form-row" ]
12[ input
13[ type_ "text"
14, placeholder "Username"
15, value username
16, onInput (UpdateUsername)
17]
18[]
19, input
20[ type_ "password"
21, placeholder "Password"
22, value password
23, onInput (UpdatePassword)
24]
25[]
26, a
27[ href "javascript:void(0)"
28, onClick (RemoveBasicAuthentication)
29]
30[ text "×" ]
31]
32]

To get the desired view we apply following rules.

  1. Check if both the values are string.
  2. Check if either of the values is string.
  3. Assume that both the values are null.

This works but we can do a better job of modelling it.

What's happening here is that we were trying to translate our API responses directly to the Model . Let's try to club username and password together into a new type called BasicAuthentication.

In the model add a parameter called basicAuthentication which would be of type Maybe BasicAuthentication. This way if user has opted to use basic authentication fields then it is a Just BasicAuthentication and we can show the input boxes. Otherwise it is Nothing and we show nothing!

Here is what the updated Model for Request.MainRequest would look like.

1type alias BasicAuthentication =
2{ username : String
3, password : String
4}
5
6type alias APISnapshotRequest =
7{ url : String
8, httpMethod : HttpMethod
9, requestParameters : RequestParameters
10, requestHeaders : RequestHeaders
11, basicAuthentication : Maybe BasicAuthentication
12, requestBody : Maybe RequestBody
13}
14
15type alias Model =
16{ request : APISnapshotRequest
17, showErrors : Bool
18}

Elm compiler is complaining that we need to make changes to JSON decoding for APISnapshotRequest type because of this change.

Before we fix that let's take a look at how JSON decoding is currently being done.

1import Json.Decode as JD
2import Json.Decode.Pipeline as JP
3
4decodeAPISnapshotRequest : Response -> APISnapshotRequest
5decodeAPISnapshotRequest hitResponse =
6let
7result =
8JD.decodeString requestDecoder hitResponse.body
9in
10case result of
11Ok decodedValue ->
12decodedValue
13
14            Err err ->
15                emptyRequest
16
17requestDecoder : JD.Decoder APISnapshotRequest
18requestDecoder =
19JP.decode Request
20|> JP.optional "username" (JD.map Just JD.string) Nothing
21|> JP.optional "password" (JD.map Just JD.string) Nothing

Now we need to derive the state of the application from our API response .

Let's introduce a type called ReceivedAPISnapshotRequest which would be the shape of our old APISnapshotRequest with no basicAuthentication field. And let's update our requestDecoder function to return a Decoder of type ReceivedAPISnapshotRequest instead of APISnapshotRequest.

1type alias ReceivedAPISnapshotRequest =
2{ url : String
3, httpMethod : HttpMethod
4, requestParameters : RequestParameters
5, requestHeaders : RequestHeaders
6, username : Maybe String
7, password : Maybe String
8, requestBody : Maybe RequestBody
9}
10
11requestDecoder : JD.Decoder ReceivedAPISnapshotRequest

We need to now move our earlier logic that checks to see if a user has opted to use the basic authentication fields or not from the view function to the decodeAPISnapshotRequest function.

1decodeAPISnapshotRequest : Response -> APISnapshotRequest
2decodeAPISnapshotRequest hitResponse =
3let
4result =
5JD.decodeString requestDecoder hitResponse.body
6in
7case result of
8Ok value ->
9let
10extractedCreds =
11( value.username, value.password )
12
13                    derivedBasicAuthentication =
14                        case extractedCreds of
15                            ( Nothing, Nothing ) ->
16                                Nothing
17
18                            ( Just receivedUsername, Nothing ) ->
19                                Just { username = receivedUsername, password = "" }
20
21                            ( Nothing, Just receivedPassword ) ->
22                                Just { username = "", password = receivedPassword }
23
24                            ( Just receivedUsername, Just receivedPassword ) ->
25                                Just { username = receivedUsername, password = receivedPassword }
26                in
27                    { url = value.url
28                    , httpMethod = value.httpMethod
29                    , requestParameters = value.requestParameters
30                    , requestHeaders = value.requestHeaders
31                    , basicAuthentication = derivedBasicAuthentication
32                    , requestBody = value.requestBody
33                    }
34
35            Err err ->
36                emptyRequest
37

We extract the username and password into extractedCreds as a Pair from ReceivedAPISnapshotRequest after decoding and construct our APISnapshotRequest from it.

And now we have a clean view function which just takes a BasicAuthentication type and returns us a Html Msg type.

1view : BasicAuthentication -> Html Msg
2view b =
3[ div [ class "form-row" ]
4[ input
5[ type_ "text"
6, placeholder "Username"
7, value b.username
8, onInput (UpdateUsername)
9]
10[]
11, input
12[ type_ "password"
13, placeholder "Password"
14, value b.password
15, onInput (UpdatePassword)
16]
17[]
18, a
19[ href "javascript:void(0)"
20, onClick (RemoveBasicAuthentication)
21]
22[ text "×" ]
23]
24]

We now have a Model that better captures the business logic. And should we change the logic of basic authentication parameter selection in the future, We do not have to worry about updating the logic in the view .

If this blog was helpful, check out our full blog archive.

Stay up to date with our blogs.

Subscribe to receive email notifications for new blog posts.