Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom keyword validation #90

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,41 @@ ExJsonSchema.Schema.resolve(%{"format" => "custom"}, custom_format_validator: fn

[format-spec]: https://json-schema.org/understanding-json-schema/reference/string.html#format

## Custom keywords

Keywords which are not part of the JSON Schema spec are ignored and not subjected to any validation by default.
Should custom validation for extended keywords be required, you can provide a custom keyword validator which will be called with `(schema, property, data, path)` as parameters and is expected to return a list of `%Error{}` structs.

This validator can be configured globally:

```elixir
config :ex_json_schema,
:custom_keyword_validator,
{MyKeywordValidator, :validate}
```

Or by passing an option as either a `{module, function_name}` tuple or an anonymous function when resolving the schema:

```elixir
ExJsonSchema.Schema.resolve(%{"x-my-keyword" => "value"}, custom_keyword_validator: {MyKeywordValidator, :validate})
```

A partical example of how to use this functionality would be to extend a schema to support validating if strings contain a certain value via a custom keyword - `x-contains`. A simple implementation:

```elixir
defmodule CustomValidator do
def validate(_schema, {"x-contains", contains}, data, _path) do
if not String.contains?(data, contains) do
[%Error{error: "#{data} does not contain #{contains}"}]
else
[]
end
end

def validate(_, _, _, _), do: []
end
```

## License

Copyright (c) 2015 Jonas Schmidt
Expand Down
4 changes: 3 additions & 1 deletion lib/ex_json_schema/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ defmodule ExJsonSchema.Schema do
end

@spec resolve(boolean | Root.t() | ExJsonSchema.object(),
custom_format_validator: {module(), atom()}
custom_format_validator: {module(), atom()},
custom_keyword_validator: {module(), atom()}
) ::
Root.t() | no_return
def resolve(schema, options \\ [])
Expand All @@ -59,6 +60,7 @@ defmodule ExJsonSchema.Schema do

def resolve(root = %Root{}, options) do
root = %Root{root | custom_format_validator: Keyword.get(options, :custom_format_validator)}
root = %Root{root | custom_keyword_validator: Keyword.get(options, :custom_keyword_validator)}
resolve_root(root)
end

Expand Down
9 changes: 7 additions & 2 deletions lib/ex_json_schema/schema/root.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@ defmodule ExJsonSchema.Schema.Root do
definitions: %{},
location: :root,
version: nil,
custom_format_validator: nil
custom_format_validator: nil,
custom_keyword_validator: nil

@type t :: %ExJsonSchema.Schema.Root{
schema: ExJsonSchema.Schema.resolved(),
refs: %{String.t() => ExJsonSchema.Schema.resolved()},
location: :root | String.t(),
definitions: %{String.t() => ExJsonSchema.Schema.resolved()},
version: non_neg_integer | nil,
custom_format_validator: {module(), atom()} | (String.t(), any() -> boolean | {:error, any()}) | nil
custom_format_validator: {module(), atom()} | (String.t(), any() -> boolean | {:error, any()}) | nil,
custom_keyword_validator:
{module(), atom()}
| (ExJsonSchema.Schema.Root.t(), any(), any() -> list(ExJsonSchema.Validator.Error.t()))
| nil
}
end
2 changes: 1 addition & 1 deletion lib/ex_json_schema/validator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -157,5 +157,5 @@ defmodule ExJsonSchema.Validator do
defp validator_for("required"), do: ExJsonSchema.Validator.Required
defp validator_for("type"), do: ExJsonSchema.Validator.Type
defp validator_for("uniqueItems"), do: ExJsonSchema.Validator.UniqueItems
defp validator_for(_), do: nil
defp validator_for(_unknown_keyword), do: ExJsonSchema.Validator.CustomKeyword
end
37 changes: 37 additions & 0 deletions lib/ex_json_schema/validator/custom_keyword.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
defmodule ExJsonSchema.Validator.CustomKeyword do
@moduledoc """
`ExJsonSchema.Validator` for custom keywords.
"""

alias ExJsonSchema.Schema.Root

@behaviour ExJsonSchema.Validator

@impl ExJsonSchema.Validator
def validate(root, schema, property, data, path) do
do_validate(root, schema, property, data, path)
end

defp do_validate(%Root{custom_keyword_validator: nil}, schema, property, data, path) do
case Application.fetch_env(:ex_json_schema, :custom_keyword_validator) do
:error -> []
{:ok, validator = {_mod, _fun}} -> validate_with_custom_validator(validator, schema, property, data, path)
end
end

defp do_validate(%Root{custom_keyword_validator: validator = {_mod, _fun}}, schema, property, data, path) do
validate_with_custom_validator(validator, schema, property, data, path)
end

defp do_validate(%Root{custom_keyword_validator: validator}, schema, property, data, path)
when is_function(validator) do
validate_with_custom_validator(validator, schema, property, data, path)
end

defp validate_with_custom_validator(validator, schema, property, data, path) do
case validator do
{mod, fun} -> apply(mod, fun, [schema, property, data, path])
fun when is_function(fun, 4) -> fun.(schema, property, data, path)
end
end
end
3 changes: 2 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ defmodule ExJsonSchema.Mixfile do
plt_add_apps: [:ex_unit],
plt_core_path: "_build/#{Mix.env()}",
plt_add_deps: :transitive
]
],
consolidate_protocols: Mix.env() != :test
]
end

