GitHup API

GitHup API

Exercício de aprendizagem

Introdução

JSON

JSON

JSON (JavaScript Object Notation) é um formato de dados e de ficheiros legível por humanos, usado habitualmente para trocar dados, em particular em aplicações web. Como tal, não é de estranhar que ocupe um lugar especial na linguagem Elm.

O Elm fornece os módulos centrais Json.Decode e Json.Encode para interpretar e escrever dados JSON. Os descodificadores de JSON são uma forma de declarar que tipo de dados é esperado e a que estrutura de dados os dados devem ser mapeados.

Vamos importar alguns aliases e tipos dos módulos:

import Json.Decode as Decode exposing (Decoder, Error)
import Json.Encode as Encode exposing (Value)

Usar Decode.decodeString : Decoder a -> String -> Result Error a com um descodificador e uma string JSON tem sucesso se a string estiver de acordo com as especificações do descodificador, e falha caso contrário. Não há forma de inspecionar os dados JSON em bruto quando defines o descodificador. Se vens de linguagens imperativas, isto pode parecer-te invulgar.

Descodificadores de tipos de dados básicos

O JSON tem um pequeno número de tipos de dados fundamentais:

  • String: codificada com aspas duplas "hello world"
  • Boolean: true ou false
  • Number: como 34 ou 0.12
  • Null: a ausência de dados, null

Cada um destes está associado a uma função 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 ...

Repara que Decode.null te deixa decidir como modelar um null no teu programa, exigindo um valor arbitrário como argumento (talvez (), Nothing, ou qualquer coisa adequada ao teu programa).

Combinar descodificadores

O JSON também define duas estruturas de dados para agrupar tipos de dados fundamentais:

  • Array: uma coleção de outros tipos de dados [0, null, "none", []]
  • Object: uma coleção de pares chave-valor {"id": 1, "is_admin": false, "more_info": {}}

O Decode também oferece funções para os descodificar:

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

Estas funções, Decode.list : Decoder a -> Decoder (List a) e Decode.dict : Decoder a -> Decoder (Dict String a), esperam um descodificador como argumento, que deve descodificar todos os elementos da estrutura de dados. Se o descodificador passado como argumento falhar a descodificação de algum elemento de um array ou de um objeto, o descodificador todo falha, o que é um padrão comum.

Combinar descodificadores simples em descodificadores mais complexos é a ideia central da técnica. Vamos apresentar mais algumas funções que podes usar para construir descodificadores de dados do mundo real arbitrariamente complexos, com a ajuda de um exemplo.

GeoJSON

Para praticares a combinação de descodificadores básicos na interpretação de dados JSON complexos, vamos construir descodificadores para interpretar (um subconjunto de) dados GeoJSON. O GeoJSON é um formato de dados especializado, baseado em JSON, usado para representar elementos geográficos, como localizações, percursos ou regiões, especificados com coordenadas geográficas 2D ou 3D, a par de outras propriedades.

Objeto de geometria

Um objeto de geometria GeoJSON é um objeto que contém um campo type com um valor string (há 7 valores possíveis para o tipo) e um campo coordinates com coordenadas geográficas (reunidas numa estrutura que depende do tipo). Por exemplo,

{
  "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]
  ]
}

Um objeto de geometria pode ser representado em Elm com o tipo seguinte

type Geometry
  = Point (List Float)
  | LineString (List (List Float))
  | ...

Para inspecionar o valor de um determinado campo, podemos usar Decode.field : String -> Decoder a -> Decoder a, que descodifica o valor de uma chave específica de um objeto.

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

Para transformar o valor interpretado com sucesso por um descodificador, podemos usar Decode.map : (a -> value) -> Decoder a -> Decoder value, ou qualquer dos seus irmãos, desde Decode.map2 (a -> b -> value) -> Decoder a -> Decoder b -> Decoder value até Decode.map8.

Neste caso, queremos decidir que descodificador de coordenadas usar consoante o valor do campo type, o que é uma tarefa ideal para Decode.andThen : (a -> Decoder b) -> Decoder a -> Decoder b e os seus amigos 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"
            )

