Enchaînement de requêtes HTTP dans ELM


Note préliminaire : dans cet article, nous utiliserons les décodeurs Elm, les tâches, les résultats et exploiterons l’Architecture Elm. Si vous n’êtes pas à l’aise avec ces concepts, vous devriez consulter leur documentation respective.

Parfois, en Elm, vous pouvez avoir du mal avec les choses les plus basiques.

C’est particulièrement vrai quand on vient d’un environnement JavaScript, où l’enchaînement des requêtes HTTP est relativement simple grâce aux Promesses. Voici un exemple concret utilisant l’API publique de Github, où nous récupérons une liste d’événements Github, sélectionnons le premier et interrogeons certaines informations utilisateur à partir de son identifiant unique.

La première requête utilise le point de terminaison https://api.github.com/events, et le JSON récupéré ressemble à cela :

JSON
[
    {
        "id": "987654321",
        "type": "ForkEvent",
        "actor": {
            "id": 1234567,
            "login": "foobar"
        }
    }
]

Je passe volontairement beaucoup d’autres propriétés des enregistrements ici, pour des raisons de brièveté.

La deuxième requête que nous devons faire est sur le point de terminaison https://api.github.com/users/{login}, et son corps ressemble à ceci :

JSON
{
    "id": 1234567,
    "login": "foobar",
    "name": "Foo Bar"
}

Encore une fois, je n’affiche que quelques champs du corps JSON réel ici.

Nous voulons donc essentiellement :

  • à partir d’une liste d’événements, choisir le premier s’il y en a,
  • ensuite choisir sa propriété actor.login,
  • interroger le point de terminaison des détails de l’utilisateur en utilisant cette valeur,
  • extraire le vrai nom de l’utilisateur de ce compte.

En utilisant JavaScript, cela ressemblerait à cela :

JavaScript
fetch("https://api.github.com/events")
    .then(responseA => {
        return responseA.json()
    })
    .then(events => {
        if (events.length == 0) {
            throw "No events."
        }
        const { actor: { login } } = events[0]
        return fetch(`https://api.github.com/users/${login}`)
    })
    .then(responseB => {
        return responseB.json()
    })
    .then(user => {
        if (!user.name) {
            console.log("unspecified")
        } else {
            console.log(user.name)
        }
    })
    .catch(err => {
        console.error(err)
    })

Cela serait un peu plus sophistiqué en utilisant async/await :

JavaScript
try {
    const responseA = await fetch("https://api.github.com/events")
    const events = await responseA.json()
    if (events.length == 0) {
        throw "No events."
    }
    const { actor: { login } } = events[0]
    const responseB = await fetch(`https://api.github.com/users/${login}`)
    const user = await responseB.json()
    if (!user.name) {
        console.log("unspecified")
    } else {
        console.log(user.name)
    }
} catch (err) {
    console.error(err)
}

C’est déjà un code compliqué à lire et à comprendre, et c’est également délicat à faire en Elm. Voyons comment réaliser la même chose, en comprenant exactement ce que nous faisons (nous avons tous déjà copié et collé du code aveuglément, ne le nions pas).

Tout d’abord, écrivons les deux requêtes dont nous avons besoin ; une pour récupérer la liste des événements, la seconde pour obtenir les détails d’un utilisateur donné à partir de son login :

Elm
import Http
import Json.Decode as Decode

eventsRequest : Http.Request (List String)
eventsRequest =
    Http.get "https://api.github.com/events"
    (Decode.list (Decode.at [ "actor", "login" ] Decode.string))

nameRequest : String -> Http.Request String
nameRequest login =
    Http.get ("https://api.github.com/users/" ++ login)
        (Decode.at [ "name" ]
            (Decode.oneOf
                [ Decode.string
                , Decode.null "unspecified"
                ]
            )
        )

Ces deux fonctions renvoient un Http.Request avec le type de données qu’elles récupéreront et décoderont du corps JSON de leurs réponses respectives.

nameRequest gère le cas où les utilisateurs de Github n’ont pas encore entré leur nom complet, donc le champ name pourrait être un null; comme avec la version JavaScript, nous passons par défaut à "non spécifié".

