Packages
jsv
0.6.3
0.21.2
0.21.1
0.21.0
0.20.0
0.19.6
0.19.5
0.19.4
0.19.3
0.19.2
0.19.1
0.19.0
0.18.3
0.18.2
0.18.1
0.18.0
0.17.1
0.17.0
0.16.0
0.15.2
0.15.1
0.15.0
0.14.0
0.13.1
0.13.0
0.12.0
0.11.5
0.11.4
0.11.3
retired
0.11.2
0.11.1
0.11.0
0.10.1
0.10.0
0.9.0
0.8.1
0.8.0
0.7.2
0.7.1
0.7.0
0.6.3
0.6.2
0.6.0
0.5.1
0.5.0
0.4.0
0.3.0
0.2.0
0.1.0
A JSON Schema Validator with complete support for the latest specifications.
Current section
Files
Jump to
Current section
Files
lib/jsv/builder.ex
defmodule JSV.Builder do
alias JSV.BooleanSchema
alias JSV.Helpers.EnumExt
alias JSV.Key
alias JSV.Ref
alias JSV.Resolver
alias JSV.Resolver.Resolved
alias JSV.RNS
alias JSV.Root
alias JSV.Schema
alias JSV.Validator
alias JSV.Vocabulary
@moduledoc """
Internal logic to build raw schemas into `JSV.Root` structs.
"""
@derive {Inspect, except: []}
@enforce_keys [:resolver]
defstruct [
:resolver,
staged: [],
vocabularies: nil,
vocabulary_impls: %{},
ns: nil,
parent_ns: nil,
opts: [],
current_rev_path: []
]
@type t :: %__MODULE__{resolver: term, staged: [term], vocabularies: term, ns: term, parent_ns: term, opts: term}
@type resolvable :: Resolver.resolvable()
@type buildable :: {:resolved, resolvable} | resolvable
@type path_segment :: binary | non_neg_integer | atom | {atom, term}
@doc """
Returns a new builder. Builders are not reusable ; a fresh builder must be
made for each different root schema.
"""
@spec new(keyword) :: t
def new(opts) do
{resolver_chain, opts} = Keyword.pop!(opts, :resolvers)
{default_meta, opts} = Keyword.pop!(opts, :default_meta)
# beware, the :vocabularies option is not the final value of the
# :vocabularies key in the Builder struct. It's a configuration option to
# build the final value. This option is kept around in the :vocabulary_impls
# struct key after being merged on top of the default implementations.
{add_vocabulary_impls, opts} = Keyword.pop!(opts, :vocabularies)
vocabulary_impls = build_vocabulary_impls(add_vocabulary_impls)
resolver = Resolver.chain_of(resolver_chain, default_meta)
struct!(__MODULE__, resolver: resolver, opts: opts, vocabulary_impls: vocabulary_impls)
end
@doc """
Builds the given raw schema into a `JSV.Root` struct.
"""
@spec build(t, JSV.raw_schema()) :: {:ok, JSV.Root.t()} | {:error, term}
def build(_builder, valid?) when is_boolean(valid?) do
{:ok, %Root{raw: valid?, root_key: :root, validators: %{root: BooleanSchema.of(valid?, [:root])}}}
end
def build(builder, module) when is_atom(module) do
build_root(builder, module.schema())
rescue
e in UndefinedFunctionError -> {:error, e}
end
def build(builder, raw_schema) when is_map(raw_schema) do
build_root(builder, raw_schema)
end
@spec build_root(t, map) :: {:ok, JSV.Root.t()} | {:error, term}
defp build_root(builder, raw_schema) do
raw_schema = Schema.normalize(raw_schema)
with {:ok, root_key, resolver} <- Resolver.resolve_root(builder.resolver, raw_schema),
builder = stage_build(%__MODULE__{builder | resolver: resolver}, root_key),
{:ok, validators} <- build_all(builder) do
{:ok, %Root{raw: raw_schema, validators: validators, root_key: root_key}}
else
{:error, _} = err -> err
end
end
@doc """
Adds a new key to be built later. A key is generatlly derived from a
reference.
"""
@spec stage_build(t, buildable) :: t()
def stage_build(%{staged: staged} = builder, buildable) do
%__MODULE__{builder | staged: append_unique(staged, buildable)}
end
defp append_unique([same | t], same) do
[same | t]
end
defp append_unique([h | t], key) do
[h | append_unique(t, key)]
end
defp append_unique([], key) do
[key]
end
@doc """
Ensures that the remote resource that the given reference or key points to is
fetched in the builder internal cache
"""
@spec ensure_resolved(t, resolvable) :: {:ok, t} | {:error, {:resolver_error, term}}
def ensure_resolved(%{resolver: resolver} = builder, resolvable) do
case Resolver.resolve(resolver, resolvable) do
{:ok, resolver} -> {:ok, %__MODULE__{builder | resolver: resolver}}
{:error, _} = err -> err
end
end
@doc """
Returns the raw schema identified by the given key. Use `ensure_resolved/2`
before if the resource may not have been fetched.
"""
@spec fetch_resolved(t, Key.t()) :: {:ok, JSV.raw_schema()} | {:error, term}
def fetch_resolved(%{resolver: resolver}, key) do
Resolver.fetch_resolved(resolver, key)
end
defp take_staged(%{staged: []}) do
:empty
end
defp take_staged(%{staged: [staged | tail]} = builder) do
{staged, %__MODULE__{builder | staged: tail}}
end
# * all_validators represent the map of schema_id_or_ref => validators for
# this schema
# * schema validators is the validators corresponding to one schema document
# * mod_validators are the created validators from part of a schema
# keywords+values and a vocabulary module
defp build_all(builder) do
build_all(builder, %{})
catch
{:thrown_build_error, reason} -> {:error, reason}
end
defp build_all(builder, all_validators) do
# We split the buildables in three cases:
# - One dynamic refs will lead to build all existing dynamic refs not
# already built.
# - Resolvables such as ID and Ref will be resolved and turned into
# :resolved tuples.
# - :resolved tuples assume to be already resolved and will be built into
# validators.
#
# We need to do that 2-pass in the stage list because some resolvables
# (dynamic refs) lead to stage and build multiple validators.
case take_staged(builder) do
{{:resolved, vkey}, builder} ->
with :buildable <- check_not_built(all_validators, vkey),
{:ok, resolved} <- Resolver.fetch_resolved(builder.resolver, vkey),
{:ok, schema_validators, builder} <- build_resolved(builder, resolved) do
build_all(builder, register_validator(all_validators, vkey, schema_validators))
else
{:already_built, _} -> build_all(builder, all_validators)
{:error, _} = err -> err
end
{%Ref{dynamic?: true}, builder} ->
builder = stage_all_dynamic(builder)
build_all(builder, all_validators)
{resolvable, builder} when is_binary(resolvable) when is_struct(resolvable, Ref) when :root == resolvable ->
with :buildable <- check_not_built(all_validators, Key.of(resolvable)),
{:ok, builder} <- resolve_and_stage(builder, resolvable) do
build_all(builder, all_validators)
else
{:already_built, _} -> build_all(builder, all_validators)
{:error, _} = err -> err
end
# Finally there is nothing more to build
:empty ->
{:ok, all_validators}
end
end
defp register_validator(all_validators, vkey, schema_validators) do
Map.put(all_validators, vkey, schema_validators)
end
defp resolve_and_stage(builder, resolvable) do
vkey = Key.of(resolvable)
case ensure_resolved(builder, resolvable) do
{:ok, new_builder} -> {:ok, stage_build(new_builder, {:resolved, vkey})}
{:error, _} = err -> err
end
end
# TODO we should only stage for build the dynamic anchors that have the same
# anchor name as the ref. Not a big deal since we will not waste time to
# rebuilt what is arealdy built thanks to check_not_built/2 -> :already_built.
defp stage_all_dynamic(builder) do
# To build all dynamic references we tap into the resolver. The resolver
# also conveniently allows to fetch by its own keys ({:dynamic_anchor, _,
# _}) instead of passing the original ref.
#
# Everytime we encounter a dynamic ref in build_all/2 we insert all dynamic
# references into the staged list. But if we insert the ref itself it will
# lead to an infinite loop, since we do that when we find a ref in this
# loop.
#
# So instead of inserting the ref we insert the Key, and the Key module and
# Resolver accept to work with that kind of schema identifier (that is,
# {:dynamic_anchor, _, _} tuple).
#
# New items only come up when we build subschemas by staging a ref in the
# builder.
#
# But to keep it clean we scan the whole list every time.
dynamic_buildables =
Enum.flat_map(builder.resolver.resolved, fn
{{:dynamic_anchor, _, _} = vkey, _resolved} -> [{:resolved, vkey}]
_ -> []
end)
%__MODULE__{builder | staged: dynamic_buildables ++ builder.staged}
end
defp check_not_built(all_validators, vkey) do
case is_map_key(all_validators, vkey) do
true -> {:already_built, vkey}
false -> :buildable
end
end
defp build_resolved(builder, {:alias_of, key}) do
# Keep the alias in the validators but ensure the value it points to gets
# built too by staging it.
#
# The alias returned by the resolver is a key, it is not a binary or a
# %Ref{} staged by some vocabulary. (Thouh a binary is a valid key). So we
# must stage it as already resolved.
#
# Since this key is provided by the resolver we have the guarantee that the
# alias target is actually resolved already.
{:ok, {:alias_of, key}, stage_build(builder, {:resolved, key})}
end
defp build_resolved(builder, resolved) do
%Resolved{meta: meta, ns: ns, parent_ns: parent_ns, rev_path: rev_path} = resolved
with {:ok, raw_vocabularies} <- fetch_vocabulary(builder, meta),
{:ok, vocabularies} <- load_vocabularies(builder, raw_vocabularies) do
builder = %__MODULE__{builder | vocabularies: vocabularies, ns: ns, parent_ns: parent_ns}
# Here we call `do_build_sub` directly instead of `build_sub` because in
# this case, if the sub schema has an $id we want to actually build it
# and not register an alias.
#
# We set the current_rev_path on the builder because if the vocabulary
# module recursively calls build_sub we will need the current path
# later.
with_current_path(builder, rev_path, fn builder ->
do_build_sub(resolved.raw, rev_path, builder)
end)
else
{:error, _} = err ->
err
end
end
defp with_current_path(builder, rev_path, fun) do
previous_rev_path = builder.current_rev_path
next = %__MODULE__{builder | current_rev_path: rev_path}
case fun.(next) do
{:ok, value, %__MODULE__{} = new_builder} ->
{:ok, value, %__MODULE__{new_builder | current_rev_path: previous_rev_path}}
end
end
defp fetch_vocabulary(builder, meta) do
Resolver.fetch_vocabulary(builder.resolver, meta)
end
@doc """
Builds a subschema. Called from vocabulary modules to build nested schemas
such as in properties, if/else, items, etc.
"""
@spec build_sub(JSV.raw_schema(), [path_segment()], t) :: {:ok, Validator.validator(), t} | {:error, term}
def build_sub(%{"$id" => id}, _add_rev_path, builder) do
with {:ok, key} <- RNS.derive(builder.ns, id) do
{:ok, {:alias_of, key}, stage_build(builder, key)}
end
end
def build_sub(raw_schema, add_rev_path, builder) when is_map(raw_schema) when is_boolean(raw_schema) do
new_rev_path = add_rev_path ++ builder.current_rev_path
with_current_path(builder, new_rev_path, fn builder ->
do_build_sub(raw_schema, new_rev_path, builder)
end)
end
defp do_build_sub(raw_schema, rev_path, builder) when is_map(raw_schema) do
{_leftovers, schema_validators, builder} =
Enum.reduce(builder.vocabularies, {raw_schema, [], builder}, fn module_or_tuple,
{remaining_pairs, schema_validators, builder} ->
# For one vocabulary module we reduce over the raw schema keywords to
# accumulate the validator map.
{module, init_opts} = mod_and_init_opts(module_or_tuple)
{remaining_pairs, mod_validators, builder} =
build_mod_validators(remaining_pairs, module, init_opts, builder, raw_schema)
case mod_validators do
:ignore -> {remaining_pairs, schema_validators, builder}
_ -> {remaining_pairs, [{module, mod_validators} | schema_validators], builder}
end
end)
# TODO we should warn if the dialect did not pick all elements from the
# schema. But this should be opt-in. We should have an option that accepts a
# fun, so an user of the library could raise, log, or pass.
#
# case leftovers do
# [] -> :ok
# other -> IO.warn("got some leftovers: #{inspect(other)}", [])
# end
# Reverse the list to keep the priority order from builder.vocabularies
schema_validators = :lists.reverse(schema_validators)
{:ok, %JSV.Subschema{validators: schema_validators, schema_path: rev_path}, builder}
end
defp do_build_sub(valid?, rev_path, builder) when is_boolean(valid?) do
{:ok, BooleanSchema.of(valid?, rev_path), builder}
end
defp mod_and_init_opts({module, opts}) when is_atom(module) and is_list(opts) do
{module, opts}
end
defp mod_and_init_opts(module) when is_atom(module) do
{module, []}
end
defp build_mod_validators(raw_pairs, module, init_opts, builder, raw_schema) when is_map(raw_schema) do
{leftovers, mod_acc, builder} =
Enum.reduce(raw_pairs, {[], module.init_validators(init_opts), builder}, fn pair, {leftovers, mod_acc, builder} ->
# "keyword" refers to the schema keywod, e.g. "type", "properties", etc,
# supported by a vocabulary.
case module.handle_keyword(pair, mod_acc, builder, raw_schema) do
{:ok, mod_acc, builder} -> {leftovers, mod_acc, builder}
:ignore -> {[pair | leftovers], mod_acc, builder}
{:error, reason} -> throw({:thrown_build_error, reason})
end
end)
{leftovers, module.finalize_validators(mod_acc), builder}
end
@spec vocabulary_enabled?(t, module) :: boolean
def vocabulary_enabled?(builder, vocab) do
Enum.find_value(builder.vocabularies, false, fn
^vocab -> true
{^vocab, _} -> true
_ -> false
end)
end
@vocabulary_impls %{
# Draft 2020-12
"https://json-schema.org/draft/2020-12/vocab/core" => Vocabulary.V202012.Core,
"https://json-schema.org/draft/2020-12/vocab/validation" => Vocabulary.V202012.Validation,
"https://json-schema.org/draft/2020-12/vocab/applicator" => Vocabulary.V202012.Applicator,
"https://json-schema.org/draft/2020-12/vocab/content" => Vocabulary.V202012.Content,
"https://json-schema.org/draft/2020-12/vocab/format-annotation" => Vocabulary.V202012.Format,
"https://json-schema.org/draft/2020-12/vocab/format-assertion" => {Vocabulary.V202012.Format, assert: true},
"https://json-schema.org/draft/2020-12/vocab/meta-data" => Vocabulary.V202012.MetaData,
"https://json-schema.org/draft/2020-12/vocab/unevaluated" => Vocabulary.V202012.Unevaluated,
# Draft 7 does not define vocabularies. The $vocabulary content is made-up
# by the resolver so we can use the same architecture for keyword dispatch
# and allow user overrides.
"https://json-schema.org/draft-07/--fallback--vocab/core" => Vocabulary.V7.Core,
"https://json-schema.org/draft-07/--fallback--vocab/validation" => Vocabulary.V7.Validation,
"https://json-schema.org/draft-07/--fallback--vocab/applicator" => Vocabulary.V7.Applicator,
"https://json-schema.org/draft-07/--fallback--vocab/content" => Vocabulary.V7.Content,
"https://json-schema.org/draft-07/--fallback--vocab/format-annotation" => Vocabulary.V7.Format,
"https://json-schema.org/draft-07/--fallback--vocab/format-assertion" => {Vocabulary.V7.Format, assert: true},
"https://json-schema.org/draft-07/--fallback--vocab/meta-data" => Vocabulary.V7.MetaData
}
defp default_vocabulary_impls do
@vocabulary_impls
end
defp build_vocabulary_impls(user_mapped) do
Map.merge(default_vocabulary_impls(), user_mapped)
end
defp load_vocabularies(builder, map) do
with {:ok, vocabs} <- do_load_vocabularies(builder, map) do
{:ok, sort_vocabularies([Vocabulary.Cast | vocabs])}
end
end
defp do_load_vocabularies(builder, map) do
impls = builder.vocabulary_impls
EnumExt.reduce_ok(map, [], fn {uri, required?}, acc ->
case Map.fetch(impls, uri) do
{:ok, impl} -> {:ok, [impl | acc]}
:error when required? -> {:error, {:unknown_vocabulary, uri}}
:error -> {:ok, acc}
end
end)
end
defp sort_vocabularies(modules) do
Enum.sort_by(modules, fn
{module, _} -> module.priority()
module -> module.priority()
end)
end
end