JSON (JavaScript Object Notation) es un formato de datos y de archivo legible por humanos que se usa comúnmente para intercambiar datos, en particular en aplicaciones web. Como tal, no sorprende que ocupe un lugar especial en el lenguaje Elm.
Elm proporciona los módulos principales Json.Decode y Json.Encode para analizar y escribir datos JSON.
Los decodificadores JSON son una forma de declarar qué tipo de datos se espera y a qué estructura de datos se deben mapear los datos.
Importemos algunos alias y tipos de los 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 con un decodificador y un string JSON tendrá éxito si el string cumple con las especificaciones del decodificador, o fallará en caso contrario.
No hay forma de inspeccionar los datos JSON sin procesar al definir el decodificador, lo cual puede resultar inusual si vienes de lenguajes imperativos.
JSON tiene una pequeña cantidad de tipos de datos básicos:
"hello world"
true o false
34 o 0.12
null
Cada uno de estos está asociado con una función 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 ...
Fíjate que Decode.null te permite decidir cómo modelar un null en tu programa al requerir un valor arbitrario como argumento (quizás (), Nothing, o cualquier cosa apropiada para tu programa).
JSON también define dos estructuras de datos para agrupar tipos de datos básicos:
[0, null, "none", []]
{"id": 1, "is_admin": false, "more_info": {}}
Decode también ofrece funciones para decodificar estos:
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 funciones Decode.list : Decoder a -> Decoder (List a) y Decode.dict : Decoder a -> Decoder (Dict String a) esperan cada una un decodificador como argumento, que debe decodificar cada uno de los elementos de la estructura de datos.
Si el decodificador del argumento no logra decodificar algún elemento de un valor de tipo array u object, el decodificador completo falla, lo cual es un patrón común.
Combinar decodificadores simples en otros más complejos es la idea central de la técnica. Presentaremos varias funciones más que se pueden usar para construir decodificadores de datos del mundo real arbitrariamente complejos mediante un ejemplo.
Para practicar la combinación de decodificadores básicos para analizar datos JSON complejos, construyamos decodificadores para analizar (un subconjunto de) datos GeoJSON. GeoJSON es un formato de datos especializado basado en JSON que se usa para representar características geográficas, como ubicaciones, rutas o regiones, especificadas con coordenadas geográficas 2D o 3D, junto con otras propiedades.
Un objeto geométrico de GeoJSON es un objeto que contiene un campo type con un valor de tipo string (hay 7 valores posibles para type) y un campo coordinates con coordenadas geográficas (recolectadas en una estructura que depende del tipo).
Por ejemplo,
{
"type": "Point",
"coordinates": [127.831, 26.461]
}
o
{
"type": "LineString",
"coordinates": [
[127.831, 26.461],
[127.829, 26.465],
[127.829, 26.469],
[127.83, 26.47]
]
}
Un objeto geométrico se podría representar en Elm con el siguiente tipo
type Geometry
= Point (List Float)
| LineString (List (List Float))
| ...
Para inspeccionar el valor de un campo determinado, podemos usar Decode.field : String -> Decoder a -> Decoder a, que decodifica el valor de una clave específica de un 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 el valor que un decodificador analizó correctamente, podemos usar Decode.map : (a -> value) -> Decoder a -> Decoder value, o cualquiera de sus variantes, desde Decode.map2 (a -> b -> value) -> Decoder a -> Decoder b -> Decoder value hasta Decode.map8.
En este caso, queremos decidir qué decodificador de coordenadas usar según el valor del campo type, lo cual es un trabajo perfecto para Decode.andThen : (a -> Decoder b) -> Decoder a -> Decoder b y sus compañeros Decode.succeed : a -> Decoder a y 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"
)
Fíjate en este punto que podríamos usar Decode.andThen para validar las coordenadas (verificando que las coordenadas vengan en pares o tríos y que los valores de las coordenadas tengan 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 ...
Un objeto Feature de GeoJSON representa algo delimitado espacialmente: tiene un campo type con el valor "Feature", un campo geometry con un objeto geométrico o un null, un campo properties con un objeto JSON arbitrario o un null, y un campo id opcional con un valor de tipo string o number de JSON.
{
"type": "Feature",
"geometry": null,
"properties": null
}
o
{
"type": "Feature",
"id": "0157",
"geometry": {
"type": "Point",
"coordinates": [127.831, 26.461]
},
"properties": {
"country": "Japan"
}
}
Abordemos primero el campo id.
Si existe, debe admitir strings o números, así que podríamos introducir un tipo personalizado para el valor o guardarlo siempre como string.
Para elegir entre dos decodificadores posibles, 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 expresar que el campo es opcional, podríamos usar la misma técnica
decodeMaybeFeatureId : Decoder (Maybe String)
decodeMaybeFeatureId =
Decode.oneOf
[ decodeFeatureId |> Decode.map Just
, Decode.succeed Nothing
]
Pero en su lugar podríamos 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
Fíjate que decodeMaybeFeatureId (o cualquier decodificador envuelto con Decode.maybe) siempre tendrá éxito, ya que siempre puede recurrir a Ok Nothing, aunque un decodificador que lo use todavía podría fallar
Decode.decodeString (Decode.list decodeMaybeFeatureId) """{"id": 17}"""
--> Err ...
A continuación, enfoquémonos en el campo geometry, que puede ser un objeto geométrico o null.
Podríamos usar Decode.oneOf una vez más, pero hay una mejor opción: 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 y Decode.nullable tienen la misma firma, pero su comportamiento es diferente, ya que el resultado OK Nothing de un Decode.nullable solo provendrá del valor JSON específico null.
Finalmente, el campo properties puede contener un objeto JSON arbitrario o un null, lo cual es trabajo 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 ...
Fíjate que Value es un tipo opaco, lo que significa que no puedes inspeccionar su contenido fácilmente.
Para usarlo, puedes escribir un decodificador para él y ejecutarlo con Decode.decodeValue : Decoder a -> Value -> Result Error a, o enviarlo a JavaScript directamente a través de un puerto.
Estamos listos para analizar objetos 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>)]) }
Los codificadores permiten escribir JSON válido a partir de valores de Elm usando la función Encode.encode : Int -> Value -> String.
El primer argumento especifica la cantidad de indentación en el resultado final, y el segundo argumento es el valor JSON que se va a escribir.
Un Value se puede obtener de Decode.value o producirse con uno de los 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}"
así como con Encode.object : List ( String, Value ) -> Value
Encode.object
[ ( "key1", Encode.int 17 )
, ( "key2", Encode.int 71 )
]
|> Encode.encode 0
--> "{\"key1\":17,\"key2\":71}"
Definamos codificadores para los decodificadores de GeoJSON definidos anteriormente
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 general, una buena prueba para tus pares decodificador-codificador es verificar que elmValue |> encoder |> decoder == elmValue.
Cuanto más aprendes sobre programación, más empiezas a pensar que esto podría ser más que una simple moda. Incluso podría haber algunas oportunidades de negocio: la gente escribe código, quiere guardarlo, compartirlo, colaborar y quizá hasta recibir algo de retroalimentación.
Muy bien, hagámoslo, construyamos un servicio para animar a la gente a programar: ¡hup, hup, gente, empiecen a usar git! Lo llamaremos... GitHup.
Te pones a construir una API REST de JSON: usuarios, pull requests, comentarios, y el backend queda listo en un santiamén. Este es un ejemplo del payload que se devuelve cuando le pides al servidor la lista de comentarios de un 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"
}
}
}
]
Por supuesto, el frontend se hará en Elm, pero primero necesitas entender de qué se tratan esos famosos decodificadores y codificadores.
Aparte de la historia humorística, los requisitos del ejercicio están tomados directamente de los endpoints de la API REST de GitHub para los comentarios de revisión de pull requests. Se eliminaron algunos campos de los esquemas JSON por simplicidad, pero esto es lo más cerca que se puede estar de una aplicación del mundo real.
Tu ojo de águila notó que tanto los comentarios como los usuarios tienen un campo id, un lugar perfecto para empezar.
Define decodeId para que pueda decodificar un objeto JSON con un id entero.
Decode.decodeString decodeId """{id: 10}"""
--> Ok 10
Te das cuenta, en el ejemplo de arriba, de que no todos los usuarios tienen un nombre.
Define decodeName para que pueda decodificar un string en el campo name si es que hay uno.
¿No hay nombre? No hay problema, simplemente devuelve Nothing.
Este decodificador nunca debe fallar.
Decode.decodeString decodeName """{"id": 10, "name": "Otto"}"""
--> Ok (Just Otto)
Decode.decodeString decodeName """null"""
--> Ok Nothing
Ya vamos más o menos a la mitad de la decodificación de usuarios, terminemos esto.
Define decodeUser para que pueda decodificar un objeto de usuario JSON.
Asegúrate de usar decodeId y decodeName en 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
--> }
Algunos comentarios en una revisión de pull request son independientes y, por lo tanto, no tienen un pull_request_review_id.
Sin embargo, la especificación menciona que, en tal caso, igual debe haber un campo pull_request_review_id, pero el valor debe ser null, el Nothing de los pobres.
Define decodePullRequestReviewId para que pueda decodificar un entero en el campo pull_request_review_id si es que hay uno.
Si no hay un entero, debe haber un null; si no hay un null, el decodificador debe fallar.
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 ...
Ahora sigue este interesante campo side.
En una revisión de pull request, puedes comentar sobre el código que se eliminó (en el lado Left de la pantalla) o sobre el código que se agregó (en el lado Right de la pantalla).
Define decodeSide para que pueda decodificar ya sea "LEFT" o "RIGHT" en el campo side y hacerlo coincidir con su variante de tipo correspondiente.
Decode.decodeString decodeSide """{"side": "LEFT"}"""
--> Ok Left
Decode.decodeString decodeSide """{"side": "middle?"}"""
--> Err ...
Cada comentario tiene un conjunto de enlaces, que no parece ser consistente entre comentarios.
Define decodeLinks para que pueda decodificar una colección de URLs en el campo _links.
Los nombres y la cantidad de enlaces pueden variar, asegúrate de poder contemplar eso.
Los enlaces en sí son objetos JSON que siempre deben tener 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" )
--> ]
--> )
Ya casi llegamos, terminemos esto.
Define decodeComment y decodeComments para que puedan decodificar un único objeto de comentario JSON o una lista de comentarios, respectivamente.
Asegúrate de usar todas las funciones definidas hasta ahora en decodeComment, con la excepción de decodeName.
Por supuesto, decodeComments también debe usar decodeComment.
Todavía falta una funcionalidad más en todo esto: poder enviar un comentario nuevo al servidor.
Para esto, necesitas lograr lo opuesto a un decodificador: poder transformar un Comment de Elm en un Value de JSON.
Define encodeComment para que pueda codificar cualquier Comment válido.
El orden de los campos no importa, siempre que el codificador pueda producir un valor que decodeComment pueda decodificar.
Regístrate en Exercism para aprender y dominar Elm con 28 conceptos110 ejercicios y mentoría humana real, todo gratis.