diff --git a/README.md b/README.md index c82edbc..92460ee 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/ex_json_schema/schema.ex b/lib/ex_json_schema/schema.ex index 965c001..0169d29 100644 --- a/lib/ex_json_schema/schema.ex +++ b/lib/ex_json_schema/schema.ex @@ -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 \\ []) @@ -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 diff --git a/lib/ex_json_schema/schema/root.ex b/lib/ex_json_schema/schema/root.ex index 604dd3b..e017cf0 100644 --- a/lib/ex_json_schema/schema/root.ex +++ b/lib/ex_json_schema/schema/root.ex @@ -4,7 +4,8 @@ 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(), @@ -12,6 +13,10 @@ defmodule ExJsonSchema.Schema.Root do 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 diff --git a/lib/ex_json_schema/validator.ex b/lib/ex_json_schema/validator.ex index 9894408..a50360c 100644 --- a/lib/ex_json_schema/validator.ex +++ b/lib/ex_json_schema/validator.ex @@ -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 diff --git a/lib/ex_json_schema/validator/custom_keyword.ex b/lib/ex_json_schema/validator/custom_keyword.ex new file mode 100644 index 0000000..ed96d04 --- /dev/null +++ b/lib/ex_json_schema/validator/custom_keyword.ex @@ -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 diff --git a/mix.exs b/mix.exs index 42255e3..3dc80ed 100644 --- a/mix.exs +++ b/mix.exs @@ -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 diff --git a/test/ex_json_schema/validator_test.exs b/test/ex_json_schema/validator_test.exs index c55ce00..c3a0c5f 100644 --- a/test/ex_json_schema/validator_test.exs +++ b/test/ex_json_schema/validator_test.exs @@ -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