Skip to content

Repository files navigation

valid_json

Modern JSON Schema validation for Erlang/OTP.

valid_json is an Erlang/OTP library that validates JSON instances against JSON Schema Draft 6, Draft 7, Draft 2019-09, and Draft 2020-12, including references across all four dialects. Schemas are compiled once when they are registered and are then validated against in one of the four standard output formats. The registry is offline — no network requests are made during validation.

CI

Why valid_json?

valid_json fills the niche of a modern JSON Schema validator for Erlang. jesse, the incumbent Erlang validator, has not followed the specification past draft 06. jsonschex implements Draft 2020-12 in full, but it is an Elixir library: using it from Erlang brings the Elixir toolchain and a struct-shaped API into your build.

If you are writing Erlang and need Draft 6 through Draft 2020-12, valid_json provides JSON Schema support without requiring an Elixir-based validation stack.

Feature valid_json jesse jsonschex
Erlang-native yes yes no (Elixir)
Draft 2020-12 yes no (drafts 03, 04, 06) yes
Draft 2019-09 yes no no
Draft 7 / Draft 6 yes / yes no / yes no / no
Cross-draft references yes, all four dialects no no
$dynamicRef / $recursiveRef yes no $dynamicRef only
unevaluatedProperties / unevaluatedItems yes no yes
Standard output formats flag, basic, detailed, verbose own error tuples own error structs
Runtime dependencies none none optional jason, decimal, idna
Network at validation time never possible for unknown $ref whatever your loader does
Command-line tool valid-json check DIR yes, escript and Docker no

See the full comparison in docs/comparison — API, architecture, and keyword-by-keyword coverage, measured against jesse 1.8.2 and jsonschex 0.9.0.

Installation

The package is not published on Hex yet; add it as a Git dependency, pinned to the latest tag:

{deps, [
    {valid_json, {git, "https://github.com/Regikul/valid_json.git", {tag, "v0.4.0"}}}
]}.

Quick start

Start the application so that the built-in meta-schemas are published, then validate.

Validate a schema once

run_schema/3 compiles the schema in the calling process and validates the instance right away — nothing is kept:

{ok, _} = application:ensure_all_started(valid_json),

Schema = #{<<"type">> => <<"integer">>, <<"minimum">> => 0},
{ok, #{<<"valid">> := true}} =
    valid_json:run_schema(Schema, 5, [{output, flag}]).

Check a set of schema documents

valid_json_schema_set:check/2 verifies a complete set of named schema documents without starting the valid_json application, creating ETS tables, or retaining compiled artifacts. Register the whole set in one call so that references between its documents can be resolved:

Entries = [
    {<<"root.json">>,
     #{<<"$schema">> => <<"https://json-schema.org/draft/2020-12/schema">>,
       <<"$ref">> => <<"definitions.json">>}},
    {<<"definitions.json">>,
     #{<<"$schema">> => <<"https://json-schema.org/draft/2020-12/schema">>,
       <<"type">> => <<"object">>}}
],

{ok, Names} =
    valid_json_schema_set:check(
      Entries, [{base_uri, <<"https://example.com/schemas/">>}]).

The call accepts base_uri, default_dialect, and schema_validation options. It returns registration errors separately from schema-validation errors, each paired with the corresponding document URI. Reading and decoding files remains the caller's responsibility.

Compile once, validate many times

Register the schema under its $id, then validate by name. Registration compiles the schema; every validation after that is a lookup:

{ok, _} = application:ensure_all_started(valid_json),

Name = <<"https://example.com/schemas/user">>,
Schema = #{
    <<"$id">> => Name,
    <<"type">> => <<"object">>,
    <<"properties">> => #{
        <<"name">> => #{<<"type">> => <<"string">>}
    },
    <<"required">> => [<<"name">>]
},
{ok, [CanonicalUri]} = valid_json:add(Schema),

