diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59eb704..0b0c396 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -# Created with GitHubActions version 0.2.22 +# Created with GitHubActions version 0.2.23 name: CI env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -14,27 +14,43 @@ jobs: elixir: - '1.13.4' - '1.14.5' - - '1.15.7' - - '1.16.2' + - '1.15.8' + - '1.16.3' + - '1.17.1' otp: - '22.3' - '23.3' - '24.3' - '25.3' - '26.2' + - '27.0' exclude: - elixir: '1.13.4' otp: '26.2' + - elixir: '1.13.4' + otp: '27.0' + - elixir: '1.14.5' + otp: '22.3' - elixir: '1.14.5' + otp: '27.0' + - elixir: '1.15.8' otp: '22.3' - - elixir: '1.15.7' + - elixir: '1.15.8' + otp: '23.3' + - elixir: '1.15.8' + otp: '27.0' + - elixir: '1.16.3' otp: '22.3' - - elixir: '1.15.7' + - elixir: '1.16.3' otp: '23.3' - - elixir: '1.16.2' + - elixir: '1.16.3' + otp: '27.0' + - elixir: '1.17.1' otp: '22.3' - - elixir: '1.16.2' + - elixir: '1.17.1' otp: '23.3' + - elixir: '1.17.1' + otp: '24.3' steps: - name: Checkout uses: actions/checkout@v4 @@ -58,7 +74,7 @@ jobs: with: path: test/support/plts key: test/support/plts-${{ runner.os }}-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} - if: ${{ contains(matrix.elixir, '1.16.2') && contains(matrix.otp, '26.2') }} + if: ${{ contains(matrix.elixir, '1.17.1') && contains(matrix.otp, '27.0') }} - name: Get dependencies run: mix deps.get - name: Compile dependencies @@ -66,20 +82,20 @@ jobs: - name: Compile project run: MIX_ENV=test mix compile --warnings-as-errors - name: Check unused dependencies - if: ${{ contains(matrix.elixir, '1.16.2') && contains(matrix.otp, '26.2') }} + if: ${{ contains(matrix.elixir, '1.17.1') && contains(matrix.otp, '27.0') }} run: mix deps.unlock --check-unused - name: Check code format - if: ${{ contains(matrix.elixir, '1.16.2') && contains(matrix.otp, '26.2') }} + if: ${{ contains(matrix.elixir, '1.17.1') && contains(matrix.otp, '27.0') }} run: mix format --check-formatted - name: Lint code - if: ${{ contains(matrix.elixir, '1.16.2') && contains(matrix.otp, '26.2') }} + if: ${{ contains(matrix.elixir, '1.17.1') && contains(matrix.otp, '27.0') }} run: mix credo --strict - name: Run tests run: mix test - if: ${{ !(contains(matrix.elixir, '1.16.2') && contains(matrix.otp, '26.2')) }} + if: ${{ !(contains(matrix.elixir, '1.17.1') && contains(matrix.otp, '27.0')) }} - name: Run tests with coverage run: mix coveralls.github - if: ${{ contains(matrix.elixir, '1.16.2') && contains(matrix.otp, '26.2') }} + if: ${{ contains(matrix.elixir, '1.17.1') && contains(matrix.otp, '27.0') }} - name: Static code analysis run: mix dialyzer --format github --force-check - if: ${{ contains(matrix.elixir, '1.16.2') && contains(matrix.otp, '26.2') }} + if: ${{ contains(matrix.elixir, '1.17.1') && contains(matrix.otp, '27.0') }} diff --git a/lib/json_xema/validation_error.ex b/lib/json_xema/validation_error.ex index 659b33a..7a489c6 100644 --- a/lib/json_xema/validation_error.ex +++ b/lib/json_xema/validation_error.ex @@ -10,13 +10,16 @@ defmodule JsonXema.ValidationError do ~s|Expected "string", got 6.| """ + alias JsonXema.ValidationError + alias JsonXema.ValidationError.Formatter + @type path :: [atom | integer | String.t()] @type opts :: [] | [path: path] defexception [:message, :reason] @impl true - def message(%{message: nil} = exception), do: format_error(exception.reason) + def message(%{message: nil} = exception), do: format_error(exception) def message(%{message: message}), do: message @@ -37,17 +40,16 @@ defmodule JsonXema.ValidationError do ...> |> JsonXema.ValidationError.format_error() ~s|Expected "integer", got 1.1.| """ - @spec format_error({:error, map} | map) :: String.t() - def format_error({:error, %__MODULE__{reason: reason}}), do: format_error(reason) + @spec format_error({:error, Exception.t()} | Exception.t(), opts :: keyword()) :: String.t() + def format_error(error, inspect_opts \\ []) - def format_error({:error, error}), do: format_error(error) + def format_error({:error, %ValidationError{} = error}, inspect_opts) do + format_error(error, inspect_opts) + end - def format_error(error), - do: - error - |> travers_errors([], &format_error/3) - |> Enum.reverse() - |> Enum.join("\n") + def format_error(%ValidationError{} = error, inspect_opts) do + Formatter.format(error, inspect_opts) + end @doc """ Traverse the error tree and invokes the given function. @@ -90,26 +92,29 @@ defmodule JsonXema.ValidationError do when acc: any def travers_errors(error, acc, fun, opts \\ []) - def travers_errors({:error, %__MODULE__{reason: reason}}, acc, fun, opts), - do: travers_errors(reason, acc, fun, opts) + def travers_errors({:error, %__MODULE__{reason: reason}}, acc, fun, opts) do + travers_errors(reason, acc, fun, opts) + end - def travers_errors(error, acc, fun, []), do: travers_errors(error, acc, fun, path: []) + def travers_errors(error, acc, fun, []) do + travers_errors(error, acc, fun, path: []) + end - def travers_errors(%{properties: properties} = error, acc, fun, opts), - do: - Enum.reduce( - properties, - fun.(error, opts[:path], acc), - fn {key, value}, acc -> travers_errors(value, acc, fun, path: opts[:path] ++ [key]) end - ) + def travers_errors(%{properties: properties} = error, acc, fun, opts) do + Enum.reduce( + properties, + fun.(error, opts[:path], acc), + fn {key, value}, acc -> travers_errors(value, acc, fun, path: opts[:path] ++ [key]) end + ) + end - def travers_errors(%{items: items} = error, acc, fun, opts), - do: - Enum.reduce( - items, - fun.(error, opts[:path], acc), - fn {key, value}, acc -> travers_errors(value, acc, fun, path: opts[:path] ++ [key]) end - ) + def travers_errors(%{items: items} = error, acc, fun, opts) do + Enum.reduce( + items, + fun.(error, opts[:path], acc), + fn {key, value}, acc -> travers_errors(value, acc, fun, path: opts[:path] ++ [key]) end + ) + end def travers_errors(errors, acc, fun, opts) when is_list(errors) do errors @@ -120,287 +125,4 @@ defmodule JsonXema.ValidationError do end def travers_errors(error, acc, fun, opts), do: fun.(error, opts[:path], acc) - - defp format_error(%{exclusiveMinimum: minimum, value: value}, path, acc) - when minimum == value do - msg = "Value #{inspect(minimum)} equals exclusive minimum value of #{inspect(minimum)}" - [msg <> at_path(path) | acc] - end - - defp format_error(%{minimum: minimum, exclusiveMinimum: true, value: value}, path, acc) - when minimum == value do - msg = "Value #{inspect(value)} equals exclusive minimum value of #{inspect(minimum)}" - [msg <> at_path(path) | acc] - end - - defp format_error(%{minimum: minimum, exclusiveMinimum: true, value: value}, path, acc) do - msg = "Value #{inspect(value)} is less than minimum value of #{inspect(minimum)}" - [msg <> at_path(path) | acc] - end - - defp format_error(%{exclusiveMinimum: minimum, value: value}, path, acc) do - msg = "Value #{inspect(value)} is less than minimum value of #{inspect(minimum)}" - [msg <> at_path(path) | acc] - end - - defp format_error(%{minimum: minimum, value: value}, path, acc) do - msg = "Value #{inspect(value)} is less than minimum value of #{inspect(minimum)}" - [msg <> at_path(path) | acc] - end - - defp format_error(%{exclusiveMaximum: maximum, value: value}, path, acc) - when maximum == value do - msg = "Value #{inspect(maximum)} equals exclusive maximum value of #{inspect(maximum)}" - [msg <> at_path(path) | acc] - end - - defp format_error(%{maximum: maximum, exclusiveMaximum: true, value: value}, path, acc) - when maximum == value do - msg = "Value #{inspect(value)} equals exclusive maximum value of #{inspect(maximum)}" - [msg <> at_path(path) | acc] - end - - defp format_error(%{maximum: maximum, exclusiveMaximum: true, value: value}, path, acc) do - msg = "Value #{inspect(value)} exceeds maximum value of #{inspect(maximum)}" - [msg <> at_path(path) | acc] - end - - defp format_error(%{exclusiveMaximum: maximum, value: value}, path, acc) do - msg = "Value #{inspect(value)} exceeds maximum value of #{inspect(maximum)}" - [msg <> at_path(path) | acc] - end - - defp format_error(%{maximum: maximum, value: value}, path, acc) do - msg = "Value #{inspect(value)} exceeds maximum value of #{inspect(maximum)}" - [msg <> at_path(path) | acc] - end - - defp format_error(%{maxLength: max, value: value}, path, acc) do - msg = "Expected maximum length of #{inspect(max)}, got #{inspect(value)}" - [msg <> at_path(path) | acc] - end - - defp format_error(%{minLength: min, value: value}, path, acc) do - msg = "Expected minimum length of #{inspect(min)}, got #{inspect(value)}" - [msg <> at_path(path) | acc] - end - - defp format_error(%{multipleOf: multiple_of, value: value}, path, acc) do - msg = "Value #{inspect(value)} is not a multiple of #{inspect(multiple_of)}" - [msg <> at_path(path) | acc] - end - - defp format_error(%{enum: _enum, value: value}, path, acc) do - msg = "Value #{inspect(value)} is not defined in enum" - [msg <> at_path(path) | acc] - end - - defp format_error(%{minProperties: min, value: value}, path, acc) do - msg = "Expected at least #{inspect(min)} properties, got #{inspect(value)}" - [msg <> at_path(path) | acc] - end - - defp format_error(%{maxProperties: max, value: value}, path, acc) do - msg = "Expected at most #{inspect(max)} properties, got #{inspect(value)}" - [msg <> at_path(path) | acc] - end - - defp format_error(%{additionalProperties: false}, path, acc) do - msg = "Expected only defined properties, got key #{inspect(path)}." - [msg | acc] - end - - defp format_error(%{additionalItems: false}, path, acc) do - msg = "Unexpected additional item" - [msg <> at_path(path) | acc] - end - - defp format_error(%{format: format, value: value}, path, acc) do - msg = "String #{inspect(value)} does not validate against format #{inspect(format)}" - [msg <> at_path(path) | acc] - end - - defp format_error(%{then: error}, path, acc) do - msg = ["Schema for then does not match#{at_path(path)}"] - - error = - error - |> travers_errors([], &format_error/3, path: path) - |> indent() - - Enum.concat([error, msg, acc]) - end - - defp format_error(%{else: error}, path, acc) do - msg = ["Schema for else does not match#{at_path(path)}"] - - error = - error - |> travers_errors([], &format_error/3, path: path) - |> indent() - - Enum.concat([error, msg, acc]) - end - - defp format_error(%{not: :ok, value: value}, path, acc) do - msg = "Value is valid against schema from not, got #{inspect(value)}" - [msg <> at_path(path) | acc] - end - - defp format_error(%{contains: errors}, path, acc) do - msg = ["No items match contains#{at_path(path)}"] - - errors = - errors - |> Enum.map(fn {index, reason} -> - travers_errors(reason, [], &format_error/3, path: path ++ [index]) - end) - |> Enum.reverse() - |> indent() - - Enum.concat([errors, msg, acc]) - end - - defp format_error(%{anyOf: errors}, path, acc) do - msg = ["No match of any schema" <> at_path(path)] - - errors = - errors - |> Enum.flat_map(fn reason -> - reason |> travers_errors([], &format_error/3, path: path) |> Enum.reverse() - end) - |> Enum.reverse() - |> indent() - - Enum.concat([errors, msg, acc]) - end - - defp format_error(%{allOf: errors}, path, acc) do - msg = ["No match of all schema#{at_path(path)}"] - - errors = - errors - |> Enum.map(fn reason -> - travers_errors(reason, [], &format_error/3, path: path) - end) - |> Enum.reverse() - |> indent() - - Enum.concat([errors, msg, acc]) - end - - defp format_error(%{oneOf: {:error, errors}}, path, acc) do - msg = ["No match of any schema#{at_path(path)}"] - - errors = - errors - |> Enum.map(fn reason -> - travers_errors(reason, [], &format_error/3, path: path) - end) - |> Enum.reverse() - |> indent() - - Enum.concat([errors, msg, acc]) - end - - defp format_error(%{oneOf: {:ok, success}}, path, acc) do - msg = "More as one schema matches (indexes: #{inspect(success)})" - [msg <> at_path(path) | acc] - end - - defp format_error(%{required: required}, path, acc) do - msg = "Required properties are missing: #{inspect(required)}" - [msg <> at_path(path) | acc] - end - - defp format_error(%{propertyNames: errors, value: _value}, path, acc) do - msg = ["Invalid property names#{at_path(path)}"] - - errors = - errors - |> Enum.map(fn {key, reason} -> - "#{inspect(key)} : #{format_error(reason, [], [])}" - end) - |> Enum.reverse() - |> indent() - - Enum.concat([errors, msg, acc]) - end - - defp format_error(%{dependencies: deps}, path, acc) do - msg = - deps - |> Enum.reduce([], fn - {key, reason}, acc when is_map(reason) -> - sub_msg = - reason - |> format_error(path, []) - |> Enum.reverse() - |> indent() - |> Enum.join("\n") - - ["Dependencies for #{inspect(key)} failed#{at_path(path)}\n#{sub_msg}" | acc] - - {key, reason}, acc -> - [ - "Dependencies for #{inspect(key)} failed#{at_path(path)}" <> - " Missing required key #{inspect(reason)}." - | acc - ] - end) - |> Enum.reverse() - |> Enum.join("\n") - - [msg | acc] - end - - defp format_error(%{minItems: min, value: value}, path, acc) do - msg = "Expected at least #{inspect(min)} items, got #{inspect(value)}" - [msg <> at_path(path) | acc] - end - - defp format_error(%{maxItems: max, value: value}, path, acc) do - msg = "Expected at most #{inspect(max)} items, got #{inspect(value)}" - [msg <> at_path(path) | acc] - end - - defp format_error(%{uniqueItems: true, value: value}, path, acc) do - msg = "Expected unique items, got #{inspect(value)}" - [msg <> at_path(path) | acc] - end - - defp format_error(%{const: const, value: value}, path, acc) do - msg = "Expected #{inspect(const)}, got #{inspect(value)}" - [msg <> at_path(path) | acc] - end - - defp format_error(%{pattern: pattern, value: value}, path, acc) do - msg = "Pattern #{inspect(pattern)} does not match value #{inspect(value)}" - [msg <> at_path(path) | acc] - end - - defp format_error(%{type: type, value: value}, path, acc) do - msg = "Expected #{inspect(type)}, got #{inspect(value)}" - [msg <> at_path(path) | acc] - end - - defp format_error(%{type: false}, path, acc) do - msg = "Schema always fails validation" - [msg <> at_path(path) | acc] - end - - defp format_error(%{properties: _}, _path, acc), do: acc - - defp format_error(%{items: _}, _path, acc), do: acc - - defp format_error(_error, path, acc) do - msg = "Unexpected error" - [msg <> at_path(path) | acc] - end - - defp at_path([]), do: "." - - defp at_path(path), do: ", at #{inspect(path)}." - - defp indent(list), do: Enum.map(list, fn str -> " #{str}" end) end diff --git a/lib/json_xema/validation_error/default_formatter.ex b/lib/json_xema/validation_error/default_formatter.ex new file mode 100644 index 0000000..016d196 --- /dev/null +++ b/lib/json_xema/validation_error/default_formatter.ex @@ -0,0 +1,394 @@ +defmodule JsonXema.ValidationError.DefaultFormatter do + @moduledoc """ + The default formatter for `JsonXema.ValidationError`s. + + TODO: add more docs + + ## Examples + + iex> schema = ~s|{"type": "integer"}| |> Jason.decode!() |> JsonXema.new() + ...> error = JsonXema.validate(schema, %{one: 1}) + ...> JsonXema.ValidationError.format_error(error) + ~S|Expected "integer", got %{one: 1}.| + ...> inspect_fun = fn value, _opts -> Jason.encode!(value) end + ...> JsonXema.ValidationError.format_error(error, inspect_fun: inspect_fun) + ~S|Expected "integer", got {"one":1}.| + ...> Application.put_env(:json_xema, ValidationError, inspect_fun: inspect_fun) + ...> JsonXema.ValidationError.format_error(error) + ~S|Expected "integer", got {"one":1}.| + ...> Application.delete_env(:json_xema, ValidationError) + :ok + + """ + + import JsonXema.ValidationError, only: [travers_errors: 3, travers_errors: 4] + + @behaviour JsonXema.ValidationError.Formatter + + @impl true + def format(%JsonXema.ValidationError{reason: error}, opts) do + opts = Keyword.merge(opts, opts()) + + error + |> travers_errors([], format_error_fun(opts)) + |> Enum.reverse() + |> Enum.join("\n") + end + + defp opts, do: Application.get_env(:json_xema, ValidationError, []) + + defp format_error_fun(opts) do + fn error, path, acc -> + format_error(error, path, opts, acc) + end + end + + defp format_error(%{exclusiveMinimum: minimum, value: value}, path, inspect_opts, acc) + when minimum == value do + msg = + "Value #{inspect(value, inspect_opts)} equals exclusive minimum value of #{inspect(minimum)}" + + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error( + %{minimum: minimum, exclusiveMinimum: true, value: value}, + path, + inspect_opts, + acc + ) + when minimum == value do + msg = + "Value #{inspect(value, inspect_opts)} equals exclusive minimum value of #{inspect(minimum)}" + + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error( + %{minimum: minimum, exclusiveMinimum: true, value: value}, + path, + inspect_opts, + acc + ) do + msg = + "Value #{inspect(value, inspect_opts)} is less than minimum value of #{inspect(minimum)}" + + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error(%{exclusiveMinimum: minimum, value: value}, path, inspect_opts, acc) do + msg = + "Value #{inspect(value, inspect_opts)} is less than minimum value of #{inspect(minimum)}" + + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error(%{minimum: minimum, value: value}, path, inspect_opts, acc) do + msg = + "Value #{inspect(value, inspect_opts)} is less than minimum value of #{inspect(minimum)}" + + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error(%{exclusiveMaximum: maximum, value: value}, path, inspect_opts, acc) + when maximum == value do + msg = + "Value #{inspect(value, inspect_opts)} equals exclusive maximum value of #{inspect(maximum)}" + + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error( + %{maximum: maximum, exclusiveMaximum: true, value: value}, + path, + inspect_opts, + acc + ) + when maximum == value do + msg = + "Value #{inspect(value, inspect_opts)} equals exclusive maximum value of #{inspect(maximum)}" + + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error( + %{maximum: maximum, exclusiveMaximum: true, value: value}, + path, + inspect_opts, + acc + ) do + msg = + "Value #{inspect(value, inspect_opts)} exceeds maximum value of #{inspect(maximum)}" + + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error(%{exclusiveMaximum: maximum, value: value}, path, inspect_opts, acc) do + msg = + "Value #{inspect(value, inspect_opts)} exceeds maximum value of #{inspect(maximum)}" + + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error(%{maximum: maximum, value: value}, path, inspect_opts, acc) do + msg = + "Value #{inspect(value, inspect_opts)} exceeds maximum value of #{inspect(maximum)}" + + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error(%{maxLength: max, value: value}, path, inspect_opts, acc) do + msg = + "Expected maximum length of #{inspect(max)}, got #{inspect(value, inspect_opts)}" + + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error(%{minLength: min, value: value}, path, inspect_opts, acc) do + msg = + "Expected minimum length of #{inspect(min)}, got #{inspect(value, inspect_opts)}" + + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error(%{multipleOf: multiple_of, value: value}, path, inspect_opts, acc) do + msg = + "Value #{inspect(value, inspect_opts)} is not a multiple of #{inspect(multiple_of)}" + + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error(%{enum: _enum, value: value}, path, inspect_opts, acc) do + msg = "Value #{inspect(value, inspect_opts)} is not defined in enum" + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error(%{minProperties: min, value: value}, path, inspect_opts, acc) do + msg = + "Expected at least #{inspect(min)} properties, got #{inspect(value, inspect_opts)}" + + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error(%{maxProperties: max, value: value}, path, inspect_opts, acc) do + msg = + "Expected at most #{inspect(max)} properties, got #{inspect(value, inspect_opts)}" + + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error(%{additionalProperties: false}, path, _inspect_opts, acc) do + msg = "Expected only defined properties, got key #{inspect(path)}." + [msg | acc] + end + + defp format_error(%{additionalItems: false}, path, inspect_opts, acc) do + msg = "Unexpected additional item" + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error(%{format: format, value: value}, path, inspect_opts, acc) do + msg = + "String #{inspect(value, inspect_opts)} does not validate against format #{inspect(format)}" + + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error(%{then: error}, path, inspect_opts, acc) do + msg = ["Schema for then does not match#{at_path(path, inspect_opts)}"] + + error = + error + |> travers_errors([], format_error_fun(inspect_opts), path: path) + |> indent() + + Enum.concat([error, msg, acc]) + end + + defp format_error(%{else: error}, path, inspect_opts, acc) do + msg = ["Schema for else does not match#{at_path(path, inspect_opts)}"] + + error = + error + |> travers_errors([], format_error_fun(inspect_opts), path: path) + |> indent() + + Enum.concat([error, msg, acc]) + end + + defp format_error(%{not: :ok, value: value}, path, inspect_opts, acc) do + msg = "Value is valid against schema from not, got #{inspect(value, inspect_opts)}" + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error(%{contains: errors}, path, inspect_opts, acc) do + msg = ["No items match contains#{at_path(path, inspect_opts)}"] + + errors = + errors + |> Enum.map(fn {index, reason} -> + travers_errors(reason, [], format_error_fun(inspect_opts), path: path ++ [index]) + end) + |> Enum.reverse() + |> indent() + + Enum.concat([errors, msg, acc]) + end + + defp format_error(%{anyOf: errors}, path, inspect_opts, acc) do + msg = ["No match of any schema" <> at_path(path, inspect_opts)] + + errors = + errors + |> Enum.flat_map(fn reason -> + reason + |> travers_errors([], format_error_fun(inspect_opts), path: path) + |> Enum.reverse() + end) + |> Enum.reverse() + |> indent() + + Enum.concat([errors, msg, acc]) + end + + defp format_error(%{allOf: errors}, path, inspect_opts, acc) do + msg = ["No match of all schema#{at_path(path, inspect_opts)}"] + + errors = + errors + |> Enum.map(fn reason -> + travers_errors(reason, [], format_error_fun(inspect_opts), path: path) + end) + |> Enum.reverse() + |> indent() + + Enum.concat([errors, msg, acc]) + end + + defp format_error(%{oneOf: {:error, errors}}, path, inspect_opts, acc) do + msg = ["No match of any schema#{at_path(path, inspect_opts)}"] + + errors = + errors + |> Enum.map(fn reason -> + travers_errors(reason, [], format_error_fun(inspect_opts), path: path) + end) + |> Enum.reverse() + |> indent() + + Enum.concat([errors, msg, acc]) + end + + defp format_error(%{oneOf: {:ok, success}}, path, inspect_opts, acc) do + msg = "More as one schema matches (indexes: #{inspect(success, inspect_opts)})" + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error(%{required: required}, path, inspect_opts, acc) do + msg = "Required properties are missing: #{inspect(required)}" + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error(%{propertyNames: errors, value: _value}, path, inspect_opts, acc) do + msg = ["Invalid property names#{at_path(path, inspect_opts)}"] + + errors = + errors + |> Enum.map(fn {key, reason} -> + "#{inspect(key)} : #{format_error(reason, [], inspect_opts, [])}" + end) + |> Enum.reverse() + |> indent() + + Enum.concat([errors, msg, acc]) + end + + defp format_error(%{dependencies: deps}, path, inspect_opts, acc) do + msg = + deps + |> Enum.reduce([], fn + {key, reason}, acc when is_map(reason) -> + sub_msg = + reason + |> format_error(path, inspect_opts, []) + |> Enum.reverse() + |> indent() + |> Enum.join("\n") + + [ + "Dependencies for #{inspect(key)} failed#{at_path(path, inspect_opts)}\n#{sub_msg}" + | acc + ] + + {key, reason}, acc -> + [ + "Dependencies for #{inspect(key)} failed#{at_path(path, inspect_opts)}" <> + " Missing required key #{inspect(reason)}." + | acc + ] + end) + |> Enum.reverse() + |> Enum.join("\n") + + [msg | acc] + end + + defp format_error(%{minItems: min, value: value}, path, inspect_opts, acc) do + msg = + "Expected at least #{inspect(min)} items, got #{inspect(value, inspect_opts)}" + + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error(%{maxItems: max, value: value}, path, inspect_opts, acc) do + msg = + "Expected at most #{inspect(max)} items, got #{inspect(value, inspect_opts)}" + + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error(%{uniqueItems: true, value: value}, path, inspect_opts, acc) do + msg = "Expected unique items, got #{inspect(value, inspect_opts)}" + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error(%{const: const, value: value}, path, inspect_opts, acc) do + msg = "Expected #{inspect(const)}, got #{inspect(value, inspect_opts)}" + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error(%{pattern: pattern, value: value}, path, inspect_opts, acc) do + msg = + "Pattern #{inspect(pattern)} does not match value #{inspect(value, inspect_opts)}" + + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error(%{type: type, value: value}, path, inspect_opts, acc) do + msg = "Expected #{inspect(type)}, got #{inspect(value, inspect_opts)}" + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error(%{type: false}, path, inspect_opts, acc) do + msg = "Schema always fails validation" + [msg <> at_path(path, inspect_opts) | acc] + end + + defp format_error(%{properties: _}, _path, _inspect_opts, acc), do: acc + + defp format_error(%{items: _}, _path, _inspect_opts, acc), do: acc + + defp format_error(_error, path, inspect_opts, acc) do + msg = "Unexpected error" + [msg <> at_path(path, inspect_opts) | acc] + end + + defp at_path([], _opts), do: "." + + defp at_path(path, opts) do + fun = Keyword.get(opts, :path_fun, fn path, _opts -> inspect(path) end) + ", at #{fun.(path, opts)}." + end + + defp indent(list), do: Enum.map(list, fn str -> " #{str}" end) +end diff --git a/lib/json_xema/validation_error/formatter.ex b/lib/json_xema/validation_error/formatter.ex new file mode 100644 index 0000000..10c75ba --- /dev/null +++ b/lib/json_xema/validation_error/formatter.ex @@ -0,0 +1,52 @@ +defmodule JsonXema.ValidationError.Formatter do + @moduledoc ~S""" + The formatter for `JsonXema.ValidationError`s. + + TODO: add more docs + + ## Examples + + iex> defmodule MyValidationErrorFormatter do + ...> alias JsonXema.ValidationError + ...> + ...> @behaviour JsonXema.ValidationError.Formatter + ...> + ...> @impl true + ...> def format(error, _opts) do + ...> error + ...> |> ValidationError.travers_errors([], &format_error/3) + ...> |> Enum.reverse() + ...> |> Enum.join("\n") + ...> end + ...> + ...> def format_error(error, path, acc) do + ...> [inspect([error: error.reason, path: path], pretty: true) | acc] + ...> end + ...> end + ...> + ...> schema = ~s|{"type": "integer"}| |> Jason.decode!() |> JsonXema.new() + ...> + ...> Application.put_env(:json_xema, ValidationError, + ...> formatter: MyValidationErrorFormatter) + ...> + ...> {:error, error} = JsonXema.validate(schema, "one") + ...> message = JsonXema.ValidationError.message(error) + ...> Application.delete_env(:json_xema, ValidationError) + ...> message + "[error: %{type: \"integer\", value: \"one\"}, path: []]" + + """ + + alias JsonXema.ValidationError.DefaultFormatter + + @callback format(error :: Exception.t(), opts :: keyword()) :: String.t() + + @spec format(Exception.t(), keyword()) :: String.t() + def format(error, opts), do: impl().format(error, opts) + + defp impl do + :json_xema + |> Application.get_env(ValidationError, []) + |> Keyword.get(:formatter, DefaultFormatter) + end +end diff --git a/mix.exs b/mix.exs index 006e325..118e54f 100644 --- a/mix.exs +++ b/mix.exs @@ -65,7 +65,7 @@ defmodule JsonXema.MixProject do {:excoveralls, "~> 0.14", only: :test}, {:httpoison, "~> 2.2", only: :test}, {:jason, "~> 1.3", only: [:dev, :test]}, - {:recode, "~> 0.7"} + {:recode, "~> 0.7", only: :dev} ] end diff --git a/mix.lock b/mix.lock index bf4a33a..4125d68 100644 --- a/mix.lock +++ b/mix.lock @@ -1,40 +1,40 @@ %{ - "benchee": {:hex, :benchee, "1.3.0", "f64e3b64ad3563fa9838146ddefb2d2f94cf5b473bdfd63f5ca4d0657bf96694", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "34f4294068c11b2bd2ebf2c59aac9c7da26ffa0068afdf3419f1b176e16c5f81"}, + "benchee": {:hex, :benchee, "1.3.1", "c786e6a76321121a44229dde3988fc772bca73ea75170a73fd5f4ddf1af95ccf", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "76224c58ea1d0391c8309a8ecbfe27d71062878f59bd41a390266bf4ac1cc56d"}, "benchee_markdown": {:hex, :benchee_markdown, "0.3.3", "d48a1d9782693fae6c294fdb12f653bb90088172d467996bedb9887ff41cf4ef", [:mix], [{:benchee, ">= 1.1.0 and < 2.0.0", [hex: :benchee, repo: "hexpm", optional: false]}], "hexpm", "106dab9ae0b448747da89b9af7285b71841f5d8131f37c6612b7370a157860a4"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, "conv_case": {:hex, :conv_case, "0.2.3", "c1455c27d3c1ffcdd5f17f1e91f40b8a0bc0a337805a6e8302f441af17118ed8", [:mix], [], "hexpm", "88f29a3d97d1742f9865f7e394ed3da011abb7c5e8cc104e676fdef6270d4b4a"}, "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"}, "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, - "credo": {:hex, :credo, "1.7.5", "643213503b1c766ec0496d828c90c424471ea54da77c8a168c725686377b9545", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f799e9b5cd1891577d8c773d245668aa74a2fcd15eb277f51a0131690ebfb3fd"}, + "credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, - "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.40", "f3534689f6b58f48aa3a9ac850d4f05832654fe257bf0549c08cc290035f70d5", [:mix], [], "hexpm", "cdb34f35892a45325bad21735fadb88033bcb7c4c296a999bde769783f53e46a"}, + "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, "escape": {:hex, :escape, "0.1.0", "548edab75e6e6938b1e199ef59cb8e504bcfd3bcf83471d4ae9a3c7a7a3c7d45", [:mix], [], "hexpm", "a5d8e92db4677155df54bc1306d401b5233875d570d474201db03cb3047491cd"}, - "ex_doc": {:hex, :ex_doc, "0.31.2", "8b06d0a5ac69e1a54df35519c951f1f44a7b7ca9a5bb7a260cd8a174d6322ece", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.1", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "317346c14febaba9ca40fd97b5b5919f7751fb85d399cc8e7e8872049f37e0af"}, + "ex_doc": {:hex, :ex_doc, "0.34.1", "9751a0419bc15bc7580c73fde506b17b07f6402a1e5243be9e0f05a68c723368", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "d441f1a86a235f59088978eff870de2e815e290e44a8bd976fe5d64470a4c9d2"}, "ex_json_schema": {:hex, :ex_json_schema, "0.10.2", "7c4b8c1481fdeb1741e2ce66223976edfb9bccebc8014f6aec35d4efe964fb71", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "37f43be60f8407659d4d0155a7e45e7f406dab1f827051d3d35858a709baf6a6"}, - "excoveralls": {:hex, :excoveralls, "0.18.0", "b92497e69465dc51bc37a6422226ee690ab437e4c06877e836f1c18daeb35da9", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1109bb911f3cb583401760be49c02cbbd16aed66ea9509fc5479335d284da60b"}, + "excoveralls": {:hex, :excoveralls, "0.18.1", "a6f547570c6b24ec13f122a5634833a063aec49218f6fff27de9df693a15588c", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d65f79db146bb20399f23046015974de0079668b9abb2f5aac074d078da60b8d"}, "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, - "glob_ex": {:hex, :glob_ex, "0.1.6", "3a311ade50f6b71d638af660edcc844c3ab4eb2a2c816cfebb73a1d521bb2f9d", [:mix], [], "hexpm", "fda1e90e10f6029bd72967fef0c9891d0d14da89ca7163076e6028bfcb2c42fa"}, + "glob_ex": {:hex, :glob_ex, "0.1.7", "eae6b6377147fb712ac45b360e6dbba00346689a87f996672fe07e97d70597b1", [:mix], [], "hexpm", "decc1c21c0c73df3c9c994412716345c1692477b9470e337f628a7e08da0da6a"}, "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, "httpoison": {:hex, :httpoison, "2.2.1", "87b7ed6d95db0389f7df02779644171d7319d319178f6680438167d7b69b1f3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "51364e6d2f429d80e14fe4b5f8e39719cacd03eb3f9a9286e61e216feac2d2df"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, - "makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"}, + "jason": {:hex, :jason, "1.4.3", "d3f984eeb96fe53b85d20e0b049f03e57d075b5acda3ac8d465c969a2536c17b", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "9a90e868927f7c777689baa16d86f4d0e086d968db5c05d917ccff6d443e58a3"}, + "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, - "makeup_erlang": {:hex, :makeup_erlang, "0.1.5", "e0ff5a7c708dda34311f7522a8758e23bfcd7d8d8068dc312b5eb41c6fd76eba", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "94d2e986428585a21516d7d7149781480013c56e30c6a233534bedf38867a59a"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, + "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "recode": {:hex, :recode, "0.7.2", "aa24873b6eb4c90e635ad1f7e12b8e21575a087698bd6bda6e72a82c1298eca1", [:mix], [{:escape, "~> 0.1", [hex: :escape, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:rewrite, "~> 0.9", [hex: :rewrite, repo: "hexpm", optional: false]}], "hexpm", "d70fc60aae3c42781ec845515c1ddd4fe55218ed3fd8fe52267d338044ec7fb8"}, - "rewrite": {:hex, :rewrite, "0.10.0", "5d756b6dc67679e7156ff6055f9654be02dbaeb177aaf1ff6af7ee8da8718248", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.13", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "68d7808cf549e7bf51b0119a8edc14d50970bad479115249030baa580c1d7b50"}, - "sourceror": {:hex, :sourceror, "0.14.1", "c6fb848d55bd34362880da671debc56e77fd722fa13b4dcbeac89a8998fc8b09", [:mix], [], "hexpm", "8b488a219e4c4d7d9ff29d16346fd4a5858085ccdd010e509101e226bbfd8efc"}, + "rewrite": {:hex, :rewrite, "0.10.5", "6afadeae0b9d843b27ac6225e88e165884875e0aed333ef4ad3bf36f9c101bed", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "51cc347a4269ad3a1e7a2c4122dbac9198302b082f5615964358b4635ebf3d4f"}, + "sourceror": {:hex, :sourceror, "1.4.0", "be87319b1579191e25464005d465713079b3fd7124a3938a1e6cf4def39735a9", [:mix], [], "hexpm", "16751ca55e3895f2228938b703ad399b0b27acfe288eff6c0e629ed3e6ec0358"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, - "xema": {:hex, :xema, "0.17.1", "fa83ed90ec7d9a5e38a223ee1f0693cfb8cd3fa0d0c7f7967f828a0643811f10", [:mix], [{:conv_case, "~> 0.2.2", [hex: :conv_case, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "3dd7213309cc8e6d7770ee54de807a0d91cdbdd9dcb78a6f3eee9dbad43889af"}, + "xema": {:hex, :xema, "0.17.3", "02cb902db5b43122a9ddb73eacba28b181f2047db1e5bd9a7bd879a7009bf855", [:mix], [{:conv_case, "~> 0.2.2", [hex: :conv_case, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "5b919c5fdcc9d4a0fea0d9600da01a4533aadb56718c1d03ad21eecfa837bdce"}, } diff --git a/test/json_xema/number_test.exs b/test/json_xema/number_test.exs index aa61320..6b02895 100644 --- a/test/json_xema/number_test.exs +++ b/test/json_xema/number_test.exs @@ -135,13 +135,13 @@ defmodule Xema.NumberTest do test "validate/2 with a minimum number", %{schema: schema} do assert {:error, error} = validate(schema, 2.0) assert error == %ValidationError{reason: %{exclusiveMinimum: 2, value: 2.0}} - assert Exception.message(error) == ~s|Value 2 equals exclusive minimum value of 2.| + assert Exception.message(error) == ~s|Value 2.0 equals exclusive minimum value of 2.| end test "validate/2 with a maximum number (float)", %{schema: schema} do assert {:error, error} = validate(schema, 4.0) assert error == %ValidationError{reason: %{value: 4.0, exclusiveMaximum: 4}} - assert Exception.message(error) == ~s|Value 4 equals exclusive maximum value of 4.| + assert Exception.message(error) == ~s|Value 4.0 equals exclusive maximum value of 4.| end test "validate/2 with a maximum number (integer)", %{schema: schema} do diff --git a/test/json_xema/validation_error_test.exs b/test/json_xema/validation_error_test.exs index da4bea1..0d00ecf 100644 --- a/test/json_xema/validation_error_test.exs +++ b/test/json_xema/validation_error_test.exs @@ -1,10 +1,11 @@ defmodule JsonXema.ValidationErrorTest do - use ExUnit.Case, async: true + use ExUnit.Case, async: false doctest JsonXema.ValidationError + doctest JsonXema.ValidationError.Formatter + doctest JsonXema.ValidationError.DefaultFormatter alias JsonXema.ValidationError - alias Xema.Validator describe "Xema.validate!/2" do setup do @@ -16,20 +17,58 @@ defmodule JsonXema.ValidationErrorTest do rescue error -> assert %ValidationError{} = error - assert error.message == nil + assert Map.fetch!(error, :message) == nil assert Exception.message(error) == ~s|Expected "integer", got "foo".| - assert error.reason == %{type: "integer", value: "foo"} + assert Map.fetch!(error, :reason) == %{type: "integer", value: "foo"} end end - describe "format_error/1" do + describe "format_error" do setup do %{schema: ~s|{"type": "integer"}| |> Jason.decode!() |> JsonXema.new()} end - test "returns a formated error for an error tuple", %{schema: schema} do - assert schema |> Validator.validate("foo") |> ValidationError.format_error() == - ~s|Expected :integer, got \"foo\".| + test "returns an error message for an error", %{schema: schema} do + error = JsonXema.validate(schema, %{a: 1}) + + assert ValidationError.format_error(error) == """ + Expected \"integer\", got %{a: 1}.\ + """ + end + + test "returns an error message for an error with opts", %{schema: schema} do + error = JsonXema.validate(schema, %{a: 1}) + + inspect_fun = fn value, _opts -> + Jason.encode!(value, pretty: true) + end + + assert ValidationError.format_error(error, inspect_fun: inspect_fun) == """ + Expected \"integer\", got { + "a": 1 + }.\ + """ + end + + test "returns an error message for an error with path_fun" do + schema = + """ + {"properties": { + "int": {"type": "array", "items": {"type": "integer"}} + }} + """ + |> Jason.decode!() + |> JsonXema.new() + + error = JsonXema.validate(schema, %{"int" => [1, "foo"]}) + + path_fun = fn path, _opts -> + "./" <> Enum.join(path, "/") + end + + assert ValidationError.format_error(error, path_fun: path_fun) == """ + Expected \"integer\", got "foo", at ./int/1.\ + """ end end