Packages

ISO/HL7 FHIR Application Server

Current section

Files

Jump to
hl7 lib validation behaviour.ex
Raw

lib/validation/behaviour.ex

defmodule Xema.Behaviour do
@moduledoc """
A behaviour module for implementing a schema validator. This behaviour is
just for `Xema` and `JsonXema`.
"""
alias Xema.{
JsonSchema,
Loader,
Ref,
Schema,
SchemaError,
ValidationError,
Validator
}
@typedoc """
The schema container.
"""
@type t :: struct
@inline_default true
@doc """
This callback initialize the schema. The function gets the data given to
`Xema.new/1` and returns a `Xema.Schema`.
"""
@callback init(any, keyword) :: Schema.t()
defmacro __using__(_opts) do
quote do
@behaviour Xema.Behaviour
alias Xema.Behaviour
@opt_fail [:immediately, :early, :finally]
@typedoc """
The return type of a validation run.
"""
@type result :: Xema.Validator.result()
@type schema :: Xema.Schema.t()
@enforce_keys [:schema]
@typedoc """
This struct contains the schema and references of the schema.
"""
@type t :: %__MODULE__{
schema: Schema.t(),
refs: map
}
defstruct schema: %Schema{},
refs: %{}
def new(data, opts \\ [])
def new(%Schema{} = schema, opts) do
schema = Behaviour.map_refs(schema)
remotes = Behaviour.get_remote_refs(schema, __MODULE__, opts)
xema =
struct!(
__MODULE__,
schema: schema,
refs: remotes
)
case opts[:remotes] do
nil -> Behaviour.update_refs(xema, opts)
_remotes -> xema
end
end
def new(data, opts) when is_map(data) or is_tuple(data) or is_list(data) or is_atom(data) do
data |> init(opts) |> new(opts)
end
@doc """
Returns `true` if the `value` is a valid value against the given `schema`;
otherwise returns `false`.
"""
@spec valid?(__MODULE__.t() | schema(), any) :: boolean
def valid?(schema, value), do: validate(schema, value, fail: :immediately) == :ok
@doc """
Returns `:ok` if the `value` is a valid value against the given `schema`;
otherwise returns an error tuple.
With the option `:fail`, you can define when the validation is aborted. This
also influences how many error reasons are returned.
- `:immediately` aborts the validation when the first validation fails.
- `:early` (default) aborts on failed validations, but runs validations
for all properties and items.
- `:finally` aborts after all possible validations.
"""
@spec validate(__MODULE__.t() | schema(), any, keyword) :: result()
def validate(%{} = schema, value, opts \\ []) do
if Keyword.get(opts, :fail, :early) not in @opt_fail do
raise ArgumentError,
message: "the optional option :fail must be one of #{inspect(@opt_fail)} when set"
end
with {:error, error} <- Validator.validate(schema, value, opts),
do: {:error, on_error(error)}
end
@doc """
Returns `:ok` if the `value` is a valid value against the given `schema`;
otherwise raises a `#{__MODULE__}.ValidationError`. See `validate3` for
available options.
"""
@spec validate!(__MODULE__.t() | schema(), any, keyword) :: :ok
def validate!(xema, value, opts \\ []) do
with {:error, reason} <- validate(xema, value, opts),
do: raise(reason)
end
# This function can be overwritten to transform the reason map of an error tuple.
defp on_error(error), do: ValidationError.exception(reason: error)
defoverridable on_error: 1
end
end
@doc false
@spec map_refs(Schema.t()) :: Schema.t()
def map_refs(%Schema{} = schema) do
map(schema, fn
%Schema{ref: ref} = schema, id when not is_nil(ref) ->
%{schema | ref: Ref.new(ref, id)}
value, _id ->
value
end)
end
@doc false
def update_refs(xema, opts) do
refs_map =
xema.refs
|> Map.keys()
|> Enum.reduce(%{master: []}, fn key, acc -> Map.put(acc, key, []) end)
|> get_refs_map(:master, xema)
refs_map =
Enum.reduce(xema.refs, refs_map, fn {key, xema}, acc ->
get_refs_map(acc, key, xema)
end)
xema
|> update_master_refs(Map.fetch!(refs_map, :master))
|> update_remote_refs(refs_map)
|> update_master_ids()
|> update_remote_ids()
|> inline(opts)
end
defp inline(xema, opts) do
case Keyword.get(opts, :inline, @inline_default) do
true -> inline(xema)
false -> xema
end
end
defp inline(xema) do
xema
|> circulars()
|> inline_refs(xema)
end
defp inline_refs(circulars, xema) do
schema = inline_refs(circulars, xema, nil, xema.schema)
refs =
xema
|> refs()
|> Enum.map(fn {ref, schema} = item ->
case {ref in circulars, schema} do
{false, _} ->
item
{true, :root} ->
item
{true, %Schema{} = schema} ->
{ref, inline_refs(circulars, xema, xema, schema)}
{true, %{schema: %Schema{} = schema} = master} ->
{ref, Map.put(master, :schema, inline_refs(circulars, master, xema, schema))}
end
end)
|> Enum.filter(fn {ref, _} -> Enum.member?(circulars, ref) end)
|> Enum.into(%{})
%{xema | schema: schema, refs: refs}
end
defp inline_refs(circulars, master, root, %Schema{} = schema) do
map(schema, fn
%Schema{ref: ref} = schema, _id when not is_nil(ref) ->
case Enum.member?(circulars, Ref.key(ref)) do
true ->
schema
false ->
case Ref.fetch!(ref, master, root) do
{%Schema{} = ref_schema, root} ->
inline_refs(circulars, master, root, ref_schema)
{xema, xema} ->
schema
{xema, root} ->
inline_refs(circulars, xema, root, xema.schema)
end
end
value, _id ->
value
end)
end
defp refs(xema), do: Enum.into(do_refs(xema), %{})
defp do_refs(%{refs: refs}) do
Enum.flat_map(refs, fn {key, schema} ->
[{key, schema} | do_refs(schema)]
end)
end
defp do_refs(_schema), do: []
defp update_master_ids(%{schema: schema} = xema) when not is_nil(schema) do
Map.update!(xema, :refs, fn value ->
Map.merge(value, get_ids(schema))
end)
end
defp update_master_ids(value), do: value
defp get_ids(%Schema{} = schema) do
reduce(schema, %{}, fn
%Schema{id: id}, acc, path when not is_nil(id) ->
case path == "#" do
false ->
Map.put(acc, id, Schema.fetch!(schema, path))
true ->
Map.put(acc, id, :root)
end
_xema, acc, _path ->
acc
end)
end
defp update_remote_ids(xema) do
refs = :maps.get(:refs, xema)
refs2 = Enum.into(refs, %{}, fn {key, ref} ->
# :io.format 'ref: ~p~n', [{}]
{key, update_master_ids(ref)} end)
Map.update!(xema, :refs, fn value ->
Map.merge(value, refs2)
end)
end
defp update_master_refs(%{schema: schema} = xema, refs) do
Map.update!(xema, :refs, fn value ->
Map.merge(value, get_schema_refs(schema, refs))
end)
end
defp update_remote_refs(%{refs: refs} = xema, refs_map) do
refs =
Enum.into(refs, %{}, fn {key, ref} ->
case Map.has_key?(refs_map, key) do
true ->
{key, update_master_refs(ref, Map.get(refs_map, key))}
false ->
{key, ref}
end
end)
Map.update!(xema, :refs, fn value ->
Map.merge(value, refs)
end)
end
defp get_schema_refs(schema, refs) do
Enum.into(refs, %{}, fn key ->
{key, Schema.fetch!(schema, key)}
end)
end
defp get_refs_map(refs, key, %{schema: schema}) do
reduce(schema, refs, fn
%Ref{pointer: pointer, uri: nil}, acc, _path ->
case pointer do
"#/" <> _ -> Map.update!(acc, key, fn list -> [pointer | list] end)
_ -> acc
end
%Ref{uri: uri} = ref, acc, _path ->
case ref.uri.fragment do
nil ->
acc
"" ->
acc
fragment ->
key = Ref.key(uri)
Map.update!(acc, key, fn list -> ["##{fragment}" | list] end)
end
_value, acc, _path ->
acc
end)
end
@doc false
@spec get_remote_refs(Schema.t(), atom, keyword) ::
%{required(String.t()) => struct}
def get_remote_refs(%Schema{} = schema, module, opts) do
reduce(schema, %{}, fn
%Ref{} = ref, acc, _path ->
put_remote_ref(acc, ref, module, opts)
_, acc, _path ->
acc
end)
end
defp put_remote_ref(map, %Ref{uri: uri} = ref, module, opts) do
case remote?(ref) do
false ->
map
true ->
key = Ref.key(uri)
remote_set = opts[:remotes] || MapSet.new()
case MapSet.member?(remote_set, key) do
true ->
map
false ->
remote_set = MapSet.put(remote_set, key)
xema =
get_remote_schema(
ref,
module,
Keyword.put(opts, :remotes, remote_set)
)
remotes = xema.refs
xema = Map.put(xema, :refs, %{})
map
|> Map.put(key, xema)
|> Map.merge(remotes)
end
end
end
defp get_remote_schema(ref, module, opts) do
case resolve(ref.uri, opts[:loader]) do
{:ok, nil} ->
nil
{:ok, data} ->
case Keyword.get(opts, :draft, :xema) do
:xema -> module.new(data, opts)
_ -> data |> JsonSchema.to_xema(opts) |> module.new(opts)
end
{:error, reason} ->
raise SchemaError, reason
end
end
defp resolve(uri, nil), do: Loader.fetch(uri)
defp resolve(uri, loader), do: loader.fetch(uri)
defp remote?(%Ref{uri: nil}), do: false
defp remote?(%Ref{uri: %URI{path: nil}}), do: false
defp remote?(%Ref{uri: %URI{path: path}, pointer: pointer}),
do:
Regex.match?(~r/(\.[a-zA-Z]+)|\#$/, path) or
String.ends_with?(pointer, "#")
# Invokes `fun` for each element in the schema tree with the accumulator.
@spec reduce(Schema.t(), any, function) :: any
defp reduce(schema, acc, fun), do: reduce(schema, acc, "#", fun)
defp reduce(%Schema{} = schema, acc, path, fun),
do:
schema
|> Map.from_struct()
|> Enum.reduce(fun.(schema, acc, path), fn {key, value}, x ->
reduce(value, x, Path.join(path, to_string(key)), fun)
end)
defp reduce(%_{} = struct, acc, path, fun),
do: fun.(struct, acc, path)
defp reduce(map, acc, path, fun) when is_map(map),
do:
Enum.reduce(map, fun.(map, acc, path), fn
{%key{}, value}, acc ->
reduce(value, acc, Path.join(path, to_string(key)), fun)
{key, value}, acc ->
reduce(value, acc, Path.join(path, to_string(key)), fun)
end)
defp reduce(list, acc, path, fun) when is_list(list),
do:
Enum.reduce(list, acc, fn value, acc ->
reduce(value, acc, path, fun)
end)
defp reduce(nil, acc, _path, _fun), do: acc
defp reduce(value, acc, path, fun), do: fun.(value, acc, path)
def update_uri(nil, nil), do: nil
def update_uri(uri_1, nil), do: URI.parse(uri_1)
def update_uri(nil, uri_2), do: URI.parse(uri_2)
def update_uri(uri_1, uri_2), do: URI.merge(uri_1, uri_2)
# Returns a schema tree where each schema is the result of invoking `fun` on
# each schema. The function gets also the current `ìd` for the schema. The
# `id` could be `nil` or a `%URI{}` struct.
@spec map(Schema.t(), function) :: Schema.t() | Ref.t()
defp map(schema, fun), do: map(schema, fun, nil)
defp map(%Schema{} = schema, fun, id) do
id = update_uri(id, schema.id)
Schema
|> struct(
schema
|> Map.from_struct()
|> Enum.map(fn {k, v} -> {k, map(v, fun, id)} end)
)
|> fun.(id)
end
defp map(%_{} = struct, _fun, _id), do: struct
defp map(map, fun, id) when is_map(map),
do: Enum.into(map, %{}, fn {k, v} -> {k, map(v, fun, id)} end)
defp map(list, fun, id) when is_list(list),
do: Enum.map(list, fn v -> map(v, fun, id) end)
defp map(value, _fun, _id), do: value
defp circulars(%{refs: refs} = xema) do
# :io.format '~p~n', [Map.keys(refs)]
Enum.reduce(refs, [], fn {ref, schema}, acc ->
Enum.concat(
circulars(schema),
case circular?(xema, ref) do
true -> [ref | acc]
false -> acc
end
)
end)
end
defp circulars(_schema), do: []
# Returns true if the `reference` builds up a circular reference.
@spec circular?(struct(), String.t()) :: boolean
defp circular?(xema, reference),
do: circular?(xema.refs[reference], reference, xema, [])
defp circular?(%Ref{} = ref, reference, root, acc) do
key = Ref.key(ref)
with false <- key == reference,
false <- key == "#" do
case Enum.member?(acc, key) do
true -> false
false -> circular?(root.refs[key], reference, root, [key | acc])
end
end
end
defp circular?(%_{} = struct, reference, root, acc),
do: struct |> Map.from_struct() |> circular?(reference, root, acc)
defp circular?(values, reference, root, acc)
when is_map(values),
do:
Enum.any?(values, fn {_, value} ->
circular?(value, reference, root, acc)
end)
defp circular?(values, reference, root, acc)
when is_list(values),
do:
Enum.any?(values, fn value ->
circular?(value, reference, root, acc)
end)
defp circular?(:root, _reference, _root, _acc), do: true
defp circular?(_ref, _reference, _root, _acc), do: false
end