Repara, a esta altura, que podíamos usar Decode.andThen para validar as coordenadas (verificando se as coordenadas aparecem em pares ou trios e se os valores das coordenadas fazem sentido geográfico).

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 ...
Objeto de feature

Um objeto de feature GeoJSON representa algo delimitado espacialmente: tem um campo type com o valor "Feature", um campo geometry com um objeto de geometria ou um null, um campo properties com um objeto JSON arbitrário ou um null, e um campo id opcional com um valor de string ou de número JSON.

{
  "type": "Feature",
  "geometry": null,
  "properties": null
}

ou

{
  "type": "Feature",
  "id": "0157",
  "geometry": {
    "type": "Point",
    "coordinates": [127.831, 26.461]
  },
  "properties": {
    "country": "Japan"
  }
}

Vamos tratar primeiro do campo id. Se existir, tem de acomodar strings ou números, por isso podíamos ou introduzir um tipo personalizado para o valor, ou guardá-lo sempre como uma string.

Para escolher entre dois descodificadores possíveis, podemos usar 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 ...

Para indicar que o campo é opcional, podíamos usar a mesma técnica

decodeMaybeFeatureId : Decoder (Maybe String)
decodeMaybeFeatureId =
    Decode.oneOf
        [ decodeFeatureId |> Decode.map Just
        , Decode.succeed Nothing
        ]

Mas podíamos, em vez disso, usar 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

Repara que decodeMaybeFeatureId (ou qualquer descodificador envolvido com Decode.maybe) tem sempre sucesso, porque pode sempre recorrer a Ok Nothing, embora um descodificador que o use ainda possa falhar

Decode.decodeString (Decode.list decodeMaybeFeatureId) """{"id": 17}"""
    --> Err ...

A seguir, vamos centrar-nos no campo geometry, que pode ser um objeto de geometria ou ser null. Podíamos usar Decode.oneOf outra vez, mas há uma opção melhor: 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 têm a mesma assinatura, mas o comportamento é diferente, porque o resultado OK Nothing de um Decode.nullable só pode vir do valor JSON específico null.

Por fim, o campo properties pode conter um objeto JSON arbitrário ou um null, o que é trabalho para 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 ...

Repara que Value é um tipo opaco, o que significa que não consegues inspecionar facilmente o seu conteúdo. Para o usar, podes ou escrever um descodificador para ele e executá-lo com Decode.decodeValue : Decoder a -> Value -> Result Error a, ou enviá-lo diretamente para o JavaScript através de um port.

Estamos prontos para interpretar objetos de 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>)]) }

Codificadores de JSON

Os codificadores permitem escrever JSON válido a partir de valores de Elm, usando a função Encode.encode : Int -> Value -> String. O primeiro argumento especifica a quantidade de indentação no resultado final, e o segundo argumento é o valor JSON a escrever.

Um Value pode ser obtido a partir de Decode.value ou produzido por um dos codificadores:

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

tal como com Encode.object : List ( String, Value ) -> Value

Encode.object
    [ ( "key1", Encode.int 17 )
    , ( "key2", Encode.int 71 )
    ]
    |> Encode.encode 0
    --> "{\"key1\":17,\"key2\":71}"

Vamos definir codificadores para os descodificadores GeoJSON que definimos antes

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

Em geral, um bom teste para os teus pares descodificador-codificador é verificar se elmValue |> encoder |> decoder == elmValue.

Instruções

Quanto mais aprendes sobre programação, mais começas a pensar que isto pode ser mais do que uma simples moda. Pode até haver algumas oportunidades de negócio: as pessoas escrevem código, querem guardá-lo, partilhá-lo, colaborar, talvez até receber algum feedback.

Muito bem, vamos a isso, vamos construir um serviço para incentivar as pessoas a programar: hup, hup, pessoal, comecem a usar git! Vamos chamá-lo... GitHup.

Começas a construir uma API REST JSON: utilizadores, pull requests, comentários, e o backend fica rapidamente pronto. Aqui está um exemplo do payload devolvido quando pedes ao servidor a lista de comentários de um 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"
      }
    }
  }
]

Claro que o frontend vai ser feito em Elm, mas primeiro tens de perceber o que são esses famosos decoders/encoders.

Note

Piada à parte, os requisitos do exercício são retirados diretamente dos endpoints da API REST do GitHub para comentários de revisão de pull requests. Alguns campos foram removidos dos esquemas JSON por uma questão de simplicidade, mas isto é o mais próximo que se chega de aplicações do mundo real.

1. ID, por favor!

O teu olho de águia reparou que tanto os comentários como os utilizadores têm um campo id, o sítio perfeito para começar.

Define decodeId de modo a que consiga descodificar um objeto JSON com um id inteiro.

Decode.decodeString decodeId """{id: 10}"""
    --> Ok 10

2. Como é que eu me chamo mesmo?

Reparas, no exemplo acima, que nem todos os utilizadores têm um nome.

Define decodeName de modo a que consiga descodificar uma string no campo name, se esta existir. Não há nome? Não há problema, devolve simplesmente Nothing. Este descodificador nunca deve falhar.

Decode.decodeString decodeName """{"id": 10, "name": "Otto"}"""
    --> Ok (Just Otto)

Decode.decodeString decodeName """null"""
    --> Ok Nothing

3. Utilizador? Nem sei quem é!

Já vamos praticamente a meio da descodificação dos utilizadores, vamos acabar isto.

Define decodeUser de modo a que consiga descodificar um objeto JSON de utilizador. Certifica-te de que usas decodeId e decodeName em 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. Sou um comentário forte e independente

Alguns comentários numa revisão de pull request são independentes e, por isso, não têm um pull_request_review_id. No entanto, a especificação refere que, nesse caso, deve continuar a existir um campo pull_request_review_id, mas com o valor null, o Nothing dos pobres.

Define decodePullRequestReviewId de modo a que consiga descodificar um inteiro no campo pull_request_review_id, se este existir. Se não houver um inteiro, deve haver um null; se não houver um null, o descodificador deve falhar.

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

A seguir, temos este campo side com um aspeto interessante. Numa revisão de pull request, podes comentar código que foi removido (no lado Left do ecrã) ou código que foi acrescentado (no lado Right do ecrã).

Define decodeSide de modo a que consiga descodificar "LEFT" ou "RIGHT" no campo side e associá-los à variante de tipo correspondente.

Decode.decodeString decodeSide """{"side": "LEFT"}"""
    --> Ok Left

Decode.decodeString decodeSide """{"side": "middle?"}"""
    --> Err ...

Cada comentário tem um conjunto de links, que não parece ser consistente de comentário para comentário.

Define decodeLinks de modo a que consiga descodificar uma coleção de URLs no campo _links. Os nomes e o número de links podem variar, certifica-te de que consegues dar conta disso. Os próprios links são objetos JSON que devem ter sempre um 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. Sim, comentários

Estamos quase lá, vamos acabar isto.

Define decodeComment e decodeComments de modo a que consigam descodificar, respetivamente, um único objeto JSON de comentário ou uma lista de comentários. Certifica-te de que usas todas as funções definidas até agora em decodeComment, com a exceção de decodeName. Claro que decodeComments também deve usar decodeComment.

8. Tudo se liga

Falta ainda uma funcionalidade em tudo isto: conseguir enviar um novo comentário para o servidor. Para isso, precisas de fazer o oposto de um descodificador, precisas de conseguir transformar um Comment de Elm num Value de JSON.

Define encodeComment de modo a que consiga codificar qualquer Comment válido. A ordem dos campos não importa, desde que o codificador consiga produzir um valor que o decodeComment consiga descodificar.

Editar via GitHub A ligação abre numa nova janela ou separador
Elm Exercism

Estás pronto para começar GitHup API?

Inscreve-te no Exercism para aprenderes e dominares Elm com 28 conceitos110 exercícios, e mentoria humana real, tudo grátis.