C’est bien, mais maintenant nous devons exécuter et enchaîner ces deux requêtes, la seconde dépendant du résultat de la première, où nous récupérons la valeur actor.login de l’objet événement.

Elm est un langage pur, ce qui signifie que vous ne pouvez pas avoir d’effets secondaires dans vos fonctions (un effet secondaire est lorsque les fonctions modifient des choses en dehors de leur portée et utilisent ces choses : une requête HTTP est un énorme effet secondaire). Ainsi, vos fonctions doivent retourner quelque chose qui représente un effet secondaire donné, au lieu de l’exécuter à l’intérieur de la portée de la fonction elle-même. Le runtime Elm sera chargé de réaliser effectivement l’effet secondaire, en utilisant une Command.

En Elm, vous utiliserez généralement une Task pour décrire les effets secondaires. Les tâches peuvent réussir ou échouer (comme le font les Promesses en JavaScript), mais elles doivent être transformées en [commande Elm] pour être réellement exécutées.

Pour citer cet excellent post sur les Tâches :

« Je trouve utile de penser aux tâches comme si elles étaient des listes de courses. Une liste de courses contient des instructions détaillées sur ce qui devrait être récupéré de l’épicerie, mais cela ne signifie pas que les courses sont faites. Je dois utiliser la liste pendant que je suis à l’épicerie pour obtenir un résultat final. »

Mais pourquoi avons-nous besoin de convertir une Task en commande, vous pourriez demander ? Parce qu’une commande peut exécuter une seule chose à la fois, donc si vous devez exécuter plusieurs effets secondaires à la fois, vous aurez besoin d’une seule tâche qui représente tous ces effets secondaires.

Donc, essentiellement :

  1. Nous commençons par créer des Http.Requests,
  2. Nous les transformons en Tasks que nous pouvons enchaîner,
  3. Nous transformons la Task résultante en une commande,
  4. Cette commande est exécutée par le runtime, et nous obtenons un résultat

Le paquet Http fournit Http.toTask pour mapper une Http.Request en une Task. Utilisons cela ici :

Elm
fetchEvents : Task Http.Error (List String)
fetchEvents =
    eventsRequest |> Http.toTask

fetchName : String -> Task Http.Error String
fetchName login =
    nameRequest login |> Http.toTask

J’ai créé ces deux fonctions simples principalement pour me concentrer sur leurs types de retour ; une Task doit définir un type d’erreur et un type de résultat. Par exemple, fetchEvents étant une tâche HTTP, recevra une Http.Error lorsque la tâche échoue, et une liste de chaînes de caractères lorsque la tâche réussit.

Mais traiter les erreurs HTTP de manière granulaire étant hors du champ de cet article de blog, et afin de garder les choses aussi simples et concises que possible, je vais utiliser Task.mapError pour transformer les erreurs HTTP complexes en leurs représentations textuelles :

Elm
toHttpTask : Http.Request a -> Task String a
toHttpTask request =
    request
        |> Http.toTask
        |> Task.mapError toString

fetchEvents : Task String (List String)
fetchEvents =
    toHttpTask eventsRequest

fetchName : String -> Task String String
fetchName login =
    toHttpTask (nameRequest login)

Ici, toHttpTask est un assistant transformant une Http.Request en une Task, transformant le Http.Error de type complexe en une version sérialisée, purement textuelle de celui-ci : une String.

Nous aurons également besoin d’une fonction permettant d’extraire le tout premier élément d’une liste, le cas échéant, comme nous l’avons fait en JavaScript en utilisant events[0]. Une telle fonction est intégrée dans le module de base List comme List.head. Et rendons cette fonction également une Task, car cela facilitera l’enchaînement de tout et nous permettra d

‘exposer un message d’erreur lorsque la liste est vide :

Elm
pickFirst : List String -> Task String String
pickFirst logins =
    case List.head logins of
        Just login ->
            Task.succeed login

        Nothing ->
            Task.fail "No events."

Notez l’utilisation de Task.succeed et Task.fail, qui sont approximativement les équivalents Elm de Promise.resolve et Promise.reject : c’est ainsi que vous créez des tâches qui réussissent ou échouent immédiatement.

