Mix.install(
[:tesla, :jason, :ex_json_schema, :nestru, :domo],
force: true,
consolidate_protocols: false
)
base_uri =
URI.new!(
"https://raw.githubusercontent.com/IvanRublev/elixir-decode-validate-json-with-nestru-domo/master/"
)
%URI{
scheme: "https",
userinfo: nil,
host: "raw.githubusercontent.com",
port: 443,
path: "/IvanRublev/elixir-decode-validate-json-with-nestru-domo/master/",
query: nil,
fragment: nil
}
JSON data format is widespread for many types of web-application interfaces. For example, HTTP endpoints or distributed queues serve messages of that format. Though JSON schema is there for data types validation, some custom code is still required to shape the JSON message into the nested structs and validate the data model's interdependent fields by meaning.
On a high level, the end-to-end deserialization process has three tasks: input data format validation, data shaping into the nested structs, and model validation. The first can be solved by ex_json_schema
library with a JSON schema as a configuration file. And what about the last two?
graph LR;
subgraph data_format_validation ["Data format validation"]
JsonMap["JSON binary decoded (Jason)"] --> MapValidated["map validated (ex_json_schema)"]
end
subgraph data_shaping ["Data shaping"]
MapValidated --> StructDecoded["Structs decoded (Nestru)"]
end
subgraph logic_validation ["Model validation"]
StructDecoded --> ModelValidated["Model validated (Domo)"]
end
ModelValidated --> ModelProcessed["Model processed"]
This article will show how the data shaping into the nested structs and model validation tasks can be automatized by using Nestru
and Domo
libraries.
Let's look at how to configure these libraries in the following example.
We download and decode a product catalogue JSON binary into a list of maps like that:
catalogue_uri = base_uri |> URI.merge("product-catalogue.json") |> URI.to_string()
catalogue = catalogue_uri |> Tesla.get!() |> Map.get(:body) |> Jason.decode!()
[
%{
"dimensions" => %{"height" => 9.5, "length" => 25.0, "width" => 1.0},
"id" => 2,
"name" => "An ice sculpture",
"price" => 12.5,
"tags" => ["cold", "ice"],
"warehouseLocation" => %{"latitude" => 40.75, "longitude" => -121.4}
},
%{
"dimensions" => %{"height" => 1.0, "length" => 3.1, "width" => 1.0},
"id" => 3,
"name" => "A blue mouse",
"price" => 25.5,
"warehouseLocation" => %{"latitude" => 52.8, "longitude" => 5.5}
}
]
Then download and resolve the JSON schema the same way:
schema_uri = base_uri |> URI.merge("product-catalogue.schema.json") |> URI.to_string()
schema =
schema_uri |> Tesla.get!() |> Map.get(:body) |> Jason.decode!() |> ExJsonSchema.Schema.resolve()
%ExJsonSchema.Schema.Root{
schema: %{
"$schema" => "http://json-schema.org/draft-04/schema#",
"items" => %{
"properties" => %{
"dimensions" => %{
"properties" => %{
"height" => %{"type" => "number"},
"length" => %{"type" => "number"},
"width" => %{"type" => "number"}
},
"required" => ["length", "width", "height"],
"type" => "object"
},
"id" => %{"description" => "The unique identifier for a product", "type" => "number"},
"name" => %{"type" => "string"},
"price" => %{"exclusiveMinimum" => true, "minimum" => 0, "type" => "number"},
"tags" => %{
"items" => %{"type" => "string"},
"minItems" => 1,
"type" => "array",
"uniqueItems" => true
},
"warehouseLocation" => %{
"description" => "Coordinates of the warehouse with the product",
"properties" => %{
"latitude" => %{"type" => "number"},
"longitude" => %{"type" => "number"}
},
"required" => ["latitude", "longitude"],
"type" => "object"
}
},
"required" => ["id", "name", "price", "warehouseLocation"],
"title" => "Product",
"type" => "object"
},
"title" => "Product set",
"type" => "array"
},
refs: %{},
definitions: %{},
location: :root,
version: 4,
custom_format_validator: nil
}
And validate the data of the map decoded from the input JSON file like the following:
:ok = ExJsonSchema.Validator.validate(schema, catalogue)
:ok
So far, so good! The result is :ok
, meaning that the input data format matches the schema.
The maps decoded from the JSON file contain no information about the structure types and their relations. And from the application's business logic perspective, it'll be helpful to represent each map as a Product
struct with Dimensions
and Warehouse
structs nested in it.
defmodule Dimensions do
defstruct [:height, :length, :width]
end
defmodule Warehouse do
defstruct [:latitude, :longitude]
end
defmodule Product do
defstruct [:name, :dimensions, :warehouse]
end
For the first top-level map from the catalogue list:
Enum.at(catalogue, 0)
%{
"dimensions" => %{"height" => 9.5, "length" => 25.0, "width" => 1.0},
"id" => 2,
"name" => "An ice sculpture",
"price" => 12.5,
"tags" => ["cold", "ice"],
"warehouseLocation" => %{"latitude" => 40.75, "longitude" => -121.4}
}
we have two impediments to shaping it into an appropriate Product
struct:
- There is no
"warehouse"
key in it, only the related"warehouseLocation"
- We need to cast the map values of the appropriate keys into
Dimensions
andWarehouse
structs
To address these, we will use the Nestru
library. The first impediment can be resolved by deriving Nestru.PreDecoder
protocol with keys name mapping, and the second one by deriving Nestru.Decoder
protocol with the key to struct modules mapping like that:
defmodule Dimensions do
@derive Nestru.Decoder
defstruct [:height, :length, :width]
end
defmodule Warehouse do
@derive Nestru.Decoder
defstruct [:latitude, :longitude]
end
defmodule Product do
@derive [
{Nestru.PreDecoder, translate: %{"warehouseLocation" => :warehouse}},
{Nestru.Decoder, hint: %{dimensions: Dimensions, warehouse: Warehouse}}
]
defstruct [:name, :dimensions, :warehouse]
end
And then, we decode the catalogue into the list of Product
structs with the call to Nestru.decode_from_list/2
:
{:ok, products} = Nestru.decode_from_list(catalogue, Product)
{:ok,
[
%Product{
name: "An ice sculpture",
dimensions: %Dimensions{height: 9.5, length: 25.0, width: 1.0},
warehouse: %Warehouse{latitude: 40.75, longitude: -121.4}
},
%Product{
name: "A blue mouse",
dimensions: %Dimensions{height: 1.0, length: 3.1, width: 1.0},
warehouse: %Warehouse{latitude: 52.8, longitude: 5.5}
}
]}
The map key's values were set to target fields in the struct and cast to specified struct types!
You can find all functions for maps decoding/encoding in the Nestru documentation.
The list of Product
s seems to be a valid model to process with business logic. However, let's imagine that we have two model constraints that came from the business side:
- The company can't process
Product
s bigger than 200 m³ volume - It works with
Warehouse
's located in USA and France only
Expressing these constraints with the JSON schema is impossible because calculations of volume and location are required to validate them.
Let's do such kind of validation with Domo
library. In order to do so we will add the standard Elixir t()
TypeSpecs to each struct. And add the calculation constraint validations with Domo.precond
macro like the following:
defmodule Dimensions1 do
@derive Nestru.Decoder
use Domo, skip_defaults: true
defstruct [:height, :width, :length]
@type t :: %__MODULE__{height: float(), width: float(), length: float()}
def volume(%__MODULE__{} = dims), do: dims.height * dims.width * dims.length
end
defmodule Warehouse1 do
@derive Nestru.Decoder
use Domo, skip_defaults: true
defstruct [:latitude, :longitude]
@type t :: %__MODULE__{latitude: float(), longitude: float()}
precond(t: &usa_or_french_constraint/1)
@france_box {42.480200, -10.151367, 51.172455, 13.216553}
@usa_box {24.396308, -124.848974, 49.384358, -66.885444}
defp usa_or_french_constraint(warehouse) do
coords = {warehouse.latitude, warehouse.longitude}
if point_in_latlong_box?(coords, @france_box) || point_in_latlong_box?(coords, @usa_box) do
:ok
else
{:error, "Warehouses outside of USA or France are disalowed."}
end
end
defp point_in_latlong_box?({lat, long}, {lat_dl, long_dl, lat_tr, long_tr}) do
lat_dl <= lat && lat <= lat_tr && long_dl <= long && long <= long_tr
end
end
defmodule Product1 do
@derive [
{Nestru.PreDecoder, translate: %{"warehouseLocation" => :warehouse}},
{Nestru.Decoder, hint: %{dimensions: Dimensions1, warehouse: Warehouse1}}
]
use Domo, skip_defaults: true
defstruct [:name, :dimensions, :warehouse]
@type t :: %__MODULE__{name: String.t(), dimensions: Dimensions1.t(), warehouse: Warehouse1.t()}
precond(t: &volume_constraint/1)
defp volume_constraint(product) do
volume = Dimensions1.volume(product.dimensions)
if volume <= 200, do: :ok, else: {:error, "Volume can't be > 200 m³ (current: #{volume} m³)"}
end
end
Domo
adds ensure_type/1
function that checks the struct's data matching its t()
type and fulfilling preconditions to each module where it was used.
We validate each product in the list like the following:
{:ok, products} = Nestru.decode_from_list(catalogue, Product1)
Enum.map(products, &Product1.ensure_type(&1))
[
error: [t: "Volume can't be > 200 m³ (current: 237.5 m³)"],
error: [
warehouse: "Invalid value %Warehouse1{latitude: 52.8, longitude: 5.5} for field :warehouse of %Product1{}. Value of field :t is invalid due to Warehouses outside of USA or France are disalowed."
]
]
There are two errors returned. The first Product1
has a dimensions volume of 237.5 m³ that is disallowed. And the second Product1
has an invalid warehouse
value that is Warehouse1
struct with coordinates out of the USA or France.
In case of valid products, the {:ok, value}
tuples will be in the list.
Let's do input data format validation, data shaping into the nested structs, and model validation for the product catalogue matching all data format and model constraints like the following:
catalogue_uri = base_uri |> URI.merge("product-catalogue-correct.json") |> URI.to_string()
catalogue = catalogue_uri |> Tesla.get!() |> Map.get(:body) |> Jason.decode!()
with :ok <- ExJsonSchema.Validator.validate(schema, catalogue),
{:ok, products} <- Nestru.decode_from_list(catalogue, Product1),
check_results = Enum.map(products, &Product1.ensure_type(&1)),
{valid, []} <- Enum.split_with(check_results, &match?({:ok, _}, &1)) do
{:ok, Enum.map(valid, &elem(&1, 1))}
else
{_, invalid} -> {:error, Enum.map(invalid, &elem(&1, 1))}
end
{:ok,
[
%Product1{
name: "An ice sculpture",
dimensions: %Dimensions{height: 9.5, width: 1.0, length: 7.0},
warehouse: %Warehouse{latitude: 40.75, longitude: -121.4}
},
%Product1{
name: "A blue mouse",
dimensions: %Dimensions{height: 1.0, width: 1.0, length: 3.1},
warehouse: %Warehouse{latitude: 48.8, longitude: -3.2}
}
]}
The returned list of Product1
structs is ready to go to the application's business logic.
Nestru
is responsible for shaping a received map into the nested structs representing the application's data model. Therefore, it ignores values for non-existing fields and bubbles up any error occurring during the decoding.
Domo
validates the nested structs to match their t()
types and associated precondition functions. It's not catching all corner cases in the business logic because it's not a static type checker. At the same time, it can ensure that high-level model constraints are fulfilled by discarding disallowed data model states at runtime.
Ivan Rublev at al., 2022-2023