Learn through the super-clean Baeldung Pro experience:
>> Membership and Baeldung Pro.
No ads, dark-mode and 6 months free of IntelliJ Idea Ultimate to start with.
Last updated: July 26, 2024
JSON (JavaScript Object Notation) has become ubiquitous for data interchange. As applications grow more complex, so do data structures. Enter the nested JSON – a powerful way to represent hierarchical data. Yet, how do we efficiently create these structures, especially when working with variables and dynamic data?
In this tutorial, we build a software deployment configuration generator using jq. This tool creates a nested JSON configuration file for deploying a microservices-based application across multiple environments. As we progress, we enhance the generator to handle increasingly complex scenarios.
Let’s quickly recap what we mean by nested JSON. In essence, nesting in JSON occurs when an object or array contains other objects or arrays. This hierarchical structure enables the representation of complex relationships in the data.
Let’s create a basic structure for the deployment configuration generator:
{
"app_name": "MyAwesomeApp",
"version": "1.0.0",
"environments": {
"dev": {
"url": "dev.myapp.com",
"resources": {
"cpu": "0.5",
"memory": "512Mi"
}
},
"prod": {
"url": "myapp.com",
"resources": {
"cpu": "2",
"memory": "2Gi"
}
}
}
}
Thus, this structure nests environment-specific configurations within an environments object, which itself is nested in the root object. Each environment then has its own nested resources object.
Before we start working on the JSON, we need to prepare the data. Real data might come from various sources:
For this example, let’s set up some variables:
$ APP_NAME="MyAwesomeApp"
VERSION="1.0.0"
ENVIRONMENTS=("dev" "prod")
DEV_URL="dev.myapp.com"
PROD_URL="myapp.com"
DEV_CPU="0.5"
DEV_MEMORY="512Mi"
PROD_CPU="2"
PROD_MEMORY="2Gi"
In a production setting, we might source these from an .env file or fetch them dynamically. The key is to have the data readily accessible for jq to process.
Now that we have the respective variables, let’s start building the nested JSON structure.
We begin with the basic configuration skeleton:
$ jq -nc \
--arg name "$APP_NAME" \
--arg version "$VERSION" \
'{
app_name: $name,
version: $version,
environments: {}
}'
{
{"app_name":"MyAwesomeApp","version":"1.0.0","environments":{}}
As a result, this jq command creates a simple object with app_name and version fields, plus an empty environments object. Further, the -n option tells jq to start with a NULL input, the -c option prints the response on a line, and we use –arg to pass in the shell variables.
Next, let’s add each environment:
$ jq -nc \
--arg name "$APP_NAME" \
--arg version "$VERSION" \
--arg dev_url "$DEV_URL" \
--arg prod_url "$PROD_URL" \
'{
app_name: $name,
version: $version,
environments: {
dev: {
url: $dev_url,
resources: {}
},
prod: {
url: $prod_url,
resources: {}
}
}
}'
{"app_name":"MyAwesomeApp","version":"1.0.0","environments":{"dev":{"url":"dev.myapp.com","resources":{}},"prod":{"url":"myapp.com","resources":{}}}}
Here, we nested the dev and prod objects within the environments object, each with its own url field and an empty resources object.
As the configuration grows more complex, we may need more advanced techniques.
Now, let’s add the resource configurations and make the environment creation more dynamic:
$ jq -nc \
--arg name "$APP_NAME" \
--arg version "$VERSION" \
--arg dev_url "$DEV_URL" \
--arg prod_url "$PROD_URL" \
--arg dev_cpu "$DEV_CPU" \
--arg dev_mem "$DEV_MEMORY" \
--arg prod_cpu "$PROD_CPU" \
--arg prod_mem "$PROD_MEMORY" \
'{
app_name: $name,
version: $version,
environments: {
dev: {
url: $dev_url,
resources: {
cpu: $dev_cpu,
memory: $dev_mem
}
},
prod: {
url: $prod_url,
resources: {
cpu: $prod_cpu,
memory: $prod_mem
}
}
}
}'
{"app_name":"MyAwesomeApp","version":"1.0.0","environments":{"dev":{"url":"dev.myapp.com","resources":{"cpu":"0.5","memory":"512Mi"}},"prod":{"url":"myapp.com","resources":{"cpu":"2","memory":"2Gi"}}}}
This works, but it’s not very DRY (Don’t Repeat Yourself). So, we can refactor the snippet to make it more dynamic:
$ jq -nc \
--arg name "$APP_NAME" \
--arg version "$VERSION" \
--arg dev_url "$DEV_URL" \
--arg prod_url "$PROD_URL" \
--arg dev_cpu "$DEV_CPU" \
--arg dev_mem "$DEV_MEMORY" \
--arg prod_cpu "$PROD_CPU" \
--arg prod_mem "$PROD_MEMORY" \
'{
app_name: $name,
version: $version,
environments: {
dev: {
url: $dev_url,
resources: {
cpu: $dev_cpu,
memory: $dev_mem
}
},
prod: {
url: $prod_url,
resources: {
cpu: $prod_cpu,
memory: $prod_mem
}
}
}
} |
.environments |= with_entries(
.value.resources |= with_entries(
.value |=
if . == "" then null
elif test("^[0-9]+$") then tonumber
else .
end
)
)'
{"app_name":"MyAwesomeApp","version":"1.0.0","environments":{"dev":{"url":"dev.myapp.com","resources":{"cpu":"0.5","memory":"512Mi"}},"prod":{"url":"myapp.com","resources":{"cpu":2,"memory":"2Gi"}}}}
This advanced jq filter performs a few steps:
For each resource value, the code uses some conditional logic:
Thus, this approach enables more flexible handling of the input data and demonstrates how we can apply complex logic within the jq filter.
We often need to combine data from various sources. Let’s enhance the deployment configuration generator to incorporate service definitions from external files.
To begin with, we create a services.json file containing definitions for the microservices we require:
$ cat services.json
{
"auth-service": {
"port": 8080,
"dependencies": ["user-db"]
},
"user-service": {
"port": 8081,
"dependencies": ["user-db", "email-service"]
},
"email-service": {
"port": 8082,
"dependencies": []
}
}
Then, we modify the jq command to include this data:
$ jq -nc \
--arg name "$APP_NAME" \
--arg version "$VERSION" \
--arg dev_url "$DEV_URL" \
--arg prod_url "$PROD_URL" \
--arg dev_cpu "$DEV_CPU" \
--arg dev_mem "$DEV_MEMORY" \
--arg prod_cpu "$PROD_CPU" \
--arg prod_mem "$PROD_MEMORY" \
--slurpfile services services.json \
'{
app_name: $name,
version: $version,
environments: {
dev: {
url: $dev_url,
resources: {
cpu: $dev_cpu,
memory: $dev_mem
}
},
prod: {
url: $prod_url,
resources: {
cpu: $prod_cpu,
memory: $prod_mem
}
}
},
services: $services[0]
} |
.environments |= with_entries(
.value.resources |= with_entries(
.value |=
if . == "" then null
elif test("^[0-9]+$") then tonumber
else .
end
)
)'
{"app_name":"MyAwesomeApp","version":"1.0.0","environments":{"dev":{"url":"dev.myapp.com","resources":{"cpu":"0.5","memory":"512Mi"}},"prod":{"url":"myapp.com","resources":{"cpu":2,"memory":"2Gi"}}},
"services":{"auth-service":{"port":8080,"dependencies":["user-db"]},"user-service":{"port":8081,"dependencies":["user-db","email-service"]},"email-service":{"port":8082,"dependencies":[]}}}
Here, we introduced the –slurpfile option to read the services.json file. The $services[0] syntax is used because –slurpfile always produces an array, even for single objects.
As the JSON structures grow, performance can become a concern.
Let’s briefly look at some strategies to keep jq running effectively:
To demonstrate, let’s optimize the deployment config generator for multiple environments.
Initially, we move the environment configurations to the external environments.json file:
$ cat environments.json
{
"dev": {
"url": "dev.myapp.com",
"resources": {
"cpu": "0.5",
"memory": "512Mi"
},
"replicas": 1
},
"staging": {
"url": "staging.myapp.com",
"resources": {
"cpu": "1",
"memory": "1Gi"
},
"replicas": 2
},
"prod": {
"url": "myapp.com",
"resources": {
"cpu": "2",
"memory": "2Gi"
},
"replicas": 3
},
"dr": {
"url": "dr.myapp.com",
"resources": {
"cpu": "2",
"memory": "2Gi"
},
"replicas": 3
}
}
Then, we make the jq command more flexible:
$ jq -nc \
--arg name "$APP_NAME" \
--arg version "$VERSION" \
--slurpfile services services.json \
--slurpfile envs environments.json \
'{
app_name: $name,
version: $version,
environments: ($envs[0] | map_values(
.resources |= map_values(
if . == "" then null
elif test("^[0-9]+$") then tonumber
else .
end
)
)),
services: $services[0]
}'
{"app_name":"MyAwesomeApp","version":"1.0.0","environments":{"dev":{"url":"dev.myapp.com","resources":{"cpu":"0.5","memory":"512Mi"},"replicas":1},
"staging":{"url":"staging.myapp.com","resources":{"cpu":1,"memory":"1Gi"},"replicas":2},"prod":{"url":"myapp.com","resources":{"cpu":2,"memory":"2Gi"},"replicas":3},
"dr":{"url":"dr.myapp.com","resources":{"cpu":2,"memory":"2Gi"},"replicas":3}},"services":{"auth-service":{"port":8080,"dependencies":["user-db"]},
"user-service":{"port":8081,"dependencies":["user-db","email-service"]},"email-service":{"port":8082,"dependencies":[]}}}
In this optimized version, we moved the environment configurations to an external file (environments.json) and used map_values() for efficient transformation. Evidently, this approach scales better for a large number of environments.
Ensuring the correctness of any configuration is important.
So, let’s do that after saving the JSON generated to a file:
$ jq -n \
--arg name "$APP_NAME" \
--arg version "$VERSION" \
--slurpfile services services.json \
--slurpfile envs environments.json \
'{
app_name: $name,
version: $version,
environments: ($envs[0] | map_values(
.resources |= map_values(
if . == "" then null
elif test("^[0-9]+$") then tonumber
else .
end
)
)),
services: $services[0]
}' | tee output.json |
jq '
if .app_name == "" then
error("app_name is empty")
elif .environments | length == 0 then
error("no environments defined")
elif .services | length == 0 then
error("no services defined")
else
"Configuration is valid"
end
'
"Configuration is valid"
This command generates the configuration, saves it to output.json, and then performs some basic validation checks.
In this article, we explored how to build a deployment configuration generator as an example of creating and handling a nested JSON structure dynamically via variables.
In conclusion, creating nested JSON structures from variables using jq is a powerful technique for generating complex configurations, data transformations, and more.