Building Schemas

View Source

To validate data with schemas, JSV turns the schemas into a custom data structure made specifically for validation.

It does not validate data with raw schemas directly. That would be too slow and would not work properly with advanced features of Draft 2020-12, such as $dynamicRef and $dynamicAnchor.

Instead, raw schemas are processed by a set of different "vocabulary" modules that are each specialized in some part of the validation. The result of this processing is then collected as a JSV.Root struct.

This guide covers the configuration and customization of this process to better serve your needs.

The build functions

The main build function is JSV.build/2. It accepts a raw schema and a set of options and returns the root.

There are variations around that function, which is very common in Elixir: JSV.build/1 with default options, JSV.build!/1, and JSV.build!/2 with or without default options that raise errors instead of returning an error tuple.

Custom build modules

The build functions do not use macros or process-based techniques. We encourage you to wrap them and define your options in a single place:

defmodule MyApp.SchemaBuilder do

  def build(raw_schema) do
    JSV.build(raw_schema, build_opts())
  end

  def build!(raw_schema) do
    JSV.build!(raw_schema, build_opts())
  end

  defp build_opts do
    [resolver: MyApp.CustomSchemaResolver, formats: true]
  end
end

Compile-time builds

Validation roots can be built at runtime, but it is recommended to build them during compilation, if possible, to avoid repeating the build step unnecessarily.

Building at runtime should be done when the JSON schema is not available during compilation.

For instance, if we have this function that should validate external data:

# DO NOT DO THIS

defp order_schema do
  "priv/schemas/order.schema.json"
    |> File.read!()
    |> JSON.decode!()
    |> JSV.build!()
end

def validate_order(order) do
  case JSV.validate(order, order_schema()) do
    {:ok, _} -> OrderHandler.handle_order(order)
    {:error, _} = err -> err
  end
end

The schema will be built each time the function is called. Building a schema is actually pretty fast, but it is a waste of resources nevertheless. In this example, it is obvious that you would not want to read from a file in every call to validate_order. But the schema fetching will generally be wrapped in a custom function or a build module as suggested above.

Make sure those builds are called at compile-time:

# Do this instead

@order_schema "priv/schemas/order.schema.json"
              |> File.read!()
              |> JSON.decode!()
              |> JSV.build!()

defp order_schema, do: @order_schema

def validate_order(order) do
  case JSV.validate(order, order_schema()) do
    {:ok, _} -> OrderHandler.handle_order(order)
    {:error, _} = err -> err
  end
end

Enable format validation

No format validation by default

By default, the https://json-schema.org/draft/2020-12/schema meta schema does not perform format validation. This is very counterintuitive, but it basically means that the following code is correct:

root = JSV.build!(%{type: :string, format: :date})
{:ok, "not a date"} = JSV.validate("not a date", root)

The format schema keyword is totally ignored. This is bad, but it is the spec! To always enable format validation when building a root schema, provide the formats: true option to JSV.build/2:

JSV.build(raw_schema, formats: true)

This is another reason to wrap JSV.build/2 with a custom builder module, so you don't forget to enable those.

Note that format validation is determined at build time. There is no way to change whether it is performed once the root schema is built.

Enable format validation using vocabularies

You can also enable format validation by using the JSON Schema specification semantics, though it is far simpler and less error-prone to use the :formats option.

For format validation to be enabled, a schema should declare the https://json-schema.org/draft/2020-12/vocab/format-assertion vocabulary instead of the https://json-schema.org/draft/2020-12/vocab/format-annotation one that is included by default in the https://json-schema.org/draft/2020-12/schema meta schema.

1. Use a new meta schema with format-assertion

{
    "$id": "custom://with-formats-on/",
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "$vocabulary": {
        "https://json-schema.org/draft/2020-12/vocab/core": true,
        "https://json-schema.org/draft/2020-12/vocab/format-assertion": true
    },
    "$dynamicAnchor": "meta",
    "allOf": [
        { "$ref": "https://json-schema.org/draft/2020-12/meta/core" },
        { "$ref": "https://json-schema.org/draft/2020-12/meta/format-assertion" }
    ]
}

This example is taken from the JSON Schema Test Suite codebase and does not include all the vocabularies, only the assertion for the formats and the core vocabulary. It will not validate anything other than formats.

2. Declare a schema using that meta schema to perform validation.

You will need a custom resolver to resolve the given URL for the new $schema property.

schema =
  JSON.decode!("""
  {
    "$schema": "custom://with-formats-on/",
    "type": "string",
    "format": "date"
  }
  """)

root = JSV.build!(schema, resolver: ...)

3. Validate

Now it will work as expected. JSV.validate/2 returns an error tuple without needing the formats: true.

{:error, _} = JSV.validate("hello", root)

Reverse use-case

If one of your schemas is using such a meta schema and you want to disable the formats validation, then the following will work:

JSV.build(raw_schema, formats: false)