hex.pm version Draft 2020-12

JSONSchex is a performance-focused JSON Schema validator for Elixir, supporting Draft 7 and Draft 2020-12.

Features

  • Draft 7 and Draft 2020-12: 100% pass rate on the bundled official JSON Schema Test Suite cases run for both drafts.
  • Compile once, validate repeatedly: reuse compiled Schema structs instead of recompiling schemas for every validation.
  • Measured performance: reproducible Benchee benchmarks compare validation throughput and memory usage across Elixir JSON Schema libraries; see the results.
  • OpenAPI integration: supports Oasis, an Elixir/Plug OpenAPI v3 server and code generator.

Installation

Requires Elixir 1.14 or later. Add JSONSchex to your dependencies:

def deps do
  [
    {:jsonschex, "~> 0.11"}
  ]
end

Quick start

Pass schemas and data as decoded Elixir terms; schema objects use maps with string keys.

{:ok, compiled} =
  JSONSchex.compile(%{
    "type" => "array",
    "items" => %{"type" => "integer"}
  })

:ok = JSONSchex.validate(compiled, [1, 2, 3])
{:error, errors} = JSONSchex.validate(compiled, [1, "bad"])

Enum.map(errors, &JSONSchex.format_error/1)
# => ["At /1: Expected type integer, got string"]

Draft selection

Draft 2020-12 is the default. A recognized root $schema selects the draft automatically:

# Draft 7 uses array-form items for positional validation.
schema = %{
  "$schema" => "http://json-schema.org/draft-07/schema#",
  "items" => [%{"type" => "integer"}],
  "additionalItems" => false
}

{:ok, compiled} = JSONSchex.compile(schema)
:ok = JSONSchex.validate(compiled, [1])
{:error, _errors} = JSONSchex.validate(compiled, [1, 2])

# Without a recognized declaration, select Draft 7 explicitly with a string value.
{:ok, _compiled} = JSONSchex.compile(Map.delete(schema, "$schema"), draft: "draft7")

The :draft option accepts "draft7" and "draft2020_12" as strings, not atoms. It must agree with a recognized root $schema; a conflict returns{:error, %JSONSchex.Types.Error{rule: :draft_conflict}}. JSONSchex.Schema.compile!/2 follows the same policy, raising CompileError instead. See the dialect guide for custom meta-schemas and vocabulary behavior.

Compile-time schemas

For static schemas, compile and embed a Schema struct in a module attribute with JSONSchex.Schema.compile!/2 or the compact ~X sigil.

Using JSONSchex.Schema.compile!/2

You can embed the compiled schema directly in your module with JSONSchex.Schema.compile!/2:

defmodule MyApp.UserSchema do
  require JSONSchex.Schema

  @schema JSONSchex.Schema.compile!(%{
    "type" => "string",
    "format" => "email"
  }, format_assertion: true)

  def schema, do: @schema
end

:ok = JSONSchex.validate(MyApp.UserSchema.schema(), "user@example.com")

Using the ~X sigil

use JSONSchex imports the ~X sigil for Elixir schema literals:

defmodule MyApp.NumberSchema do
  use JSONSchex

  @schema ~X|%{"type" => "integer", "minimum" => 10}|

  def schema, do: @schema
end

For a narrower import, use import JSONSchex.Sigil, only: [sigil_X: 2] instead. ~X parses Elixir code, not JSON, and supports these modifiers:

  • f — format_assertion: true
  • c — content_assertion: true

For compile-time embeddable options such as :loader, prefer remote captures like &MyLoader.fetch/1 over anonymous functions.

~X is preferred over ~J to avoid the common sigil-name conflict with Jason.

How it works

  1. Compile — Convert schema keywords into reusable, serializable rule descriptors; register resource IDs, anchors, and eligible local references; and select the draft's keywords and vocabularies.
  2. Validate — Execute those rules against each value, resolve references, track evaluated properties/items where required, and return structured validation errors.

Both drafts support $ref; Draft 2020-12 also supports $dynamicRef. Canonical meta-schemas for both drafts are embedded and need no loader. Other external resources require a caller-supplied :loader when resolved.

Reusing the compiled schema avoids repeating the initial schema compilation for every value. Human-readable error messages are formatted only when requested.

Error reporting

JSONSchex.validate/2 returns :ok or {:error, errors}, where each error is a JSONSchex.Types.Error struct:

  • path — Path segments stored deepest-first: ["email", 0, "users"] represents /users/0/email. An empty list identifies the root.
  • rule — The failed validation rule, such as :type or :minimum.
  • context — An ErrorContext struct carrying the constraint, processed input, and additional details, when present.
  • value — The failing input value, when available.

Use JSONSchex.format_error/1 to render a root-first JSON Pointer and a readable message:

schema = %{
  "type" => "object",
  "properties" => %{
    "user" => %{
      "type" => "object",
      "properties" => %{"age" => %{"type" => "integer", "minimum" => 0}}
    }
  }
}

