Siguendo con el tema de ayer, hoy quiero revisar otro bloque de código.

En este caso, se construye un array en JSON usando Bash puro, cuando es el proceso se simplifica enormemente gracias a la función --slurp de jq.

En el ejemplo de hoy usaré la API pública de randomuser.me para obtener tres usuarios aleatorios (de nacionalidad española) mediante el comando:

curl -s https://randomuser.me/api?nat=es&results=3 > users.json

El documento obtenido tiene la estructura descrita en la documentación de randomuser.me.

Escenario de salida

En el escenario que estuve analizando, el resultado de la consulta a la API podía devolver un número variable de resultados, por lo que el primer paso era averiguar cuántos resultados contenía el JSON devuelto.

Este número se usará para recorrer los resultados y extraer el DNI y el nombre completo (nombre + apellido) de cada usuario:

number_of_results=$(jq '.results | length' users.json)

for ((i=0; i<number_of_results; i++)); do
    dni=$(jq -r ".results[$i].id.value" users.json)
    nombre=$(jq -r ".results[$i].name.first" users.json)
    apellido=$(jq -r ".results[$i].name.last" users.json)
    # For demo purposes only
    printf '{"dni": "%s", "nombre_completo": "%s %s"}\n' "$dni" "$nombre" "$apellido"
done

El resultado hasta aquí es:

$ bash script.sh
{"dni": "34533796-A", "nombre_completo": "Martin Ortega"}
{"dni": "67448880-E", "nombre_completo": "Lucia Guerrero"}
{"dni": "22894495-W", "nombre_completo": "Luisa Fuentes"}

A continuación, el script construye cada uno de los objetos en json_elements y los concatena, añdiendo una , tras cada uno de ellos:

#!/bin/bash

number_of_results=$(jq '.results | length' users.json)

json=''

for ((i=0; i<number_of_results; i++)); do
    dni=$(jq -r ".results[$i].id.value" users.json)
    nombre=$(jq -r ".results[$i].name.first" users.json)
    apellido=$(jq -r ".results[$i].name.last" users.json)
    json_element=$(printf '{"dni": "%s", "nombre_completo": "%s %s"}' "$dni" "$nombre" "$apellido")
    json+="$json_element,"
done

echo $json

El resultado es:

$ bash script.sh
{"dni": "34533796-A", "nombre_completo": "Martin Ortega"},{"dni": "67448880-E", "nombre_completo": "Lucia Guerrero"},{"dni": "22894495-W", "nombre_completo": "Luisa Fuentes"},

El problema es que el resultado no es un objeto JSON válido: sobra una coma al final y el resultado debe ir entre [ ].

Así que se recurre al clásico sed:

json="$(echo $json | sed 's/,$//')"
json="[ $json ]"

El script tenía un aspecto similar a:

#!/bin/bash

number_of_results=$(jq '.results | length' users.json)

json=''

for ((i=0; i<number_of_results; i++)); do
    dni=$(jq -r ".results[$i].id.value" users.json)
    nombre=$(jq -r ".results[$i].name.first" users.json)
    apellido=$(jq -r ".results[$i].name.last" users.json)
    json_element=$(printf '{"dni": "%s", "nombre_completo": "%s %s"}' "$dni" "$nombre" "$apellido")
    json+="$json_element,"
done

json=$(echo $json | sed 's/,$//')
json="[ $json ]"

echo $json | tee final.json

Y el resultado de ejecutarlo es un fichero final.json que contiene:

[ {"dni": "34533796-A", "nombre_completo": "Martin Ortega"},{"dni": "67448880-E", "nombre_completo": "Lucia Guerrero"},{"dni": "22894495-W", "nombre_completo": "Luisa Fuentes"} ]

Simplificando

jq permite construir un objeto JSON a partir de variables. Del manual de jq:

--arg name value:

This option passes a value to the jq program as a predefined variable. If you run jq with --arg foo bar, then $foo is available in the program and has the value "bar". Note that value will be treated as a string, so --arg foo 123 will bind $foo to "123".

Named arguments are also available to the jq program as $ARGS.named.

En mi opinión, esta forma de construir el objeto es más robusta; printf se limita a insertar las string en el lugar indicado y el orden indicado en una cadena… printf no valida que “la cadena resultante” sea un objeto JSON válido. Por tanto, si queremos generar un documento JSON complejo, rápidamente controlar qué valor se está insertando dónde se convierte en una pesadilla (especialmente si se debe modificar la estructura del documento JSON)…

Usando jq asignamos nombres a las variables; el orden en el que se pasan los valores deja de ser relevante; además, el nombre de la variable permite identificar qué se está insertando en el JSON final.

Cambiamos el printf por jq (y de paso eliminamos la variable intermedia json_element):

