Combining HTTP requests with Task in Elm
2019-02-15

There are situations where you need to make more than one HTTP request in order to fetch the data you’re interested in.

For making parallel requests, we have Cmd.batch. But what about the situation when we need to make requests in sequence, for example when we need an item of information from a response to make the subsequent request? We can manage this in update, handling each response and issuing a subsequent command, but of course this means adding messages for each step in the process and handling all of them in update, and possibly storing some intermediate results in the model. It’s doable, but it ends up complicating both Msg and update with ephemeral stuff.

Is there another way? It turns out there is! Let’s consider an example based on the jsonplaceholder.typicode.com REST API, a fake API for testing and prototyping. Among its endpoints, there are these ones:

/users
/posts?userId=zzz
/comments?postId=zzz

Let’s use them to take the first user and get all of the comments on that user’s posts. In real life, we could be using a similar sequence of requests to retrieve the highest-voted comments from the posts created by users who subscribed in the last month, for example. The key thing is that we have to make multiple HTTP requests in sequence, using bits of information from responses to make subsequent requests.

The steps involved are:

  1. Request the list of users
  2. Take the first user (if present)
  3. Request the list of posts for that user
  4. For each of the posts, make a request to get the comments
  5. Combine all the comments into a single list that goes into the model.

You can find a working solution in this Ellie. The rest of this post is a walkthrough of that code.

Our goal is to add just one message:

type Msg
    = GotComments (Result Error (List Comment))

In order to achieve this, we need to use Task from the Elm core library. Like a JSON decoder, a Task value is a description of what we want to happen — simply defining a task doesn’t cause the Elm runtime to do anything. In order to actually execute the task, we need to pass it either to Task.perform (for tasks that can’t fail) or Task.attempt (for tasks that can fail). These functions turn tasks into commands which can be returned from update or used to form init.

HTTP requests can definitely fail, so we are going to use Task.attempt. It has this signature:

(Result x a -> msg) -> Task x a -> Cmd msg

In other words, it takes an appropriate message constructor and a task, and returns a Cmd value.

We’ve already defined the message (GotComments), and for this example we’ll just use a command in the init:

init : ( Model, Cmd Msg )
init =
    ( { comments = [], log = [ "Starting requests" ] }
    , Task.attempt GotComments getUserComments
    )

getUserComments is going to be the task describing the sequence of HTTP requests needed to produce a list of comments. Since its type parameters have to match the message constructor, its type has to be Task Error (List Comment).

A nice thing about tasks is that they are composable, so we are going to define tasks for individual HTTP requests and then combine them to produce getUserComments.

We know that the responses will be in the form of JSON strings, so let’s start by defining the types and JSON decoders for the data:

module Main exposing (main)

import Browser
import Html exposing (..)
import Html.Events exposing (onClick)
import Http
import Json.Decode exposing (Decoder, field)
import Process
import Task exposing (Task)

type alias User =
    { id : Int
    , name : String
    }


type alias Post =
    { id : Int
    , userId : Int
    , title : String
    }


type alias Comment =
    { id : Int
    , postId : Int
    , body : String
    }

userDecoder : Decoder User
userDecoder =
    Json.Decode.map2 User
        (field "id" Json.Decode.int)
        (field "name" Json.Decode.string)


postDecoder : Decoder Post
postDecoder =
    Json.Decode.map3 Post
        (field "id" Json.Decode.int)
        (field "userId" Json.Decode.int)
        (field "title" Json.Decode.string)


commentDecoder : Decoder Comment
commentDecoder =
    Json.Decode.map3 Comment
        (field "id" Json.Decode.int)
        (field "postId" Json.Decode.int)
        (field "body" Json.Decode.string)

The model will store the list of comments (along with a log of information messages):

type alias Model =
    { comments : List Comment
    , log : List String
    }

Next, we can write code for step 1 — making a user request. Regular Http module functions like get and post return commands, but the Http module also provides the task function which is similar to request but returns Task rather than Cmd.

(If you’d like to learn about the basics of making HTTP requests, you can find examples of Http.get, Http.post and Http.request in my book Practical Elm.)

The Task type has two type parameters: one for the error value, and another for the result value.

Here is the task for the user request:

serverUrl : String
serverUrl =
    "https://jsonplaceholder.typicode.com/"

getUsers : Task Http.Error (List User)
getUsers =
    Http.task
        { method = "GET"
        , headers = []
        , url = serverUrl ++ "users"
        , body = Http.emptyBody
        , resolver = Http.stringResolver <| handleJsonResponse <| Json.Decode.list userDecoder
        , timeout = Nothing
        }

In the record given to Http.task, we specify the details of the request, as well as a resolver, which describes how to deal with the response produced by the task. There are two resolvers available: stringResolver and bytesResolver. Since we are going to get a JSON string from the server, we’re using stringResolver whose type signature is (Response String -> Result x a) -> Resolver x a.

For its argument, we’re using the helper function handleJsonResponse which deals with possible errors as well as decoding the JSON string:

handleJsonResponse : Decoder a -> Http.Response String -> Result Http.Error a
handleJsonResponse decoder response =
    case response of
        Http.BadUrl_ url ->
            Err (Http.BadUrl url)

        Http.Timeout_ ->
            Err Http.Timeout

        Http.BadStatus_ { statusCode } _ ->
            Err (Http.BadStatus statusCode)

        Http.NetworkError_ ->
            Err Http.NetworkError

        Http.GoodStatus_ _ body ->
            case Json.Decode.decodeString decoder body of
                Err _ ->
                    Err (Http.BadBody body)

                Ok result ->
                    Ok result

