Rupa.JsonSchema (rupa v0.1.0)

Copy Markdown View Source

JSON Schema draft 2020-12, in both directions.

encode/1 is total: Rupa's vocabulary is a subset of JSON Schema's declarative one, so every schema that validates has a document. decode/1 is not, and cannot be — JSON Schema says far more than Rupa does. What it will not do is fail quietly: a keyword Rupa has no answer for is a named error with the path to it, never a silently dropped constraint.

encode/1 writes what the wire sees

It stages the schema first, so the document describes the document — rename_all:, from: and keys: are already spent, and the property names are the wire keys rather than the field names. A field carrying both from: and to: reads one key and writes another, and what the document describes is the one it reads.

Staging also resolves refs, so a $ref that is not on a cycle is inlined and only genuine recursion comes back out as $defs and $ref. The document says the same thing either way; it is just longer.

decode/1 interns nothing

The objects it produces are named with strings, so no property name in a document ever becomes an atom. That is the whole reason Rupa.Schema grew string field names, and it is what makes it safe to decode a schema that arrived over the wire. keys: :atom is still there if you want atoms out of a document you trust.

Two constructs in Rupa's own vocabulary cannot come back this way, because both would have to intern an atom from the document to exist at all:

  • a tagged union — its decoded tag is an atom, so oneOf decodes to nothing
  • a recursive ref — $defs names are atoms, so a $ref on a cycle decodes to nothing

Both encode fine; neither decodes. A $ref that is not on a cycle is inlined instead, which needs no name at all, so ordinary reuse through $defs works.

Where Rupa and JSON Schema disagree

Documented rather than papered over, and measured: the official JSON-Schema-Test-Suite is vendored under test/fixtures/json-schema-suite/ and every case runs on every build. What follows is the whole of what it reports.

Rupa is more permissive on three, and encode/1 emits the keyword anyway, so a reader that follows the spec is stricter than Rupa rather than looser:

  • format asserts. JSON Schema makes format an annotation by default; Rupa's formats validate and several convert. Rupa.Schema.formats/0 is the fixed set.
  • pattern is PCRE, not ECMA-262, because it compiles with Erlang's :re.
  • minLength and maxLength count grapheme clusters, not code points — what a reader would count.

Rupa is stricter on one, and it is one decision seen from several angles: a number is compared by term, not by mathematical value. JSON Schema says 1.0 is an integer, that {"const": 0} matches 0.0, and that an enum of 1 matches 1.0. On the BEAM those are different terms, and reconciling them would put a check on every decode in every schema to serve the documents that write 1.0 and mean 1.

Everything else the suite asks, on the keywords Rupa claims, Rupa answers the way the spec says. A keyword it does not claim is a skip with a reason, never a quiet pass.

Summary

Functions

Turns a draft 2020-12 document into a schema, or says what it could not.

decode/1, raising Rupa.SchemaError instead of returning errors.

Turns a schema into a draft 2020-12 document, as a decoded JSON term.

encode/1, raising Rupa.SchemaError instead of returning errors.

Functions

decode(document)

@spec decode(term()) :: {:ok, Rupa.Schema.t()} | {:error, [Rupa.Error.t()]}

Turns a draft 2020-12 document into a schema, or says what it could not.

Takes the document as a decoded term, or as JSON text. The objects it produces are named with strings, so nothing in the document becomes an atom.

iex> {:ok, schema} = Rupa.JsonSchema.decode(~s({"type": "string", "minLength": 1}))
iex> schema
{:string, [min: 1]}

iex> {:error, [error]} = Rupa.JsonSchema.decode(%{"allOf" => []})
iex> {error.code, error.meta.keyword}
{:unsupported_keyword, "allOf"}

A schema it hands back compiles: what the document says in a way Rupa refuses -- a minLength above its maxLength, a pattern on a date-time -- is reported here, as the schema error Rupa.compile/2 would have given, rather than left for the compile to find.

iex> {:error, [error]} = Rupa.JsonSchema.decode(%{"type" => "string", "minLength" => 5, "maxLength" => 2})
iex> error.code
:contradictory_bounds

decode!(document)

@spec decode!(term()) :: Rupa.Schema.t()

decode/1, raising Rupa.SchemaError instead of returning errors.

iex> Rupa.JsonSchema.decode!(%{"type" => "integer", "minimum" => 0})
{:integer, [gte: 0]}

encode(schema)

@spec encode(term()) :: {:ok, map()} | {:error, [Rupa.Error.t()]}

Turns a schema into a draft 2020-12 document, as a decoded JSON term.

The only errors are the schema's own, because staging runs first.

iex> Rupa.JsonSchema.encode!(%{name: Rupa.T.string(min: 1)})
%{
  "$schema" => "https://json-schema.org/draft/2020-12/schema",
  "type" => "object",
  "properties" => %{"name" => %{"type" => "string", "minLength" => 1}},
  "required" => ["name"]
}

encode!(schema)

@spec encode!(term()) :: map()

encode/1, raising Rupa.SchemaError instead of returning errors.

iex> Rupa.JsonSchema.encode!(Rupa.T.boolean())
%{"$schema" => "https://json-schema.org/draft/2020-12/schema", "type" => "boolean"}