# json_element=$(printf '{"dni": "%s", "nombre_completo": "%s %s"}' "$dni" "$nombre" "$apellido")
# json+="$json_element,"
json+=$(jq -n --arg dni "$dni" --arg nombre "$nombre" --arg apellido "$apellido" \
'{
    "dni": $dni,
    "nombre_completo": ($nombre + " " + $apellido)
}')

Usamos también jq para combinar nombre y apellido en un único campo nombre_completo en el JSON, separados por un espacio. Esta opción de combinar dos (o más) variables en un solo campo permite unir, por ejemplo, una IP y una máscara para crear un CIDR, IP:Puerto, etc:

"cidr": ($ip + "/" $mask)

Si más tarde se debe modificar la estructura del fichero JSON, no hay ningún problema en identificar qué valor se inserta dónde (sin importar el orden); en el siguiente ejemplo, generamos el JSON con los tres campos de forma independiente, sin tener que modificar las variables:

# ejemplo
json+=$(jq -n --arg dni "$dni" --arg nombre "$nombre" --arg apellido "$apellido" \
'{
    "nombre": $nombre,
    "apellido": $apellido,
    "dni": $dni
}')

En este punto, el contenido de $json al finalizar el bucle es:

Muestro cada bloque del JSON en diferentes líneas para que sea más fácil observar que, al haber sustituido el printf por jq -n ..., no tenemos la “coma” tras cada objeto en el JSON resultante.

{ "dni": "34533796-A", "nombre_completo": "Martin Ortega" }
{ "dni": "67448880-E", "nombre_completo": "Lucia Guerrero" }
{ "dni": "22894495-W", "nombre_completo": "Luisa Fuentes" }

Usando el comando --slurp (o -s, en su versión corta), jq procesa cada elemento; en nuestro caso sólo queremos añadirlo a un array, sin más modificaciones (y volcar el resultado a un fichero):

echo "$json" | jq --slurp '.' > final.json

El resultado final es el deseado, pero evitamos:

  • tener que añadir , tras cada elemento
  • eliminar la , tras el último elemento (usando sed y expresiones regulares)
  • añadir [ ] para convertirlo en un array de JSON válido
[
  {
    "dni": "34533796-A",
    "nombre_completo": "Martin Ortega"
  },
  {
    "dni": "67448880-E",
    "nombre_completo": "Lucia Guerrero"
  },
  {
    "dni": "22894495-W",
    "nombre_completo": "Luisa Fuentes"
  }
]

El resultado hasta ahora es:

#!/bin/bash

number_of_results=$(jq '.results | length' users.json)

json=''

for ((i=0; i<number_of_results; i++)); do
    dni=$(jq -r ".results[$i].id.value" users.json)
    nombre=$(jq -r ".results[$i].name.first" users.json)
    apellido=$(jq -r ".results[$i].name.last" users.json)

    json+=$(jq -n --arg dni "$dni" --arg nombre "$nombre" --arg apellido "$apellido" \
           '{
              "dni": $dni,
              "nombre_completo": ($nombre + " " + $apellido)
            }')
done

echo "$json" | jq --slurp '.' | tee final.json

Podríamos extraer el template JSON a una variable:

json_template='{
                  "dni": $dni,
                  "nombre_completo": ($nombre + " " + $apellido)
              }'

Y así simplificar la expresión que construye los objetos JSON:

json+=$(jq -n
           --arg dni "$dni" \
           --arg nombre "$nombre" \
           --arg apellido "$apellido" \
             "$json_template"
    )

De esta forma, obtenemos:

#!/bin/bash

json_template='
{
  "dni": $dni,
  "nombre_completo": ($nombre + " " + $apellido)
}'

json=''
number_of_results=$(jq '.results | length' users.json)

for ((i = 0; i < number_of_results; i++)); do
    dni=$(jq -r ".results[$i].id.value" users.json)
    nombre=$(jq -r ".results[$i].name.first" users.json)
    apellido=$(jq -r ".results[$i].name.last" users.json)

    json+=$(jq --null-input "$json_template"           \
                            --arg dni      "$dni"      \
                            --arg nombre   "$nombre"   \
                            --arg apellido "$apellido"  
            )
done

echo "$json" | jq --slurp '.' | tee final.json

Conclusión

En esta versión, aprovechamos mejor la funcionalidad que nos ofrece jq.

En primer lugar, para generar los objetos JSON a partir de variables en Bash. De esta forma, tenemos flexibilidad para modificar la estructura del documento JSON. Además, jq se asegura de que el documento generado sea correcto, entrecomillando los valores, etc.

Por otro lado, mediante --slurp, combinamos los objetos generados y los convertimos en un array. De nuevo, jq se encarga de añadir las comas para separar los elementos del array, incluir los []. Así evitamos que se cuelen errores teniendo que manipular con comandos como sed (y expresiones regulares) que no “saben” que están modificando un objeto JSON.

Aunque las dos versiones funcionan, IMHO la segunda es más sencilla, robusta y flexible.