This helper, in turn, takes a JSON decoder argument. We’re expecting a list of users, so we’re giving it Json.Decode.list userDecoder.

Step 2 is getting a list of posts for a given user ID. Setting up a task for it looks very similar to the one we’ve just created for the list of users:

getPosts : Int -> Task Http.Error (List Post)
getPosts userId =
    Http.task
        { method = "GET"
        , headers = []
        , url = serverUrl ++ "posts?userId=" ++ String.fromInt userId
        , body = Http.emptyBody
        , resolver = Http.stringResolver <| handleJsonResponse <| Json.Decode.list postDecoder
        , timeout = Nothing
        }

We will need one more task for step 4, getting a list of comments for a given post. This one is also very similar:

getComments : Int -> Task Http.Error (List Comment)
getComments postId =
    Http.task
        { method = "GET"
        , headers = []
        , url = serverUrl ++ "comments?postId=" ++ String.fromInt postId
        , body = Http.emptyBody
        , resolver = Http.stringResolver <| handleJsonResponse <| Json.Decode.list commentDecoder
        , timeout = Nothing
        }

Finally, we can combine these tasks to produce getUserComments, which is a great opportunity to employ a lot of the Task module machinery:

type DataError
    = NoUsers
    | NoPosts


type Error
    = HttpError Http.Error
    | DataError DataError


getUserComments : Task Error (List Comment)
getUserComments =
    getUsers
        |> Task.map List.head
        |> Task.mapError HttpError
        |> Task.andThen
            (\user ->
                case user of
                    Nothing ->
                        Task.fail (DataError NoUsers)

                    Just { id } ->
                        getPosts id
                            |> Task.mapError HttpError
            )
        |> Task.andThen
            (\posts ->
                case posts of
                    [] ->
                        Task.fail (DataError NoPosts)

                    _ ->
                        List.map (getComments << .id) posts
                            |> Task.sequence
                            |> Task.mapError HttpError
            )
        |> Task.map List.concat

We start with getUsers, then use Task.map to map List.head over its result (List User) in order to get the first user.

We also need to use Task.mapError to transform the error type from Http.Error to our custom type Error by applying its HttpError constructor. As you can see from the definition of Error, we need to do this transformation because in addition to regular HTTP request errors, there are two other potential error conditions we have to deal with: either an empty list of users or an empty list of posts.

Next, we use Task.andThen which chains together a task and a callback:

andThen : (a -> Task x b) -> Task x a -> Task x b

With this, the task given as the second argument is executed first, and if it’s successful, its result is passed into the callback (the first argument). The callback returns another task to execute subsequently.

In our case, we’re getting a list of users, applying List.head to it, and then creating another task based on the result of List.head (which is Maybe User).

If the user list is empty, then we return a task that immediately fails with Task.fail (DataError NoUsers). Otherwise, we move onto getPosts:

        |> Task.andThen
            (\user ->
                case user of
                    Nothing ->
                        Task.fail (DataError NoUsers)

                    Just { id } ->
                        getPosts id
                            |> Task.mapError HttpError
            )

Once we have a list of posts, we need to make requests for comments for each of them (step 4 in our sequence of steps at the start of this post).

Again, if the list of posts is empty, we have to produce a failing task with Task.fail (DataError NoPosts). Otherwise, we generate a list of getComments tasks from the list of posts, and then turn them into a single task with Task.sequence:

        |> Task.andThen
            (\posts ->
                case posts of
                    [] ->
                        Task.fail (DataError NoPosts)

                    _ ->
                        List.map (getComments << .id) posts
                            |> Task.sequence
                            |> Task.mapError HttpError
            )

Task.sequence converts a list of tasks into a task that produces a list of results: List (Task x a) -> Task x (List a). The tasks in the list are executed sequentially in order, and if any task fails, then the sequence fails.

This is fine for the purposes of learning about Task, but in practice it might be better to execute all the comment requests in parallel for performance reasons. However, Task only allows us to organise things in sequence, not in parallel. Parallel execution would involve using Cmd.batch and handling each set of comments in update.

As Task.sequence is going to produce List (List comment), the last tweak we need to make at the end of the chain is fusing all of the comments together into a single list:

    |> Task.map List.concat

What remains is handling the GotComments message in update:

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        GotComments (Err err) ->
            ( { model | log = errorString err :: model.log }, Cmd.none )

        GotComments (Ok comments) ->
            ( Debug.log "model" { model
                | comments = comments
                , log = ("Got " ++ (String.fromInt <| List.length comments) ++ " comments") :: model.log
              }
            , Cmd.none
            )

Final remarks

Aside from manually managing the order of side effects via messages and update, tasks are the only mechanism in Elm to execute side effects in sequence.

Tasks are composable, so they allow you to translate a sequence of steps into a sequence of function calls – that’s very readable!

Tasks allow you to cut down the number of messages required to handle a sequence of steps (potentially all the way down to one message).

Tasks are useful for more than just HTTP requests. For example, you could use a task to get the current time, or to delay the execution for a specified period.

In more complex scenarios where you need to make both sequential and parallel requests, you will need to combine tasks with Cmd.batch and handling intermediate step messages in update.

Once again, a working version of this code is in this Ellie.

Would you like to dive further into Elm?
📢 My book
Practical Elm
skips the basics and gets straight into the nuts-and-bolts of building non-trivial apps.
🛠 Things like building out the UI, communicating with servers, parsing JSON, structuring the application as it grows, testing, and so on.
Practical Elm