defmodule Xema do @moduledoc """ A schema validator inspired by [JSON Schema](http://json-schema.org). All available keywords to construct a schema are described on page [Usage](usage.html). This module can be used to construct a schema module. Should a module contain multiple schemas the option `multi: true` is required. `use Xema` imports `Xema.Builder` and extends the module with the functions + `__MODULE__.valid?/2` + `__MODULE__.validate/2` + `__MODULE__.validate!/2` + `__MODULE__.cast/2` + `__MODULE__.cast!/2` + `__MODULE__.xema/1` The macro `xema/2` supports the construction of a schema. After that the schema is available via the functions above. In a multi schema module a schema can be tagged with the option `default: :schema_name` and then called by + `__MODULE__.valid?/1` + `__MODULE__.validate/1` + `__MODULE__.validate!/1` + `__MODULE__.cast/1` + `__MODULE__.cast!/1` + `__MODULE__.xema/0` The functions with arity 1 are also available for single schema modules. The macro `xema_struct/1` creates a schema with the coresponding struct. ## Examples Single schema module: iex> defmodule SingleSchema do ...> use Xema ...> ...> # The name :num is optional. ...> xema :num, do: number(minimum: 1) ...> end iex> iex> SingleSchema.valid?(:num, 6) true iex> SingleSchema.valid?(5) true iex> SingleSchema.validate(0) {:error, %Xema.ValidationError{ reason: %{minimum: 1, value: 0} }} iex> SingleSchema.cast("5") {:ok, 5} iex> SingleSchema.cast("-5") {:error, %Xema.ValidationError{ reason: %{minimum: 1, value: -5} }} Multi schema module: iex> defmodule Schema do ...> use Xema, multi: true, default: :user ...> ...> @pos integer(minimum: 0) ...> @neg integer(maximum: 0) ...> ...> xema :user do ...> map( ...> properties: %{ ...> name: string(min_length: 1), ...> age: @pos ...> } ...> ) ...> end ...> ...> xema :nums do ...> map( ...> properties: %{ ...> pos: list(items: @pos), ...> neg: list(items: @neg) ...> } ...> ) ...> end ...> end iex> iex> Schema.valid?(:user, %{name: "John", age: 21}) true iex> Schema.valid?(%{name: "John", age: 21}) true iex> Schema.valid?(%{name: "", age: 21}) false iex> Schema.validate(%{name: "John", age: 21}) :ok iex> Schema.validate(%{name: "", age: 21}) {:error, %Xema.ValidationError{ reason: %{ properties: %{name: %{min_length: 1, value: ""}}} } } iex> Schema.valid?(:nums, %{pos: [1, 2, 3]}) true iex> Schema.valid?(:nums, %{neg: [1, 2, 3]}) false Struct schema module: iex> defmodule StructA do ...> use Xema ...> ...> xema_struct do ...> field :foo, :integer, minimum: 0 ...> end ...> end ...> ...> defmodule StructB do ...> use Xema ...> ...> xema_struct do ...> field :a, :string, min_length: 3 ...> field :b, StructA ...> required [:a] ...> end ...> end ...> ...> data = StructB.cast!(a: "abc", b: %{foo: 5}) ...> data.a "abc" iex> Map.from_struct(data.b) %{foo: 5} For more examples to construct schemas see "[Examples](examples.html)". """ use Xema.Behaviour import Xema.Castable.Helper, only: [to_existing_atom: 1, to_sorted_list: 1] alias Xema.{ CastError, JsonSchema, Ref, Schema, SchemaError, SchemaValidator, ValidationError } @keywords Schema.keywords() @types Schema.types() @doc false defmacro __using__(opts) do multi = Keyword.get(opts, :multi, false) default = Keyword.get(opts, :default) quote do import Xema.Builder @xemas [] @__xema_default__ unquote(default) @multi unquote(multi) end end @doc """ This function creates the schema from the given `data`. Possible options: + `:loader` - a loader for remote schemas. This option will overwrite the loader from the config. See [Configure a loader](loader.html) to how to define a loader. + `inline` - inlined all references in the schema. Default `:true`. ## Examples Simple schema: iex> schema = Xema.new :string iex> Xema.valid? schema, "hello" true iex> Xema.valid? schema, 42 false Schema: iex> schema = Xema.new {:string, min_length: 3, max_length: 12} iex> Xema.valid? schema, "hello" true iex> Xema.valid? schema, "hi" false Nested schemas: iex> schema = Xema.new {:list, items: {:number, minimum: 2}} iex> Xema.validate(schema, [2, 3, 4]) :ok iex> Xema.valid?(schema, [2, 3, 4]) true iex> Xema.validate(schema, [2, 3, 1]) {:error, %Xema.ValidationError{ reason: %{ items: %{2 => %{value: 1, minimum: 2}}} } } More examples can be found on page [Usage](https://hexdocs.pm/xema/usage.html#content). """ @spec new(Schema.t() | Schema.type() | tuple | atom | keyword, keyword) :: __MODULE__.t() def new(data, opts) # The implementation of `init`. # # This function prepares the given keyword list for the function schema. @impl true @doc false @spec init(atom | keyword | {atom | [atom], keyword}, keyword) :: Schema.t() def init(type, opts) when is_atom(type), do: init({type, []}, opts) def init(val, opts) when is_list(val) do case Keyword.keyword?(val) do true -> # init without a given type init({:any, val}, opts) false -> # init with multiple types init({val, []}, opts) end end def init({:ref, pointer}, opts), do: init({:any, ref: pointer}, opts) def init(data, opts) do # If opts contains key :draft the schema is created from_json_schema and # already checked. if !Keyword.has_key?(opts, :draft), do: SchemaValidator.validate!(data) schema(data) end @doc """ Creates a `Xema` from a JSON Schema. The argument `json_schema` is expected as a decoded JSON Schema. All keys that are not standard JSON Schema keywords have to be known atoms. If the schema has additional keys that are unknown atoms the option `atom: :force` is needed. In this case the atoms will be created. This is not needed for keys expected by JSON Schema (e.g. in properties) Options: * `:draft` specifies the draft to check the given JSON Schema. Possible values are `"draft4"`, `"draft6"`, and `"draft7"`, default is `"draft7"`. If `:draft` not set and the schema contains `$schema` then the value for `$schema` is used for this option. * `:atoms` creates atoms for unknown atoms when set to `:force`. This is just needed for additional JSON Schema keywords. ## Examples iex> Xema.from_json_schema(%{"type" => "integer", "minimum" => 5}) %Xema{schema: %Xema.Schema{minimum: 5, type: :integer}} iex> schema = %{ ...> "type" => "object", ...> "properties" => %{"foo" => %{"type" => "integer"}} ...> } iex> Xema.from_json_schema(schema) %Xema{schema: %Xema.Schema{ properties: %{"foo" => %Xema.Schema{type: :integer}}, type: :map, keys: :strings } } iex> Xema.from_json_schema(%{"type" => "integer", "foo" => "bar"}, atom: :force) %Xema{schema: %Xema.Schema{data: %{foo: "bar"}, type: :integer}} iex> Xema.from_json_schema(%{"exclusiveMaximum" => 5}, draft: "draft7") %Xema{schema: %Xema.Schema{exclusive_maximum: 5}} iex> Xema.from_json_schema(%{"exclusiveMaximum" => 5}, draft: "draft4") ** (Xema.SchemaError) Can't build schema: Dependencies for "exclusiveMaximum" failed. Missing required key "maximum". """ @spec from_json_schema(atom | map, keyword) :: __MODULE__.t() def from_json_schema(json_schema, opts \\ []) do json_schema |> JsonSchema.to_xema(opts) |> new(opts) end # This function creates a schema from the given data. defp schema(type, opts \\ []) # Extracts the schema form a `%Xema{}` struct. @spec schema(Xema.t(), keyword) :: Schema.t() defp schema(%Xema{schema: schema}, _), do: schema # Creates a schema from a list. Expected a list of types or a keyword list # for an any schema. # This function will be just called for nested schemas. @spec schema([Schema.type()] | keyword, keyword) :: Schema.t() defp schema(list, opts) when is_list(list) do case Keyword.keyword?(list) do true -> schema({:any, list}, opts) false -> schema({list, []}, opts) end end # Creates a schema from an atom type. @spec schema(atom, keyword) :: Schema.t() defp schema(value, opts) when value in @types do schema({value, []}, opts) end # Creates a schema from a `Xema` module. defp schema(value, _opts) when is_atom(value) do ensure_behaviour!(value).xema().schema end # Creates a bool schema. Keywords and opts will be ignored. @spec schema({Schema.type() | [Schema.type()], keyword}, keyword) :: Schema.t() defp schema({bool, _}, _) when is_boolean(bool), do: Schema.new(type: bool) # Creates a schema for a reference. defp schema({:ref, keywords}, _), do: schema({:any, [{:ref, keywords}]}) defp schema({type, keywords}, _) do keywords |> Keyword.put(:type, type) |> update() |> Schema.new() end # This function creates the schema tree. @spec update(keyword) :: keyword defp update(keywords) do keywords |> Keyword.update(:additional_items, nil, &bool_or_schema/1) |> Keyword.update(:additional_properties, nil, &bool_or_schema/1) |> Keyword.update(:all_of, nil, &schemas/1) |> Keyword.update(:any_of, nil, &schemas/1) |> Keyword.update(:contains, nil, &schema/1) |> Keyword.update(:dependencies, nil, &dependencies/1) |> Keyword.update(:else, nil, &schema/1) |> Keyword.update(:if, nil, &schema/1) |> Keyword.update(:items, nil, &items/1) |> Keyword.update(:not, nil, &schema/1) |> Keyword.update(:one_of, nil, &schemas/1) |> Keyword.update(:pattern_properties, nil, &schemas/1) |> Keyword.update(:properties, nil, &schemas/1) |> Keyword.update(:property_names, nil, &schema/1) |> Keyword.update(:definitions, nil, &schemas/1) |> Keyword.update(:required, nil, &MapSet.new/1) |> Keyword.update(:then, nil, &schema/1) |> update_allow() |> update_data() end @spec schemas(list) :: list defp schemas(list) when is_list(list), do: Enum.map(list, fn schema -> schema(schema) end) @spec schemas(map) :: map defp schemas(map) when is_map(map), do: map_values(map, &schema/1) @spec dependencies(map) :: map defp dependencies(map) do Enum.into(map, %{}, fn {key, dep} when is_list(dep) -> case Keyword.keyword?(dep) do true -> {key, schema(dep)} false -> {key, dep} end {key, dep} when is_boolean(dep) -> {key, schema(dep)} {key, dep} when is_atom(dep) -> {key, [dep]} {key, dep} when is_binary(dep) -> {key, [dep]} {key, dep} -> {key, schema(dep)} end) end @spec bool_or_schema(boolean | atom) :: boolean | Schema.t() defp bool_or_schema(bool) when is_boolean(bool), do: bool defp bool_or_schema(schema), do: schema(schema) @spec items(any) :: list defp items(%Xema{schema: schema}), do: schema defp items(schema) when is_atom(schema) or is_tuple(schema), do: schema(schema) defp items(value) when is_list(value) do case Keyword.keyword?(value) do true -> case schemas?(value) do true -> schemas(value) false -> schema(value) end false -> schemas(value) end end @spec schemas?(keyword) :: boolean defp schemas?(value), do: value |> Keyword.keys() |> Enum.all?(fn type -> type in [:ref | @types] end) defp update_allow(keywords) do case Keyword.pop(keywords, :allow, :undefined) do {:undefined, keywords} -> keywords {value, keywords} when is_list(value) -> Keyword.update!(keywords, :type, fn types when is_list(types) -> Enum.concat(value, types) type -> [type | value] end) {value, keywords} -> Keyword.update!(keywords, :type, fn types when is_list(types) -> [value | types] type -> [type, value] end) end end defp update_data(keywords) do {data, keywords} = do_update_data(keywords) data = case Enum.empty?(data) do true -> nil false -> data end Keyword.put(keywords, :data, data) end @spec do_update_data(keyword) :: {map, keyword} defp do_update_data(keywords), do: keywords |> diff_keywords() |> Enum.reduce({%{}, keywords}, fn key, {data, keywords} -> {value, keywords} = Keyword.pop(keywords, key) {Map.put(data, key, maybe_schema(value)), keywords} end) defp maybe_schema(list) when is_list(list) do case Keyword.keyword?(list) do true -> case has_keyword?(list) do true -> schema(list) false -> list end false -> Enum.map(list, &maybe_schema/1) end end defp maybe_schema(atom) when is_atom(atom) do case atom in Schema.types() do true -> schema(atom) false -> atom end end defp maybe_schema({:ref, str} = ref) when is_binary(str), do: schema(ref) defp maybe_schema({atom, list} = tuple) when is_atom(atom) and is_list(list) do case atom in Schema.types() do true -> schema(tuple) false -> tuple end end defp maybe_schema(%_{} = struct), do: struct defp maybe_schema(map) when is_map(map), do: map_values(map, &maybe_schema/1) defp maybe_schema(value), do: value defp diff_keywords(list), do: list |> Keyword.keys() |> MapSet.new() |> MapSet.difference(MapSet.new(@keywords)) |> MapSet.to_list() defp has_keyword?(list), do: list |> Keyword.keys() |> MapSet.new() |> MapSet.disjoint?(MapSet.new(@keywords)) |> Kernel.not() # Returns a map where each value is the result of invoking `fun` on each # value of the given `map`. @spec map_values(map, (any -> any)) :: map defp map_values(map, fun) when is_map(map) and is_function(fun), do: Enum.into(map, %{}, fn {key, val} -> {key, fun.(val)} end) @doc """ Returns the source for a given `xema`. The output can differ from the input if the schema contains references. To get the original source the schema must be created with `inline: false`. ## Examples iex> {:integer, minimum: 1} |> Xema.new() |> Xema.source() {:integer, minimum: 1} """ @spec source(Xema.t() | Schema.t()) :: atom | keyword | {atom, keyword} def source(%Xema{} = xema), do: source(xema.schema) def source(%Schema{} = schema) do type = schema.type data = Map.get(schema, :data) || %{} keywords = schema |> Schema.to_map() |> Map.delete(:type) |> Map.delete(:data) |> Map.merge(data) |> Enum.map(fn {key, val} -> {key, nested_source(val)} end) |> map_ref() case {type, keywords} do {type, []} -> type {:any, keywords} -> keywords tuple -> tuple end end defp map_ref(keywords) do case Keyword.has_key?(keywords, :ref) do true -> if length(keywords) == 1 do keywords[:ref] else {_, pointer} = keywords[:ref] Keyword.put(keywords, :ref, pointer) end false -> keywords end end defp nested_source(%Schema{} = val), do: source(val) defp nested_source(%Ref{} = val), do: {:ref, val.pointer} defp nested_source(%MapSet{} = val), do: Map.keys(val.map) defp nested_source(%_{} = struct), do: struct defp nested_source(val) when is_map(val) do map_values(val, &nested_source/1) end defp nested_source(val) when is_list(val), do: Enum.map(val, &nested_source/1) defp nested_source(val), do: val @doc """ Converts the given data using the specified schema. Returns the converted data or an exception. """ @spec cast!(Xema.t(), term) :: term def cast!(xema, value, opts \\ []) do case cast(xema, value, opts) do {:ok, cast} -> cast {:error, exception} -> raise exception end end @doc """ Converts the given data using the specified schema. Returns `{:ok, result}` or `{:error, reason}`. The `result` is converted and validated with the schema. ## Examples: iex> schema = Xema.new({:integer, minimum: 1}) iex> Xema.cast(schema, "5") {:ok, 5} iex> Xema.cast(schema, "five") {:error, %Xema.CastError{ key: nil, path: [], to: :integer, value: "five" }} iex> Xema.cast(schema, "0") {:error, %Xema.ValidationError{ reason: %{minimum: 1, value: 0} }} ## Multiple types If for a value multiple types are defined the function used the result of the first successful conversion. ## Examples iex> schema = Xema.new([:integer, :string, nil]) iex> Xema.cast(schema, 5) {:ok, 5} iex> Xema.cast(schema, 5.5) {:ok, "5.5"} iex> Xema.cast(schema, "5") {:ok, 5} iex> Xema.cast(schema, "five") {:ok, "five"} iex> Xema.cast(schema, nil) {:ok, nil} iex> Xema.cast(schema, [5]) {:error, %Xema.CastError{path: [], to: [:integer, :string, nil], value: [5]} } ## Cast with `any_of`, `all_of`, and `one_of` Schemas in a combiner will be cast independently one by one in reverse order. ## Examples iex> schema = Xema.new(any_of: [ ...> [properties: %{a: :integer}], ...> [properties: %{a: :string}] ...> ]) iex> Xema.cast(schema, %{a: 5}) {:ok, %{a: 5}} iex> Xema.cast(schema, %{a: 5.5}) {:ok, %{a: "5.5"}} iex> Xema.cast(schema, %{a: "5"}) {:ok, %{a: 5}} iex> Xema.cast(schema, %{a: "five"}) {:ok, %{a: "five"}} iex> Xema.cast(schema, %{a: [5]}) {:error, %Xema.CastError{ error: nil, key: nil, message: nil, path: [], to: [ %{path: [:a], to: :integer, value: [5]}, %{path: [:a], to: :string, value: [5]} ], value: %{a: [5]} }} ## Options With the option `additional_properties: :delete` additional properties will be deleted on cast. Additional properties will be deleted in schemas with `additional_properties: false`. ## Examples iex> schema = Xema.new( ...> properties: %{ ...> a: [ ...> properties: %{ ...> foo: :integer ...> }, ...> additional_properties: false ...> ], ...> b: [ ...> properties: %{ ...> foo: :integer ...> } ...> ] ...> } ...> ) iex> iex> Xema.cast(schema, %{ ...> a: %{foo: "6", bar: "7"}, ...> b: %{foo: "6", bar: "7"}, ...> }, additional_properties: :delete) {:ok, %{ a: %{foo: 6}, b: %{foo: 6, bar: "7"} }} """ @spec cast(Xema.t(), term) :: {:ok, term} | {:error, term} def cast(xema, value, opts \\ []) def cast(%Xema{schema: schema}, value, opts) do cast(schema, value, opts) end def cast(%Schema{} = schema, value, opts) do with {:ok, result} <- do_cast(schema, value, opts, []), :ok <- validate(schema, result) do {:ok, result} else {:error, %ValidationError{}} = validation_error -> validation_error {:error, reason} -> {:error, CastError.exception( error: Map.get(reason, :error), key: Map.get(reason, :key), path: Map.get(reason, :path), required: Map.get(reason, :required), to: Map.get(reason, :to), value: Map.get(reason, :value) )} end end @spec do_cast(Schema.t(), term, keyword, list) :: {:ok, term} | {:error, term} defp do_cast(%Schema{} = schema, data, opts, path) when is_list(data) or is_tuple(data) or is_map(data) do with {:ok, values} <- cast_values(schema, data, opts, path), {:ok, cast} <- castable_cast(schema, values) do cast_combiner(schema, cast, opts, path) else {:error, reason} -> {:error, Map.put_new(reason, :path, Enum.reverse(path))} end end defp do_cast(%Schema{} = schema, value, opts, path) do case castable_cast(schema, value) do {:ok, cast} -> cast_combiner(schema, cast, opts, path) {:error, reason} -> {:error, Map.put(reason, :path, Enum.reverse(path))} end end defp do_cast(nil, value, _opts, _path), do: {:ok, value} @spec castable_cast(Schema.t(), term) :: {:ok, term} | {:error, term} defp castable_cast(%Schema{} = schema, value) do case do_castable_cast(schema, value) do {:ok, _} = ok -> ok {:error, _} = error -> error _ -> case schema do %{type: :struct, module: module} -> {:error, %{to: module, value: value}} %{type: type} -> {:error, %{to: type, value: value}} end end end defp do_castable_cast(%Schema{caster: caster}, value) when is_function(caster), do: caster.(value) defp do_castable_cast(%Schema{caster: {caster, fun}}, value) when is_atom(caster) and is_atom(fun), do: apply(caster, fun, [value]) defp do_castable_cast(%Schema{caster: {caster, fun, args}}, value) when is_atom(caster) and is_atom(fun), do: apply(caster, fun, [value | args]) defp do_castable_cast(%Schema{caster: caster}, value) when caster != nil and is_atom(caster), do: caster.cast(value) defp do_castable_cast(%Schema{caster: caster}, value) do caster.cast(value) end @spec cast_values(Schema.t(), term, keyword, list) :: term defp cast_values(schema, tuple, opts, path) when is_tuple(tuple) do with {:ok, values} <- cast_values(schema, Tuple.to_list(tuple), opts, path) do {:ok, List.to_tuple(values)} end end defp cast_values(schema, %module{} = struct, opts, path) do with {:ok, values} <- cast_values(schema, Map.from_struct(struct), opts, path) do {:ok, struct!(module, values)} end end defp cast_values(%Schema{} = schema, data, opts, path) when is_list(data) do case Keyword.keyword?(data) do true -> cast_values_keyword(schema, data, opts, path) false -> cast_values_list(schema, data, opts, path) end end defp cast_values(%Schema{type: :list, items: items} = schema, data, opts, path) when is_map(data) do case items do nil -> {:ok, data} %Schema{} = schema -> result = Enum.reduce_while(data, [], fn {index, item}, acc -> case do_cast(schema, item, opts, [index | path]) do {:ok, cast} -> {:cont, [{index, cast} | acc]} {:error, _} = error -> {:halt, error} end end) case result do {:error, _} = error -> error values -> {:ok, Map.new(values)} end [_ | _] -> case to_sorted_list(data) do :error -> {:error, %{to: :list, path: path, value: data}} {:ok, list} -> cast_values_map(schema, list, opts, path) end end end defp cast_values( %Schema{ type: type, keys: keys, properties: properties, pattern_properties: pattern_properties, additional_properties: additional_properties } = schema, data, opts, path ) when is_map(data) do key_type = if type in [:keyword, :struct], do: :atoms, else: keys with :ok <- check_required(schema, data, path) do data |> Enum.reduce_while([], fn {key, value}, acc -> schema = get_properties_schema( properties, pattern_properties, additional_properties, key_to(key_type, key) ) case do_cast(schema, value, opts, [key | path]) do {:ok, cast} -> {:cont, [{key, cast} | acc]} {:error, _} = error -> {:halt, error} end end) |> case do {:error, _} = error -> error values -> {:ok, values |> delete_additional_properties(schema, opts) |> Enum.into(%{}) |> add_defaults(schema, opts)} end end end @spec cast_values_map(Schema.t(), list(), keyword(), list()) :: {:error, term} | {:ok, map} defp cast_values_map(%Schema{type: :list, items: items} = schema, list, opts, path) when is_list(items) do additional_items = Map.get(schema, :additional_items) result = list |> Enum.with_index() |> Enum.reduce_while([], fn {{key, item}, index}, acc -> schema = Enum.at(items, index, additional_items) case do_cast(schema, item, opts, [key | path]) do {:ok, cast} -> {:cont, [{index, cast} | acc]} error -> {:halt, error} end end) case result do {:error, _} = error -> error values -> {:ok, Map.new(values)} end end @spec cast_values_keyword(Schema.t(), term, keyword, list) :: term defp cast_values_keyword( %Schema{ keys: keys, properties: properties, pattern_properties: pattern_properties, additional_properties: additional_properties } = schema, data, opts, path ) when is_list(data) do with :ok <- check_required(schema, data, path) do data |> Enum.reduce_while([], fn {key, value}, acc -> schema = get_properties_schema( properties, pattern_properties, additional_properties, key_to(keys, key) ) case do_cast(schema, value, opts, [key | path]) do {:ok, cast} -> {:cont, [{key, cast} | acc]} {:error, _} = error -> {:halt, error} end end) |> case do {:error, _} = error -> error values -> {:ok, values |> delete_additional_properties(schema, opts) |> add_defaults(schema, opts) |> Enum.reverse()} end end end @spec cast_values_list(Schema.t(), term, keyword, list) :: term defp cast_values_list(%Schema{items: items} = schema, data, opts, path) when is_list(data) do case items do nil -> {:ok, data} %Schema{} = schema -> data |> Enum.with_index() |> Enum.reduce_while([], fn {item, index}, acc -> case do_cast(schema, item, opts, [index | path]) do {:ok, cast} -> {:cont, [cast | acc]} {:error, _} = error -> {:halt, error} end end) |> case do {:error, _} = error -> error values -> {:ok, Enum.reverse(values)} end items -> additional_items = Map.get(schema, :additional_items) data |> Enum.with_index() |> Enum.reduce_while([], fn {item, index}, acc -> schema = Enum.at(items, index, additional_items) case do_cast(schema, item, opts, [index | path]) do {:ok, cast} -> {:cont, [cast | acc]} {:error, _} = error -> {:halt, error} end end) |> case do {:error, _} = error -> error values -> {:ok, Enum.reverse(values)} end end end @spec check_required(Schema.t(), term, list) :: :ok | {:error, [String.t()] | [:atom]} defp check_required(%Schema{required: nil}, _data, _path), do: :ok defp check_required( %Schema{type: type, module: module, keys: keys, required: required}, data, path ) do with {:error, keys} <- do_check_required(required, data, keys || :atoms) do to = if module == nil, do: type, else: module {:error, %{to: to, value: data, required: keys, path: path}} end end defp do_check_required(required, data, keys_type) do keys = data |> keys() |> cast_keys(keys_type) |> MapSet.new() required |> MapSet.difference(keys) |> MapSet.to_list() |> case do [] -> :ok keys -> {:error, keys} end end @spec cast_keys([String.t()] | [atom], :strings | :atoms) :: [String.t()] | [atom] defp cast_keys(keys, keys_type) do Enum.map(keys, fn key -> case Xema.Castable.Helper.cast_key(key, keys_type) do :error -> key {:ok, cast} -> cast end end) end # additional_properties false will be ignored defp get_properties_schema(properties, pattern_properties, false, key), do: get_properties_schema(properties, pattern_properties, nil, key) defp get_properties_schema(nil, nil, additional_properties, _key), do: additional_properties defp get_properties_schema(properties, nil, additional_properties, key), do: Map.get(properties, key, additional_properties) defp get_properties_schema(nil, pattern_properties, additional_properties, key) do Enum.find_value(pattern_properties, additional_properties, fn {regex, schema} -> with true <- Regex.match?(regex, to_string(key)), do: schema end) end defp get_properties_schema(properties, pattern_properties, additional_properties, key) do get_properties_schema(properties, nil, additional_properties, key) || get_properties_schema(nil, pattern_properties, additional_properties, key) end defp delete_additional_properties(data, %Schema{additional_properties: false} = schema, opts) do case Keyword.get(opts, :additional_properties) do :delete -> keys = Map.keys(Map.get(schema, :properties) || %{}) patterns = Map.keys(Map.get(schema, :pattern_properties) || %{}) Enum.filter(data, fn {key, _} -> key?(key, keys, patterns) end) _ -> data end end defp delete_additional_properties(data, _schema, _opts), do: data defp add_defaults(data, schema, _opts) do schema |> get_defaults() |> merge_defaults(data) end defp get_defaults(%Schema{properties: nil}), do: %{} defp get_defaults(%Schema{properties: properties}) do Enum.reduce(properties, %{}, fn {_key, %Schema{default: nil}}, acc -> acc {key, %Schema{default: default}}, acc -> Map.put(acc, key, get_default(default)) end) end defp get_default(fun) when is_function(fun), do: fun.() defp get_default({mod, fun}) when is_atom(mod) and is_atom(fun), do: apply(mod, fun, []) defp get_default({mod, fun, arg}) when is_atom(mod) and is_atom(fun) and is_list(arg), do: apply(mod, fun, arg) defp get_default(value), do: value defp merge_defaults(defaults, data) when defaults == %{}, do: data defp merge_defaults(defaults, data) when is_map(data) do Enum.reduce(defaults, data, fn {key, value}, acc -> case {Map.get(acc, key), Map.get(acc, to_string(key))} do {nil, nil} -> Map.put(acc, key, value) _ -> acc end end) end defp merge_defaults(defaults, data) when is_list(data) do Enum.reduce(defaults, data, fn {key, value}, acc -> case Keyword.get(data, key) do nil -> Keyword.put(acc, key, value) _ -> acc end end) end defp key?(key, keys, []), do: key in keys defp key?(key, [], patterns), do: Enum.find_value(patterns, false, fn regex -> Regex.match?(regex, to_string(key)) end) defp key?(key, keys, patterns), do: key?(key, keys, []) && key?(key, [], patterns) defp cast_combiner(schema, data, opts, path) do schema |> get_combiner() |> do_cast_combiner(data, opts, path) end defp do_cast_combiner(nil, data, _opts, _path), do: {:ok, data} defp do_cast_combiner({type, schemas}, data, opts, path) when type in [:any, :one] do schemas |> Enum.reverse() |> Enum.reduce({nil, []}, fn schema, {result, errors} -> case cast(schema, data, opts) do {:ok, cast} -> {cast, errors} {:error, %ValidationError{} = validation_error} -> error = %{to: schema.type, module: schema.module, value: data, reason: validation_error} {result, [error | errors]} {:error, %CastError{path: path, to: to, value: value}} -> error = %{path: path, to: to, value: value} {result, [error | errors]} end end) |> case do {data, errors} when length(errors) < length(schemas) -> {:ok, data} {_, errors} -> {:error, %{ to: errors, value: data, path: Enum.reverse(path) }} end end defp do_cast_combiner({:all, schemas}, data, opts, path) do schemas |> Enum.reverse() |> Enum.reduce({data, []}, fn schema, {data, errors} -> case do_cast(schema, data, opts, []) do {:ok, cast} -> {cast, errors} {:error, error} -> {data, [error | errors]} end end) |> case do {data, errors} when length(errors) < length(schemas) -> {:ok, data} {_, errors} -> {:error, %{ to: errors, value: data, path: Enum.reverse(path) }} end end defp get_combiner(%Schema{} = schema) do cond do schema.any_of != nil -> {:any, schema.any_of} schema.all_of != nil -> {:all, schema.all_of} schema.one_of != nil -> {:one, schema.one_of} true -> nil end end defp key_to(:atoms, key) when is_binary(key), do: to_existing_atom(key) defp key_to(:strings, key) when is_atom(key), do: to_string(key) defp key_to(_, key) when is_binary(key) or is_atom(key), do: key defp keys(data) when is_map(data), do: Map.keys(data) defp keys(data) when is_list(data), do: Keyword.keys(data) @doc false def ensure_behaviour!(name) when is_atom(name) do case behaviour?(name) do true -> name false -> raise SchemaError, "Module #{inspect(name)} is not a Xema behaviour" end end @doc false def behaviour?(name) when is_atom(name) do module = case Code.ensure_compiled(name) do {:module, module} -> module {:error, _} -> raise SchemaError, "Module #{inspect(name)} not compiled" end function_exported?(module, :xema, 0) end end