JSON (JavaScript Object Notation) è un formato di dati e file leggibile da persone, comunemente usato per scambiare dati, in particolare per applicazioni web. Non sorprende quindi che occupi un posto speciale nel linguaggio Elm.
Elm fornisce i moduli principali Json.Decode e Json.Encode per analizzare e scrivere dati JSON.
I decoder JSON sono un modo per dichiarare quale tipo di dati è atteso e a quale struttura dati i dati devono corrispondere.
Importiamo alcuni alias e tipi dai moduli:
import Json.Decode as Decode exposing (Decoder, Error)
import Json.Encode as Encode exposing (Value)
Usare Decode.decodeString : Decoder a -> String -> Result Error a con un decoder e una stringa JSON avrà successo se la stringa è conforme alle specifiche del decoder, oppure fallirà in caso contrario.
Non c'è modo di ispezionare i dati JSON grezzi quando si definisce il decoder, cosa che può sembrare insolita se si viene da linguaggi imperativi.
JSON ha un piccolo numero di tipi di dati principali:
"hello world"
true o false
34 o 0.12
null
Ognuno di questi è associato a una funzione di 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 ...
Nota che Decode.null consente di decidere come modellare un null nel programma richiedendo un valore arbitrario come argomento (magari (), Nothing, o qualsiasi cosa appropriata al programma).
JSON definisce anche due strutture dati per raccogliere i tipi di dati di base:
[0, null, "none", []]
{"id": 1, "is_admin": false, "more_info": {}}
Decode offre anche funzioni per decodificare questi:
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 ...
Queste funzioni Decode.list : Decoder a -> Decoder (List a) e Decode.dict : Decoder a -> Decoder (Dict String a) si aspettano ciascuna un decoder come argomento, che deve decodificare ogni singolo elemento della struttura dati.
Se il decoder passato come argomento non riesce a decodificare un elemento qualsiasi di un array o di un oggetto, l'intero decoder fallisce, il che è uno schema comune.
Combinare decoder semplici in decoder più complessi è l'idea centrale della tecnica. Introdurremo diverse altre funzioni che possono essere usate per costruire decoder di dati reali arbitrariamente complessi attraverso un esempio.
Per esercitarsi a combinare decoder di base per analizzare dati JSON complessi, costruiamo dei decoder per analizzare (un sottoinsieme di) dati GeoJSON. GeoJSON è un formato di dati specializzato basato su JSON, usato per rappresentare elementi geografici, come località, percorsi o regioni, specificati con coordinate geografiche 2D o 3D, insieme ad altre proprietà.
Un oggetto Geometry di GeoJSON è un oggetto che contiene un campo type con un valore stringa (ci sono 7 possibili valori di tipo) e un campo coordinates con coordinate geografiche (raccolte in una struttura che dipende dal tipo).
Per esempio,
{
"type": "Point",
"coordinates": [127.831, 26.461]
}
oppure
{
"type": "LineString",
"coordinates": [
[127.831, 26.461],
[127.829, 26.465],
[127.829, 26.469],
[127.83, 26.47]
]
}
Un oggetto Geometry potrebbe essere rappresentato in Elm con il seguente tipo
type Geometry
= Point (List Float)
| LineString (List (List Float))
| ...
Per esaminare il valore di un certo campo, possiamo usare Decode.field : String -> Decoder a -> Decoder a, che decodifica il valore di una chiave specifica di un oggetto.
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))
Per trasformare il valore analizzato con successo da un decoder, possiamo usare Decode.map : (a -> value) -> Decoder a -> Decoder value, o uno dei suoi simili, da Decode.map2 (a -> b -> value) -> Decoder a -> Decoder b -> Decoder value fino a Decode.map8.
In questo caso, vogliamo decidere quale decoder di coordinate usare in base al valore nel campo type, un compito perfetto per Decode.andThen : (a -> Decoder b) -> Decoder a -> Decoder b e i suoi amici Decode.succeed : a -> Decoder a e 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"
)
Nota a questo punto che potremmo usare Decode.andThen per validare le coordinate (verificando che le coordinate siano in coppie o terne e che i valori delle coordinate abbiano senso geografico).
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 oggetto Feature di GeoJSON rappresenta qualcosa di delimitato spazialmente: ha un campo type con il valore "Feature", un campo geometry con un oggetto Geometry o un null, un campo properties con un oggetto JSON arbitrario o un null, e un campo opzionale id con un valore JSON stringa o numero.
{
"type": "Feature",
"geometry": null,
"properties": null
}
oppure
{
"type": "Feature",
"id": "0157",
"geometry": {
"type": "Point",
"coordinates": [127.831, 26.461]
},
"properties": {
"country": "Japan"
}
}
Occupiamoci prima del campo id.
Se esiste, deve poter contenere stringhe o numeri, quindi potremmo introdurre un tipo personalizzato per il valore, oppure salvarlo sempre come stringa.
Per scegliere tra due possibili decoder, possiamo usare 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 ...
Per esprimere che il campo è opzionale, potremmo usare la stessa tecnica
decodeMaybeFeatureId : Decoder (Maybe String)
decodeMaybeFeatureId =
Decode.oneOf
[ decodeFeatureId |> Decode.map Just
, Decode.succeed Nothing
]
Ma potremmo invece usare 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
Nota che decodeMaybeFeatureId (o qualsiasi decoder avvolto con Decode.maybe) avrà sempre successo, poiché può sempre ripiegare su Ok Nothing, anche se un decoder che lo usa potrebbe comunque fallire
Decode.decodeString (Decode.list decodeMaybeFeatureId) """{"id": 17}"""
--> Err ...
Ora concentriamoci sul campo geometry, che può essere un oggetto Geometry oppure null.
Potremmo usare di nuovo Decode.oneOf, ma c'è un'opzione migliore: 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 e Decode.nullable hanno la stessa firma, ma il loro comportamento è diverso, poiché il risultato OK Nothing di un Decode.nullable deriverà solo dal valore JSON specifico null.
Infine, il campo properties può contenere un oggetto JSON arbitrario o un null, un compito per 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 ...
Nota che Value è un tipo opaco, il che significa che non puoi esaminarne facilmente il contenuto.
Per usarlo, puoi scrivere un decoder per esso ed eseguirlo con Decode.decodeValue : Decoder a -> Value -> Result Error a, oppure inviarlo direttamente a JavaScript tramite un port.
Siamo pronti ad analizzare gli oggetti 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>)]) }
Gli encoder permettono di scrivere JSON valido a partire da valori Elm usando la funzione Encode.encode : Int -> Value -> String.
Il primo argomento specifica la quantità di indentazione nel risultato finale, e il secondo argomento è il valore JSON da scrivere.
Un Value può essere ottenuto da Decode.value oppure prodotto da uno degli encoder:
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}"
così come con Encode.object : List ( String, Value ) -> Value
Encode.object
[ ( "key1", Encode.int 17 )
, ( "key2", Encode.int 71 )
]
|> Encode.encode 0
--> "{\"key1\":17,\"key2\":71}"
Definiamo gli encoder per i decoder GeoJSON definiti in precedenza
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
)
]
)
In generale, un buon test per le coppie decoder-encoder è verificare che elmValue |> encoder |> decoder == elmValue.
Più impari a programmare, più inizi a pensare che questa possa essere più di una semplice moda. Potrebbero esserci anche delle opportunità di business: le persone scrivono codice, vogliono salvarlo, condividerlo, collaborare, magari ricevere anche qualche feedback.
Va bene, facciamolo, costruiamo un servizio per incoraggiare le persone a programmare: hup, hup, gente, iniziate a usare git! Chiamiamolo... GitHup.
Ti metti a costruire un'API REST JSON: utenti, pull request, commenti, il backend è fatto in fretta. Ecco un esempio del payload che viene servito quando chiedi al server la lista dei commenti su una 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"
}
}
}
]
Certo, il frontend sarà fatto in Elm, ma prima devi capire cosa sono quei famigerati decoder/encoder.
A parte la storia umoristica, i requisiti dell'esercizio sono presi direttamente dagli endpoint dell'API REST di GitHub per i commenti di revisione delle pull request. Alcuni campi sono stati rimossi dagli schemi JSON per semplicità, ma questo è il più vicino possibile alle applicazioni del mondo reale.
Il tuo occhio di falco ha notato che sia i commenti sia gli utenti hanno un campo id, un punto perfetto da cui iniziare.
Definisci decodeId in modo che possa decodificare un oggetto JSON con un id intero.
Decode.decodeString decodeId """{id: 10}"""
--> Ok 10
Nell'esempio qui sopra noti che non tutti gli utenti hanno un nome.
Definisci decodeName in modo che possa decodificare una stringa nel campo name, se c'è.
Nessun nome? Nessun problema, restituisci semplicemente Nothing.
Questo decoder non dovrebbe mai fallire.
Decode.decodeString decodeName """{"id": 10, "name": "Otto"}"""
--> Ok (Just Otto)
Decode.decodeString decodeName """null"""
--> Ok Nothing
Per decodificare gli utenti siamo praticamente a metà strada, finiamolo.
Definisci decodeUser in modo che possa decodificare un oggetto utente JSON.
Assicurati di usare decodeId e decodeName in 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
--> }
Alcuni commenti in una revisione di una pull sono autonomi, e quindi non hanno un pull_request_review_id.
Tuttavia, la specifica dice che in tal caso dovrebbe comunque esserci un campo pull_request_review_id, ma il valore dovrebbe essere null, il Nothing dei poveri.
Definisci decodePullRequestReviewId in modo che possa decodificare un intero nel campo pull_request_review_id, se c'è.
Se non c'è un intero, dovrebbe esserci un null; se non c'è un null, il decoder dovrebbe fallire.
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 ...
Passiamo a questo interessante campo side.
In una revisione di una pull, puoi commentare il codice che è stato rimosso (sul lato Left dello schermo) o il codice che è stato aggiunto (sul lato Right dello schermo).
Definisci decodeSide in modo che possa decodificare "LEFT" o "RIGHT" nel campo side e associarli alla variante di tipo corrispondente.
Decode.decodeString decodeSide """{"side": "LEFT"}"""
--> Ok Left
Decode.decodeString decodeSide """{"side": "middle?"}"""
--> Err ...
Ogni commento ha un insieme di link, che non sembra essere coerente tra i vari commenti.
Definisci decodeLinks in modo che possa decodificare una collezione di URL nel campo _links.
I nomi e il numero di link possono variare, assicurati di tenerne conto.
I link stessi sono oggetti JSON che dovrebbero sempre avere un campo 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" )
--> ]
--> )
Ci siamo quasi, finiamolo.
Definisci decodeComment e decodeComments in modo che possano decodificare rispettivamente un singolo oggetto commento JSON o una lista di commenti.
Assicurati di usare in decodeComment tutte le funzioni definite finora, con l'eccezione di decodeName.
Naturalmente, anche decodeComments dovrebbe usare decodeComment.
Manca ancora una funzionalità in tutto questo: poter inviare un nuovo commento al server.
Per questo devi ottenere l'opposto di un decoder, devi essere in grado di trasformare un Comment di Elm in un Value JSON.
Definisci encodeComment in modo che possa codificare qualsiasi Comment valido.
L'ordine dei campi non conta, purché l'encoder riesca a produrre un valore che decodeComment possa decodificare.
Iscriviti a Exercism per imparare e padroneggiare Elm con 28 concetti110 esercizi e il mentoring di persone reali, tutto gratis.