In some situations, one HTTP request may be dependent on information from another request. For example, you may need to get an API token and then fetch a piece of information with that token. In cases like these, it can be useful to model multiple requests as a single operation.
Thankfully, Elm has just the thing to help us out…
Tasks
In Elm, Tasks model asynchronous operations that are not guaranteed to succeed. Tasks are a part of Elm’s core library and are an implementation of “futures”.
They are kind of interesting because they act less like functions that get called and more like data that describes how an action should be performed. Tasks are not called directly, but turned into commands and eventually executed by the Elm runtime.
Chaining Tasks
Tasks can be chained using the andThen function, which takes a callback and a task. The given task will run and, if it is successful, its result will be given to the callback.
Take the following example:
succeed "hello"
|> andThen (\h -> succeed (h ++ " world"))
If run, this task would succeed with the value hello world
.
When the first task given to andThen
fails, execution stops and the
callback is never fired.
In this example:
fail "error"
|> andThen (\h -> succeed (h ++ " world"))
If run, the task would fail with the string error
and the callback
would never fire.
Taking HTTP Requests To Task
Elm’s HTTP package
conveniently has a function,
toTask,
which converts an HTTP.Request
to a Task.Task
.
In a typical use case, an HTTP request might look like this:
type Msg
= RequestDone (Result Http.Error String)
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
RequestDone (Ok _) ->
model ! []
RequestDone (Err _) ->
model ! []
makeRequest : Cmd Msg
makeRequest =
Http.getString "https://url.com"
|> Http.send RequestDone
If we wanted to operate on the HTTP request as a task, we would only need to
change makeRequest
like so:
...
makeRequest : Cmd Msg
makeRequest =
Http.getString "https://url.com"
|> Http.toTask
|> Task.attempt RequestDone
An Example Using The Elm Architecture
We have seen how andThen
is used to chain tasks, and how HTTP.Request
s
are converted to tasks with toTask
. Let’s put these ideas together by
chaining two HTTP requests.
In this example, we will use two APIs to display what country the International Space Station is currently over. First, we will use the Open Notify ISS Current Location API to get the current position of the ISS. Next, we will use the Nominatim Reverse Geocoding API to determine what country the ISS is over.
The Elm Architecture
The Elm Architecture is an Elm pattern used to model and create applications that are easily extended and refactored.
Using The Elm Architecture, the logic of every Elm program is broken up into three distinct parts:
- The model is a representation of the application’s current state.
- The update function responds to messages and modifies the application’s state when necessary.
- The view defines how the application’s state should be represented in HTML
The Model
We will start by defining our model and a new type:
type alias Coordinates =
{ lat : String
, lon : String
}
type alias Model =
{ country : Maybe String
}
The Coordinates
type holds the positional data returned by the ISS API. The
Model
type manages the application’s internal state using information
returned by the reverse geocoding API.
Messages And The Update Function
In this application, we have two messages:
type Msg
= OnFetchISSLocation
| FetchISSLocationDone (Result Http.Error String)
And the update function defines how our application responds to these messages:
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
OnFetchISSLocation ->
model ! [ getISSLocation ]
FetchISSLocationDone (Ok country) ->
{ model | country = Just country } ! []
FetchISSLocationDone (Err _) ->
{ model | country = Just "the Earth (probably an Ocean)" } ! []
In the case of an OnFetchISSLocation
message, the getISSLocation
command
will be used to request the external APIs.
In the case of a successful FetchISSLocationDone
message, the model will be
updated with the returned country name. If, however, the requests are
unsuccessful, the model will be updated to reflect this.
Note: One of the limitations of the reverse geocoding API is that it returns an error when the provided corrdinates are not above a land mass. For the sake of simplicity, it is assumed that any error encountered while making these requests is due to this fact. We’ll see this in action soon!
Requesting The ISS’s Location
The Open Notify ISS Current Location API responds with data in the following format:
{
"message": "success",
"timestamp": UNIX_TIME_STAMP,
"iss_position": {
"latitude": CURRENT_LATITUDE,
"longitude": CURRENT_LONGITUDE
}
}
We will start by creating a JSON decoder to extract the latitude and longitude
into our previously defined Coordinates
type:
decodeCoordinates : Decode.Decoder Coordinates
decodeCoordinates =
Decode.at [ "iss_position" ]
(Decode.map2
Coordinates
(Decode.field "latitude" Decode.string)
(Decode.field "longitude" Decode.string)
)
Next, we define how the request is formed and convert the HTTP.Request
to a
Task.Task
:
getISSCoords : Task.Task Http.Error Coordinates
getISSCoords =
Http.get "http://api.open-notify.org/iss-now.json" decodeCoordinates
|> Http.toTask
Reverse Geocoding The Coordinates
The Nominatim Reverse Geocoding API responds with data in the following form:
{
"place_id": "173409570",
"licence": "Data © OpenStreetMap contributors, ODbL 1.0. http://www.openstreetmap.org/copyright",
"osm_type": "relation",
"osm_id": "339514",
"lat": "38.9675925",
"lon": "-0.1803423",
"display_name": "Gandia, Safor, Valencia, Valencian Community, Spain",
"address": {
"city": "Gandia",
"region": "Safor",
"county": "Valencia",
"state": "Valencian Community",
"country": "Spain",
"country_code": "es"
},
"boundingbox": [
"38.94981",
"39.0412816",
"-0.2970744",
"-0.1442421"
]
}
Like before, we will start by defining a decoder:
decodeCountry : Decode.Decoder String
decodeCountry =
Decode.at [ "address" ]
(Decode.field "country" Decode.string)
And then describe the request:
getCountry : Coordinates -> Task.Task Http.Error String
getCountry coords =
Http.get
("http://nominatim.openstreetmap.org/reverse?format=json&lat="
++ coords.lat
++ "&lon="
++ coords.lon
++ "&zoom=18&addressdetails=1"
)
decodeCountry
|> Http.toTask
Chaining The Requests
We can now chain these two requests together with:
getISSLocation : Cmd Msg
getISSLocation =
getISSCoords
|> Task.andThen (\coords -> getCountry coords)
|> Task.attempt FetchISSLocationDone
This command is run when OnFetchISSLocation
messages are given to the
update
function and it does the following:
- Gets the current location of the ISS from an external service
- Gets the country a set of coordinates belong to from an external service
- Maps the result of this command to the
FetchISSLocationDone
message
Putting It All Together
The full, working implementation of this example can be seen below:
module Main exposing (..)
import Html exposing (Html, a, div, h2, program, text)
import Html.Attributes exposing (style)
import Html.Events exposing (onClick)
import Http
import Json.Decode as Decode
import Task
main =
Html.program
{ init = init
, view = view
, update = update
, subscriptions = (\_ -> Sub.none)
}
-- MODEL
type alias Coordinates =
{ lat : String
, lon : String
}
type alias Model =
{ country : Maybe String
}
init : ( Model, Cmd Msg )
init =
Model Nothing ! []
-- UPDATE
type Msg
= OnFetchISSLocation
| FetchISSLocationDone (Result Http.Error String)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
OnFetchISSLocation ->
model ! [ getISSLocation ]
FetchISSLocationDone (Ok country) ->
{ model | country = Just country } ! []
FetchISSLocationDone (Err _) ->
{ model | country = Just "the Earth (probably an Ocean)" } ! []
-- VIEW
view : Model -> Html Msg
view model =
div
[ style
[ ( "align-items", "center" )
, ( "display", "flex" )
, ( "height", "100%" )
, ( "justify-content", "center" )
, ( "text-align", "center" )
]
]
[ div []
[ locationText model
, a
[ onClick OnFetchISSLocation
, style
[ ( "background", "#d86c70" )
, ( "border-radius", "2px" )
, ( "color", "#fff" )
, ( "cursor", "pointer" )
, ( "display", "inline-block" )
, ( "margin-top", "40px" )
, ( "padding", "20px 30px" )
]
]
[ text "Get Location Of ISS" ]
]
]
locationText : Model -> Html Msg
locationText model =
h2 []
[ text
("The ISS is over: "
++ (Maybe.withDefault "the Earth" model.country)
)
]
-- HTTP
getISSLocation : Cmd Msg
getISSLocation =
getISSCoords
|> Task.andThen (\coords -> getCountry coords)
|> Task.attempt FetchISSLocationDone
getISSCoords : Task.Task Http.Error Coordinates
getISSCoords =
Http.get "http://api.open-notify.org/iss-now.json" decodeCoordinates
|> Http.toTask
getCountry : Coordinates -> Task.Task Http.Error String
getCountry coords =
Http.get
("http://nominatim.openstreetmap.org/reverse?format=json&lat="
++ coords.lat
++ "&lon="
++ coords.lon
++ "&zoom=18&addressdetails=1"
)
decodeCountry
|> Http.toTask
decodeCoordinates : Decode.Decoder Coordinates
decodeCoordinates =
Decode.at [ "iss_position" ]
(Decode.map2
Coordinates
(Decode.field "latitude" Decode.string)
(Decode.field "longitude" Decode.string)
)
decodeCountry : Decode.Decoder String
decodeCountry =
Decode.at [ "address" ]
(Decode.field "country" Decode.string)