JSON (JavaScript Object Notation) é um formato de dados e de arquivo legível por humanos, muito usado para trocar dados, em especial em aplicações web. Por isso, não surpreende que ele ocupe um lugar especial na linguagem Elm.
O Elm fornece os módulos centrais Json.Decode e Json.Encode para analisar e escrever dados JSON.
Os decodificadores JSON são uma forma de declarar que tipo de dado é esperado e a que estrutura de dados o dado deve ser mapeado.
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 decodificador e uma string JSON vai dar certo se a string estiver de acordo com as especificações do decodificador, ou vai falhar caso contrário.
Não há como inspecionar os dados JSON brutos ao definir o decodificador, o que pode parecer incomum para quem vem de linguagens imperativas.
O JSON tem um pequeno número de tipos de dados centrais:
"hello world"
true ou false
34 ou 0.12
null
Cada um deles está associado a uma função do 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 ...
Repare que Decode.null permite que você decida como modelar um null no seu programa, exigindo um valor arbitrário como argumento (talvez (), Nothing, ou qualquer coisa adequada ao seu programa).
O JSON também define duas estruturas de dados para agrupar tipos de dados centrais:
[0, null, "none", []]
{"id": 1, "is_admin": false, "more_info": {}}
O Decode também oferece funções para decodificar esses tipos:
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 ...
Essas funções, Decode.list : Decoder a -> Decoder (List a) e Decode.dict : Decoder a -> Decoder (Dict String a), esperam cada uma um decodificador como argumento, que deve decodificar cada elemento da estrutura de dados.
Se o decodificador passado como argumento falhar ao decodificar qualquer elemento de um array ou de um objeto, o decodificador inteiro falha, e esse é um padrão comum.
Combinar decodificadores simples em decodificadores mais complexos é a ideia central da técnica. Vamos apresentar várias outras funções que podem ser usadas para construir decodificadores de dados do mundo real arbitrariamente complexos, por meio de um exemplo.
Para praticar a combinação de decodificadores básicos na análise de dados JSON complexos, vamos construir decodificadores para analisar (um subconjunto de) dados GeoJSON. O GeoJSON é um formato de dados especializado, baseado em JSON, usado para representar elementos geográficos, como localidades, caminhos ou regiões, especificados com coordenadas geográficas 2D ou 3D, junto com outras propriedades.
Um Objeto de geometria GeoJSON é um objeto que contém um campo type com um valor string (há 7 valores possíveis para o type) e um campo coordinates com coordenadas geográficas (reunidas em uma estrutura que depende do type).
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 poderia ser representado em Elm com o seguinte tipo
type Geometry
= Point (List Float)
| LineString (List (List Float))
| ...
Para examinar o valor de um determinado campo, podemos usar Decode.field : String -> Decoder a -> Decoder a, que decodifica 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 analisado com sucesso por um decodificador, podemos usar Decode.map : (a -> value) -> Decoder a -> Decoder value, ou qualquer um de seus irmãos, de Decode.map2 (a -> b -> value) -> Decoder a -> Decoder b -> Decoder value até Decode.map8.
Neste caso, queremos decidir qual decodificador de coordenadas usar, dependendo do valor no campo type, o que é um trabalho perfeito para Decode.andThen : (a -> Decoder b) -> Decoder a -> Decoder b e 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"
)
Repare, neste ponto, que poderíamos usar Decode.andThen para validar as coordenadas (verificando se as coordenadas vêm 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 ...
Um Objeto de feature GeoJSON representa algo espacialmente delimitado: ele 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 string ou numérico.
{
"type": "Feature",
"geometry": null,
"properties": null
}
ou
{
"type": "Feature",
"id": "0157",
"geometry": {
"type": "Point",
"coordinates": [127.831, 26.461]
},
"properties": {
"country": "Japan"
}
}
Vamos começar pelo campo id.
Se ele existir, precisa acomodar strings ou números, então podemos ou criar um tipo personalizado para o valor, ou sempre salvá-lo como uma string.
Para escolher entre dois decodificadores 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 expressar que o campo é opcional, poderíamos usar a mesma técnica
decodeMaybeFeatureId : Decoder (Maybe String)
decodeMaybeFeatureId =
Decode.oneOf
[ decodeFeatureId |> Decode.map Just
, Decode.succeed Nothing
]
Mas poderí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
Repare que decodeMaybeFeatureId (ou qualquer decodificador envolvido por Decode.maybe) sempre vai dar certo, já que ele sempre pode recorrer a Ok Nothing, ainda que um decodificador que o use ainda possa falhar
Decode.decodeString (Decode.list decodeMaybeFeatureId) """{"id": 17}"""
--> Err ...
Agora, vamos nos concentrar no campo geometry, que pode ser um Objeto de geometria ou null.
Poderíamos usar Decode.oneOf de novo, 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 seu comportamento é diferente, já que o resultado OK Nothing de um Decode.nullable só virá do valor JSON null específico.
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 ...
Repare que Value é um tipo opaco, o que significa que você não consegue examinar seu conteúdo facilmente.
Para usá-lo, você pode escrever um decodificador para ele e rodá-lo com Decode.decodeValue : Decoder a -> Value -> Result Error a, ou enviá-lo diretamente ao JavaScript por meio de ports.
Agora podemos analisar 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>)]) }
Os codificadores permitem escrever JSON válido a partir de valores 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 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}"
assim 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 decodificadores 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 seus pares decodificador-codificador é verificar se elmValue |> encoder |> decoder == elmValue.
Quanto mais você aprende sobre programação, mais começa a achar que isso pode ser mais do que só uma moda. Talvez até existam oportunidades de negócio: as pessoas escrevem código, querem salvá-lo, compartilhá-lo, colaborar, quem sabe até receber um feedback.
Beleza, vamos fazer isso, vamos construir um serviço para incentivar as pessoas a programar: hup, hup, gente, comecem a usar git! Vamos chamar de... GitHup.
Você começa a construir uma API REST JSON: usuários, pull requests, comentários, e o backend fica pronto rapidinho. Aqui está um exemplo do payload que é servido quando você pede 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, o frontend vai ser feito em Elm, mas primeiro você precisa entender o que são esses tais decodificadores/codificadores.
Deixando a historinha de lado, os requisitos do exercício vêm diretamente dos endpoints da API REST do GitHub para comentários de revisão de pull request. Alguns campos foram removidos dos esquemas JSON para simplificar, mas isso é o mais próximo de aplicações do mundo real que dá para chegar.
Seu olho de águia percebeu que tanto os comentários quanto os usuários têm um campo id, lugar perfeito para começar.
Defina decodeId de forma que ele consiga decodificar um objeto JSON com um id inteiro.
Decode.decodeString decodeId """{id: 10}"""
--> Ok 10
Você percebe no exemplo acima que nem todos os usuários têm um nome.
Defina decodeName de forma que ele consiga decodificar uma string no campo name, se houver uma.
Sem nome? Sem problema, basta retornar Nothing.
Esse decodificador nunca deve falhar.
Decode.decodeString decodeName """{"id": 10, "name": "Otto"}"""
--> Ok (Just Otto)
Decode.decodeString decodeName """null"""
--> Ok Nothing
Já estamos quase na metade para decodificar usuários, vamos terminar isso.
Defina decodeUser de forma que ele consiga decodificar um objeto JSON de usuário.
Não deixe de usar 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
--> }
Alguns comentários em uma revisão de pull são independentes e, portanto, não têm um pull_request_review_id.
No entanto, a especificação diz que, nesse caso, ainda deve haver um campo pull_request_review_id, mas o valor deve ser null, o Nothing dos pobres.
Defina decodePullRequestReviewId de forma que ele consiga decodificar um inteiro no campo pull_request_review_id, se houver um.
Se não houver um inteiro, deve haver um null; se não houver um null, o decodificador 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 ...
Agora este campo side de aparência interessante.
Em uma revisão de pull, você pode comentar sobre código que foi removido (no lado Left da tela) ou sobre código que foi adicionado (no lado Right da tela).
Defina decodeSide de forma que ele consiga decodificar "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 parece não ser consistente entre os comentários.
Defina decodeLinks de forma que ele consiga decodificar uma coleção de URLs no campo _links.
Os nomes e a quantidade de links podem variar, então dê um jeito de lidar com isso.
Os próprios links são objetos JSON que sempre devem ter 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" )
--> ]
--> )
Quase lá, vamos terminar.
Defina decodeComment e decodeComments de forma que consigam decodificar um único objeto JSON de comentário ou uma lista de comentários, respectivamente.
Não deixe de usar todas as funções definidas até agora em decodeComment, com exceção de decodeName.
Claro, decodeComments também deve usar decodeComment.
Falta ainda um recurso em tudo isso: conseguir enviar um novo comentário ao servidor.
Para isso, você precisa fazer o oposto de um decodificador: transformar um Comment do Elm em um Value JSON.
Defina encodeComment de forma que ele consiga codificar qualquer Comment válido.
A ordem dos campos não importa, desde que o codificador produza um valor que decodeComment consiga decodificar.
Crie sua conta no Exercism para aprender e dominar Elm com 28 conceitos110 exercícios e mentoria humana de verdade, tudo de graça.