Donc, afin d’enchaîner toutes les pièces que nous avons jusqu’à présent, nous avons évidemment besoin de colle. Et cette colle est la fonction Task.andThen, qui peut enchaîner nos tâches de cette manière :

Elm
fetchEvents
    |> Task.andThen pickFirst
    |> Task.andThen fetchName

Propre. Mais attendez. Comme nous l’avons mentionné précédemment, les Tâches sont des descriptions d’effets secondaires, pas leur exécution réelle. La fonction Task.attempt nous aidera à faire cela, en transformant une Task en une Command, à condition que nous définissions un Msg qui sera responsable de gérer le résultat reçu :

Elm
type Msg
    = Name (Result String String)

Result String String reflète le résultat de la requête HTTP et partage les mêmes définitions de type pour l’erreur (une String) et la valeur (le nom complet de l’utilisateur, une String également). Utilisons ce Msg avec Task.attempt :

Elm
fetchEvents
    |> Task.andThen pickFirst
    |> Task.andThen fetchName
    |> Task.attempt Name

Ici :

  • Nous commençons par récupérer tous les événements,
  • Puis si la Tâche réussit, nous choisissons le premier événement,
  • Puis si nous en avons un, nous récupérons le nom complet de l’utilisateur de l’événement,
  • Et nous mappons le futur résultat de cette tâche au message Name.

La chose cool ici est que si quelque chose échoue le long de la chaîne, la chaîne s’arrête et l’erreur sera propagée jusqu’au gestionnaire Name. Pas besoin de vérifier les erreurs pour chaque opération ! Oui, cela ressemble beaucoup à la façon dont les .catch des Promesses JavaScript fonctionnent.

Maintenant, comment allons-nous exécuter la commande résultante et traiter le résultat ? Nous devons configurer l’Architecture Elm et sa bonne vieille fonction update :

Elm
module Main exposing (main)

import Html exposing (..)
import Http
import Json.Decode as Decode
import Task exposing (Task)


type alias Model =
    { name : Maybe String
    , error : String
    }

type Msg
    = Name (Result String String)

eventsRequest : Http.Request (List String)
eventsRequest =
    Http.get "https://api.github.com/events"
        (Decode.list (Decode.at [ "actor", "login" ] Decode.string))

nameRequest : String -> Http.Request String
nameRequest login =
    Http.get ("https://api.github.com/users/" ++ login)
        (Decode.at [ "name" ]
            (Decode.oneOf
                [ Decode.string
                , Decode.null "unspecified"
                ]
            )
        )

toHttpTask : Http.Request a -> Task String a
toHttpTask request =
    request
        |> Http.toTask
        |> Task.mapError toString

fetchEvents : Task String (List String)
fetchEvents =
    toHttpTask eventsRequest

fetchName : String -> Task String String
fetchName login =
    toHttpTask (nameRequest login)

pickFirst : List String -> Task String String
pickFirst events =
    case List.head events of
        Just event ->
            Task.succeed event

        Nothing ->
            Task.fail "No events."

init : ( Model, Cmd Msg )
init =
    { name = Nothing, error = "" }
        ! [ fetchEvents
                |> Task.andThen pickFirst
                |> Task.andThen fetchName
                |> Task.attempt Name
          ]

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        Name (Ok name) ->
            { model | name = Just name } ! []

        Name (Err error) ->
            { model | error = error } ! []

view : Model -> Html Msg
view model =
    div []
        [ if model.error /= "" then
            div []
                [ h4 [] [ text "Error encountered" ]
                , pre [] [ text model.error ]
                ]
          else
            text ""
        , p [] [ text <| Maybe.withDefault "Fetching..." model.name ]
        ]

main =
    Html.program
        { init = init
        , update = update
        , subscriptions = always Sub.none
        , view = view
        }

C’est certainement plus de code qu’avec l’exemple JavaScript, mais n’oubliez pas que la version Elm rend HTML, pas seulement des logs dans la console, et que le code JavaScript pourrait être refactorisé pour ressembler beaucoup à la version Elm. De plus, la version Elm est entièrement typée et protégée contre les problèmes imprévus, ce qui fait une énorme différence lorsque votre application grandit.

Comme toujours, un Ellie est disponible publiquement pour que vous puissiez jouer avec le code.

Partagez c'est sympa !

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *