Current section

Files

Jump to
jsv lib jsv builder.ex
Raw

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