{ok, #{<<"valid">> := true}} =
    valid_json:validate(
        CanonicalUri,
        #{<<"name">> => <<"Ada">>},
        [{output, flag}]).

Registered schemas are compiled once and reused. Artifacts are stored in a supervised ETS table, so a schema used more than once belongs in a store; a schema that arrives with a single request can use run_schema/3, at the price of compiling it on every call.

Command line

rebar3 escriptize builds valid-json, a self-contained escript: the built-in meta-schemas are embedded at compile time, so the command needs neither the application nor the network.

rebar3 escriptize
_build/default/bin/valid-json check priv/schemas

check DIRECTORY reads every .json document below the directory and checks it against its meta-schema. The documents are registered together, so a $ref between them resolves. A schema is named by its own $id, and by its path below the directory when it has none. --default-dialect URI names the dialect for documents that declare no $schema.

Streams are split by what the command is for. Validation results go to stdout as one standard output document per line — the specification's detailed format, one line per schema that failed, each naming its subject in an extra instance member:

{
  "instance": "file:///srv/schemas/user.json",
  "valid": false,
  "keywordLocation": "",
  "instanceLocation": "",
  "absoluteKeywordLocation": "https://json-schema.org/draft/2020-12/schema#",
  "errors": [
    {
      "valid": false,
      "keywordLocation": "/allOf/3/$ref/properties/minimum/type",
      "absoluteKeywordLocation":
        "https://json-schema.org/draft/2020-12/meta/validation#/properties/minimum/type",
      "instanceLocation": "/minimum",
      "error": "expected number, got string"
    }
  ]
}

Pretty-printed here; the stream puts each document on a single line.

Everything the command could not check — a bad argument, an unreadable directory, a document that would not register or compile — goes to stderr as prose. The exit code follows the same split:

code meaning
0 every schema passed; nothing is printed
1 a schema failed its meta-schema; stdout carries the output documents
2 something could not be checked; stderr says what

An empty directory is exit code 2, not 0: silence there would be indistinguishable from a checked set.

Features

Supported dialects

  • JSON Schema Draft 2020-12
  • JSON Schema Draft 2019-09
  • JSON Schema Draft 7
  • JSON Schema Draft 6
  • Cross-draft references between all four

References and schema resources

  • $id, $anchor, $defs, definitions, $ref
  • Draft 6/7 fragment-only $id targets and their $ref-only sibling semantics
  • $dynamicRef / $dynamicAnchor (Draft 2020-12)
  • $recursiveRef / $recursiveAnchor (Draft 2019-09)
  • $vocabulary, built-in meta-schemas, and user-provided meta-schemas from the store (vocabularies begin with Draft 2019-09)
  • References are resolved eagerly at compile time, so a reference closure is a finite, comparable value — cyclic schemas are not a problem

Validation

  • All standard assertion keywords: type, enum, const, numeric bounds, pattern, length and collection-size keywords, uniqueItems, required, dependentRequired, plus both forms of Draft 6/7 dependencies
  • Applicators: allOf, anyOf, oneOf, not, if / then / else, dependentSchemas (if / then / else begin with Draft 7)
  • Object applicators: properties, patternProperties, additionalProperties, propertyNames
  • Array applicators: prefixItems, items, contains, minContains, maxContains, plus the Draft 6/7/2019-09 array form of items and additionalItems
  • unevaluatedProperties and unevaluatedItems

Output

  • The four standard output formats of the specification: flag, basic, detailed, and verbose

Registry and storage

  • Schemas are compiled once on registration; artifacts live in a supervised ETS table
  • Transactional reload and removal with dependency checking — a document that others reference cannot be removed
  • A directory loader reads .json files recursively at startup
  • run_schema/3 for schemas that are used once

Schema references and the registry

The registry is deliberately offline: documents reachable through $ref must be registered before compilation. No network requests are made at runtime, so validation is deterministic, has no latency from fetching, and exposes no fetching surface for untrusted schemas.

A document is addressed by its $id; a schema that declares none can be named from the outside with add_at/1,2 or by the loader. Sets of documents that reference one another are registered in one call, because references are resolved eagerly:

{ok, [CanonicalUriA, CanonicalUriB]} = valid_json:add([SchemaA, SchemaB]).

The full identifier model — $id, base_uri, relative names, embedded resources, and the directory loader — is described in Schema resources and identifiers.

Output formats

Validation returns the standard output document of the specification, so {ok, Output} may well describe a failed validation — valid is a field inside Output, not the shape of the return. The address itself may be a relative, short name: on a miss it is resolved against the store's base_uri before reporting not_found.

{ok, #{<<"valid">> := false, <<"errors">> := [_ | _]}} =
    valid_json:validate(RelativelUri, #{<<"name">> => 42}, [{output, detailed}]).

{error, Reason} is reserved for not getting as far as evaluating: not_found, unavailable, or an evaluation error.

Specification compliance

valid_json runs the declared validation profile from the official JSON Schema Test Suite for all four dialects. The pinned conformance run executes 1355 test groups and 6125 test cases, plus the 8 official output test cases (standardized only for Draft 2019-09 and Draft 2020-12) and 58 remote documents used by refRemote tests. Remote documents are compiled under their own $schema, so the run also verifies cross-draft resolution; they are registered in advance, and validation makes no network requests.

The declared capability profiles include:

  • optional/bignum, optional/id, optional/non-bmp-regex, and optional/unknownKeyword in all four dialects;
  • optional/anchor and optional/no-schema in Draft 2019-09 and Draft 2020-12, optional/cross-draft where the suite supplies it, and the Draft 2020-12 optional/dynamicRef profile;
  • a format profile compiled with {assert_format, true}: 10 files in Draft 6, 14 in Draft 7, and 16 each in Draft 2019-09 and Draft 2020-12. The four IDN/IRI files and the A-label group are declared exclusions where present;
  • the official output tests, which pin the basic format; flag, detailed, and verbose are covered by the project's own golden tests, because the official suite does not exercise them.

Every schema resource is checked against its own meta-schema when it is compiled by default; a schema that fails its meta-schema is rejected at registration. A caller that has already verified its schemas may pass {trust_schema, true} to run_schema/3 or configure the same option on a store. This skips only meta-schema evaluation: dialect and vocabulary resolution, reference checks, regex compilation, emitter safety checks, and instance validation still run. schema_validation selects the diagnostic format when the check is enabled.

trust_schema is a store-wide policy. It applies equally to loader startup, later additions, rebuilds, and recovery after an artifact-table restart; use separate stores for trusted and untrusted sources. The conformance policy, including the exact list of files, excluded groups, and the pinned census, lives in okf/testing/conformance-policy.md.

Format

format is collected as an annotation by default in all four dialects. Format assertions are opt-in: compiling with {assert_format, true} enables string checking for the implemented formats. An annotation is still collected for a passing value, and a value of a non-string type always passes.

Schema = #{<<"format">> => <<"ipv4">>},

%% Annotation only: the string passes, whatever it contains.
{ok, #{<<"valid">> := true}} =
    valid_json:run_schema(Schema, <<"999.1.1.1">>, []),

%% With assertions enabled, the value is checked.
{ok, #{<<"valid">> := false}} =
    valid_json:run_schema(Schema, <<"999.1.1.1">>,
                          [{assert_format, true}]).
Formats
Assertion (with assert_format) date, time, date-time, duration, ipv4, ipv6, hostname, email, uri, uri-reference, uri-template, json-pointer, relative-json-pointer, uuid, regex — 15 formats
Annotation only, by declaration idn-email, idn-hostname, iri, iri-reference

Unknown format names always pass and still produce an annotation. The Format-Assertion vocabulary is not claimed: a meta-schema that declares it true is rejected, as the specification requires of an implementation that does not check every format name. contentEncoding, contentMediaType, and contentSchema are annotations and do not decode or validate string content.

The per-format algorithms and their exact boundaries are documented in okf/architecture/format-attributes.md.

Known limitations

  • Regular expression dialect. pattern, patternProperties, and format: regex are compiled with Erlang's re module, which is PCRE rather than ECMA-262. A pattern outside the subset shared by both dialects — for example \p{Letter} — fails to compile and the whole schema is rejected. The ECMA-262/PCRE differences are measured in okf/architecture/ecma-to-pcre-adaptation.md.
  • IDN and IRI formats. idn-email, idn-hostname, iri, and iri-reference are always annotations: the string itself is not checked. This is a declared exclusion of the profile, not a temporary gap.
  • hostname and A-labels. An xn--… label is treated as an ordinary LDH label: A-labels are not decoded and IDNA2008 rules are not applied to their contents.
  • Numeric precision. multipleOf and numeric comparisons are computed on doubles, so a decimal fraction with no exact binary representation can disagree with decimal arithmetic.
  • Content keywords. contentEncoding, contentMediaType, and contentSchema are annotations only; string content is not decoded.

Documentation

Requirements

  • Erlang/OTP 20 or later. CI runs the full suite (compile, conformance, EUnit) on OTP 20 through 29.
  • rebar3

OTP releases before 27 use vendored copies of the stdlib json and uri_string modules, taken from OTP 28.1.1 and compiled only on the old releases. This is what keeps {deps, []} empty — the library has no third-party dependencies, on any OTP version. See THIRD_PARTY.md.

Development

rebar3 compile
rebar3 eunit
rebar3 conformance

conformance is an alias that runs the conformance profiles alone — the JSON Schema Test Suite and the official output tests — without the remaining unit tests. In CI, the ci profile turns compiler warnings into errors and runs rebar3 as ci compile, rebar3 as ci conformance, and rebar3 as ci eunit.

Project status

valid_json is version 0.4.0 and is under active development. Draft 6, Draft 7, Draft 2019-09, and Draft 2020-12 are supported within the conformance profile declared above; the remaining work is tracked in ROADMAP.md — the Format-Assertion vocabulary of phase P8, the HTTP loader, and the cross-cutting items.

The records' reason and location fields are the stable error contract; the wording produced by format_error/1 is an implementation detail and may change. The public API may have breaking changes before 1.0.

License

Licensed under the Apache License 2.0.

About

Modern JSON Schema validator and CLI for Erlang/OTP, supporting Draft 6, Draft 7, 2019-09, and 2020-12 with cross-draft references, compiled schemas, standard output formats, and zero runtime dependencies.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages