JSON (JavaScript Object Notation) est un format de données et de fichiers lisible par un humain, couramment utilisé pour échanger des données, en particulier dans les applications web. Il n'est donc pas étonnant qu'il occupe une place à part dans le langage Elm.
Elm fournit les modules de base Json.Decode et Json.Encode pour analyser et écrire des données JSON.
Les décodeurs JSON permettent de déclarer le type de données attendu et la structure de données vers laquelle ces données doivent être converties.
Importons quelques alias et types depuis les modules :
import Json.Decode as Decode exposing (Decoder, Error)
import Json.Encode as Encode exposing (Value)
Utiliser Decode.decodeString : Decoder a -> String -> Result Error a avec un décodeur et une string JSON réussit si cette string est conforme aux spécifications du décodeur, et échoue sinon.
Il n'y a aucun moyen d'inspecter les données JSON brutes au moment de définir le décodeur, ce qui peut paraître inhabituel quand on vient de langages impératifs.
JSON possède un petit nombre de types de données fondamentaux :
"hello world"
true, soit false
34 ou 0.12
null
Chacun d'entre eux est associé à une fonction de Decode :
Decode.decodeString Decode.string """\"hello\""""
--> Ok "hello"
Decode.decodeString Decode.string """null"""
--> Err ...
Decode.decodeString Decode.bool """true"""
--> Ok True
Decode.decodeString Decode.bool """0"""
--> Err ...
Decode.decodeString Decode.int """12"""
--> Ok 12
Decode.decodeString Decode.float """3.14"""
--> Ok 3.14
Decode.decodeString (Decode.null AnyValue) """null"""
--> Ok AnyValue
Decode.decodeString (Decode.null AnyValue) """true"""
--> Err ...
Note que Decode.null permet de choisir comment modéliser un null dans ton programme, en exigeant une valeur arbitraire en argument (par exemple (), Nothing, ou toute valeur adaptée à ton programme).
JSON définit également deux structures de données pour regrouper les types de base :
[0, null, "none", []]
{"id": 1, "is_admin": false, "more_info": {}}
Decode propose aussi des fonctions pour les décoder :
Decode.decodeString (Decode.list Decode.int) """[1, 2, 3]"""
--> Ok [1, 2, 3]
Decode.decodeString (Decode.list Decode.int) """[1, 2, 3, "not an int"]"""
--> Err ...
Decode.decodeString (Decode.dict Decode.int) """{ "key1": 17, "key2": 71 }"""
--> Ok (Dict.fromList [("key1", 17), ("key2", 71)])
Decode.decodeString (Decode.dict Decode.int) """{ "key1": 17, "key2": "seventy-one" }"""
--> Err ...
Ces fonctions Decode.list : Decoder a -> Decoder (List a) et Decode.dict : Decoder a -> Decoder (Dict String a) attendent chacune un décodeur en argument, censé décoder chaque élément de la structure de données.
Si le décodeur passé en argument ne parvient pas à décoder un seul élément d'un tableau ou d'un objet, c'est le décodeur entier qui échoue, ce qui est un schéma courant.
Combiner des décodeurs simples en décodeurs plus complexes est l'idée centrale de cette technique. On va présenter, à travers un exemple, plusieurs autres fonctions qui permettent de construire des décodeurs de données réelles arbitrairement complexes.
Pour nous entraîner à combiner des décodeurs de base afin d'analyser des données JSON complexes, construisons des décodeurs pour analyser (un sous-ensemble de) données GeoJSON. GeoJSON est un format de données spécialisé basé sur JSON, utilisé pour représenter des objets géographiques tels que des lieux, des tracés ou des régions, décrits par des coordonnées géographiques en 2D ou 3D, accompagnées d'autres propriétés.
Un objet géométrique GeoJSON est un objet qui contient un champ type dont la valeur est une string (il y a 7 valeurs de type possibles) et un champ coordinates contenant des coordonnées géographiques (rassemblées dans une structure qui dépend du type).
Par exemple,
{
"type": "Point",
"coordinates": [127.831, 26.461]
}
ou
{
"type": "LineString",
"coordinates": [
[127.831, 26.461],
[127.829, 26.465],
[127.829, 26.469],
[127.83, 26.47]
]
}
Un objet géométrique pourrait être représenté en Elm par le type suivant :
type Geometry
= Point (List Float)
| LineString (List (List Float))
| ...
Pour examiner la valeur d'un champ donné, on peut utiliser Decode.field : String -> Decoder a -> Decoder a, qui décode la valeur d'une clé précise d'un objet.
decodePointCoordinates : Decoder (List Float)
decodePointCoordinates =
Decode.field "coordinates" (Decode.list Decode.float)
decodeLineStringCoordinates : Decoder (List (List Float))
decodeLineStringCoordinates =
Decode.field "coordinates" (Decode.list (Decode.list Decode.float))
Pour transformer la valeur correctement analysée par un décodeur, on peut utiliser Decode.map : (a -> value) -> Decoder a -> Decoder value, ou l'une de ses variantes, de Decode.map2 (a -> b -> value) -> Decoder a -> Decoder b -> Decoder value à Decode.map8.
Dans ce cas, on veut choisir le décodeur de coordonnées à utiliser en fonction de la valeur du champ type : c'est exactement le rôle de Decode.andThen : (a -> Decoder b) -> Decoder a -> Decoder b, accompagné de ses acolytes Decode.succeed : a -> Decoder a et Decode.fail : String -> Decoder a.
decodeGeometry : Decoder Geometry
decodeGeometry =
Decode.field "type" Decode.string
|> Decode.andThen
(\value ->
case value of
"Point" ->
decodePointCoordinates |> Decode.map Point
"LineString" ->
decodeLineStringCoordinates |> Decode.map LineString
_ ->
Decode.fail "Geometry not implemented yet, or invalid type value"
)
Note à ce stade qu'on pourrait utiliser Decode.andThen pour valider les coordonnées (vérifier qu'elles vont par paires ou par triplets et que leurs valeurs ont un sens géographique).
Decode.decodeString decodeGeometry """{"type": "Point", "coordinates": [127.831, 26.461]}"""
--> Ok (Point [127.831, 26.461])
Decode.decodeString decodeGeometry """{"type": "LineString", "coordinates": [127.831, 26.461]}"""
--> Err ...
Un objet Feature GeoJSON représente quelque chose de délimité dans l'espace : il possède un champ type valant "Feature", un champ geometry contenant un objet géométrique ou null, un champ properties contenant un objet JSON arbitraire ou null, et un champ id facultatif dont la valeur est une string ou un nombre JSON.
{
"type": "Feature",
"geometry": null,
"properties": null
}
ou
{
"type": "Feature",
"id": "0157",
"geometry": {
"type": "Point",
"coordinates": [127.831, 26.461]
},
"properties": {
"country": "Japan"
}
}
Commençons par le champ id.
S'il est présent, il doit pouvoir contenir aussi bien des strings que des nombres ; on peut donc soit introduire un type personnalisé pour cette valeur, soit toujours l'enregistrer sous forme de string.
Pour choisir entre deux décodeurs possibles, on peut utiliser Decode.oneOf : List (Decoder a) -> Decoder a.
decodeFeatureId : Decoder String
decodeFeatureId =
Decode.oneOf
[ Decode.string
, Decode.int |> Decode.map String.fromInt
, Decode.float |> Decode.map String.fromFloat
]
|> Decode.field "id"
Decode.decodeString decodeFeatureId """{"id": "seventeen"}"""
--> Ok "seventeen"
Decode.decodeString decodeFeatureId """{"id": 17}"""
--> Ok "17"
Decode.decodeString decodeFeatureId """{"type": "Point"}"""
--> Er ...
Pour exprimer que le champ est facultatif, on pourrait utiliser la même technique :
decodeMaybeFeatureId : Decoder (Maybe String)
decodeMaybeFeatureId =
Decode.oneOf
[ decodeFeatureId |> Decode.map Just
, Decode.succeed Nothing
]
Mais on peut aussi utiliser Decode.maybe : Decoder a -> Decoder (Maybe a) :
decodeMaybeFeatureId : Decoder (Maybe String)
decodeMaybeFeatureId =
Decode.maybe decodeFeatureId
Decode.decodeString decodeMaybeFeatureId """{"id": 17}"""
--> Ok (Just "17")
Decode.decodeString decodeMaybeFeatureId """{"type": "Point"}"""
--> Ok Nothing
Note que decodeMaybeFeatureId (ou tout décodeur enveloppé avec Decode.maybe) réussit toujours, puisqu'il peut toujours se rabattre sur Ok Nothing, même si un décodeur qui l'utilise peut encore échouer :
Decode.decodeString (Decode.list decodeMaybeFeatureId) """{"id": 17}"""
--> Err ...
Intéressons-nous maintenant au champ geometry, qui peut être soit un objet géométrique, soit null.
On pourrait de nouveau utiliser Decode.oneOf, mais il existe une meilleure option : Decode.nullable : Decoder a -> Decoder (Maybe a).
decodeFeatureGeometry : Decoder (Maybe Geometry)
decodeFeatureGeometry =
Decode.nullable decodeGeometry
|> Decode.field "geometry"
Decode.decodeString decodeFeatureGeometry """{"geometry": {"type": "Point", "coordinates": [127.831, 26.461]}}"""
--> Ok (Just (Point [127.831, 26.461]))
Decode.decodeString decodeFeatureGeometry """{"geometry": null}"""
--> Ok Nothing
Decode.decodeString decodeFeatureGeometry """{"geometry": {}}"""
--> Err ...
Decode.maybe et Decode.nullable ont la même signature, mais leur comportement diffère : le résultat OK Nothing d'un Decode.nullable ne peut provenir que de la valeur JSON null.
Enfin, le champ properties peut contenir un objet JSON arbitraire ou null, ce qui est le rôle de Decode.value : Decoder Value.
decodeProperties : Decoder (Maybe (Dict String Value))
decodeProperties =
Decode.nullable (Decode.dict Decode.value)
|> Decode.field "properties"
Decode.decodeString decodeProperties """{"properties": null}"""
--> Ok Nothing
Decode.decodeString decodeProperties """{"properties": {"country": "Japan"}}"""
--> Ok (Just (Dict.fromList [("country", <internals>)]))
Decode.decodeString decodeProperties """{"properties": 17}"""
--> Err ...
Note que Value est un type opaque, ce qui veut dire qu'on ne peut pas facilement inspecter son contenu.
Pour l'utiliser, on peut soit écrire un décodeur et l'exécuter avec Decode.decodeValue : Decoder a -> Value -> Result Error a, soit l'envoyer directement à JavaScript via un port.
On est prêt à analyser des objets Feature :
type alias Feature =
{ id : Maybe String
, geometry : Maybe Geometry
, properties : Maybe (Dict String Value)
}
decodeFeature : Decoder Feature
decodeFeature =
Decode.field "type" Decode.string
|> Decode.andThen
(\value ->
if value /= "Feature" then
Decode.fail "not a Feature"
else
Decode.map3 Feature
decodeMaybeFeatureId
decodeFeatureGeometry
decodeProperties
)
Decode.decodeString decodeFeature """{"type": "Feature", "geometry": null, "properties": null}"""
--> Ok { geometry = Nothing, id = Nothing, properties = Nothing }
"""
{
"type": "Feature",
"id": "0157",
"geometry": {
"type": "Point",
"coordinates": [127.831, 26.461]
},
"properties": {
"country": "Japan"
}
}
"""
|> Decode.decodeString decodeFeature
--> Ok { geometry = Just (Point [127.831,26.461]), id = Just "0157", properties = Just (Dict.fromList [("country",<internals>)]) }
Les encodeurs permettent d'écrire du JSON valide à partir de valeurs Elm, grâce à la fonction Encode.encode : Int -> Value -> String.
Le premier argument indique la quantité d'indentation du résultat final, et le second est la valeur JSON à écrire.
Une Value peut être obtenue à partir de Decode.value ou produite par l'un des encodeurs :
Encode.encode 0 (Encode.string "hello")
--> "hello"
Encode.encode 0 (Encode.bool True)
--> "true"
Encode.encode 0 (Encode.int 12)
--> "12"
Encode.encode 0 (Encode.float 3.14)
--> "3.14"
Encode.encode 0 Encode.null
--> "null"
Encode.encode 0 (Encode.list Encode.int [1, 2, 3])
--> "[1,2,3]"
Encode.encode 4 (Encode.list Encode.int [1, 2, 3])
--> "[\n 1,\n 2,\n 3\n]"
Encode.encode 0 (Encode.dict String.toLower Encode.int (Dict.fromList [("KEY1", 17), ("KEY2", 71)]))
--> "{\"key1\":17,\"key2\":71}"
Encode.encode 4 (Encode.dict String.toLower Encode.int (Dict.fromList [("KEY1", 17), ("KEY2", 71)]))
--> "{\n \"key1\": 17,\n \"key2\": 71\n}"
ainsi qu'avec Encode.object : List ( String, Value ) -> Value :
Encode.object
[ ( "key1", Encode.int 17 )
, ( "key2", Encode.int 71 )
]
|> Encode.encode 0
--> "{\"key1\":17,\"key2\":71}"
Définissons maintenant les encodeurs correspondant aux décodeurs GeoJSON définis plus haut :
encodeGeometry : Geometry -> Value
encodeGeometry geometry =
case geometry of
Point coord ->
Encode.object
[ ( "type", Encode.string "Point" )
, ( "coordinates", Encode.list Encode.float coord )
]
LineString coord ->
Encode.object
[ ( "type", Encode.string "LineString" )
, ( "coordinates", Encode.list (Encode.list Encode.float) coord )
]
encodeFeature : Feature -> Value
encodeFeature feature =
let
maybeId =
case feature.id of
Nothing ->
[]
Just id ->
[ ( "id", Encode.string id ) ]
in
Encode.object
(maybeId
++ [ ( "type", Encode.string "Feature" )
, ( "geometry"
, case feature.geometry of
Nothing ->
Encode.null
Just geometry ->
encodeGeometry geometry
)
, ( "properties"
, case feature.properties of
Nothing ->
Encode.null
Just dict ->
Encode.dict identity identity dict
)
]
)
En général, un bon moyen de tester un couple décodeur-encodeur consiste à vérifier que elmValue |> encoder |> decoder == elmValue.
Plus tu apprends la programmation, plus tu commences à te dire que ce n'est peut-être pas qu'un simple engouement passager. Il y a même peut-être là des opportunités commerciales : des gens écrivent du code, ils veulent le sauvegarder, le partager, collaborer, et même recevoir quelques retours.
Allez, on se lance, construisons un service pour encourager les gens à coder : hup, hup, tout le monde, commencez à utiliser git ! Et appelons-le... GitHup.
Tu te lances dans la construction d'une API REST JSON : des utilisateurs, des pull requests, des commentaires, et le backend est vite terminé. Voici un exemple de la charge utile renvoyée quand tu demandes au serveur la liste des commentaires d'une pull request :
[
{
"id": 256,
"pull_request_review_id": 42,
"user": {
"id": 101,
"login": "octodog",
"avatar_url": "https://githup.com/images/error/octodog_happy.gif",
"site_admin": false
},
"body": "Great stuff!",
"side": "RIGHT",
"_links": {
"self": {
"href": "https://api.githup.com/repos/octodog/Hello-World/pulls/comments/1"
},
"html": {
"href": "https://githup.com/octodog/Hello-World/pull/1#discussion-diff-1"
},
"pull_request": {
"href": "https://api.githup.com/repos/octodog/Hello-World/pulls/1"
}
}
},
{
"id": 11,
"pull_request_review_id": null,
"user": {
"id": 2,
"login": "hexacat",
"name": "Alex Kate",
"avatar_url": "https://githup.com/images/error/hexacat_happy.gif",
"site_admin": true
},
"body": "Amazing stuff!",
"side": "LEFT",
"_links": {
"self": {
"href": "https://api.githup.com/repos/octodog/Hello-World/pulls/comments/1"
}
}
}
]
Bien sûr, le frontend sera fait en Elm, mais il faut d'abord que tu comprennes ce que sont ces fameux décodeurs et encodeurs.
L'histoire humoristique mise à part, les exigences de cet exercice sont tirées directement des points de terminaison de l'API REST de GitHub pour les commentaires de revue de pull request. Certains champs ont été retirés des schémas JSON par souci de simplicité, mais on est ici au plus près d'une application du monde réel.
Ton œil de lynx a remarqué que les commentaires comme les utilisateurs ont un champ id, un endroit parfait pour commencer.
Définis decodeId pour qu'il puisse décoder un objet JSON, avec un id entier.
Decode.decodeString decodeId """{id: 10}"""
--> Ok 10
Tu remarques dans l'exemple ci-dessus que tous les utilisateurs n'ont pas de nom.
Définis decodeName pour qu'il puisse décoder une string dans le champ name s'il y en a une.
Pas de nom ? Pas de problème, renvoie simplement Nothing.
Ce décodeur ne doit jamais échouer.
Decode.decodeString decodeName """{"id": 10, "name": "Otto"}"""
--> Ok (Just Otto)
Decode.decodeString decodeName """null"""
--> Ok Nothing
On est à peu près à mi-chemin pour décoder les utilisateurs, finissons le travail.
Définis decodeUser pour qu'il puisse décoder un objet JSON représentant un utilisateur.
Assure-toi d'utiliser decodeId et decodeName dans decodeUser.
Decode.decodeString decodeUser
"""
{
"id": 101,
"login": "octodog",
"avatar_url": "https://githup.com/images/error/octodog_happy.gif",
"site_admin": false
}
"""
--> Ok
--> { id = 101
--> , name = Nothing
--> , login = "octodog"
--> , avatarUrl = "https://githup.com/images/error/octodog_happy.gif
--> , siteAdmin = False
--> }
Certains commentaires d'une revue de pull request sont autonomes, et n'ont donc pas de pull_request_review_id.
Cependant, la spécification indique que dans un tel cas, il doit quand même y avoir un champ pull_request_review_id, mais que sa valeur doit être null, le Nothing du pauvre.
Définis decodePullRequestReviewId pour qu'il puisse décoder un entier dans le champ pull_request_review_id s'il y en a un.
S'il n'y a pas d'entier, il doit y avoir un null, et s'il n'y a pas de null, le décodeur doit échouer.
Decode.decodeString decodePullRequestReviewId """{"pull_request_review_id": 3}"""
--> Ok (Just 3)
Decode.decodeString decodePullRequestReviewId """{"pull_request_review_id": null}"""
--> Ok Nothing
Decode.decodeString decodePullRequestReviewId """{"id": 3}"""
--> Err ...
Passons à ce champ side à l'allure intéressante.
Dans une revue de pull request, tu peux commenter du code qui a été supprimé (du côté Left de l'écran) ou du code qui a été ajouté (du côté Right de l'écran).
Définis decodeSide pour qu'il puisse décoder soit "LEFT" soit "RIGHT" dans le champ side et les associer à la variante de type correspondante.
Decode.decodeString decodeSide """{"side": "LEFT"}"""
--> Ok Left
Decode.decodeString decodeSide """{"side": "middle?"}"""
--> Err ...
Chaque commentaire a un ensemble de liens, qui ne semble pas cohérent d'un commentaire à l'autre.
Définis decodeLinks pour qu'il puisse décoder une collection d'URL dans le champ _links.
Les noms et le nombre de liens peuvent varier, assure-toi d'en tenir compte.
Les liens eux-mêmes sont des objets JSON qui doivent toujours avoir un champ href.
Decode.decodeString decodeLinks """
{
"_links": {
"self": { "href": "https://api.githup.com/repos/octodog/Hello-World/pulls/comments/1" },
"html": { "href": "https://githup.com/octodog/Hello-World/pull/1#discussion-diff-1" },
"pull_request": { "href": "https://api.githup.com/repos/octodog/Hello-World/pulls/1" }
}
}
"""
--> Ok
--> (Dict.fromList
--> [ ( "self", "https://api.githup.com/repos/octodog/Hello-World/pulls/comments/1" )
--> , ( "html", "https://githup.com/octodog/Hello-World/pull/1#discussion-diff-1" )
--> , ( "pull_request", "https://api.githup.com/repos/octodog/Hello-World/pulls/1" )
--> ]
--> )
On y est presque, finissons le travail.
Définis decodeComment et decodeComments pour qu'ils puissent décoder respectivement un objet JSON représentant un seul commentaire ou un tableau de commentaires.
Assure-toi d'utiliser toutes les fonctions définies jusqu'ici dans decodeComment, à l'exception de decodeName.
Bien sûr, decodeComments doit aussi utiliser decodeComment.
Il manque encore une fonctionnalité à tout cela : pouvoir envoyer un nouveau commentaire au serveur.
Pour cela, il te faut réaliser l'inverse d'un décodeur, tu dois pouvoir transformer un Comment Elm en une Value JSON.
Définis encodeComment pour qu'il puisse encoder n'importe quel Comment valide.
L'ordre des champs n'a pas d'importance, tant que l'encodeur peut produire une valeur que decodeComment peut décoder.
Inscris-toi sur Exercism pour apprendre et maîtriser Elm avec 28 concepts110 exercices, et un vrai mentorat humain, le tout gratuitement.