Track
/
Elm
Elm
/
Esercizi
/
GitHup API
GitHup API

GitHup API

Esercizio di apprendimento

Introduzione

JSON

JSON

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.

Decoder per i tipi di dati di base

JSON ha un piccolo numero di tipi di dati principali:

  • Stringa: codificata con virgolette doppie "hello world"
  • Booleano: true o false
  • Numero: come 34 o 0.12
  • Null: l'assenza di dati 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).

Combinare i decoder

JSON definisce anche due strutture dati per raccogliere i tipi di dati di base:

  • Array: una raccolta di altri tipi di dati [0, null, "none", []]
  • Oggetto: una raccolta di coppie chiave-valore {"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.

GeoJSON

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à.

Oggetto Geometry

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 ...
Oggetto Feature

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>)]) }

Encoder JSON

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.

Istruzioni

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.

Note

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.

1. L'ID, per favore!

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

2. Come mi chiamo, già?

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

3. User? Lo conosco a malapena!

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
    -->   }

4. Sono un commento forte e indipendente

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 ...

5. Attività collaterale

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" )
    -->       ]
    -->   )

7. Commenti sì

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.

8. Tutto si ricollega

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.

Modifica tramite GitHub Il link si apre in una nuova finestra o scheda
Elm Exercism

Vuoi iniziare GitHup API?

Iscriviti a Exercism per imparare e padroneggiare Elm con 28 concetti110 esercizi e il mentoring di persone reali, tutto gratis.