{:ok, compiled} = JSONSchex.compile(schema)
{:error, [error]} = JSONSchex.validate(compiled, %{"user" => %{"age" => -5}})

{error.rule, error.path, error.value}
# => {:minimum, ["age", "user"], -5}

JSONSchex.format_error(error)
# => "At /user/age: Value -5 is less than minimum 0"

Regex engine failures return :indeterminate_pattern and stop validation. See the regex validation guide for execution limits, error details, and guidance on avoiding excessive backtracking.

Compile options

JSONSchex.compile/2 accepts these options:

OptionPurpose
:draft"draft7" or "draft2020_12"; see draft selection.
:base_uriStarting URI or path for resolving relative IDs and references.
:loaderCallback that loads decoded external resources; see the loader guide.
:format_assertionExplicitly enable format checks (default: false). An active format-assertion vocabulary also enables them.
:content_assertionEnable supported content* checks (default: false).

The canonical Draft 2020-12 dialect treats format as annotation-only by default. In Draft 2020-12, omitting $schema or using an unresolved custom meta-schema activates a fallback vocabulary set that includes format assertions, even with format_assertion: false. See the content and format guide for assertion behavior and the dialect guide for vocabulary selection.

Fragments and reference resolution

Compile or bundle a fragment

Use JSONSchex.compile_fragment/2 when a schema lives inside a larger document, such as OpenAPI, so local references retain their containing-document context:

document = %{
  "components" => %{"schemas" => %{"Name" => %{"type" => "string"}}},
  "schema" => %{"$ref" => "#/components/schemas/Name"}
}

{:ok, compiled} = JSONSchex.compile_fragment(document, entry: "#/schema")
:ok = JSONSchex.validate(compiled, "Ada")
  • :entry accepts a JSON Pointer or URI reference, such as "#/schema" or "/api/openapi.yaml#/schema".
  • Draft detection uses the containing document's root $schema; use :draft when it is absent or unrecognized.
  • Supply :base_uri for relative external references. If omitted, a URI/path in :entry supplies that base.
  • Loaders may return {:ok, schema} or {:ok, %{document: schema, base_uri: base_uri}}. Wrapper metadata uses atom keys only.

JSONSchex.bundle_fragment/2 accepts the same entry options and returns {:ok, raw_schema} with reachable external resources mounted under $defs. Compile the complete returned schema with JSONSchex.compile/2. Generated jsonschex_ definition keys are collision-safe internal storage names, not stable reference paths; resources retain their identity through $id and anchors.

Resolve selected references

JSONSchex.Ref.resolve_selected/2 resolves selected $ref nodes in JSON-like documents without compiling a schema. Its required :select callback receives (path, node) and decides which reference nodes to replace with their target values. Unselected references are preserved, while their sibling values are still traversed. Preserved descendant references are rebased when external content is inlined. External loading uses the same :base_uri and :loader options.

Optional dependencies

Add only the dependencies your application needs alongside :jsonschex in mix.exs:

DependencyWhen to add it
{:jason, "~> 1.4"}JSON decoding and encoding on Elixir versions before 1.18. Elixir 1.18+ uses the built-in JSON module.
{:decimal, "~> 3.2"}High-precision multipleOf checks. Without it, native arithmetic can lose precision or overflow.
{:idna, "~> 6.0 or ~> 7.1"}Internationalized domain names in idn-hostname and idn-email format checks.

Guides

See the guide/ directory for detailed documentation:

Development

Clone the repository, initialize its pinned test fixtures, and run the tests:

git clone https://github.com/xinz/jsonschex.git
cd jsonschex
git submodule update --init --recursive
mix deps.get
mix test

The submodules provide the official JSON Schema Test Suite and RFC 6570 URI Template tests. To deliberately update the official fixtures to their upstream revision:

git submodule update --remote -- test/fixtures/JSON-Schema-Test-Suite
git submodule status -- test/fixtures/JSON-Schema-Test-Suite

Test in an Alpine container

The Dockerfile of the code repository uses elixir:1.20.5-otp-29-alpine and treats project compiler warnings as errors. Initialize the fixture submodules before building; they are copied into the image with the tests.

git submodule update --init --recursive
docker build -t jsonschex-test .
docker run --rm jsonschex-test

The image compiles in MIX_ENV=test and runs mix test --warnings-as-errors by default. You can use podman instead of docker for both commands.

Test suite summary

mix test runs both official draft suites, including optional format and content fixtures with their assertion options enabled. The optional/cross-draft fixtures are excluded: automatic cross-draft resolution is outside the current support scope.

Run just the official suites with:

mix test test/official_jsts_draft7_test.exs test/official_jsts_test.exs

See the test coverage guide for fixture paths, exclusions, and focused debugging.

Benchmark

More benchmark details can be found in the bench/ directory.