Expand Down
120 changes: 120 additions & 0 deletions test/ex_json_schema/validator_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,126 @@ defmodule ExJsonSchema.ValidatorTest do
assert {:error, [{"Type mismatch. Expected String but got Integer.", "#"}]} = validate(%{"type" => "string"}, 666)
end

defmodule MinDateError do
defstruct [:min, :actual]
end

defimpl String.Chars, for: MinDateError do
def to_string(%MinDateError{actual: _actual, min: min}), do: "Must be on or after #{min}."
end

defmodule MaxDateError do
defstruct [:max, :actual]
end

defimpl String.Chars, for: MaxDateError do
def to_string(%MaxDateError{actual: _actual, max: max}), do: "Must be on or before #{max}."
end

defmodule MyCustomKeywordValidator do
def validate(_schema, property, data, _path) do
case property do
{"x-min-date", min_date} -> validate_min_date(min_date, data)
{"x-max-date", max_date} -> validate_max_date(max_date, data)
_ -> []
end
end

defp validate_min_date(min_date, date) do
with {:ok, min} <- Date.from_iso8601(min_date),
{:ok, current} <- Date.from_iso8601(date) do
case Date.compare(current, min) do
:lt -> [%Error{error: %MinDateError{actual: date, min: min_date}}]
_ -> []
end
else
{:error, error} -> [%Error{error: error}]
_ -> []
end
end

defp validate_max_date(max_date, date) do
with {:ok, max} <- Date.from_iso8601(max_date),
{:ok, current} <- Date.from_iso8601(date) do
case Date.compare(current, max) do
:gt -> [%Error{error: %MaxDateError{actual: date, max: max_date}}]
_ -> []
end
else
{:error, error} -> [%Error{error: error}]
_ -> []
end
end
end

test "configuring a custom keyword validator" do
schema =
Schema.resolve(
%{
"properties" => %{
"date" => %{
"type" => "string",
"format" => "date",
"x-min-date" => "2020-01-01",
"x-max-date" => "2038-01-19"
}
}
},
custom_keyword_validator: {MyCustomKeywordValidator, :validate}
)

assert :ok = validate(schema, %{"date" => "2024-01-01"})

assert_validation_errors(
schema,
%{"date" => "2019-12-31"},
[{"Must be on or after 2020-01-01.", "#/date"}],
[%Error{error: %MinDateError{actual: "2019-12-31", min: "2020-01-01"}, path: "#/date"}]
)

assert_validation_errors(
schema,
%{"date" => "2038-01-20"},
[{"Must be on or before 2038-01-19.", "#/date"}],
[%Error{error: %MaxDateError{actual: "2038-01-20", max: "2038-01-19"}, path: "#/date"}]
)
end

test "configuring a custom keyword validator as a function" do
validator = fn schema, property, data, path -> MyCustomKeywordValidator.validate(schema, property, data, path) end

schema =
Schema.resolve(
%{
"properties" => %{
"date" => %{
"type" => "string",
"format" => "date",
"x-min-date" => "2020-01-01",
"x-max-date" => "2038-01-19"
}
}
},
custom_keyword_validator: validator
)

assert :ok = validate(schema, %{"date" => "2024-01-01"})

assert_validation_errors(
schema,
%{"date" => "2019-12-31"},
[{"Must be on or after 2020-01-01.", "#/date"}],
[%Error{error: %MinDateError{actual: "2019-12-31", min: "2020-01-01"}, path: "#/date"}]
)

assert_validation_errors(
schema,
%{"date" => "2038-01-20"},
[{"Must be on or before 2038-01-19.", "#/date"}],
[%Error{error: %MaxDateError{actual: "2038-01-20", max: "2038-01-19"}, path: "#/date"}]
)
end

defp assert_validation_errors(schema, data, expected_errors, expected_error_structs) do
assert {:error, errors} = validate(schema, data, error_formatter: false)
assert errors == expected_error_structs
Expand Down
Loading