Current section

Files

Jump to
jsv lib jsv error_formatter.ex
Raw

lib/jsv/error_formatter.ex

defmodule JSV.ErrorFormatter do
alias JSV.Key
alias JSV.Ref
alias JSV.ValidationError
alias JSV.Validator
alias JSV.Validator.Error
@moduledoc """
Error formatting loosely following the guidelines described at
https://json-schema.org/blog/posts/fixing-json-schema-output
Errors are grouped by similar instance location (the bit of data that was
invalidated) and schema location (the part of the schema that invalidated it).
"""
@type annotation :: %{
required(:valid) => boolean,
required(:instanceLocation) => binary,
required(:evaluationPath) => binary,
required(:schemaLocation) => binary,
optional(:errors) => [collected_error],
optional(:detail) => [annotation]
}
@type collected_error :: %{
required(:kind) => atom,
required(:message) => String.t(),
optional(:detail) => [annotation]
}
@type raw_path :: [raw_path] | binary | integer | atom
@doc """
Returns a JSON-able version of the errors contained in the ValidationError.
This is generatlly useful to generate HTTP API responses or message broker
responses.
"""
@spec normalize_error(ValidationError.t()) :: map()
def normalize_error(%ValidationError{} = e) do
normalize_error(e, [])
end
# TODO maybe remove opts as they are no more used. We may need them if we want
# to use custom JSON encoding for errors.
defp normalize_error(e, opts) do
%{valid: false, details: normalize_errors(e.errors, opts)}
end
defp normalize_errors(errors, opts) do
errors
|> Enum.group_by(fn
%Error{data_path: dp, eval_path: ep, schema_path: sp} -> {dp, ep, sp}
%{valid: _, instanceLocation: _, evaluationPath: _, schemaLocation: _} = already_normalized -> already_normalized
end)
|> Enum.map(fn
{{data_path, eval_path, schema_path}, errors} -> error_annot(data_path, eval_path, schema_path, errors, opts)
{already_normalized, [already_normalized]} -> already_normalized
end)
|> Enum.sort_by(& &1.schemaLocation)
end
defp error_annot(rev_data_path, rev_eval_path, rev_schema_path, errors, opts) do
errors_fmt = Enum.map(errors, &build_error(&1, opts))
%{
valid: false,
errors: errors_fmt,
instanceLocation: format_data_path(rev_data_path),
evaluationPath: format_eval_path(rev_eval_path),
schemaLocation: format_schema_path(rev_schema_path)
}
end
defp build_error(error, opts) do
%Error{kind: kind, data: data, formatter: formatter, args: args} =
error
args_map = Map.new(args)
case formatter.format_error(kind, args_map, data) do
message when is_binary(message) ->
%{message: message, kind: kind}
{message, sub_errors} when is_binary(message) ->
%{message: message, kind: kind, details: normalize_errors(sub_errors, opts)}
{new_kind, message, sub_errors} when is_binary(message) ->
%{message: message, kind: new_kind, details: normalize_errors(sub_errors, opts)}
end
end
@doc """
Returns an output unit with `valid: true` for the given
`#{inspect(Validator)}`. This can be substitued to an Error struct in the
nested details of an error. Mostly used to show multiple validated schemas
with `:oneOf`.
"""
@spec valid_annot(Validator.validator(), Validator.context()) :: annotation
def valid_annot(subschema, vctx) do
evaluation_path = format_eval_path(vctx.eval_path)
%{
valid: true,
instanceLocation: format_data_path(vctx.data_path),
evaluationPath: evaluation_path,
schemaLocation: format_schema_path(subschema)
}
end
@spec format_data_path(raw_path) :: String.t()
def format_data_path([]) do
"#"
end
def format_data_path(rev_data_path) do
iodata =
List.foldl(rev_data_path, [], fn
index, acc when is_integer(index) -> [?/, Integer.to_string(index) | acc]
key, acc -> [?/, Ref.escape_json_pointer(key) | acc]
end)
IO.iodata_to_binary(["#" | iodata])
end
defp format_eval_path([]) do
"#"
end
defp format_eval_path(rev_eval_path) do
# map to string but also reverse the path
iodata = List.foldl(rev_eval_path, [], fn segment, acc -> [?/, format_eval_path_segment(segment) | acc] end)
IO.iodata_to_binary(["#" | iodata])
end
# defp format_schema_path([]) do
# "#"
# end
defp format_schema_path(rev_path) when is_list(rev_path) do
format_schema_path(rev_path, [])
end
defp format_schema_path({:alias_of, key}) do
[Key.to_iodata(key)]
end
defp format_schema_path(%{schema_path: sp}) do
format_schema_path(sp)
end
defp format_schema_path([segment, next | rev_path], acc) do
format_schema_path([next | rev_path], [?/, format_eval_path_segment(segment) | acc])
end
defp format_schema_path([last], acc) do
final_path =
case last do
:root -> IO.iodata_to_binary(["#" | acc])
id when is_binary(id) -> [id, "#" | acc]
end
IO.iodata_to_binary(final_path)
end
defp format_eval_path_segment(item) do
case item do
atom when is_atom(atom) -> Atom.to_string(atom)
key when is_binary(key) -> key
index when is_integer(index) -> Integer.to_string(index)
# {tag, atom} when is_atom(atom) -> [Atom.to_string(tag), ?/, Atom.to_string(atom)]
{tag, key} when is_binary(key) -> [Atom.to_string(tag), ?/, Ref.escape_json_pointer(key)]
{tag, index} when is_integer(index) -> [Atom.to_string(tag), ?/, Integer.to_string(index)]
{_, :"$ref", _} -> "$ref"
{_, :"$dynamicRef", _} -> "$dynamicRef"
other -> raise "invalid eval path segment: #{inspect(other)}"
end
end
end