Packages

An Elixir implementation of JSON Data Definition Format.

Current section

Files

Jump to
jddf lib validator.ex
Raw

lib/validator.ex

defmodule JDDF.Validator do
@moduledoc """
Validates JSON data against instances of `JDDF.Schema`.
Validators have two properties to control how validation behaves:
* `max_depth` controls how "deeply" references in JDDF schemas (using the
`ref` JDDF keyword) should be followed. If `max_depth` is exceeded, then
`validate/3` or `validate!/3` will throw
`JDDF.Validator.MaxDepthExceededError`.
For example, setting `max_depth` to `5` means that a 5-deep chain of `ref`s
would trigger `JDDF.Validator.MaxDepthExceededError`.
By default, `max_depth` is `0`. A `max_depth` of `0` disables any maximum
depth; `JDDF.Validator.MaxDepthExceededError` will never be thrown. In this
case, validating against a circularly-defined schema may exhaust all memory.
* `max_errors` controls the maximum number of errors to return. If
`max_errors` is set to `N`, then `validate/3` and `validate!/3` will return
`N` `JDDF.Validator.ValidationError`s or fewer. You can use `max_errors` to
optimize JDDF validation.
For example, if you only care about whether there exist *any* validation
errors for an input, you can set `max_errors` to `1`.
By default, `max_errors` is `0`. A `max_errors` of `0` disables any maximum
number of errors; `validate/3` and `validate!/3` will return all errors.
The examples below demonstrate `max_depth` and `max_errors`. See also the docs
for `JDDF` for more introductory examples.
## Examples
Here's an example of `max_depth` causing an error, rather than letting a
circularly-defined schema cause an infinite loop:
iex> schema = JDDF.Schema.from_json!(%{
...> "definitions" => %{
...> "loop" => %{ "ref" => "loop" },
...> },
...> "ref" => "loop",
...> })
iex> validator = %JDDF.Validator{max_depth: 32}
%JDDF.Validator{max_depth: 32, max_errors: 0}
iex> JDDF.Validator.validate!(validator, schema, nil)
** (JDDF.Validator.MaxDepthExceededError) maximum depth exceeded during validation
Here's an example of `max_errors` limiting the return value to just 1 value:
iex> schema = JDDF.Schema.from_json!(%{
...> "elements" => %{ "type" => "string" }
...> })
iex> validator = %JDDF.Validator{max_errors: 1}
%JDDF.Validator{max_depth: 0, max_errors: 1}
iex> length(JDDF.Validator.validate!(validator, schema, [nil, nil, nil, nil, nil]))
1
Instead of returning five errors (one for each element of `[nil, nil, nil,
nil, nil]`), just one error comes back. Internally, `validate/3` and
`validate!/3` optimizes for `max_errors`, and stops validation as soon as
`max_errors` is reached.
"""
defmodule ValidationError do
@moduledoc """
Represents a single JDDF validation error.
* `instance_path` is the path to the part of the input that was rejected.
* `schema_path` is the path the part of the schema that rejected the input.
The precise values that go into `instance_path` and `schema_path` is
standardized. The JDDF specification formalizes what exactly needs to go
into these fields. `JDDF.Validator` confirms to the JDDF specification.
"""
defstruct [:instance_path, :schema_path]
@type t :: %__MODULE__{instance_path: list(String.t()), schema_path: list(String.t())}
end
defmodule MaxDepthExceededError do
@moduledoc """
Indicates that the validator's max depth was exceeded during validation.
This error is raised when a `JDDF.Validator`'s `max_depth` is exceeded while
doing validation, through either `JDDF.Validator.validate/3` or
`JDDF.Validator.validate!/3`. See docs for `JDDF.Validator` for further
explanation of `max_depth`, and why this error may be considered desirable.
"""
defexception message: "maximum depth exceeded during validation"
@type t :: %__MODULE__{message: String.t()}
end
defmodule VM do
@moduledoc false
defmodule TooManyErrorsError do
@moduledoc false
defexception [:message, :errors]
end
defstruct [:max_depth, :max_errors, :root, :instance_tokens, :schema_tokens, :errors]
def validate!(vm, schema, instance, parent_tag \\ nil) do
case schema.form do
{:empty} ->
vm
{:ref, ref} ->
if length(vm.schema_tokens) === vm.max_depth do
raise JDDF.Validator.MaxDepthExceededError
end
push_schema(vm, [ref, "definitions"])
|> validate!(vm.root.definitions[ref], instance)
|> pop_schema
{:type, type} ->
vm = push_schema_token(vm, "type")
vm =
case type do
:boolean ->
if !is_boolean(instance) do
push_error!(vm)
else
vm
end
:float32 ->
if !is_float(instance) && !is_integer(instance) do
push_error!(vm)
else
vm
end
:float64 ->
if !is_float(instance) && !is_integer(instance) do
push_error!(vm)
else
vm
end
:int8 ->
validate_int!(vm, instance, -128, 127)
:uint8 ->
validate_int!(vm, instance, 0, 255)
:int16 ->
validate_int!(vm, instance, -32768, 32767)
:uint16 ->
validate_int!(vm, instance, 0, 65535)
:int32 ->
validate_int!(vm, instance, -2_147_483_648, 2_147_483_647)
:uint32 ->
validate_int!(vm, instance, 0, 4_294_967_295)
:string ->
if !is_binary(instance) do
push_error!(vm)
else
vm
end
:timestamp ->
if !is_binary(instance) || elem(DateTime.from_iso8601(instance), 0) !== :ok do
push_error!(vm)
else
vm
end
end
pop_schema_token(vm)
{:enum, enum} ->
vm = push_schema_token(vm, "enum")
vm =
if MapSet.member?(enum, instance) do
vm
else
push_error!(vm)
end
pop_schema_token(vm)
{:elements, schema} ->
vm = push_schema_token(vm, "elements")
if is_list(instance) do
instance
|> Enum.with_index()
|> Enum.reduce(vm, fn {elem, index}, vm ->
vm
|> push_instance_token(Integer.to_string(index))
|> validate!(schema, elem)
|> pop_instance_token
end)
else
vm |> push_error!
end
|> pop_schema_token
{:properties, required, optional, additional} ->
if is_map(instance) do
vm =
if required !== nil do
vm = push_schema_token(vm, "properties")
required
|> Enum.reduce(vm, fn {key, sub_schema}, vm ->
vm = push_schema_token(vm, key)
vm =
if Map.has_key?(instance, key) do
push_instance_token(vm, key)
|> validate!(sub_schema, instance[key])
|> pop_instance_token
else
push_error!(vm)
end
pop_schema_token(vm)
end)
|> pop_schema_token
else
vm
end
vm =
if optional !== nil do
vm = push_schema_token(vm, "optionalProperties")
optional
|> Enum.reduce(vm, fn {key, sub_schema}, vm ->
vm = push_schema_token(vm, key)
vm =
if Map.has_key?(instance, key) do
push_instance_token(vm, key)
|> validate!(sub_schema, instance[key])
|> pop_instance_token
else
vm
end
pop_schema_token(vm)
end)
|> pop_schema_token
else
vm
end
if additional do
vm
else
Map.keys(instance)
|> Enum.reduce(vm, fn key, vm ->
if !Map.has_key?(required || %{}, key) && !Map.has_key?(optional || %{}, key) &&
key !== parent_tag do
push_instance_token(vm, key) |> push_error! |> pop_instance_token
else
vm
end
end)
end
else
if required !== nil do
push_schema_token(vm, "properties")
else
push_schema_token(vm, "optionalProperties")
end
|> push_error!
|> pop_schema_token
end
{:values, schema} ->
vm = push_schema_token(vm, "values")
if is_map(instance) do
instance
|> Enum.reduce(vm, fn {key, value}, vm ->
vm
|> push_instance_token(key)
|> validate!(schema, value)
|> pop_instance_token
end)
else
vm |> push_error!
end
|> pop_schema_token
{:discriminator, tag, mapping} ->
vm = push_schema_token(vm, "discriminator")
if is_map(instance) do
if Map.has_key?(instance, tag) do
if is_binary(instance[tag]) do
if Map.has_key?(mapping, instance[tag]) do
vm
|> push_schema_token("mapping")
|> push_schema_token(instance[tag])
|> validate!(mapping[instance[tag]], instance, tag)
|> pop_schema_token
|> pop_schema_token
else
vm
|> push_schema_token("mapping")
|> push_instance_token(tag)
|> push_error!
|> pop_instance_token
|> pop_schema_token
end
else
vm
|> push_schema_token("tag")
|> push_instance_token(tag)
|> push_error!
|> pop_instance_token
|> pop_schema_token
end
else
vm
|> push_schema_token("tag")
|> push_error!
|> pop_schema_token
end
else
push_error!(vm)
end
|> pop_schema_token
end
end
defp validate_int!(vm, instance, min, max) do
if is_float(instance) || is_integer(instance) do
if round(instance) !== instance || instance < min || instance > max do
push_error!(vm)
else
vm
end
else
push_error!(vm)
end
end
defp push_schema(vm, tokens) do
%{vm | schema_tokens: [tokens | vm.schema_tokens]}
end
defp pop_schema(vm) do
%{vm | schema_tokens: tl(vm.schema_tokens)}
end
defp push_schema_token(vm, token) do
[tokens | rest] = vm.schema_tokens
%{vm | schema_tokens: [[token | tokens] | rest]}
end
defp pop_schema_token(vm) do
[[_ | tokens] | rest] = vm.schema_tokens
%{vm | schema_tokens: [tokens | rest]}
end
defp push_instance_token(vm, token) do
%{vm | instance_tokens: [token | vm.instance_tokens]}
end
defp pop_instance_token(vm) do
%{vm | instance_tokens: vm.instance_tokens |> tl}
end
defp push_error!(vm) do
errors = [
%JDDF.Validator.ValidationError{
instance_path: Enum.reverse(vm.instance_tokens),
schema_path: Enum.reverse(vm.schema_tokens |> hd)
}
| vm.errors
]
if length(errors) === vm.max_errors do
raise TooManyErrorsError, message: "too many errors", errors: errors
end
%{vm | errors: errors}
end
end
defstruct max_depth: 0, max_errors: 0
@type t :: %__MODULE__{max_depth: integer, max_errors: integer}
@doc """
Validate a `JDDF.Schema` against a JSON input (an "instance").
`instance` should be an Elixir representation of JSON data. You should first
parse the JSON before passing it into `validate/3`. You can construct this
data manually, or parse it using:
* [`Jason.decode!/2`](https://hexdocs.pm/jason/Jason.html#decode!/2), or
* [`Poison.decode!/2`](https://github.com/devinus/poison#usage)
Most JSON implementations for Elixir are compatible with this function.
See `JDDF.Validator` docs for how to control certain aspects of this function.
This function implements the formal specification of JSON Data Definition
Format validation.
iex> JDDF.Validator.validate(
...> %JDDF.Validator{},
...> JDDF.Schema.from_json!(%{"type" => "boolean"}),
...> nil
...> )
{:ok, [%JDDF.Validator.ValidationError{instance_path: [], schema_path: ["type"]}]}
iex> schema = JDDF.Schema.from_json!(%{
...> "definitions" => %{
...> "loop" => %{ "ref" => "loop" },
...> },
...> "ref" => "loop",
...> })
iex> validator = %JDDF.Validator{max_depth: 32}
%JDDF.Validator{max_depth: 32, max_errors: 0}
iex> JDDF.Validator.validate(validator, schema, nil)
{:error, %JDDF.Validator.MaxDepthExceededError{message: "maximum depth exceeded during validation"}}
"""
@spec validate(validator :: __MODULE__.t(), schema :: JDDF.Schema.t(), instance :: any()) ::
{:ok, [ValidationError.t()]} | {:error, MaxDepthExceededError.t()}
def validate(validator, schema, instance) do
{:ok, validate!(validator, schema, instance)}
rescue
e in MaxDepthExceededError -> {:error, e}
end
@doc """
Validate a `JDDF.Schema` against a JSON input (an "instance").
Similar to `validate/3`, except it will unwrap the result and will raise in
case of errors.
iex> JDDF.Validator.validate!(
...> %JDDF.Validator{},
...> JDDF.Schema.from_json!(%{"type" => "boolean"}),
...> nil
...> )
[%JDDF.Validator.ValidationError{instance_path: [], schema_path: ["type"]}]
iex> schema = JDDF.Schema.from_json!(%{
...> "definitions" => %{
...> "loop" => %{ "ref" => "loop" },
...> },
...> "ref" => "loop",
...> })
iex> validator = %JDDF.Validator{max_depth: 32}
%JDDF.Validator{max_depth: 32, max_errors: 0}
iex> JDDF.Validator.validate!(validator, schema, nil)
** (JDDF.Validator.MaxDepthExceededError) maximum depth exceeded during validation
"""
@spec validate!(validator :: __MODULE__.t(), schema :: JDDF.Schema.t(), instance :: any()) :: [
ValidationError.t()
]
def validate!(validator, schema, instance) do
vm = %VM{
max_depth: validator.max_depth,
max_errors: validator.max_errors,
root: schema,
instance_tokens: [],
schema_tokens: [[]],
errors: []
}
try do
vm = VM.validate!(vm, schema, instance)
vm.errors
rescue
e in VM.TooManyErrorsError -> e.errors
end
end
end