JSON (JavaScript Object Notation) es un formato de datos y de archivos legible por humanos que se usa habitualmente para intercambiar datos, en particular en aplicaciones web. Como era de esperar, ocupa 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 de JSON son una forma de declarar qué tipo de datos se esperan y a qué estructura de datos deben asignarse esos datos.
Vamos a importar 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 la cadena se ajusta a las especificaciones del decodificador, o fallará en caso contrario.
No hay forma de inspeccionar los datos JSON sin procesar al definir el decodificador, algo que puede resultar poco habitual si vienes de lenguajes imperativos.
JSON tiene un número reducido de tipos de datos fundamentales:
"hello world"
true o false
34 o 0.12
null
Cada uno de ellos está asociado a 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 en que Decode.null te permite decidir cómo modelar un null en tu programa, ya que requiere que le pases un valor arbitrario como argumento (quizá (), Nothing o cualquier cosa que encaje con tu programa).
JSON también define dos estructuras de datos para agrupar los tipos de datos fundamentales:
[0, null, "none", []]
{"id": 1, "is_admin": false, "more_info": {}}
Decode también ofrece funciones para decodificarlas:
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 un decodificador como argumento, que se encargará de decodificar cada uno de los elementos de la estructura de datos.
Si el decodificador que recibe como argumento no consigue decodificar algún elemento de un array o de un objeto, el decodificador completo falla, algo que es un patrón habitual.
Combinar decodificadores simples en otros más complejos es la idea central de esta técnica. Vamos a presentar varias funciones más que pueden usarse para construir decodificadores de datos del mundo real arbitrariamente complejos mediante un ejemplo.
Para practicar la combinación de decodificadores básicos con los que analizar datos JSON complejos, vamos a construir decodificadores para analizar (un subconjunto de) los datos GeoJSON. GeoJSON es un formato de datos especializado basado en JSON que se usa para representar elementos geográficos, como ubicaciones, rutas o regiones, especificados mediante coordenadas geográficas 2D o 3D, junto con otras propiedades.
Un objeto Geometry 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 (agrupadas 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 Geometry podría representarse en Elm con el siguiente tipo
type Geometry
= Point (List Float)
| LineString (List (List Float))
| ...
Para consultar el valor de un campo concreto, 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 ha analizado correctamente un decodificador, podemos usar Decode.map : (a -> value) -> Decoder a -> Decoder value, o cualquiera de sus hermanas, 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, una tarea perfecta para Decode.andThen : (a -> Decoder b) -> Decoder a -> Decoder b y sus compañeras 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 que, llegados a este punto, podríamos usar Decode.andThen para validar las coordenadas (comprobar que vienen en pares o tríos y que sus valores tienen 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 Geometry 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 número de JSON.
{
"type": "Feature",
"geometry": null,
"properties": null
}
o
{
"type": "Feature",
"id": "0157",
"geometry": {
"type": "Point",
"coordinates": [127.831, 26.461]
},
"properties": {
"country": "Japan"
}
}
Vamos a abordar primero el campo id.
Si existe, debe admitir strings o números, así que podríamos o bien crear un tipo personalizado para el valor, o bien 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 también 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 en 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, vamos a centrarnos en el campo geometry, que puede ser un objeto Geometry o bien null.
Podríamos usar Decode.oneOf una vez más, pero hay una opción mejor: 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 signatura, pero su comportamiento es distinto, ya que el resultado OK Nothing de un Decode.nullable solo procederá del valor JSON concreto null.
Por último, el campo properties puede contener un objeto JSON arbitrario o un null, algo para lo que sirve 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 en 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 directamente a JavaScript mediante un port.
Ya 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 sangría del resultado final, y el segundo es el valor JSON que se va a escribir.
Un Value puede obtenerse 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}"
Vamos a definir codificadores para los decodificadores de GeoJSON que hemos definido 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
)
]
)
En general, una buena prueba para tus pares decodificador-codificador es comprobar que elmValue |> encoder |> decoder == elmValue.
Cuanto más aprendes sobre programación, más empiezas a pensar que esto podría ser algo más que una moda pasajera. Incluso puede que haya oportunidades de negocio: la gente escribe código, quiere guardarlo, compartirlo, colaborar y hasta recibir comentarios.
Vale, vamos a ello, construyamos un servicio que anime a la gente a programar: ¡hup, hup, gente, empezad a usar git! Vamos a llamarlo... GitHup.
Te pones a construir una API REST JSON: usuarios, pull requests, comentarios; el backend se hace en un momento. Aquí tienes un ejemplo del payload que sirve el servidor cuando le pides el array 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 tienes que averiguar de qué van 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 han eliminado algunos campos de los esquemas JSON para simplificar, pero esto es lo más parecido a una aplicación del mundo real que te vas a encontrar.
Tu ojo de halcón se ha dado cuenta de que tanto los comentarios como los usuarios tienen un campo id; un sitio perfecto para empezar.
Define decodeId para que pueda decodificar un objeto JSON con un id entero.
Decode.decodeString decodeId """{id: 10}"""
--> Ok 10
Te has fijado en que, en el ejemplo de arriba, no todos los usuarios tienen un nombre.
Define decodeName para que pueda decodificar un string en el campo name si lo hay.
¿Que no hay nombre? No pasa nada, simplemente devuelve Nothing.
Este decodificador no debe fallar nunca.
Decode.decodeString decodeName """{"id": 10, "name": "Otto"}"""
--> Ok (Just Otto)
Decode.decodeString decodeName """null"""
--> Ok Nothing
Ya llevamos medio camino en lo que a decodificar usuarios se refiere, vamos a terminarlo.
Define decodeUser para que pueda decodificar un objeto JSON de usuario.
Asegúrate de usar decodeId y decodeName dentro de 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 de una revisión de pull request son independientes y, por lo tanto, no tienen un pull_request_review_id.
Sin embargo, la especificación indica que, en ese caso, debe seguir habiendo un campo pull_request_review_id, pero su 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 lo hay.
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 le toca a este interesante campo side.
En una revisión de pull request puedes comentar el código que se ha eliminado (en el lado Left de la pantalla) o el código que se ha añadido (en el lado Right de la pantalla).
Define decodeSide para que pueda decodificar "LEFT" o "RIGHT" en el campo side y asociarlo 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 el número de enlaces pueden variar; asegúrate de tenerlo en cuenta.
Los enlaces son en sí 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 está, vamos a terminarlo.
Define decodeComment y decodeComments para que puedan decodificar un único objeto de comentario JSON o un array de comentarios, respectivamente.
Asegúrate de usar en decodeComment todas las funciones definidas hasta ahora, con la excepción de decodeName.
Por supuesto, decodeComments también debe usar decodeComment.
Todavía falta una funcionalidad más: poder enviar un comentario nuevo al servidor.
Para ello necesitas lo contrario de un decodificador, ser capaz de transformar un Comment de Elm en un Value JSON.
Define encodeComment para que pueda codificar cualquier Comment válido.
El orden de los campos da igual, siempre que el codificador pueda producir un valor que decodeComment sea capaz de decodificar.
Regístrate en Exercism para aprender y dominar Elm con 28 conceptos110 ejercicios y mentoría humana real, todo gratis.