Unmarshal es uno de esos verbos ingleses que es difícil de traducir al castellano, al menos para mí. Según Google, sería algo así como “desmantelar”.
Aplicado a la programación, “to unmarshal” está relacionado con la idea de que, partiendo de un contenido ordenado, en JSON, desperdigamos su contenido en variables que podemos utilizar en nuestra aplicación.
Go, por ejemplo, proporciona la función Unmarshal, sin embargo en Bash, no he encontrado nada parecido.
Escenario
El otro día, trabajando, me encontré con que tenía que leer una propiedad de un documento JSON, validar que el valor no estuviera vacío y entonces, asignar su valor a una variable.
De hecho, en el código (en Bash), ya lo había hecho para otras dos propiedades del documento JSON… Así que tener que hacerlo una tercera vez removió algo en mí; dos veces suele ser mi umbral antes de convertir un bloque de código en una función…
Como es una tarea bastante común, pensé que alguien ya habría desarrollado algo por el estilo… Como hoy tenía mucho tiempo libre, he realizado una (superficial) búsqueda por internet y no he encontrado nada como lo que necesitaba…
Idea
Imagina que tienes un fichero JSON como:
{
"customer": "Dunder Mifflin Scranton",
"uuid": "3f6b0814-e923-415b-9fd8-db9407e69546",
"active": true
}
Mi idea era usar una una función como unmarshal --path customer.json
en el que el script cree una variable para cada una de las keys del documento JSON. Además, debe asignar el valor de la key en el documento a la variable en Bash; es decir:
#!/usr/bin/env bash
# ...
unmarshal --path customer.json
# ...
echo "CustomerID for customer $customer" is $uuid"
El resultado debe ser:
$ bash demo.sh --path customer.json
CustomerID for customer 'Dunder Mifflin Scranton' is '3f6b0814-e923-415b-9fd8-db9407e69546'
Por si no te suena Dunder Mifflin Scranton
Leer las keys del documento JSON
Una de las mejores maneras para interaccionar con JSON desde Bash es usando Jq.
Para obtener las keys de un documento JSON, usamos jq -r 'keys[]' "$document"
(donde $document
contiene el valor pasado desde la CLI mediante --path
).
En nuestro caso:
$ jq -r 'keys[]' customer.json
active
customer
uuid
Pero lo que quiero es añadir las keys del documento a un array en Bash. Esto lo consigo mediante:
mapfile -t keys < <(jq -r 'keys[]' "$document")
readarray
es un sinónimo demapfile
. Usomapfile
porquemapfile --help
devuelve más información quereadarray --help
.
Esto era lo más sencillo; el siguiente paso es más complicado.
Definir variables cuyo nombre es el valor de un variable
Definir una variable en Bash y asignarle el valor de una propiedad de un documento JSON, usando Jq, puede conseguirse mediante:
active=$(jq -r '.active' "$document")
customer=$(jq -r '.customer' "$document")
uuid=$(jq -r '.uuid' "$document")
El problema es que, dado que quiero que la función unmarshal
funcione con cualquier documento JSON (*), el script no conoce a priori los nombres de las keys en el documento JSON.
(*)= Un documento JSON de tipo object
{...}
; otros tipos de documentos JSON válidos, de tipo “string”, “number” o “array” no están soportados.
Por tanto, lo primero que tengo que hacer es convertir el array de keys devuelto por Jq y convertirlo en un array de Bash; a continuación, puedo recorrer el array en Bash para declarar una variable con el nombre de cada elemento:
declare -a keys # requires Bash 4+
mapfile -t keys < <(jq -r 'keys[]' "$document")
for k in "${keys[@]}"; do
# ...
done
Es decir, $k
contiene active
en la primera iteración, customer
en la segunda, etc…
Dentro del bucle, quiero definir una variable llamada active
en la primera iteración, customer
en la segunda, etc…
¿Cómo puedo definir una variable cuyo nombre no conozco a priori?
La solución es definir una variable como referencia:
for k in "${keys[@]}"; do
declare -n ref="$k"
# ...
done
Definimos ref
como referencia a active
en la primera iteración, customer
en la segunda… active
, customer
, etc. Mediante declare -n
, Bash define una variable llamada active
y guarda la referencia a la variable en la variable $ref
. Lo mismo para las siguiente iteraciones del bucle.
Lo que es importante tener en cuenta es que no puedo acceder directamente a esas variables, por ejemplo, para asignarles un valor.
Es decir, $k="my_customer"
es inválido; sin embargo, mediante ref="my_customer"
asigno "my_customer"
a $ref
, y como $ref
es una referencia a la variable $customer
(por ejemplo, en la segunda iteración),ref="my_customer"
es equivalente a customer="my_customer"
, aunque nunca se haya definido explícitamente customer
como variable (se hace implícitamente al definir una referencia a la variable mediante declare -n
).
Esto resuelve la primera parte del problema, definir una variable para cada key del documento JSON.
for k in "${keys[@]}"; do
declare -n ref="$k"
ref=... # We have a variable per each key of the JSON document
# ...
done
El siguiente paso es leer la key correspondiente en Jq para asignar su valor a la variable.
En Jq, obtenemos el valor de la key customer
(por ejemplo), mediante:
$ jq '.customer' customer.json
"Dunder Mifflin Scranton"
Sin embargo, en nuestro caso, el nombre de la propiedad que queremos “leer” desde el documento mediante Jq lo tenemos almacenado en una variable ($k
) y es diferente en cada iteración del bucle.
Podemos definir variables en Jq y usarlas en nuestros filtros usando --arg name value
.
Generalmente, queremos usar el valor de value
como el valor de una key en el documento (por ejemplo, para seleccionar sólo una parte del documento JSON)
$ UUID="3f6b0814-e923-415b-9fd8-db9407e69546"; jq --arg uuid "$UUID" 'select( .uuid == $uuid )' customer.json
{
"customer": "Dunder Mifflin Scranton",
"uuid": "3f6b0814-e923-415b-9fd8-db9407e69546",
"active": true
}
# This UUID does not exist in the 'customer.json' document
$ UUID="eecea761-9476-4f8a-9563-ed927472d418"; jq --arg uuid "$UUID" 'select( .uuid == $uuid )' customer.json
# <- Found no results
En nuestro caso, queremos usar el valor de la variable como una key (no como un valor) en el documento JSON. La solución es usar el “value iterator construct”, según se indica, por ejemplo, en JSON: using jq with variable keys.
$ jq --arg key "customer" '.[$key]' customer.json
"Dunder Mifflin Scranton"
$ jq --arg key "active" '.[$key]' customer.json
true
Por tanto, ahora tenemos la pieza final del puzzle:
for k in "${keys[@]}"; do
declare -n ref="$k"
ref=$(jq -r --arg key "$k" '.[$key]' "$document")
done
MVP
Ya es posible probar una primera versión del script:
argparse.sh proviene de
https://github.com/yaacov/argparse-sh/
#!/usr/bin/env bash
source ./argparse.sh
define_arg "doc" "" "Path to document" "string" "true"
parse_args "$@"
# Main script logic
declare -a keys
mapfile -t keys < <(jq -r 'keys[]' "$doc")
for k in "${keys[@]}"; do
declare -n ref="$k"
ref=$(jq -r --arg key "$k" '.[$key]' "$doc")
done
# Use variables
echo "CustomerID for customer '$customer' is '$uuid' (active: $active)"
Y vemos que, efectivamente, funciona como esperamos:
$ bash mvp_unmarshal.sh --doc customer.json
CustomerID for customer 'Dunder Mifflin Scranton' is '3f6b0814-e923-415b-9fd8-db9407e69546' (active: true)
Limitaciones
El MVP es muy básico; no me refiero a que no se comprueba si el fichero proporcionado existe (por ejemplo), sino a la funcionalidad relacionada con el unmarshalling del contenido del documento.
Por ejemplo, si alguna de las keys del documento contiene un array o un object, el resultado no es el esperado:
{
"characters": ["jim", "pam", "michael"],
"show": { "title": "the office" }
}
Cada “bloque de texto” se interpreta como un valor (no importa cómo se formatee el fichero JSON):
$ bash mvp_unmarshal.sh --doc invalid.json
characters:
- [
- "jim",
- "pam",
- "michael"
- ]
show:
- {
- "title":
- "the
- office"
- }
En la segunda parte del artículo, añado soporte para estos tipos de valores.