diff --git a/CHANGELOG.md b/CHANGELOG.md index 28b1fdf7..a5dbb7a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # CHANGELOG +## Unreleased + +* Add partial support for JSON Schema draft 2019-09 and draft 2020-12. New + dialect modules `jesse_validator_draft2019_09` and + `jesse_validator_draft2020_12`, dispatched from the schema's `$schema` URI + (`https://json-schema.org/draft/2019-09/schema` and + `.../2020-12/schema`, with or without a trailing `#`). Implemented keywords: + `dependentRequired`, `dependentSchemas`, `if`/`then`/`else`, + `minContains`/`maxContains`, `$defs`, local `$anchor` resolution, `$ref` + evaluated alongside sibling keywords, and + `unevaluatedProperties`/`unevaluatedItems` (with the full annotation + model — adjacent keywords and successful in-place applicators contribute, + cousins/uncles do not). Draft 2020-12 additionally handles the `prefixItems` + rename (tuple validation) and `items` as the after-`prefixItems` applicator. + `format` is annotation-only (non-asserting), per the dialect default. + Not yet implemented — `$recursiveRef` (2019-09) and `$dynamicRef` (2020-12) — + raise a `keyword_not_supported` schema error instead of being silently + ignored, so they can never false-accept invalid data. + Validated against the official JSON-Schema-Test-Suite: 927/1003 individual + draft 2019-09 tests and 935/997 draft 2020-12 tests pass; the remainder are + for `$recursiveRef`/`$dynamicRef`, remote-schema fetching, and in-document + `$id` scoping. + ## 1.5.6 * Improving the error messages from jesse when using oneOf/anyOf diff --git a/Makefile b/Makefile index 9762fb2e..ac7843d5 100644 --- a/Makefile +++ b/Makefile @@ -116,6 +116,14 @@ symlinks: test/JSON-Schema-Test-Suite/tests ln -sf ../../test/JSON-Schema-Test-Suite/tests/draft6 standard && \ ln -sf ../../test/JSON-Schema-Test-Suite/remotes remotes + cd test/jesse_tests_draft2019_09_SUITE_data && \ + ln -sf ../../test/JSON-Schema-Test-Suite/tests/draft2019-09 standard && \ + ln -sf ../../test/JSON-Schema-Test-Suite/remotes remotes + + cd test/jesse_tests_draft2020_12_SUITE_data && \ + ln -sf ../../test/JSON-Schema-Test-Suite/tests/draft2020-12 standard && \ + ln -sf ../../test/JSON-Schema-Test-Suite/remotes remotes + # https://github.com/erlang/rebar3/issues/2903 ./rebar3.OTP18: $(CURL) -qfsSL -o $@ https://github.com/erlang/rebar3/releases/download/3.15.3/rebar3 && $(CHMOD) +x $@ diff --git a/README.md b/README.md index 8f4f5cbf..3add2ee8 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,12 @@ jesse implements the following specifications: * [Draft 03](http://tools.ietf.org/html/draft-zyp-json-schema-03) * [Draft 04](http://tools.ietf.org/html/draft-zyp-json-schema-04) * [Draft 06](https://datatracker.ietf.org/doc/html/draft-wright-json-schema-00) +* [Draft 2019-09](https://json-schema.org/draft/2019-09/json-schema-core.html) + (partial: `$recursiveRef` and remote-schema fetching are not yet supported + and raise an error rather than being silently ignored) +* [Draft 2020-12](https://json-schema.org/draft/2020-12/json-schema-core.html) + (partial: `$dynamicRef` and remote-schema fetching are not yet supported + and raise an error rather than being silently ignored) Install from git or https://hex.pm/packages/jesse . @@ -241,9 +247,14 @@ Maps example ## JSON Schema versions -jesse currently supports JSON Schema draft3, draft4 and draft6. To decide which +jesse currently supports JSON Schema draft3, draft4, draft6 and (partially) +draft 2019-09 and draft 2020-12. To decide which validator to use jesse tries to read `$schema` property from the given schema, and checks if it's a supported one, otherwise it will return an error. +For draft 2019-09/2020-12, keywords that the dialect defines but jesse does not +yet implement (`$recursiveRef`, `$dynamicRef`) raise a `keyword_not_supported` +schema error instead of being silently ignored, so a schema relying on them can +never quietly accept data those keywords would reject. If `$schema` property isn't provided in the given schema, jesse will use the default validator (currently the validator for draft3). diff --git a/rebar.config b/rebar.config index 6dceb92c..0e125070 100644 --- a/rebar.config +++ b/rebar.config @@ -54,7 +54,12 @@ , {elvis_style, no_behavior_info} , { elvis_style , module_naming_convention - , #{ regex => "^([a-z][a-z0-9]*_?)*(_SUITE)?$" + %% Allow purely-numeric underscore-delimited segments + %% (e.g. the "_09" in jesse_validator_draft2019_09) so + %% draft-year_month dialect modules can be named after + %% their metaschema version. + , #{ regex => + "^([a-z][a-z0-9]*)(_[a-z0-9]+)*(_SUITE)?$" , ignore => [] } } diff --git a/src/jesse.erl b/src/jesse.erl index f5432137..e5b8bd0d 100644 --- a/src/jesse.erl +++ b/src/jesse.erl @@ -35,6 +35,7 @@ , validate/3 , validate_with_schema/2 , validate_with_schema/3 + , supported_dialect/1 ]). -export_type([ allowed_errors/0 @@ -247,6 +248,15 @@ validate_with_schema(Schema, Data, Options) -> throw:Error -> {error, Error} end. +%% @doc Whether the given `$schema' dialect URI is one jesse can validate +%% against. Useful for rejecting a schema that declares an unsupported dialect +%% at registration time, instead of accepting it and failing every validation +%% at run time. Draft 2019-09/2020-12 URIs are accepted with or without a +%% trailing `#'. +-spec supported_dialect(SchemaURI :: binary()) -> boolean(). +supported_dialect(SchemaURI) -> + jesse_schema_validator:is_supported_dialect(SchemaURI). + %%% Internal functions %% @doc Wraps up calls to a third party json parser. %% @private diff --git a/src/jesse_lib.erl b/src/jesse_lib.erl index f6b9371f..a92b0404 100644 --- a/src/jesse_lib.erl +++ b/src/jesse_lib.erl @@ -266,6 +266,10 @@ compare_properties(Value1, Value2) -> get_schema_id_key(Schema) -> case jesse_json_path:value(?SCHEMA, Schema, ?json_schema_draft6) of ?json_schema_draft6 -> ?ID; + ?json_schema_draft2019_09 -> ?ID; + <<"https://json-schema.org/draft/2019-09/schema#">> -> ?ID; + ?json_schema_draft2020_12 -> ?ID; + <<"https://json-schema.org/draft/2020-12/schema#">> -> ?ID; _ -> ?ID_OLD end. diff --git a/src/jesse_schema_validator.erl b/src/jesse_schema_validator.erl index 3218e385..b1b6a23b 100644 --- a/src/jesse_schema_validator.erl +++ b/src/jesse_schema_validator.erl @@ -26,6 +26,7 @@ %% API -export([ validate/3 , validate_with_state/3 + , is_supported_dialect/1 ]). %% Includes @@ -57,6 +58,23 @@ validate_with_state(JsonSchema, Value, State) -> SchemaVer = get_schema_ver(JsonSchema, State), select_and_run_validator(SchemaVer, JsonSchema, Value, State). +%% @doc Whether a schema's `$schema' dialect URI is one jesse can validate +%% against. Intended for callers that want to reject a schema declaring an +%% unsupported dialect up-front (e.g. at registration) instead of having every +%% validation fail at run time. Draft 2019-09/2020-12 URIs are accepted with or +%% without a trailing `#'. +-spec is_supported_dialect(SchemaURI :: binary()) -> boolean(). +is_supported_dialect(?json_schema_draft3) -> true; +is_supported_dialect(?json_schema_draft4) -> true; +is_supported_dialect(?json_schema_draft6) -> true; +is_supported_dialect(SchemaURI) when is_binary(SchemaURI) -> + case normalize_schema_ver(SchemaURI) of + ?json_schema_draft2019_09 -> true; + ?json_schema_draft2020_12 -> true; + _ -> false + end; +is_supported_dialect(_) -> false. + %%% Internal functions %% @doc Returns "$schema" property from `JsonSchema' if it is present, %% otherwise the default schema version from `State' is returned. @@ -94,5 +112,33 @@ select_and_run_validator(?json_schema_draft6, JsonSchema, Value, State) -> , jesse_json_path:unwrap_value(JsonSchema) , State ); -select_and_run_validator(SchemaURI, _JsonSchema, _Value, State) -> - jesse_error:handle_schema_invalid({?schema_unsupported, SchemaURI}, State). +select_and_run_validator(SchemaURI, JsonSchema, Value, State) -> + case normalize_schema_ver(SchemaURI) of + ?json_schema_draft2019_09 -> + jesse_validator_draft2019_09:check_value( + Value, jesse_json_path:unwrap_value(JsonSchema), State); + ?json_schema_draft2020_12 -> + jesse_validator_draft2020_12:check_value( + Value, jesse_json_path:unwrap_value(JsonSchema), State); + _ -> + jesse_error:handle_schema_invalid({?schema_unsupported, SchemaURI}, State) + end. + +%% @doc Normalize a "$schema" URI so that draft 2019-09/2020-12 schemas dispatch +%% regardless of a trailing "#" fragment or http/https scheme. Draft 3/4/6 are +%% matched verbatim by the clauses above and never reach here. +%% @private +normalize_schema_ver(SchemaURI) when is_binary(SchemaURI) -> + Stripped = + case SchemaURI of + <> -> Base; + _ -> SchemaURI + end, + case Stripped of + <<"http://json-schema.org/", Rest/binary>> -> + <<"https://json-schema.org/", Rest/binary>>; + _ -> + Stripped + end; +normalize_schema_ver(SchemaURI) -> + SchemaURI. diff --git a/src/jesse_schema_validator.hrl b/src/jesse_schema_validator.hrl index 4ebcd5c1..fb0e35b4 100644 --- a/src/jesse_schema_validator.hrl +++ b/src/jesse_schema_validator.hrl @@ -82,6 +82,26 @@ -define(MAXPROPERTIES, <<"maxProperties">>). -define(MINPROPERTIES, <<"minProperties">>). +%% Keywords introduced in draft 2019-09 / 2020-12 +-define(DEFS, <<"$defs">>). +-define(ANCHOR, <<"$anchor">>). +-define(RECURSIVEANCHOR, <<"$recursiveAnchor">>). +-define(RECURSIVEREF, <<"$recursiveRef">>). +-define(DYNAMICANCHOR, <<"$dynamicAnchor">>). +-define(DYNAMICREF, <<"$dynamicRef">>). +-define(VOCABULARY, <<"$vocabulary">>). +-define(COMMENT, <<"$comment">>). +-define(DEPENDENTREQUIRED, <<"dependentRequired">>). +-define(DEPENDENTSCHEMAS, <<"dependentSchemas">>). +-define(IF, <<"if">>). +-define(THEN, <<"then">>). +-define(ELSE, <<"else">>). +-define(MINCONTAINS, <<"minContains">>). +-define(MAXCONTAINS, <<"maxContains">>). +-define(PREFIXITEMS, <<"prefixItems">>). +-define(UNEVALUATEDPROPERTIES, <<"unevaluatedProperties">>). +-define(UNEVALUATEDITEMS, <<"unevaluatedItems">>). + %% Constant definitions for Json types -define(ANY, <<"any">>). -define(ARRAY, <<"array">>). @@ -96,6 +116,14 @@ -define(json_schema_draft3, <<"http://json-schema.org/draft-03/schema#">>). -define(json_schema_draft4, <<"http://json-schema.org/draft-04/schema#">>). -define(json_schema_draft6, <<"http://json-schema.org/draft-06/schema#">>). +%% Draft 2019-09 and 2020-12 canonical metaschema URIs omit the trailing "#". +%% Incoming "$schema" values are normalized (trailing "#" stripped, scheme +%% coerced to https) before being matched against these, so both the +%% fragment and non-fragment forms dispatch to the right dialect. +-define(json_schema_draft2019_09, + <<"https://json-schema.org/draft/2019-09/schema">>). +-define(json_schema_draft2020_12, + <<"https://json-schema.org/draft/2020-12/schema">>). -define(default_schema_ver, ?json_schema_draft6). -define(default_schema_loader_fun, fun jesse_database:load_uri/1). -define(default_error_handler_fun, fun jesse_error:default_error_handler/3). @@ -107,6 +135,10 @@ -define(schema_invalid, 'schema_invalid'). -define(schema_not_found, 'schema_not_found'). -define(schema_unsupported, 'schema_unsupported'). +%% Raised when a schema uses a keyword that the dialect defines but jesse does +%% not yet implement. Surfacing it (instead of silently ignoring the keyword) +%% prevents dangerous false-acceptance of data the keyword would have rejected. +-define(keyword_not_supported, 'keyword_not_supported'). -define(wrong_all_of_schema_array, 'wrong_all_of_schema_array'). -define(wrong_any_of_schema_array, 'wrong_any_of_schema_array'). -define(wrong_max_properties, 'wrong_max_properties'). diff --git a/src/jesse_state.erl b/src/jesse_state.erl index 16ffddae..c0c090bc 100644 --- a/src/jesse_state.erl +++ b/src/jesse_state.erl @@ -33,6 +33,8 @@ , get_default_schema_ver/1 , get_error_handler/1 , get_error_list/1 + , get_evaluated/1 + , set_evaluated/2 , new/2 , remove_last_from_path/1 , set_allowed_errors/2 @@ -62,6 +64,11 @@ , id :: jesse:schema_id() , root_schema :: jesse:schema() , schema_loader_fun :: jesse:schema_loader_fun() + %% Draft 2019-09+ annotation accumulator for the current schema + %% evaluation: which properties/items have been evaluated so far. + %% An opaque term managed by the dialect validator; unused by + %% draft 3/4/6. + , evaluated :: term() } ). @@ -118,6 +125,17 @@ get_error_handler(#state{error_handler = ErrorHandler}) -> get_error_list(#state{error_list = ErrorList}) -> ErrorList. +%% @doc Getter for the draft 2019-09+ `evaluated' annotation accumulator. +%% The value is opaque here — the dialect validator defines its shape. +-spec get_evaluated(State :: state()) -> term(). +get_evaluated(#state{evaluated = Evaluated}) -> + Evaluated. + +%% @doc Setter for the draft 2019-09+ `evaluated' annotation accumulator. +-spec set_evaluated(State :: state(), Evaluated :: term()) -> state(). +set_evaluated(#state{} = State, Evaluated) -> + State#state{evaluated = Evaluated}. + %% @doc Returns newly created state. -spec new( JsonSchema :: jesse:schema() , Options :: jesse:options() @@ -154,6 +172,7 @@ new(JsonSchema, Options) -> , default_schema_ver = DefaultSchemaVer , schema_loader_fun = LoaderFun , external_validator = ExternalValidator + , evaluated = undefined }, set_current_schema(NewState, JsonSchema). @@ -187,9 +206,10 @@ set_current_schema(#state{ id = Id ListSchema = jesse_json_path:unwrap_value(NewSchema0), [{?REF, Ref} | lists:keydelete(?REF, 1, ListSchema)] end, - IdTag = case jesse_json_path:value(?SCHEMA, NewSchema, DefaultSchemaVer) of - ?json_schema_draft6 -> ?ID; - _ -> ?ID_OLD + SchemaVer = jesse_json_path:value(?SCHEMA, NewSchema, DefaultSchemaVer), + IdTag = case uses_dollar_id(SchemaVer) of + true -> ?ID; + false -> ?ID_OLD end, NewSchemaId = jesse_json_path:value(IdTag, NewSchema, undefined), NewId = combine_id(Id, NewSchemaId), @@ -240,15 +260,97 @@ resolve_ref(State, Reference) -> {RemoteState, RemoteSchema} end end, - Path = jesse_json_path:parse(Pointer), - try load_local_schema(set_current_schema(State1, BaseSchema), Path) - catch throw:?not_found -> - jesse_error:handle_schema_invalid( { ?schema_not_found - , CanonicalReference} - , State1 - ) + case anchor_fragment(Pointer) of + {anchor, Anchor} -> + %% Plain-name fragment (e.g. "#foo"): a draft 2019-09+ "$anchor" + %% (or draft6 "$id" location-independent identifier). Search the base + %% document for a subschema declaring that anchor. + case find_anchor(BaseSchema, Anchor) of + {ok, AnchorSchema} -> + set_current_schema(State1, AnchorSchema); + not_found -> + jesse_error:handle_schema_invalid( { ?schema_not_found + , CanonicalReference} + , State1 + ) + end; + pointer -> + Path = jesse_json_path:parse(Pointer), + try load_local_schema(set_current_schema(State1, BaseSchema), Path) + catch throw:?not_found -> + jesse_error:handle_schema_invalid( { ?schema_not_found + , CanonicalReference} + , State1 + ) + end + end. + +%% @doc Classify the fragment part of a `$ref'. A JSON Pointer fragment is +%% either empty or starts with "/"; anything else is a plain-name anchor. +%% @private +anchor_fragment(Pointer) -> + case iolist_to_binary(Pointer) of + <<>> -> pointer; + <<"/", _/binary>> -> pointer; + Anchor -> {anchor, Anchor} + end. + +%% @doc Recursively search a schema document for a subschema whose "$anchor" +%% equals `Anchor'. Values held by instance-data keywords (enum/const/default/ +%% examples) are skipped, since a "$anchor" buried there is not a real +%% identifier. +%% @private +find_anchor(Schema, Anchor) -> + case jesse_lib:is_json_object(Schema) of + true -> + case jesse_json_path:value(?ANCHOR, Schema, ?not_found) of + Anchor -> + {ok, Schema}; + _ -> + find_anchor_children(jesse_json_path:unwrap_value(Schema), Anchor) + end; + false -> + case jesse_lib:is_array(Schema) of + true -> find_anchor_list(Schema, Anchor); + false -> not_found + end + end. + +%% @private +find_anchor_children([], _Anchor) -> + not_found; +find_anchor_children([{Key, _Value} | Rest], Anchor) + when Key =:= ?ENUM; + Key =:= ?CONST; + Key =:= ?EXAMPLES; + Key =:= <<"default">> -> + find_anchor_children(Rest, Anchor); +find_anchor_children([{_Key, Value} | Rest], Anchor) -> + case find_anchor(Value, Anchor) of + {ok, _} = Found -> Found; + not_found -> find_anchor_children(Rest, Anchor) end. +%% @private +find_anchor_list([], _Anchor) -> + not_found; +find_anchor_list([Item | Rest], Anchor) -> + case find_anchor(Item, Anchor) of + {ok, _} = Found -> Found; + not_found -> find_anchor_list(Rest, Anchor) + end. + +%% @doc Whether a metaschema version uses "$id" (draft6, 2019-09, 2020-12) as +%% opposed to the legacy "id" (draft3/4). Matches both the fragment and +%% non-fragment forms of the 2019-09/2020-12 URIs. +%% @private +uses_dollar_id(?json_schema_draft6) -> true; +uses_dollar_id(?json_schema_draft2019_09) -> true; +uses_dollar_id(<<"https://json-schema.org/draft/2019-09/schema#">>) -> true; +uses_dollar_id(?json_schema_draft2020_12) -> true; +uses_dollar_id(<<"https://json-schema.org/draft/2020-12/schema#">>) -> true; +uses_dollar_id(_) -> false. + %% @doc Revert changes made by resolve_reference. -spec undo_resolve_ref(state(), state()) -> state(). undo_resolve_ref(RefState, OriginalState) -> diff --git a/src/jesse_validator_draft2019_09.erl b/src/jesse_validator_draft2019_09.erl new file mode 100644 index 00000000..7d84eedc --- /dev/null +++ b/src/jesse_validator_draft2019_09.erl @@ -0,0 +1,1314 @@ +%%%============================================================================= +%% Copyright (c) 2026 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%% +%% @doc Json schema validation module for draft 2019-09. +%% +%% https://json-schema.org/draft/2019-09/json-schema-core.html +%% https://json-schema.org/draft/2019-09/json-schema-validation.html +%% +%% This module is forked from `jesse_validator_draft6'. The differences that +%% matter for 2019-09 are: +%% * "$ref" is evaluated alongside sibling keywords instead of replacing +%% them (draft 2019-09 dropped the "ref replaces the whole schema" rule). +%% * "dependencies" was split into "dependentRequired" and "dependentSchemas". +%% * "if"/"then"/"else" conditional application. +%% * "contains" gained the "minContains"/"maxContains" bounds. +%% * "$defs"/"$anchor" identifiers (resolution lives in `jesse_state'). +%% * "format" is annotation-only by default (non-asserting). +%% * "unevaluatedProperties"/"unevaluatedItems": see the annotation model +%% below. +%% +%% == unevaluated* annotation model == +%% +%% "unevaluatedProperties"/"unevaluatedItems" apply to the object properties / +%% array items that were NOT "evaluated" by any adjacent keyword or by a +%% *successful* in-place applicator (allOf/anyOf/oneOf/if-then-else/$ref/ +%% dependentSchemas). To track this, each schema-object evaluation carries an +%% "evaluated" accumulator in `jesse_state' (a `{PropNameSet, ItemIndexSet}'). +%% `check_value/3' resets it on entry (so cousins in separate subschemas can't +%% see each other's annotations) and returns the set for that object; in-place +%% applicators merge the sets of their *passing* subschemas upward; child +%% instance recursion restores the parent's set. `not' never contributes +%% (its subschema must fail). This mirrors the 2019-09 annotation rules and is +%% exercised by the official test-suite cousin/uncle/nested cases. +%% +%% Safety rule: a keyword that this dialect *defines* but jesse does not yet +%% implement (`$recursiveRef') raises `keyword_not_supported' rather than being +%% silently ignored, so it can never false-accept data the keyword would have +%% rejected. Only genuinely annotation-only keywords are ignored. +%% @end +%%%============================================================================= + +-module(jesse_validator_draft2019_09). + +%% API +-export([ check_value/3 + ]). + +%% Includes +-include("jesse_schema_validator.hrl"). + + +-type schema_error() :: ?invalid_dependency + | ?only_ref_allowed + | ?schema_invalid + | ?wrong_all_of_schema_array + | ?wrong_any_of_schema_array + | ?wrong_max_properties + | ?wrong_min_properties + | ?wrong_multiple_of + | ?wrong_one_of_schema_array + | ?wrong_required_array + | ?wrong_type_dependency + | ?wrong_type_items + | ?wrong_type_specification + | ?keyword_not_supported. + +-type schema_error_type() :: schema_error() + | {schema_error(), jesse:json_term()}. + +-type data_error() :: ?all_schemas_not_valid + | ?any_schemas_not_valid + | ?missing_dependency + | ?missing_required_property + | ?no_extra_items_allowed + | ?no_extra_properties_allowed + | ?no_match + | ?not_found + | ?not_in_enum + | ?not_in_range + | ?not_multiple_of + | ?not_one_schema_valid + | ?more_than_one_schema_valid + | ?not_schema_valid + | ?too_few_properties + | ?too_many_properties + | ?wrong_length + | ?wrong_size + | ?wrong_type + | ?external. + +-type data_error_type() :: data_error() + | {data_error(), binary()} + | {data_error(), [jesse_error:error_reason()]}. + +%% The evaluated-annotation accumulator: sets of evaluated property names and +%% item indexes for the schema object currently being validated. +-type evaluated() :: {#{binary() => true}, #{non_neg_integer() => true}}. + +%%% API +%% @doc Validate `Value' against the schema object `JsonSchema'. +%% +%% This is the per-schema-object entry point: it resets the evaluated +%% accumulator, walks the keywords, then applies "unevaluatedProperties"/ +%% "unevaluatedItems" against whatever was left un-evaluated. On return the +%% state's evaluated set describes what this object evaluated, for the caller +%% (an in-place applicator) to merge upward. +-spec check_value( Value :: jesse:json_term() + , JsonSchema :: jesse:schema() + , State :: jesse_state:state() + ) -> jesse_state:state() | no_return(). +check_value(Value, JsonSchema, State0) -> + State1 = set_evaluated(State0, ev_new()), + State2 = walk(Value, JsonSchema, State1), + apply_unevaluated(Value, JsonSchema, State2). + +%%% Internal functions +%% @doc Walk the keyword list of a single schema object, accumulating both +%% validation errors and the evaluated-annotation set. +%% @private +%% Draft 2019-09: "$ref" is evaluated alongside its sibling keywords rather than +%% replacing the schema, so we continue the keyword walk after resolving it. +walk(Value, [{?REF, RefSchemaURI} | Attrs], State) -> + NewState = validate_ref(Value, RefSchemaURI, State), + walk(Value, Attrs, NewState); +walk(Value, [{?TYPE, Type} | Attrs], State) -> + NewState = check_type(Value, Type, State), + walk(Value, Attrs, NewState); +walk(Value, [{?PROPERTIES, Properties} | Attrs], State) -> + NewState = case jesse_lib:is_json_object(Value) of + true -> check_properties( Value + , unwrap(Properties) + , State + ); + false -> State + end, + walk(Value, Attrs, NewState); +walk( Value + , [{?PATTERNPROPERTIES, PatternProperties} | Attrs] + , State + ) -> + NewState = case jesse_lib:is_json_object(Value) of + true -> check_pattern_properties( Value + , PatternProperties + , State + ); + false -> State + end, + walk(Value, Attrs, NewState); +walk( Value + , [{?PROPERTYNAMES, PropertiesSchema} | Attrs] + , State + ) -> + NewState = case jesse_lib:is_json_object(Value) of + true -> check_property_names( Value + , canonical(PropertiesSchema) + , State + ); + false -> State + end, + walk(Value, Attrs, NewState); +walk( Value + , [{?ADDITIONALPROPERTIES, AdditionalProperties} | Attrs] + , State + ) -> + NewState = case jesse_lib:is_json_object(Value) of + true -> check_additional_properties( Value + , AdditionalProperties + , State + ); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?ITEMS, Items} | Attrs], State) -> + NewState = case jesse_lib:is_array(Value) of + true -> check_items(Value, Items, State); + false -> State + end, + walk(Value, Attrs, NewState); +%% "additionalItems" is consumed together with the tuple form of "items". +walk( Value + , [{?ADDITIONALITEMS, _AdditionalItems} | Attrs] + , State + ) -> + walk(Value, Attrs, State); +walk(Value, [{?CONTAINS, Schema} | Attrs], State) -> + NewState = case jesse_lib:is_array(Value) of + true -> check_contains(Value, Schema, State); + false -> State + end, + walk(Value, Attrs, NewState); +%% "minContains"/"maxContains" are consumed together with "contains" (which +%% reads them off the current schema). Standalone, they have no effect. +walk(Value, [{?MINCONTAINS, _} | Attrs], State) -> + walk(Value, Attrs, State); +walk(Value, [{?MAXCONTAINS, _} | Attrs], State) -> + walk(Value, Attrs, State); +walk(Value, [{?REQUIRED, Required} | Attrs], State) -> + NewState = case jesse_lib:is_json_object(Value) of + true -> check_required(Value, Required, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?DEPENDENTREQUIRED, Dependencies} | Attrs], State) -> + NewState = case jesse_lib:is_json_object(Value) of + true -> check_dependent_required(Value, Dependencies, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?DEPENDENTSCHEMAS, Dependencies} | Attrs], State) -> + NewState = case jesse_lib:is_json_object(Value) of + true -> check_dependent_schemas(Value, Dependencies, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?IF, IfSchema} | Attrs], State) -> + NewState = check_if_then_else(Value, canonical(IfSchema), State), + walk(Value, Attrs, NewState); +%% "then"/"else" are applied by the "if" clause above; alone they are inert. +walk(Value, [{?THEN, _} | Attrs], State) -> + walk(Value, Attrs, State); +walk(Value, [{?ELSE, _} | Attrs], State) -> + walk(Value, Attrs, State); +walk(Value, [{?MINIMUM, Minimum} | Attrs], State) -> + NewState = case is_number(Value) of + true -> check_minimum(Value, Minimum, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?EXCLUSIVEMINIMUM, ExclusiveMinimum} | Attrs], State) -> + NewState = case is_number(Value) of + true -> check_exclusive_minimum(Value, ExclusiveMinimum, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?MAXIMUM, Maximum} | Attrs], State) -> + NewState = case is_number(Value) of + true -> check_maximum(Value, Maximum, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?EXCLUSIVEMAXIMUM, ExclusiveMaximum} | Attrs], State) -> + NewState = case is_number(Value) of + true -> check_exclusive_maximum(Value, ExclusiveMaximum, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?MINITEMS, MinItems} | Attrs], State) -> + NewState = case jesse_lib:is_array(Value) of + true -> check_min_items(Value, MinItems, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?MAXITEMS, MaxItems} | Attrs], State) -> + NewState = case jesse_lib:is_array(Value) of + true -> check_max_items(Value, MaxItems, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?UNIQUEITEMS, Uniqueitems} | Attrs], State) -> + NewState = case jesse_lib:is_array(Value) of + true -> check_unique_items(Value, Uniqueitems, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?PATTERN, Pattern} | Attrs], State) -> + NewState = case is_binary(Value) of + true -> check_pattern(Value, Pattern, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?MINLENGTH, MinLength} | Attrs], State) -> + NewState = case is_binary(Value) of + true -> check_min_length(Value, MinLength, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?MAXLENGTH, MaxLength} | Attrs], State) -> + NewState = case is_binary(Value) of + true -> check_max_length(Value, MaxLength, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?ENUM, Enum} | Attrs], State) -> + NewState = check_enum(Value, Enum, State), + walk(Value, Attrs, NewState); +walk(Value, [{?CONST, Const} | Attrs], State) -> + NewState = check_enum(Value, [Const], State), + walk(Value, Attrs, NewState); +%% Draft 2019-09 "format" is annotation-only by default (the format-assertion +%% vocabulary is opt-in and not implemented here), so it never asserts. +walk(Value, [{?FORMAT, _Format} | Attrs], State) -> + walk(Value, Attrs, State); +walk(Value, [{?MULTIPLEOF, Multiple} | Attrs], State) -> + NewState = case is_number(Value) of + true -> check_multiple_of(Value, Multiple, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?MAXPROPERTIES, MaxProperties} | Attrs], State) -> + NewState = case jesse_lib:is_json_object(Value) of + true -> check_max_properties(Value, MaxProperties, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?MINPROPERTIES, MinProperties} | Attrs], State) -> + NewState = case jesse_lib:is_json_object(Value) of + true -> check_min_properties(Value, MinProperties, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?ALLOF, Schemas} | Attrs], State) -> + NewState = check_all_of(Value, Schemas, State), + walk(Value, Attrs, NewState); +walk(Value, [{?ANYOF, Schemas} | Attrs], State) -> + NewState = check_any_of(Value, Schemas, State), + walk(Value, Attrs, NewState); +walk(Value, [{?ONEOF, Schemas} | Attrs], State) -> + NewState = check_one_of(Value, Schemas, State), + walk(Value, Attrs, NewState); +walk(Value, [{?NOT, Schema} | Attrs], State) -> + NewState = check_not(Value, canonical(Schema), State), + walk(Value, Attrs, NewState); +%% "unevaluatedProperties"/"unevaluatedItems" are deferred to apply_unevaluated, +%% which runs after every adjacent keyword and in-place applicator. +walk(Value, [{?UNEVALUATEDPROPERTIES, _} | Attrs], State) -> + walk(Value, Attrs, State); +walk(Value, [{?UNEVALUATEDITEMS, _} | Attrs], State) -> + walk(Value, Attrs, State); +%% Defined-but-not-yet-implemented keyword: surface an error instead of +%% silently ignoring it (which would false-accept). See milestone J3. +walk(Value, [{?RECURSIVEREF, _} | Attrs], State) -> + NewState = unsupported_keyword(?RECURSIVEREF, State), + walk(Value, Attrs, NewState); +walk(Value, Bool, State) when is_boolean(Bool) -> + %% Boolean schemas: true always passes, false always fails. + walk(Value, unwrap(canonical(Bool)), State); +walk(Value, [], State) -> + maybe_external_check_value(Value, State); +%% Unknown keywords (including "$id", "$anchor", "$defs", "$comment", +%% "$vocabulary", "$recursiveAnchor", "definitions", the annotation/metadata +%% keywords, and any content-vocabulary keyword) carry no assertion and are +%% ignored, per spec. Identifier keywords are consumed by `jesse_state'. +walk(Value, [_Attr | Attrs], State) -> + walk(Value, Attrs, State). + +%% @doc Raise a schema error for a keyword the dialect defines but which jesse +%% does not implement yet, so it surfaces instead of false-accepting. +%% @private +unsupported_keyword(Keyword, State) -> + handle_schema_invalid({?keyword_not_supported, Keyword}, State). + +%% @doc Validate a child instance (property value / array item) against its +%% subschema. The child is a fresh schema-object evaluation with its own +%% evaluated set, so we restore the parent's evaluated set afterward — the +%% parent keyword handler is responsible for recording the child key/index. +%% @private +check_value(Property, Value, Attrs, State) -> + ParentEvaluated = get_evaluated(State), + State1 = add_to_path(State, Property), + State2 = jesse_schema_validator:validate_with_state(Attrs, Value, State1), + State3 = remove_last_from_path(State2), + set_evaluated(State3, ParentEvaluated). + +%%============================================================================= +%% Evaluated-annotation accumulator helpers +%% @private +-spec ev_new() -> evaluated(). +ev_new() -> {#{}, #{}}. + +%% @private +ev_add_props(State, Names) -> + {Props, Items} = get_evaluated(State), + Props1 = lists:foldl(fun(N, Acc) -> Acc#{N => true} end, Props, Names), + set_evaluated(State, {Props1, Items}). + +%% @private +ev_add_items(State, Indexes) -> + {Props, Items} = get_evaluated(State), + Items1 = lists:foldl(fun(I, Acc) -> Acc#{I => true} end, Items, Indexes), + set_evaluated(State, {Props, Items1}). + +%% @doc Merge the evaluated set of a successful in-place applicator subschema +%% (`From') into the base state. Used to propagate annotations upward. +%% @private +ev_merge(Base, From) -> + {Pb, Ib} = get_evaluated(Base), + {Pf, If} = get_evaluated(From), + set_evaluated(Base, {maps:merge(Pb, Pf), maps:merge(Ib, If)}). + +%% @doc 5.5.2. type +%% @private +check_type(Value, Type, State) -> + try + IsValid = case jesse_lib:is_array(Type) of + true -> check_union_type(Value, Type, State); + false -> is_type_valid(Value, Type) + end, + case IsValid of + true -> State; + false -> wrong_type(Value, State) + end + catch + %% The schema was invalid + error:function_clause -> + handle_schema_invalid(?wrong_type_specification, State) + end. + + +%% @private +is_type_valid(Value, ?STRING) -> is_binary(Value); +is_type_valid(Value, ?NUMBER) -> is_number(Value); +is_type_valid(Value, ?INTEGER) when is_float(Value) -> + (Value - trunc(Value)) == 0.0; +is_type_valid(Value, ?INTEGER) -> is_integer(Value); +is_type_valid(Value, ?BOOLEAN) -> is_boolean(Value); +is_type_valid(Value, ?OBJECT) -> jesse_lib:is_json_object(Value); +is_type_valid(Value, ?ARRAY) -> jesse_lib:is_array(Value); +is_type_valid(Value, ?NULL) -> jesse_lib:is_null(Value). + +%% @private +check_union_type(Value, [_ | _] = UnionType, _State) -> + lists:any(fun(Type) -> is_type_valid(Value, Type) end, UnionType); +check_union_type(_Value, _InvalidTypes, State) -> + handle_schema_invalid(?wrong_type_specification, State). + +%% @private +wrong_type(Value, State) -> + handle_data_invalid(?wrong_type, Value, State). + + +%% @doc properties. Records every present, matching property as evaluated. +%% @private +check_properties(Value, Properties, State) -> + TmpState + = lists:foldl( fun({PropertyName, PropertySchema}, CurrentState) -> + case get_value(PropertyName, Value) of + ?not_found -> + CurrentState; + Property -> + NewState = set_current_schema( + CurrentState + , canonical(PropertySchema)), + check_value( PropertyName + , Property + , canonical(PropertySchema) + , NewState + ) + end + end + , State + , Properties + ), + State1 = set_current_schema(TmpState, get_current_schema(State)), + PresentNames = [ PN || {PN, _} <- Properties + , get_value(PN, Value) =/= ?not_found ], + ev_add_props(State1, PresentNames). + +%% @doc patternProperties. Records every property matching a pattern as +%% evaluated. +%% @private +check_pattern_properties(Value, PatternProperties, State) -> + P1P2 = [{P1, P2} || P1 <- unwrap(Value), + P2 <- unwrap(PatternProperties)], + TmpState = lists:foldl( fun({Property, Pattern}, CurrentState) -> + check_match(Property, Pattern, CurrentState) + end + , State + , P1P2 + ), + State1 = set_current_schema(TmpState, get_current_schema(State)), + Matched = [ PN + || {PN, _} <- unwrap(Value) + , {Pat, _} <- unwrap(PatternProperties) + , jesse_lib:re_run(PN, Pat) =:= match ], + ev_add_props(State1, Matched). + +check_property_names(Value, PropertiesSchema, State) -> + SubState = set_current_schema(State , PropertiesSchema), + TmpState = lists:foldl( + fun({PropertyName, _Value}, CurrentState) -> + check_value( PropertyName + , PropertyName + , PropertiesSchema + , CurrentState) + end + , SubState + , unwrap(Value) + ), + set_current_schema(TmpState, get_current_schema(State)). + +%% @private +check_match({PropertyName, PropertyValue}, {Pattern, Schema0}, State) -> + Schema = canonical(Schema0), + case jesse_lib:re_run(PropertyName, Pattern) of + match -> + check_value( PropertyName + , PropertyValue + , Schema + , set_current_schema(State, Schema) + ); + nomatch -> + State + end. + +%% @doc additionalProperties. Records the "additional" properties (those not +%% covered by properties/patternProperties) as evaluated when the keyword +%% permits them. +%% @private +check_additional_properties(Value, false, State) -> + case additional_property_names(Value, State) of + [] -> State; + Extras -> + lists:foldl( fun(Property, State1) -> + State2 + = handle_data_invalid( ?no_extra_properties_allowed + , Value + , add_to_path(State1, Property) + ), + remove_last_from_path(State2) + end + , State + , Extras + ) + end; +check_additional_properties(Value, true, State) -> + ev_add_props(State, additional_property_names(Value, State)); +check_additional_properties(Value, AdditionalProperties, State) -> + JsonSchema = get_current_schema(State), + case additional_property_names(Value, State) of + [] -> State; + Extras -> + TmpState + = lists:foldl( fun(ExtraName, CurrentState) -> + NewState = set_current_schema( CurrentState + , AdditionalProperties + ), + check_value( ExtraName + , get_value(ExtraName, Value) + , AdditionalProperties + , NewState + ) + end + , State + , Extras + ), + State1 = set_current_schema(TmpState, JsonSchema), + ev_add_props(State1, Extras) + end. + +%% @doc Names of the properties not covered by "properties" or +%% "patternProperties" of the current schema. +%% @private +additional_property_names(Value, State) -> + JsonSchema = get_current_schema(State), + Properties = empty_if_not_found(get_value(?PROPERTIES, JsonSchema)), + PatternProperties = empty_if_not_found(get_value( ?PATTERNPROPERTIES + , JsonSchema)), + ValuePropertiesNames = [Name || {Name, _} <- unwrap(Value)], + SchemaPropertiesNames = [Name || {Name, _} <- unwrap(Properties)], + Patterns = [Pattern || {Pattern, _} <- unwrap(PatternProperties)], + ExtraNames0 = lists:subtract(ValuePropertiesNames, SchemaPropertiesNames), + lists:foldl( fun(Pattern, ExtraAcc) -> + filter_extra_names(Pattern, ExtraAcc) + end + , ExtraNames0 + , Patterns + ). + +%% @private +filter_extra_names(Pattern, ExtraNames) -> + Filter = fun(ExtraName) -> + case jesse_lib:re_run(ExtraName, Pattern) of + match -> false; + nomatch -> true + end + end, + lists:filter(Filter, ExtraNames). + +%% @doc items / additionalItems. Records the covered indexes as evaluated. +%% @private +check_items(Value, Items0, State) -> + case jesse_lib:is_json_object(Items0) orelse is_boolean(Items0) of + true -> + %% Single-schema form: applies to (and evaluates) every item. + Items = canonical(Items0), + {_, TmpState} = lists:foldl( fun(Item, {Index, CurrentState}) -> + { Index + 1 + , check_value( Index + , Item + , Items + , CurrentState + ) + } + end + , {0, set_current_schema(State, Items)} + , Value + ), + State1 = set_current_schema(TmpState, get_current_schema(State)), + ev_add_items(State1, lists:seq(0, length(Value) - 1)); + false when is_list(Items0) -> + check_items_array(Value, lists:map(fun canonical/1, Items0), State); + _ -> + handle_schema_invalid({?wrong_type_items, Items0}, State) + end. + +%% @doc contains / minContains / maxContains +%% +%% An array is valid if the number of elements matching the "contains" schema is +%% at least "minContains" (default 1) and at most "maxContains" (default +%% unbounded). "minContains" of 0 makes "contains" trivially satisfied. Matching +%% items are recorded as evaluated. +%% @private +check_contains(Values, Schema0, State) -> + Schema = canonical(Schema0), + JsonSchema = get_current_schema(State), + MinContains = contains_bound(get_value(?MINCONTAINS, JsonSchema), 1), + MaxContains = contains_bound(get_value(?MAXCONTAINS, JsonSchema), ?infinity), + MatchedIndexes = contains_matches(Values, Schema, State), + MatchCount = length(MatchedIndexes), + case in_contains_range(MatchCount, MinContains, MaxContains) of + true -> ev_add_items(State, MatchedIndexes); + false -> handle_data_invalid(?data_invalid, Values, State) + end. + +%% @private +contains_bound(?not_found, Default) -> Default; +contains_bound(Value, _Default) -> Value. + +%% @private +in_contains_range(Count, Min, Max) -> + Count >= Min andalso (Max =:= ?infinity orelse Count =< Max). + +%% @doc Returns the indexes of the array elements matching the schema. +%% @private +contains_matches(Values, Schema, State) -> + {_, Matched} = + lists:foldl( fun(Value, {Index, Acc}) -> + case validate_schema(Value, Schema, State) of + {true, _} -> {Index + 1, [Index | Acc]}; + {false, _} -> {Index + 1, Acc} + end + end + , {0, []} + , Values + ), + lists:reverse(Matched). + +%% @private +check_items_array(Value, Items, State) -> + JsonSchema = get_current_schema(State), + NExtra = length(Value) - length(Items), + TupleCount = min(length(Value), length(Items)), + case NExtra > 0 of + true -> + case get_value(?ADDITIONALITEMS, JsonSchema) of + ?not_found -> + %% Only the tuple positions are evaluated; the rest are left for + %% "unevaluatedItems". + State1 = check_items_fun(lists:zip( lists:sublist(Value, TupleCount) + , Items) + , State), + ev_add_items(State1, lists:seq(0, TupleCount - 1)); + true -> + State1 = check_items_fun(lists:zip( lists:sublist(Value, TupleCount) + , Items) + , State), + ev_add_items(State1, lists:seq(0, length(Value) - 1)); + false -> + handle_data_invalid(?no_extra_items_allowed, Value, State); + AdditionalItems -> + ExtraSchemas = lists:duplicate(NExtra, AdditionalItems), + Tuples = lists:zip(Value, lists:append(Items, ExtraSchemas)), + State1 = check_items_fun(Tuples, State), + ev_add_items(State1, lists:seq(0, length(Value) - 1)) + end; + false -> + RelevantItems = case NExtra of + 0 -> Items; + _ -> lists:sublist(Items, length(Value)) + end, + State1 = check_items_fun(lists:zip(Value, RelevantItems), State), + ev_add_items(State1, lists:seq(0, length(Value) - 1)) + end. + +%% @private +check_items_fun(Tuples, State) -> + {_, TmpState} = lists:foldl( fun({Item, Schema}, {Index, CurrentState}) -> + NewState = set_current_schema( CurrentState + , Schema + ), + { Index + 1 + , check_value(Index, Item, Schema, NewState) + } + end + , {0, State} + , Tuples + ), + set_current_schema(TmpState, get_current_schema(State)). + +%% @doc dependentRequired +%% +%% Object keyword. Value is a map of property-name -> array of property names +%% that must also be present when the key property is present. +%% @private +check_dependent_required(Value, Dependencies, State) -> + lists:foldl( fun({DependencyName, RequiredNames}, CurrentState) -> + case get_value(DependencyName, Value) of + ?not_found -> CurrentState; + _ -> check_dependency_array( Value + , DependencyName + , RequiredNames + , CurrentState + ) + end + end + , State + , unwrap(Dependencies) + ). + +%% @doc dependentSchemas +%% +%% Object keyword. Value is a map of property-name -> subschema that the whole +%% instance must validate against when the key property is present. Each +%% dependent schema is an in-place applicator, so its evaluated set merges up. +%% @private +check_dependent_schemas(Value, Dependencies, State) -> + lists:foldl( fun({DependencyName, DependencySchema}, CurrentState) -> + case get_value(DependencyName, Value) of + ?not_found -> CurrentState; + _ -> apply_dependent_schema( + Value + , canonical(DependencySchema) + , CurrentState + ) + end + end + , State + , unwrap(Dependencies) + ). + +%% @private +apply_dependent_schema(Value, DependencySchema, State) -> + case jesse_lib:is_json_object(DependencySchema) of + true -> + case validate_schema(Value, DependencySchema, State) of + {true, SubState} -> + ev_merge(State, SubState); + {false, Errors} -> + handle_data_invalid({?all_schemas_not_valid, Errors}, Value, State) + end; + false -> + handle_schema_invalid({?wrong_type_dependency, DependencySchema}, State) + end. + +check_dependency(Value, Dependency, State) + when is_binary(Dependency) -> + case get_value(Dependency, Value) of + ?not_found -> + handle_data_invalid({?missing_dependency, Dependency}, Value, State); + _ -> + State + end; +check_dependency(_Value, _Dependency, State) -> + handle_schema_invalid(?invalid_dependency, State). + +%% @private +check_dependency_array(Value, DependencyName, Dependency, State) + when is_list(Dependency) -> + lists:foldl( fun(PropertyName, CurrentState) -> + case get_value(DependencyName, Value) of + ?not_found -> + CurrentState; + _Exists -> + check_dependency( Value + , PropertyName + , CurrentState + ) + end + end + , State + , Dependency + ); +check_dependency_array(_Value, _DependencyName, Dependency, State) -> + handle_schema_invalid({?wrong_type_dependency, Dependency}, State). + +%% @doc if / then / else +%% +%% If the instance validates against "if", it must validate against "then" +%% (when present); otherwise it must validate against "else" (when present). +%% "if" itself never produces validation errors of its own, but when it passes +%% its annotations (and the branch's) are merged in. +%% @private +check_if_then_else(Value, IfSchema, State) -> + JsonSchema = get_current_schema(State), + case validate_schema(Value, IfSchema, State) of + {true, IfSub} -> + State1 = ev_merge(State, IfSub), + apply_branch(Value, ?THEN, JsonSchema, State1); + {false, _} -> + apply_branch(Value, ?ELSE, JsonSchema, State) + end. + +%% @private +apply_branch(Value, Keyword, JsonSchema, State) -> + case get_value(Keyword, JsonSchema) of + ?not_found -> + State; + BranchSchema -> + case validate_schema(Value, canonical(BranchSchema), State) of + {true, SubState} -> + ev_merge(State, SubState); + {false, Errors} -> + handle_data_invalid({?not_schema_valid, Errors}, Value, State) + end + end. + +%% @doc minimum / exclusiveMinimum +%% @private +check_minimum(Value, Minimum, State) -> + case (Value >= Minimum) of + true -> State; + false -> handle_data_invalid(?not_in_range, Value, State) + end. + +check_exclusive_minimum(Value, ExclusiveMinimum, State) -> + case (Value > ExclusiveMinimum) of + true -> State; + false -> handle_data_invalid(?not_in_range, Value, State) + end. + +%% @doc maximum / exclusiveMaximum +%% @private +check_maximum(Value, Maximum, State) -> + case (Value =< Maximum) of + true -> State; + false -> handle_data_invalid(?not_in_range, Value, State) + end. + +check_exclusive_maximum(Value, ExclusiveMaximum, State) -> + case (Value < ExclusiveMaximum) of + true -> State; + false -> handle_data_invalid(?not_in_range, Value, State) + end. + +%% @doc minItems +%% @private +check_min_items(Value, MinItems, State) when length(Value) >= MinItems -> + State; +check_min_items(Value, _MinItems, State) -> + handle_data_invalid(?wrong_size, Value, State). + +%% @doc maxItems +%% @private +check_max_items(Value, MaxItems, State) when length(Value) =< MaxItems -> + State; +check_max_items(Value, _MaxItems, State) -> + handle_data_invalid(?wrong_size, Value, State). + +%% @doc uniqueItems +%% @private +check_unique_items(_, false, State) -> + State; +check_unique_items([], true, State) -> + State; +check_unique_items([_], true, State) -> + State; +check_unique_items(Value, true, State) -> + try + NormalizedValue = jesse_lib:normalize_and_sort(Value), + NoDuplicates = ?SET_FROM_LIST(NormalizedValue), + case sets:size(NoDuplicates) == length(Value) of + true -> State; + false -> + lists:foldl( fun compare_rest_items/2 + , tl(Value) + , Value + ), + State + end + catch + throw:ErrorInfo -> handle_data_invalid(ErrorInfo, Value, State) + end. + +%% @private +compare_rest_items(_Item, []) -> + ok; +compare_rest_items(Item, RestItems) -> + lists:foreach( fun(ItemFromRest) -> + case jesse_lib:is_equal(Item, ItemFromRest) of + true -> throw({?not_unique, Item}); + false -> ok + end + end + , RestItems + ), + tl(RestItems). + +%% @doc pattern +%% @private +check_pattern(Value, Pattern, State) -> + case jesse_lib:re_run(Value, Pattern) of + match -> State; + nomatch -> handle_data_invalid(?no_match, Value, State) + end. + +%% @doc minLength +%% @private +check_min_length(Value, MinLength, State) -> + case length(unicode:characters_to_list(Value)) >= MinLength of + true -> State; + false -> handle_data_invalid(?wrong_length, Value, State) + end. + +%% @doc maxLength +%% @private +check_max_length(Value, MaxLength, State) -> + case length(unicode:characters_to_list(Value)) =< MaxLength of + true -> State; + false -> handle_data_invalid(?wrong_length, Value, State) + end. + +%% @doc enum / const +%% @private +check_enum(Value, Enum, State) -> + IsValid = lists:any( fun(ExpectedValue) -> + jesse_lib:is_equal(Value, ExpectedValue) + end + , Enum + ), + case IsValid of + true -> State; + false -> handle_data_invalid(?not_in_enum, Value, State) + end. + +%% @doc multipleOf +%% @private +check_multiple_of(Value, MultipleOf, State) + when is_number(MultipleOf), MultipleOf > 0 -> + try (Value / MultipleOf - trunc(Value / MultipleOf)) * MultipleOf == 0.0 of + true -> State; + _ -> handle_data_invalid(?not_multiple_of, Value, State) + catch error:badarith -> + %% eg, division by zero or overflow + handle_schema_invalid(?wrong_multiple_of, State) + end; +check_multiple_of(_Value, _MultipleOf, State) -> + handle_schema_invalid(?wrong_multiple_of, State). + +%% @doc required +%% @private +check_required(Value, [] = Required, State) -> + check_required_values(Value, Required, State); +check_required(Value, [_ | _] = Required, State) -> + check_required_values(Value, Required, State); +check_required(_Value, _InvalidRequired, State) -> + handle_schema_invalid(?wrong_required_array, State). + +check_required_values(_Value, [], State) -> State; +check_required_values(Value, [PropertyName | Required], State) -> + case get_value(PropertyName, Value) =/= ?not_found of + 'false' -> + NewState = + handle_data_invalid(?missing_required_property, PropertyName, State), + check_required_values(Value, Required, NewState); + 'true' -> + check_required_values(Value, Required, State) + end. + +%% @doc maxProperties +%% @private +check_max_properties(Value, MaxProperties, State) + when is_integer(MaxProperties), MaxProperties >= 0 -> + case length(unwrap(Value)) =< MaxProperties of + true -> State; + false -> handle_data_invalid(?too_many_properties, Value, State) + end; +check_max_properties(_Value, _MaxProperties, State) -> + handle_schema_invalid(?wrong_max_properties, State). + +%% @doc minProperties +%% @private +check_min_properties(Value, MinProperties, State) + when is_integer(MinProperties), MinProperties >= 0 -> + case length(unwrap(Value)) >= MinProperties of + true -> State; + false -> handle_data_invalid(?too_few_properties, Value, State) + end; +check_min_properties(_Value, _MaxProperties, State) -> + handle_schema_invalid(?wrong_min_properties, State). + +%% @doc allOf. Every subschema must pass; the union of their evaluated sets is +%% propagated up. +%% @private +check_all_of(Value, [_ | _] = Schemas, State) -> + check_all_of_(Value, Schemas, State); +check_all_of(_Value, _InvalidSchemas, State) -> + handle_schema_invalid(?wrong_all_of_schema_array, State). + +check_all_of_(_Value, [], State) -> + State; +check_all_of_(Value, [Schema | Schemas], State) -> + case validate_schema(Value, Schema, State) of + {true, NewState} -> + check_all_of_(Value, Schemas, ev_merge(NewState, State)); + {false, Errors} -> + handle_data_invalid({?all_schemas_not_valid, Errors}, Value, State) + end. + +%% @doc anyOf. Valid if at least one subschema passes; annotations from *all* +%% passing subschemas are merged in (required for "unevaluatedProperties with +%% anyOf" where several branches match). +%% @private +check_any_of(Value, [_ | _] = Schemas, State) -> + {AnyValid, MergedState, ShortestErrors} = + lists:foldl( fun(Schema, {Valid, StateAcc, Errors}) -> + case validate_schema(Value, Schema, State) of + {true, SubState} -> + {true, ev_merge(StateAcc, SubState), Errors}; + {false, NewErrors} -> + {Valid, StateAcc, shortest(NewErrors, Errors)} + end + end + , {false, State, empty} + , Schemas + ), + case AnyValid of + true -> MergedState; + false -> any_of_error(Value, State, ShortestErrors) + end; +check_any_of(_Value, _InvalidSchemas, State) -> + handle_schema_invalid(?wrong_any_of_schema_array, State). + +%% @private +any_of_error(Value, State, empty) -> + handle_data_invalid(?any_schemas_not_valid, Value, State); +any_of_error(Value, State, Errors) -> + handle_data_invalid({?any_schemas_not_valid, Errors}, Value, State). + +%% @doc oneOf. Valid if exactly one subschema passes; that subschema's +%% evaluated set is merged in. +%% @private +check_one_of(Value, [_ | _] = Schemas, State) -> + {ValidCount, ValidSubState, Errors} = + lists:foldl( fun(Schema, {Count, SubAcc, ErrAcc}) -> + case validate_schema(Value, Schema, State) of + {true, SubState} -> + {Count + 1, SubState, ErrAcc}; + {false, NewErrors} -> + {Count, SubAcc, ErrAcc ++ NewErrors} + end + end + , {0, undefined, []} + , Schemas + ), + case ValidCount of + 1 -> ev_merge(State, ValidSubState); + 0 -> handle_data_invalid({?not_one_schema_valid, Errors}, Value, State); + _ -> handle_data_invalid(?more_than_one_schema_valid, Value, State) + end; +check_one_of(_Value, _InvalidSchemas, State) -> + handle_schema_invalid(?wrong_one_of_schema_array, State). + +%% @doc not. The subschema must fail; "not" never contributes annotations. +%% @private +check_not(Value, Schema, State) -> + case validate_schema(Value, Schema, State) of + {true, _} -> handle_data_invalid(?not_schema_valid, Value, State); + {false, _} -> State + end. + +%% @doc unevaluatedProperties / unevaluatedItems. Applied after every adjacent +%% keyword and in-place applicator has contributed to the evaluated set. +%% @private +apply_unevaluated(Value, Schema, State) when is_list(Schema) -> + State1 = apply_unevaluated_properties(Value, Schema, State), + apply_unevaluated_items(Value, Schema, State1); +apply_unevaluated(_Value, _Schema, State) -> + State. + +%% @private +apply_unevaluated_properties(Value, Schema, State) -> + case schema_keyword(?UNEVALUATEDPROPERTIES, Schema) of + ?not_found -> + State; + UnevalSchema -> + case jesse_lib:is_json_object(Value) of + true -> + check_unevaluated_properties(Value, canonical(UnevalSchema), State); + false -> + State + end + end. + +%% @private +check_unevaluated_properties(Value, UnevalSchema, State) -> + {EvaluatedProps, _} = get_evaluated(State), + Leftover = [ {N, V} || {N, V} <- unwrap(Value) + , not maps:is_key(N, EvaluatedProps) ], + case Leftover of + [] -> + State; + _ -> + TmpState = + lists:foldl( fun({N, V}, CurrentState) -> + NewState = set_current_schema( CurrentState + , UnevalSchema), + check_value(N, V, UnevalSchema, NewState) + end + , State + , Leftover + ), + State1 = set_current_schema(TmpState, get_current_schema(State)), + %% The leftovers are now evaluated; record them so that an enclosing + %% "unevaluatedProperties" (via an in-place applicator) sees them. + ev_add_props(State1, [N || {N, _} <- Leftover]) + end. + +%% @private +apply_unevaluated_items(Value, Schema, State) -> + case schema_keyword(?UNEVALUATEDITEMS, Schema) of + ?not_found -> + State; + UnevalSchema -> + case jesse_lib:is_array(Value) of + true -> + check_unevaluated_items(Value, canonical(UnevalSchema), State); + false -> + State + end + end. + +%% @private +check_unevaluated_items(Value, UnevalSchema, State) -> + {_, EvaluatedItems} = get_evaluated(State), + Indexed = lists:zip(lists:seq(0, length(Value) - 1), Value), + Leftover = [ {I, V} || {I, V} <- Indexed + , not maps:is_key(I, EvaluatedItems) ], + case Leftover of + [] -> + State; + _ -> + TmpState = + lists:foldl( fun({I, V}, CurrentState) -> + NewState = set_current_schema( CurrentState + , UnevalSchema), + check_value(I, V, UnevalSchema, NewState) + end + , State + , Leftover + ), + State1 = set_current_schema(TmpState, get_current_schema(State)), + ev_add_items(State1, [I || {I, _} <- Leftover]) + end. + +%% @doc Validate a value against a schema, returning the resulting state (which +%% carries the subschema's evaluated set) on success. Used by all in-place +%% applicators to run and roll back a subschema evaluation. +%% @private +validate_schema(Value, Schema0, State0) -> + Schema = canonical(Schema0), + try + case jesse_lib:is_json_object(Schema) of + true -> + State1 = set_current_schema(State0, Schema), + State2 = jesse_schema_validator:validate_with_state( Schema + , Value + , State1 + ), + {true, set_current_schema(State2, get_current_schema(State0))}; + false -> + handle_schema_invalid(?schema_invalid, State0) + end + catch + throw:Errors -> {false, Errors} + end. + +canonical(true) -> + #{}; +canonical(false) -> + #{?NOT => #{}}; +canonical(MaybeObject) -> + MaybeObject. + +%% @private +validate_ref(Value, Reference, State) -> + ParentEvaluated = get_evaluated(State), + ResultState = + case resolve_ref(Reference, State) of + {error, NewState} -> + undo_resolve_ref(NewState, State); + {ok, NewState, Schema0} -> + Schema = canonical(Schema0), + RefState = + jesse_schema_validator:validate_with_state(Schema, Value, NewState), + undo_resolve_ref(RefState, State) + end, + %% "$ref" is an in-place applicator: merge the ref target's evaluated set + %% into the referring schema's. + {RefProps, RefItems} = get_evaluated(ResultState), + {PProps, PItems} = ParentEvaluated, + set_evaluated( ResultState + , {maps:merge(PProps, RefProps), maps:merge(PItems, RefItems)}). + +%% @doc Resolve a JSON reference +%% The "$id" keyword is taken care of behind the scenes in jesse_state. +%% @private +resolve_ref(Reference, State) -> + CurrentErrors = jesse_state:get_error_list(State), + NewState = jesse_state:resolve_ref(State, Reference), + NewErrors = jesse_state:get_error_list(NewState), + case length(CurrentErrors) =:= length(NewErrors) of + true -> + Schema = get_current_schema(NewState), + {ok, NewState, Schema}; + false -> {error, NewState} + end. + +undo_resolve_ref(State, OriginalState) -> + jesse_state:undo_resolve_ref(State, OriginalState). + +%%============================================================================= +%% Wrappers +%% @private +get_value(Key, Schema) -> + jesse_json_path:value(Key, Schema, ?not_found). + +%% @doc Look up a schema keyword by exact key, returning `?not_found' when +%% absent. Unlike `get_value/2' this uses a plain proplist lookup and so is +%% safe on the empty object (which `jesse_json_path' otherwise treats as a +%% KVC list and returns `[]' for any key). +%% @private +schema_keyword(Key, Schema) -> + case lists:keyfind(Key, 1, unwrap(Schema)) of + {_, Value} -> Value; + false -> ?not_found + end. + +%% @private +unwrap(Value) -> + jesse_json_path:unwrap_value(Value). + +%% @private +get_evaluated(State) -> + jesse_state:get_evaluated(State). + +%% @private +set_evaluated(State, Evaluated) -> + jesse_state:set_evaluated(State, Evaluated). + +%% @private +-spec handle_data_invalid( Info :: data_error_type() + , Value :: jesse:json_term() + , State :: jesse_state:state() + ) -> jesse_state:state(). +handle_data_invalid(Info, Value, State) -> + jesse_error:handle_data_invalid(Info, Value, State). + +%% @private +-spec handle_schema_invalid( Info :: schema_error_type() + , State :: jesse_state:state() + ) -> jesse_state:state(). +handle_schema_invalid(Info, State) -> + jesse_error:handle_schema_invalid(Info, State). + +%% @private +get_current_schema(State) -> + jesse_state:get_current_schema(State). + +%% @private +set_current_schema(State, NewSchema) -> + jesse_state:set_current_schema(State, NewSchema). + +%% @private +empty_if_not_found(Value) -> + jesse_lib:empty_if_not_found(Value). + +%% @private +add_to_path(State, Property) -> + jesse_state:add_to_path(State, Property). + +%% @private +remove_last_from_path(State) -> + jesse_state:remove_last_from_path(State). + +maybe_external_check_value(Value, State) -> + case jesse_state:get_external_validator(State) of + undefined -> + State; + Fun -> + Fun(Value, State) + end. + +%% @private +-spec shortest(list() | empty, list() | empty) -> list() | empty. +shortest(X, empty) -> + X; +shortest(empty, Y) -> + Y; +shortest(X, Y) when length(X) < length(Y) -> + X; +shortest(_, Y) -> + Y. diff --git a/src/jesse_validator_draft2020_12.erl b/src/jesse_validator_draft2020_12.erl new file mode 100644 index 00000000..a2ce0e19 --- /dev/null +++ b/src/jesse_validator_draft2020_12.erl @@ -0,0 +1,1310 @@ +%%%============================================================================= +%% Copyright (c) 2026 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%% +%% @doc Json schema validation module for draft 2020-12. +%% +%% https://json-schema.org/draft/2020-12/json-schema-core.html +%% https://json-schema.org/draft/2020-12/json-schema-validation.html +%% +%% This module is forked from `jesse_validator_draft2019_09'. Draft 2020-12 is +%% identical to 2019-09 except for the array keywords and the dynamic +%% referencing keywords: +%% * The tuple form of "items" became "prefixItems"; "items" is now the +%% single-schema applicator for the items *after* any "prefixItems" +%% (i.e. it replaced 2019-09 "additionalItems"). "additionalItems" is gone. +%% * "$dynamicRef"/"$dynamicAnchor" replaced "$recursiveRef"/ +%% "$recursiveAnchor". "$dynamicRef" is not yet implemented — see the safety +%% rule below. +%% +%% Everything else — "$ref" alongside siblings, dependentRequired/ +%% dependentSchemas, if/then/else, min/maxContains, $defs/$anchor, +%% annotation-only format, and the unevaluated* annotation model — is inherited +%% unchanged from 2019-09. +%% +%% == unevaluated* annotation model == +%% +%% "unevaluatedProperties"/"unevaluatedItems" apply to the object properties / +%% array items that were NOT "evaluated" by any adjacent keyword or by a +%% *successful* in-place applicator (allOf/anyOf/oneOf/if-then-else/$ref/ +%% dependentSchemas). To track this, each schema-object evaluation carries an +%% "evaluated" accumulator in `jesse_state' (a `{PropNameSet, ItemIndexSet}'). +%% `check_value/3' resets it on entry (so cousins in separate subschemas can't +%% see each other's annotations) and returns the set for that object; in-place +%% applicators merge the sets of their *passing* subschemas upward; child +%% instance recursion restores the parent's set. `not' never contributes +%% (its subschema must fail). This mirrors the 2019-09 annotation rules and is +%% exercised by the official test-suite cousin/uncle/nested cases. +%% +%% Safety rule: a keyword that this dialect *defines* but jesse does not yet +%% implement (`$dynamicRef') raises `keyword_not_supported' rather than being +%% silently ignored, so it can never false-accept data the keyword would have +%% rejected. Only genuinely annotation-only keywords are ignored. +%% @end +%%%============================================================================= + +-module(jesse_validator_draft2020_12). + +%% API +-export([ check_value/3 + ]). + +%% Includes +-include("jesse_schema_validator.hrl"). + + +-type schema_error() :: ?invalid_dependency + | ?only_ref_allowed + | ?schema_invalid + | ?wrong_all_of_schema_array + | ?wrong_any_of_schema_array + | ?wrong_max_properties + | ?wrong_min_properties + | ?wrong_multiple_of + | ?wrong_one_of_schema_array + | ?wrong_required_array + | ?wrong_type_dependency + | ?wrong_type_items + | ?wrong_type_specification + | ?keyword_not_supported. + +-type schema_error_type() :: schema_error() + | {schema_error(), jesse:json_term()}. + +-type data_error() :: ?all_schemas_not_valid + | ?any_schemas_not_valid + | ?missing_dependency + | ?missing_required_property + | ?no_extra_items_allowed + | ?no_extra_properties_allowed + | ?no_match + | ?not_found + | ?not_in_enum + | ?not_in_range + | ?not_multiple_of + | ?not_one_schema_valid + | ?more_than_one_schema_valid + | ?not_schema_valid + | ?too_few_properties + | ?too_many_properties + | ?wrong_length + | ?wrong_size + | ?wrong_type + | ?external. + +-type data_error_type() :: data_error() + | {data_error(), binary()} + | {data_error(), [jesse_error:error_reason()]}. + +%% The evaluated-annotation accumulator: sets of evaluated property names and +%% item indexes for the schema object currently being validated. +-type evaluated() :: {#{binary() => true}, #{non_neg_integer() => true}}. + +%%% API +%% @doc Validate `Value' against the schema object `JsonSchema'. +%% +%% This is the per-schema-object entry point: it resets the evaluated +%% accumulator, walks the keywords, then applies "unevaluatedProperties"/ +%% "unevaluatedItems" against whatever was left un-evaluated. On return the +%% state's evaluated set describes what this object evaluated, for the caller +%% (an in-place applicator) to merge upward. +-spec check_value( Value :: jesse:json_term() + , JsonSchema :: jesse:schema() + , State :: jesse_state:state() + ) -> jesse_state:state() | no_return(). +check_value(Value, JsonSchema, State0) -> + State1 = set_evaluated(State0, ev_new()), + State2 = walk(Value, JsonSchema, State1), + apply_unevaluated(Value, JsonSchema, State2). + +%%% Internal functions +%% @doc Walk the keyword list of a single schema object, accumulating both +%% validation errors and the evaluated-annotation set. +%% @private +%% Draft 2019-09: "$ref" is evaluated alongside its sibling keywords rather than +%% replacing the schema, so we continue the keyword walk after resolving it. +walk(Value, [{?REF, RefSchemaURI} | Attrs], State) -> + NewState = validate_ref(Value, RefSchemaURI, State), + walk(Value, Attrs, NewState); +walk(Value, [{?TYPE, Type} | Attrs], State) -> + NewState = check_type(Value, Type, State), + walk(Value, Attrs, NewState); +walk(Value, [{?PROPERTIES, Properties} | Attrs], State) -> + NewState = case jesse_lib:is_json_object(Value) of + true -> check_properties( Value + , unwrap(Properties) + , State + ); + false -> State + end, + walk(Value, Attrs, NewState); +walk( Value + , [{?PATTERNPROPERTIES, PatternProperties} | Attrs] + , State + ) -> + NewState = case jesse_lib:is_json_object(Value) of + true -> check_pattern_properties( Value + , PatternProperties + , State + ); + false -> State + end, + walk(Value, Attrs, NewState); +walk( Value + , [{?PROPERTYNAMES, PropertiesSchema} | Attrs] + , State + ) -> + NewState = case jesse_lib:is_json_object(Value) of + true -> check_property_names( Value + , canonical(PropertiesSchema) + , State + ); + false -> State + end, + walk(Value, Attrs, NewState); +walk( Value + , [{?ADDITIONALPROPERTIES, AdditionalProperties} | Attrs] + , State + ) -> + NewState = case jesse_lib:is_json_object(Value) of + true -> check_additional_properties( Value + , AdditionalProperties + , State + ); + false -> State + end, + walk(Value, Attrs, NewState); +%% Draft 2020-12: the positional/tuple form of "items". +walk(Value, [{?PREFIXITEMS, PrefixItems} | Attrs], State) -> + NewState = case jesse_lib:is_array(Value) of + true -> check_prefix_items(Value, PrefixItems, State); + false -> State + end, + walk(Value, Attrs, NewState); +%% Draft 2020-12: "items" is the single-schema applicator for items *after* any +%% "prefixItems" (it replaced 2019-09 "additionalItems"). +walk(Value, [{?ITEMS, Items} | Attrs], State) -> + NewState = case jesse_lib:is_array(Value) of + true -> check_items(Value, Items, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?CONTAINS, Schema} | Attrs], State) -> + NewState = case jesse_lib:is_array(Value) of + true -> check_contains(Value, Schema, State); + false -> State + end, + walk(Value, Attrs, NewState); +%% "minContains"/"maxContains" are consumed together with "contains" (which +%% reads them off the current schema). Standalone, they have no effect. +walk(Value, [{?MINCONTAINS, _} | Attrs], State) -> + walk(Value, Attrs, State); +walk(Value, [{?MAXCONTAINS, _} | Attrs], State) -> + walk(Value, Attrs, State); +walk(Value, [{?REQUIRED, Required} | Attrs], State) -> + NewState = case jesse_lib:is_json_object(Value) of + true -> check_required(Value, Required, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?DEPENDENTREQUIRED, Dependencies} | Attrs], State) -> + NewState = case jesse_lib:is_json_object(Value) of + true -> check_dependent_required(Value, Dependencies, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?DEPENDENTSCHEMAS, Dependencies} | Attrs], State) -> + NewState = case jesse_lib:is_json_object(Value) of + true -> check_dependent_schemas(Value, Dependencies, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?IF, IfSchema} | Attrs], State) -> + NewState = check_if_then_else(Value, canonical(IfSchema), State), + walk(Value, Attrs, NewState); +%% "then"/"else" are applied by the "if" clause above; alone they are inert. +walk(Value, [{?THEN, _} | Attrs], State) -> + walk(Value, Attrs, State); +walk(Value, [{?ELSE, _} | Attrs], State) -> + walk(Value, Attrs, State); +walk(Value, [{?MINIMUM, Minimum} | Attrs], State) -> + NewState = case is_number(Value) of + true -> check_minimum(Value, Minimum, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?EXCLUSIVEMINIMUM, ExclusiveMinimum} | Attrs], State) -> + NewState = case is_number(Value) of + true -> check_exclusive_minimum(Value, ExclusiveMinimum, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?MAXIMUM, Maximum} | Attrs], State) -> + NewState = case is_number(Value) of + true -> check_maximum(Value, Maximum, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?EXCLUSIVEMAXIMUM, ExclusiveMaximum} | Attrs], State) -> + NewState = case is_number(Value) of + true -> check_exclusive_maximum(Value, ExclusiveMaximum, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?MINITEMS, MinItems} | Attrs], State) -> + NewState = case jesse_lib:is_array(Value) of + true -> check_min_items(Value, MinItems, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?MAXITEMS, MaxItems} | Attrs], State) -> + NewState = case jesse_lib:is_array(Value) of + true -> check_max_items(Value, MaxItems, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?UNIQUEITEMS, Uniqueitems} | Attrs], State) -> + NewState = case jesse_lib:is_array(Value) of + true -> check_unique_items(Value, Uniqueitems, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?PATTERN, Pattern} | Attrs], State) -> + NewState = case is_binary(Value) of + true -> check_pattern(Value, Pattern, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?MINLENGTH, MinLength} | Attrs], State) -> + NewState = case is_binary(Value) of + true -> check_min_length(Value, MinLength, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?MAXLENGTH, MaxLength} | Attrs], State) -> + NewState = case is_binary(Value) of + true -> check_max_length(Value, MaxLength, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?ENUM, Enum} | Attrs], State) -> + NewState = check_enum(Value, Enum, State), + walk(Value, Attrs, NewState); +walk(Value, [{?CONST, Const} | Attrs], State) -> + NewState = check_enum(Value, [Const], State), + walk(Value, Attrs, NewState); +%% Draft 2019-09 "format" is annotation-only by default (the format-assertion +%% vocabulary is opt-in and not implemented here), so it never asserts. +walk(Value, [{?FORMAT, _Format} | Attrs], State) -> + walk(Value, Attrs, State); +walk(Value, [{?MULTIPLEOF, Multiple} | Attrs], State) -> + NewState = case is_number(Value) of + true -> check_multiple_of(Value, Multiple, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?MAXPROPERTIES, MaxProperties} | Attrs], State) -> + NewState = case jesse_lib:is_json_object(Value) of + true -> check_max_properties(Value, MaxProperties, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?MINPROPERTIES, MinProperties} | Attrs], State) -> + NewState = case jesse_lib:is_json_object(Value) of + true -> check_min_properties(Value, MinProperties, State); + false -> State + end, + walk(Value, Attrs, NewState); +walk(Value, [{?ALLOF, Schemas} | Attrs], State) -> + NewState = check_all_of(Value, Schemas, State), + walk(Value, Attrs, NewState); +walk(Value, [{?ANYOF, Schemas} | Attrs], State) -> + NewState = check_any_of(Value, Schemas, State), + walk(Value, Attrs, NewState); +walk(Value, [{?ONEOF, Schemas} | Attrs], State) -> + NewState = check_one_of(Value, Schemas, State), + walk(Value, Attrs, NewState); +walk(Value, [{?NOT, Schema} | Attrs], State) -> + NewState = check_not(Value, canonical(Schema), State), + walk(Value, Attrs, NewState); +%% "unevaluatedProperties"/"unevaluatedItems" are deferred to apply_unevaluated, +%% which runs after every adjacent keyword and in-place applicator. +walk(Value, [{?UNEVALUATEDPROPERTIES, _} | Attrs], State) -> + walk(Value, Attrs, State); +walk(Value, [{?UNEVALUATEDITEMS, _} | Attrs], State) -> + walk(Value, Attrs, State); +%% Defined-but-not-yet-implemented keyword: surface an error instead of +%% silently ignoring it (which would false-accept). "$dynamicAnchor" is an +%% identifier that carries no assertion, so it falls through to the ignored +%% catch-all below. +walk(Value, [{?DYNAMICREF, _} | Attrs], State) -> + NewState = unsupported_keyword(?DYNAMICREF, State), + walk(Value, Attrs, NewState); +walk(Value, Bool, State) when is_boolean(Bool) -> + %% Boolean schemas: true always passes, false always fails. + walk(Value, unwrap(canonical(Bool)), State); +walk(Value, [], State) -> + maybe_external_check_value(Value, State); +%% Unknown keywords (including "$id", "$anchor", "$defs", "$comment", +%% "$vocabulary", "$recursiveAnchor", "definitions", the annotation/metadata +%% keywords, and any content-vocabulary keyword) carry no assertion and are +%% ignored, per spec. Identifier keywords are consumed by `jesse_state'. +walk(Value, [_Attr | Attrs], State) -> + walk(Value, Attrs, State). + +%% @doc Raise a schema error for a keyword the dialect defines but which jesse +%% does not implement yet, so it surfaces instead of false-accepting. +%% @private +unsupported_keyword(Keyword, State) -> + handle_schema_invalid({?keyword_not_supported, Keyword}, State). + +%% @doc Validate a child instance (property value / array item) against its +%% subschema. The child is a fresh schema-object evaluation with its own +%% evaluated set, so we restore the parent's evaluated set afterward — the +%% parent keyword handler is responsible for recording the child key/index. +%% @private +check_value(Property, Value, Attrs, State) -> + ParentEvaluated = get_evaluated(State), + State1 = add_to_path(State, Property), + State2 = jesse_schema_validator:validate_with_state(Attrs, Value, State1), + State3 = remove_last_from_path(State2), + set_evaluated(State3, ParentEvaluated). + +%%============================================================================= +%% Evaluated-annotation accumulator helpers +%% @private +-spec ev_new() -> evaluated(). +ev_new() -> {#{}, #{}}. + +%% @private +ev_add_props(State, Names) -> + {Props, Items} = get_evaluated(State), + Props1 = lists:foldl(fun(N, Acc) -> Acc#{N => true} end, Props, Names), + set_evaluated(State, {Props1, Items}). + +%% @private +ev_add_items(State, Indexes) -> + {Props, Items} = get_evaluated(State), + Items1 = lists:foldl(fun(I, Acc) -> Acc#{I => true} end, Items, Indexes), + set_evaluated(State, {Props, Items1}). + +%% @doc Merge the evaluated set of a successful in-place applicator subschema +%% (`From') into the base state. Used to propagate annotations upward. +%% @private +ev_merge(Base, From) -> + {Pb, Ib} = get_evaluated(Base), + {Pf, If} = get_evaluated(From), + set_evaluated(Base, {maps:merge(Pb, Pf), maps:merge(Ib, If)}). + +%% @doc 5.5.2. type +%% @private +check_type(Value, Type, State) -> + try + IsValid = case jesse_lib:is_array(Type) of + true -> check_union_type(Value, Type, State); + false -> is_type_valid(Value, Type) + end, + case IsValid of + true -> State; + false -> wrong_type(Value, State) + end + catch + %% The schema was invalid + error:function_clause -> + handle_schema_invalid(?wrong_type_specification, State) + end. + + +%% @private +is_type_valid(Value, ?STRING) -> is_binary(Value); +is_type_valid(Value, ?NUMBER) -> is_number(Value); +is_type_valid(Value, ?INTEGER) when is_float(Value) -> + (Value - trunc(Value)) == 0.0; +is_type_valid(Value, ?INTEGER) -> is_integer(Value); +is_type_valid(Value, ?BOOLEAN) -> is_boolean(Value); +is_type_valid(Value, ?OBJECT) -> jesse_lib:is_json_object(Value); +is_type_valid(Value, ?ARRAY) -> jesse_lib:is_array(Value); +is_type_valid(Value, ?NULL) -> jesse_lib:is_null(Value). + +%% @private +check_union_type(Value, [_ | _] = UnionType, _State) -> + lists:any(fun(Type) -> is_type_valid(Value, Type) end, UnionType); +check_union_type(_Value, _InvalidTypes, State) -> + handle_schema_invalid(?wrong_type_specification, State). + +%% @private +wrong_type(Value, State) -> + handle_data_invalid(?wrong_type, Value, State). + + +%% @doc properties. Records every present, matching property as evaluated. +%% @private +check_properties(Value, Properties, State) -> + TmpState + = lists:foldl( fun({PropertyName, PropertySchema}, CurrentState) -> + case get_value(PropertyName, Value) of + ?not_found -> + CurrentState; + Property -> + NewState = set_current_schema( + CurrentState + , canonical(PropertySchema)), + check_value( PropertyName + , Property + , canonical(PropertySchema) + , NewState + ) + end + end + , State + , Properties + ), + State1 = set_current_schema(TmpState, get_current_schema(State)), + PresentNames = [ PN || {PN, _} <- Properties + , get_value(PN, Value) =/= ?not_found ], + ev_add_props(State1, PresentNames). + +%% @doc patternProperties. Records every property matching a pattern as +%% evaluated. +%% @private +check_pattern_properties(Value, PatternProperties, State) -> + P1P2 = [{P1, P2} || P1 <- unwrap(Value), + P2 <- unwrap(PatternProperties)], + TmpState = lists:foldl( fun({Property, Pattern}, CurrentState) -> + check_match(Property, Pattern, CurrentState) + end + , State + , P1P2 + ), + State1 = set_current_schema(TmpState, get_current_schema(State)), + Matched = [ PN + || {PN, _} <- unwrap(Value) + , {Pat, _} <- unwrap(PatternProperties) + , jesse_lib:re_run(PN, Pat) =:= match ], + ev_add_props(State1, Matched). + +check_property_names(Value, PropertiesSchema, State) -> + SubState = set_current_schema(State , PropertiesSchema), + TmpState = lists:foldl( + fun({PropertyName, _Value}, CurrentState) -> + check_value( PropertyName + , PropertyName + , PropertiesSchema + , CurrentState) + end + , SubState + , unwrap(Value) + ), + set_current_schema(TmpState, get_current_schema(State)). + +%% @private +check_match({PropertyName, PropertyValue}, {Pattern, Schema0}, State) -> + Schema = canonical(Schema0), + case jesse_lib:re_run(PropertyName, Pattern) of + match -> + check_value( PropertyName + , PropertyValue + , Schema + , set_current_schema(State, Schema) + ); + nomatch -> + State + end. + +%% @doc additionalProperties. Records the "additional" properties (those not +%% covered by properties/patternProperties) as evaluated when the keyword +%% permits them. +%% @private +check_additional_properties(Value, false, State) -> + case additional_property_names(Value, State) of + [] -> State; + Extras -> + lists:foldl( fun(Property, State1) -> + State2 + = handle_data_invalid( ?no_extra_properties_allowed + , Value + , add_to_path(State1, Property) + ), + remove_last_from_path(State2) + end + , State + , Extras + ) + end; +check_additional_properties(Value, true, State) -> + ev_add_props(State, additional_property_names(Value, State)); +check_additional_properties(Value, AdditionalProperties, State) -> + JsonSchema = get_current_schema(State), + case additional_property_names(Value, State) of + [] -> State; + Extras -> + TmpState + = lists:foldl( fun(ExtraName, CurrentState) -> + NewState = set_current_schema( CurrentState + , AdditionalProperties + ), + check_value( ExtraName + , get_value(ExtraName, Value) + , AdditionalProperties + , NewState + ) + end + , State + , Extras + ), + State1 = set_current_schema(TmpState, JsonSchema), + ev_add_props(State1, Extras) + end. + +%% @doc Names of the properties not covered by "properties" or +%% "patternProperties" of the current schema. +%% @private +additional_property_names(Value, State) -> + JsonSchema = get_current_schema(State), + Properties = empty_if_not_found(get_value(?PROPERTIES, JsonSchema)), + PatternProperties = empty_if_not_found(get_value( ?PATTERNPROPERTIES + , JsonSchema)), + ValuePropertiesNames = [Name || {Name, _} <- unwrap(Value)], + SchemaPropertiesNames = [Name || {Name, _} <- unwrap(Properties)], + Patterns = [Pattern || {Pattern, _} <- unwrap(PatternProperties)], + ExtraNames0 = lists:subtract(ValuePropertiesNames, SchemaPropertiesNames), + lists:foldl( fun(Pattern, ExtraAcc) -> + filter_extra_names(Pattern, ExtraAcc) + end + , ExtraNames0 + , Patterns + ). + +%% @private +filter_extra_names(Pattern, ExtraNames) -> + Filter = fun(ExtraName) -> + case jesse_lib:re_run(ExtraName, Pattern) of + match -> false; + nomatch -> true + end + end, + lists:filter(Filter, ExtraNames). + +%% @doc prefixItems (draft 2020-12). Positional/tuple validation of the leading +%% array items; the covered indexes are recorded as evaluated. +%% @private +check_prefix_items(Value, PrefixItems0, State) when is_list(PrefixItems0) -> + PrefixItems = lists:map(fun canonical/1, PrefixItems0), + TupleCount = min(length(Value), length(PrefixItems)), + Tuples = lists:zip( lists:sublist(Value, TupleCount) + , lists:sublist(PrefixItems, TupleCount)), + State1 = check_items_fun(Tuples, State), + ev_add_items(State1, lists:seq(0, TupleCount - 1)); +check_prefix_items(_Value, PrefixItems0, State) -> + handle_schema_invalid({?wrong_type_items, PrefixItems0}, State). + +%% @doc items (draft 2020-12). Single-schema applicator for the items *after* +%% any "prefixItems"; the covered indexes are recorded as evaluated. +%% @private +check_items(Value, Items0, State) -> + case jesse_lib:is_json_object(Items0) orelse is_boolean(Items0) of + true -> + Items = canonical(Items0), + JsonSchema = get_current_schema(State), + StartIndex = prefix_items_count(JsonSchema), + case StartIndex >= length(Value) of + true -> + %% No items beyond "prefixItems"; nothing for "items" to do. + State; + false -> + Rest = lists:nthtail(StartIndex, Value), + {_, TmpState} = + lists:foldl( fun(Item, {Index, CurrentState}) -> + { Index + 1 + , check_value(Index, Item, Items, CurrentState) + } + end + , {StartIndex, set_current_schema(State, Items)} + , Rest + ), + State1 = set_current_schema(TmpState, JsonSchema), + ev_add_items(State1, lists:seq(StartIndex, length(Value) - 1)) + end; + _ -> + handle_schema_invalid({?wrong_type_items, Items0}, State) + end. + +%% @doc Number of leading items covered by "prefixItems" of the current schema. +%% @private +prefix_items_count(JsonSchema) -> + case schema_keyword(?PREFIXITEMS, JsonSchema) of + PrefixList when is_list(PrefixList) -> length(PrefixList); + _ -> 0 + end. + +%% @doc contains / minContains / maxContains +%% +%% An array is valid if the number of elements matching the "contains" schema is +%% at least "minContains" (default 1) and at most "maxContains" (default +%% unbounded). "minContains" of 0 makes "contains" trivially satisfied. Matching +%% items are recorded as evaluated. +%% @private +check_contains(Values, Schema0, State) -> + Schema = canonical(Schema0), + JsonSchema = get_current_schema(State), + MinContains = contains_bound(get_value(?MINCONTAINS, JsonSchema), 1), + MaxContains = contains_bound(get_value(?MAXCONTAINS, JsonSchema), ?infinity), + MatchedIndexes = contains_matches(Values, Schema, State), + MatchCount = length(MatchedIndexes), + case in_contains_range(MatchCount, MinContains, MaxContains) of + true -> ev_add_items(State, MatchedIndexes); + false -> handle_data_invalid(?data_invalid, Values, State) + end. + +%% @private +contains_bound(?not_found, Default) -> Default; +contains_bound(Value, _Default) -> Value. + +%% @private +in_contains_range(Count, Min, Max) -> + Count >= Min andalso (Max =:= ?infinity orelse Count =< Max). + +%% @doc Returns the indexes of the array elements matching the schema. +%% @private +contains_matches(Values, Schema, State) -> + {_, Matched} = + lists:foldl( fun(Value, {Index, Acc}) -> + case validate_schema(Value, Schema, State) of + {true, _} -> {Index + 1, [Index | Acc]}; + {false, _} -> {Index + 1, Acc} + end + end + , {0, []} + , Values + ), + lists:reverse(Matched). + +%% @private +check_items_fun(Tuples, State) -> + {_, TmpState} = lists:foldl( fun({Item, Schema}, {Index, CurrentState}) -> + NewState = set_current_schema( CurrentState + , Schema + ), + { Index + 1 + , check_value(Index, Item, Schema, NewState) + } + end + , {0, State} + , Tuples + ), + set_current_schema(TmpState, get_current_schema(State)). + +%% @doc dependentRequired +%% +%% Object keyword. Value is a map of property-name -> array of property names +%% that must also be present when the key property is present. +%% @private +check_dependent_required(Value, Dependencies, State) -> + lists:foldl( fun({DependencyName, RequiredNames}, CurrentState) -> + case get_value(DependencyName, Value) of + ?not_found -> CurrentState; + _ -> check_dependency_array( Value + , DependencyName + , RequiredNames + , CurrentState + ) + end + end + , State + , unwrap(Dependencies) + ). + +%% @doc dependentSchemas +%% +%% Object keyword. Value is a map of property-name -> subschema that the whole +%% instance must validate against when the key property is present. Each +%% dependent schema is an in-place applicator, so its evaluated set merges up. +%% @private +check_dependent_schemas(Value, Dependencies, State) -> + lists:foldl( fun({DependencyName, DependencySchema}, CurrentState) -> + case get_value(DependencyName, Value) of + ?not_found -> CurrentState; + _ -> apply_dependent_schema( + Value + , canonical(DependencySchema) + , CurrentState + ) + end + end + , State + , unwrap(Dependencies) + ). + +%% @private +apply_dependent_schema(Value, DependencySchema, State) -> + case jesse_lib:is_json_object(DependencySchema) of + true -> + case validate_schema(Value, DependencySchema, State) of + {true, SubState} -> + ev_merge(State, SubState); + {false, Errors} -> + handle_data_invalid({?all_schemas_not_valid, Errors}, Value, State) + end; + false -> + handle_schema_invalid({?wrong_type_dependency, DependencySchema}, State) + end. + +check_dependency(Value, Dependency, State) + when is_binary(Dependency) -> + case get_value(Dependency, Value) of + ?not_found -> + handle_data_invalid({?missing_dependency, Dependency}, Value, State); + _ -> + State + end; +check_dependency(_Value, _Dependency, State) -> + handle_schema_invalid(?invalid_dependency, State). + +%% @private +check_dependency_array(Value, DependencyName, Dependency, State) + when is_list(Dependency) -> + lists:foldl( fun(PropertyName, CurrentState) -> + case get_value(DependencyName, Value) of + ?not_found -> + CurrentState; + _Exists -> + check_dependency( Value + , PropertyName + , CurrentState + ) + end + end + , State + , Dependency + ); +check_dependency_array(_Value, _DependencyName, Dependency, State) -> + handle_schema_invalid({?wrong_type_dependency, Dependency}, State). + +%% @doc if / then / else +%% +%% If the instance validates against "if", it must validate against "then" +%% (when present); otherwise it must validate against "else" (when present). +%% "if" itself never produces validation errors of its own, but when it passes +%% its annotations (and the branch's) are merged in. +%% @private +check_if_then_else(Value, IfSchema, State) -> + JsonSchema = get_current_schema(State), + case validate_schema(Value, IfSchema, State) of + {true, IfSub} -> + State1 = ev_merge(State, IfSub), + apply_branch(Value, ?THEN, JsonSchema, State1); + {false, _} -> + apply_branch(Value, ?ELSE, JsonSchema, State) + end. + +%% @private +apply_branch(Value, Keyword, JsonSchema, State) -> + case get_value(Keyword, JsonSchema) of + ?not_found -> + State; + BranchSchema -> + case validate_schema(Value, canonical(BranchSchema), State) of + {true, SubState} -> + ev_merge(State, SubState); + {false, Errors} -> + handle_data_invalid({?not_schema_valid, Errors}, Value, State) + end + end. + +%% @doc minimum / exclusiveMinimum +%% @private +check_minimum(Value, Minimum, State) -> + case (Value >= Minimum) of + true -> State; + false -> handle_data_invalid(?not_in_range, Value, State) + end. + +check_exclusive_minimum(Value, ExclusiveMinimum, State) -> + case (Value > ExclusiveMinimum) of + true -> State; + false -> handle_data_invalid(?not_in_range, Value, State) + end. + +%% @doc maximum / exclusiveMaximum +%% @private +check_maximum(Value, Maximum, State) -> + case (Value =< Maximum) of + true -> State; + false -> handle_data_invalid(?not_in_range, Value, State) + end. + +check_exclusive_maximum(Value, ExclusiveMaximum, State) -> + case (Value < ExclusiveMaximum) of + true -> State; + false -> handle_data_invalid(?not_in_range, Value, State) + end. + +%% @doc minItems +%% @private +check_min_items(Value, MinItems, State) when length(Value) >= MinItems -> + State; +check_min_items(Value, _MinItems, State) -> + handle_data_invalid(?wrong_size, Value, State). + +%% @doc maxItems +%% @private +check_max_items(Value, MaxItems, State) when length(Value) =< MaxItems -> + State; +check_max_items(Value, _MaxItems, State) -> + handle_data_invalid(?wrong_size, Value, State). + +%% @doc uniqueItems +%% @private +check_unique_items(_, false, State) -> + State; +check_unique_items([], true, State) -> + State; +check_unique_items([_], true, State) -> + State; +check_unique_items(Value, true, State) -> + try + NormalizedValue = jesse_lib:normalize_and_sort(Value), + NoDuplicates = ?SET_FROM_LIST(NormalizedValue), + case sets:size(NoDuplicates) == length(Value) of + true -> State; + false -> + lists:foldl( fun compare_rest_items/2 + , tl(Value) + , Value + ), + State + end + catch + throw:ErrorInfo -> handle_data_invalid(ErrorInfo, Value, State) + end. + +%% @private +compare_rest_items(_Item, []) -> + ok; +compare_rest_items(Item, RestItems) -> + lists:foreach( fun(ItemFromRest) -> + case jesse_lib:is_equal(Item, ItemFromRest) of + true -> throw({?not_unique, Item}); + false -> ok + end + end + , RestItems + ), + tl(RestItems). + +%% @doc pattern +%% @private +check_pattern(Value, Pattern, State) -> + case jesse_lib:re_run(Value, Pattern) of + match -> State; + nomatch -> handle_data_invalid(?no_match, Value, State) + end. + +%% @doc minLength +%% @private +check_min_length(Value, MinLength, State) -> + case length(unicode:characters_to_list(Value)) >= MinLength of + true -> State; + false -> handle_data_invalid(?wrong_length, Value, State) + end. + +%% @doc maxLength +%% @private +check_max_length(Value, MaxLength, State) -> + case length(unicode:characters_to_list(Value)) =< MaxLength of + true -> State; + false -> handle_data_invalid(?wrong_length, Value, State) + end. + +%% @doc enum / const +%% @private +check_enum(Value, Enum, State) -> + IsValid = lists:any( fun(ExpectedValue) -> + jesse_lib:is_equal(Value, ExpectedValue) + end + , Enum + ), + case IsValid of + true -> State; + false -> handle_data_invalid(?not_in_enum, Value, State) + end. + +%% @doc multipleOf +%% @private +check_multiple_of(Value, MultipleOf, State) + when is_number(MultipleOf), MultipleOf > 0 -> + try (Value / MultipleOf - trunc(Value / MultipleOf)) * MultipleOf == 0.0 of + true -> State; + _ -> handle_data_invalid(?not_multiple_of, Value, State) + catch error:badarith -> + %% eg, division by zero or overflow + handle_schema_invalid(?wrong_multiple_of, State) + end; +check_multiple_of(_Value, _MultipleOf, State) -> + handle_schema_invalid(?wrong_multiple_of, State). + +%% @doc required +%% @private +check_required(Value, [] = Required, State) -> + check_required_values(Value, Required, State); +check_required(Value, [_ | _] = Required, State) -> + check_required_values(Value, Required, State); +check_required(_Value, _InvalidRequired, State) -> + handle_schema_invalid(?wrong_required_array, State). + +check_required_values(_Value, [], State) -> State; +check_required_values(Value, [PropertyName | Required], State) -> + case get_value(PropertyName, Value) =/= ?not_found of + 'false' -> + NewState = + handle_data_invalid(?missing_required_property, PropertyName, State), + check_required_values(Value, Required, NewState); + 'true' -> + check_required_values(Value, Required, State) + end. + +%% @doc maxProperties +%% @private +check_max_properties(Value, MaxProperties, State) + when is_integer(MaxProperties), MaxProperties >= 0 -> + case length(unwrap(Value)) =< MaxProperties of + true -> State; + false -> handle_data_invalid(?too_many_properties, Value, State) + end; +check_max_properties(_Value, _MaxProperties, State) -> + handle_schema_invalid(?wrong_max_properties, State). + +%% @doc minProperties +%% @private +check_min_properties(Value, MinProperties, State) + when is_integer(MinProperties), MinProperties >= 0 -> + case length(unwrap(Value)) >= MinProperties of + true -> State; + false -> handle_data_invalid(?too_few_properties, Value, State) + end; +check_min_properties(_Value, _MaxProperties, State) -> + handle_schema_invalid(?wrong_min_properties, State). + +%% @doc allOf. Every subschema must pass; the union of their evaluated sets is +%% propagated up. +%% @private +check_all_of(Value, [_ | _] = Schemas, State) -> + check_all_of_(Value, Schemas, State); +check_all_of(_Value, _InvalidSchemas, State) -> + handle_schema_invalid(?wrong_all_of_schema_array, State). + +check_all_of_(_Value, [], State) -> + State; +check_all_of_(Value, [Schema | Schemas], State) -> + case validate_schema(Value, Schema, State) of + {true, NewState} -> + check_all_of_(Value, Schemas, ev_merge(NewState, State)); + {false, Errors} -> + handle_data_invalid({?all_schemas_not_valid, Errors}, Value, State) + end. + +%% @doc anyOf. Valid if at least one subschema passes; annotations from *all* +%% passing subschemas are merged in (required for "unevaluatedProperties with +%% anyOf" where several branches match). +%% @private +check_any_of(Value, [_ | _] = Schemas, State) -> + {AnyValid, MergedState, ShortestErrors} = + lists:foldl( fun(Schema, {Valid, StateAcc, Errors}) -> + case validate_schema(Value, Schema, State) of + {true, SubState} -> + {true, ev_merge(StateAcc, SubState), Errors}; + {false, NewErrors} -> + {Valid, StateAcc, shortest(NewErrors, Errors)} + end + end + , {false, State, empty} + , Schemas + ), + case AnyValid of + true -> MergedState; + false -> any_of_error(Value, State, ShortestErrors) + end; +check_any_of(_Value, _InvalidSchemas, State) -> + handle_schema_invalid(?wrong_any_of_schema_array, State). + +%% @private +any_of_error(Value, State, empty) -> + handle_data_invalid(?any_schemas_not_valid, Value, State); +any_of_error(Value, State, Errors) -> + handle_data_invalid({?any_schemas_not_valid, Errors}, Value, State). + +%% @doc oneOf. Valid if exactly one subschema passes; that subschema's +%% evaluated set is merged in. +%% @private +check_one_of(Value, [_ | _] = Schemas, State) -> + {ValidCount, ValidSubState, Errors} = + lists:foldl( fun(Schema, {Count, SubAcc, ErrAcc}) -> + case validate_schema(Value, Schema, State) of + {true, SubState} -> + {Count + 1, SubState, ErrAcc}; + {false, NewErrors} -> + {Count, SubAcc, ErrAcc ++ NewErrors} + end + end + , {0, undefined, []} + , Schemas + ), + case ValidCount of + 1 -> ev_merge(State, ValidSubState); + 0 -> handle_data_invalid({?not_one_schema_valid, Errors}, Value, State); + _ -> handle_data_invalid(?more_than_one_schema_valid, Value, State) + end; +check_one_of(_Value, _InvalidSchemas, State) -> + handle_schema_invalid(?wrong_one_of_schema_array, State). + +%% @doc not. The subschema must fail; "not" never contributes annotations. +%% @private +check_not(Value, Schema, State) -> + case validate_schema(Value, Schema, State) of + {true, _} -> handle_data_invalid(?not_schema_valid, Value, State); + {false, _} -> State + end. + +%% @doc unevaluatedProperties / unevaluatedItems. Applied after every adjacent +%% keyword and in-place applicator has contributed to the evaluated set. +%% @private +apply_unevaluated(Value, Schema, State) when is_list(Schema) -> + State1 = apply_unevaluated_properties(Value, Schema, State), + apply_unevaluated_items(Value, Schema, State1); +apply_unevaluated(_Value, _Schema, State) -> + State. + +%% @private +apply_unevaluated_properties(Value, Schema, State) -> + case schema_keyword(?UNEVALUATEDPROPERTIES, Schema) of + ?not_found -> + State; + UnevalSchema -> + case jesse_lib:is_json_object(Value) of + true -> + check_unevaluated_properties(Value, canonical(UnevalSchema), State); + false -> + State + end + end. + +%% @private +check_unevaluated_properties(Value, UnevalSchema, State) -> + {EvaluatedProps, _} = get_evaluated(State), + Leftover = [ {N, V} || {N, V} <- unwrap(Value) + , not maps:is_key(N, EvaluatedProps) ], + case Leftover of + [] -> + State; + _ -> + TmpState = + lists:foldl( fun({N, V}, CurrentState) -> + NewState = set_current_schema( CurrentState + , UnevalSchema), + check_value(N, V, UnevalSchema, NewState) + end + , State + , Leftover + ), + State1 = set_current_schema(TmpState, get_current_schema(State)), + %% The leftovers are now evaluated; record them so that an enclosing + %% "unevaluatedProperties" (via an in-place applicator) sees them. + ev_add_props(State1, [N || {N, _} <- Leftover]) + end. + +%% @private +apply_unevaluated_items(Value, Schema, State) -> + case schema_keyword(?UNEVALUATEDITEMS, Schema) of + ?not_found -> + State; + UnevalSchema -> + case jesse_lib:is_array(Value) of + true -> + check_unevaluated_items(Value, canonical(UnevalSchema), State); + false -> + State + end + end. + +%% @private +check_unevaluated_items(Value, UnevalSchema, State) -> + {_, EvaluatedItems} = get_evaluated(State), + Indexed = lists:zip(lists:seq(0, length(Value) - 1), Value), + Leftover = [ {I, V} || {I, V} <- Indexed + , not maps:is_key(I, EvaluatedItems) ], + case Leftover of + [] -> + State; + _ -> + TmpState = + lists:foldl( fun({I, V}, CurrentState) -> + NewState = set_current_schema( CurrentState + , UnevalSchema), + check_value(I, V, UnevalSchema, NewState) + end + , State + , Leftover + ), + State1 = set_current_schema(TmpState, get_current_schema(State)), + ev_add_items(State1, [I || {I, _} <- Leftover]) + end. + +%% @doc Validate a value against a schema, returning the resulting state (which +%% carries the subschema's evaluated set) on success. Used by all in-place +%% applicators to run and roll back a subschema evaluation. +%% @private +validate_schema(Value, Schema0, State0) -> + Schema = canonical(Schema0), + try + case jesse_lib:is_json_object(Schema) of + true -> + State1 = set_current_schema(State0, Schema), + State2 = jesse_schema_validator:validate_with_state( Schema + , Value + , State1 + ), + {true, set_current_schema(State2, get_current_schema(State0))}; + false -> + handle_schema_invalid(?schema_invalid, State0) + end + catch + throw:Errors -> {false, Errors} + end. + +canonical(true) -> + #{}; +canonical(false) -> + #{?NOT => #{}}; +canonical(MaybeObject) -> + MaybeObject. + +%% @private +validate_ref(Value, Reference, State) -> + ParentEvaluated = get_evaluated(State), + ResultState = + case resolve_ref(Reference, State) of + {error, NewState} -> + undo_resolve_ref(NewState, State); + {ok, NewState, Schema0} -> + Schema = canonical(Schema0), + RefState = + jesse_schema_validator:validate_with_state(Schema, Value, NewState), + undo_resolve_ref(RefState, State) + end, + %% "$ref" is an in-place applicator: merge the ref target's evaluated set + %% into the referring schema's. + {RefProps, RefItems} = get_evaluated(ResultState), + {PProps, PItems} = ParentEvaluated, + set_evaluated( ResultState + , {maps:merge(PProps, RefProps), maps:merge(PItems, RefItems)}). + +%% @doc Resolve a JSON reference +%% The "$id" keyword is taken care of behind the scenes in jesse_state. +%% @private +resolve_ref(Reference, State) -> + CurrentErrors = jesse_state:get_error_list(State), + NewState = jesse_state:resolve_ref(State, Reference), + NewErrors = jesse_state:get_error_list(NewState), + case length(CurrentErrors) =:= length(NewErrors) of + true -> + Schema = get_current_schema(NewState), + {ok, NewState, Schema}; + false -> {error, NewState} + end. + +undo_resolve_ref(State, OriginalState) -> + jesse_state:undo_resolve_ref(State, OriginalState). + +%%============================================================================= +%% Wrappers +%% @private +get_value(Key, Schema) -> + jesse_json_path:value(Key, Schema, ?not_found). + +%% @doc Look up a schema keyword by exact key, returning `?not_found' when +%% absent. Unlike `get_value/2' this uses a plain proplist lookup and so is +%% safe on the empty object (which `jesse_json_path' otherwise treats as a +%% KVC list and returns `[]' for any key). +%% @private +schema_keyword(Key, Schema) -> + case lists:keyfind(Key, 1, unwrap(Schema)) of + {_, Value} -> Value; + false -> ?not_found + end. + +%% @private +unwrap(Value) -> + jesse_json_path:unwrap_value(Value). + +%% @private +get_evaluated(State) -> + jesse_state:get_evaluated(State). + +%% @private +set_evaluated(State, Evaluated) -> + jesse_state:set_evaluated(State, Evaluated). + +%% @private +-spec handle_data_invalid( Info :: data_error_type() + , Value :: jesse:json_term() + , State :: jesse_state:state() + ) -> jesse_state:state(). +handle_data_invalid(Info, Value, State) -> + jesse_error:handle_data_invalid(Info, Value, State). + +%% @private +-spec handle_schema_invalid( Info :: schema_error_type() + , State :: jesse_state:state() + ) -> jesse_state:state(). +handle_schema_invalid(Info, State) -> + jesse_error:handle_schema_invalid(Info, State). + +%% @private +get_current_schema(State) -> + jesse_state:get_current_schema(State). + +%% @private +set_current_schema(State, NewSchema) -> + jesse_state:set_current_schema(State, NewSchema). + +%% @private +empty_if_not_found(Value) -> + jesse_lib:empty_if_not_found(Value). + +%% @private +add_to_path(State, Property) -> + jesse_state:add_to_path(State, Property). + +%% @private +remove_last_from_path(State) -> + jesse_state:remove_last_from_path(State). + +maybe_external_check_value(Value, State) -> + case jesse_state:get_external_validator(State) of + undefined -> + State; + Fun -> + Fun(Value, State) + end. + +%% @private +-spec shortest(list() | empty, list() | empty) -> list() | empty. +shortest(X, empty) -> + X; +shortest(empty, Y) -> + Y; +shortest(X, Y) when length(X) < length(Y) -> + X; +shortest(_, Y) -> + Y. diff --git a/test/jesse_schema_validator_tests.erl b/test/jesse_schema_validator_tests.erl index 9f571900..3315f2a8 100644 --- a/test/jesse_schema_validator_tests.erl +++ b/test/jesse_schema_validator_tests.erl @@ -251,6 +251,25 @@ schema_unsupported_test_draft(URI) -> , jesse_schema_validator:validate(UnsupportedSchema, Json, []) ). +supported_dialect_test() -> + Supported = [ <<"http://json-schema.org/draft-03/schema#">> + , <<"http://json-schema.org/draft-04/schema#">> + , <<"http://json-schema.org/draft-06/schema#">> + , <<"https://json-schema.org/draft/2019-09/schema">> + , <<"https://json-schema.org/draft/2019-09/schema#">> + , <<"https://json-schema.org/draft/2020-12/schema">> + , <<"https://json-schema.org/draft/2020-12/schema#">> + %% http scheme is coerced to https for the 2019+ URIs + , <<"http://json-schema.org/draft/2020-12/schema">> + ], + [ ?assert(jesse:supported_dialect(URI)) || URI <- Supported ], + Unsupported = [ <<"http://json-schema.org/draft-05/schema#">> + , <<"https://json-schema.org/draft/2021-99/schema">> + , <<"not-a-uri">> + , not_a_binary + ], + [ ?assertNot(jesse:supported_dialect(URI)) || URI <- Unsupported ]. + data_invalid_one_of_test() -> [ data_invalid_one_of_test_draft(URI) || URI <- [ <<"http://json-schema.org/draft-04/schema#">> diff --git a/test/jesse_tests_draft2019_09_SUITE.erl b/test/jesse_tests_draft2019_09_SUITE.erl new file mode 100644 index 00000000..99cf812c --- /dev/null +++ b/test/jesse_tests_draft2019_09_SUITE.erl @@ -0,0 +1,235 @@ +%%%============================================================================= +%% Copyright (c) 2026 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%% +%% +%% @doc jesse test suite which covers Draft 2019-09. It uses the official +%% JSON-Schema-Test-Suite +%% (https://github.com/json-schema/JSON-Schema-Test-Suite) as the test data. +%% +%% Groups/cases not yet supported by jesse are enumerated in the skip-list +%% below (with the reason) rather than being silently dropped. See the module +%% doc of `jesse_validator_draft2019_09' for the safety rationale. +%% @end +%%%============================================================================= + +-module(jesse_tests_draft2019_09_SUITE). + +-compile([ export_all + , nowarn_export_all + ]). + +-define(EXCLUDED_FUNS, [ module_info + , all + , init_per_suite + , end_per_suite + ]). + +-include_lib("common_test/include/ct.hrl"). +-include_lib("stdlib/include/assert.hrl"). + +-import(jesse_tests_util, [ get_tests/3 + , do_test/2 + ]). + +-define(json_schema_draft2019_09, + <<"https://json-schema.org/draft/2019-09/schema">>). + +all() -> + Exports = ?MODULE:module_info(exports), + %% Test cases are the arity-1 exported functions; helpers like skip_list/0 + %% are excluded by the arity guard. + [F || {F, 1} <- Exports, not lists:member(F, ?EXCLUDED_FUNS)]. + +init_per_suite(Config) -> + {ok, _} = application:ensure_all_started(jesse), + get_tests("standard", ?json_schema_draft2019_09, Config) + ++ [{skip_list, skip_list()}] + ++ Config. + +end_per_suite(_Config) -> + ok. + +%% @doc Cases that jesse does not yet handle for draft 2019-09. +%% `{File, '_'}' skips every case in a file; `{File, Description}' skips one. +%% Each entry is deliberate: silently ignoring an unsupported keyword could +%% false-accept invalid data, so unsupported keywords hard-error and their +%% test groups are skip-listed here instead. +skip_list() -> + %% "$recursiveRef"/"$recursiveAnchor": milestone J3 (dynamic scope stack). + %% "$recursiveRef" hard-errors so it cannot false-accept. + [ {<<"recursiveRef">>, '_'} + %% Remote-schema fetching harness not wired for this dialect yet. + , {<<"refRemote">>, '_'} + , {<<"ref">>, <<"remote ref, containing refs itself">>} + , {<<"ref">>, <<"Recursive references between schemas">>} + %% "$defs" against the metaschema needs the remote 2019-09 metaschema + %% (which itself uses "$recursiveRef"). + , {<<"defs">>, <<"validate definition against metaschema">>} + %% "$anchor" resolution across an "$id" base-URI change (in-document remote + %% scope map) is deferred; the local-anchor cases are supported. + , {<<"anchor">>, <<"Location-independent identifier with absolute URI">>} + , {<<"anchor">>, <<"Location-independent identifier with base URI change" + " in subschema">>} + %% Every id.json case but the last validates a schema document against the + %% remote 2019-09 metaschema (which uses "$recursiveRef"); the last needs an + %% in-document "$id" scope map. Both are deferred, so skip the whole file. + , {<<"id">>, '_'} + %% "$id" buried in an unknown keyword: needs in-document "$id" scoping. + , {<<"unknownKeyword">>, <<"$id inside an unknown keyword is not a" + " real identifier">>} + ]. + +%%% Testcases (one per keyword file in tests/draft2019-09) + +additionalItems(Config) -> + do_test("additionalItems", Config). + +additionalProperties(Config) -> + do_test("additionalProperties", Config). + +allOf(Config) -> + do_test("allOf", Config). + +anchor(Config) -> + do_test("anchor", Config). + +anyOf(Config) -> + do_test("anyOf", Config). + +boolean_schema(Config) -> + do_test("boolean_schema", Config). + +const(Config) -> + do_test("const", Config). + +contains(Config) -> + do_test("contains", Config). + +content(Config) -> + do_test("content", Config). + +default(Config) -> + do_test("default", Config). + +defs(Config) -> + do_test("defs", Config). + +dependentRequired(Config) -> + do_test("dependentRequired", Config). + +dependentSchemas(Config) -> + do_test("dependentSchemas", Config). + +enum(Config) -> + do_test("enum", Config). + +exclusiveMaximum(Config) -> + do_test("exclusiveMaximum", Config). + +exclusiveMinimum(Config) -> + do_test("exclusiveMinimum", Config). + +format(Config) -> + do_test("format", Config). + +id(Config) -> + do_test("id", Config). + +'if-then-else'(Config) -> + do_test("if-then-else", Config). + +'infinite-loop-detection'(Config) -> + do_test("infinite-loop-detection", Config). + +items(Config) -> + do_test("items", Config). + +maxContains(Config) -> + do_test("maxContains", Config). + +maximum(Config) -> + do_test("maximum", Config). + +maxItems(Config) -> + do_test("maxItems", Config). + +maxLength(Config) -> + do_test("maxLength", Config). + +maxProperties(Config) -> + do_test("maxProperties", Config). + +minContains(Config) -> + do_test("minContains", Config). + +minimum(Config) -> + do_test("minimum", Config). + +minItems(Config) -> + do_test("minItems", Config). + +minLength(Config) -> + do_test("minLength", Config). + +minProperties(Config) -> + do_test("minProperties", Config). + +multipleOf(Config) -> + do_test("multipleOf", Config). + +'not'(Config) -> + do_test("not", Config). + +oneOf(Config) -> + do_test("oneOf", Config). + +pattern(Config) -> + do_test("pattern", Config). + +patternProperties(Config) -> + do_test("patternProperties", Config). + +properties(Config) -> + do_test("properties", Config). + +propertyNames(Config) -> + do_test("propertyNames", Config). + +recursiveRef(Config) -> + do_test("recursiveRef", Config). + +ref(Config) -> + do_test("ref", Config). + +refRemote(Config) -> + do_test("refRemote", Config). + +required(Config) -> + do_test("required", Config). + +type(Config) -> + do_test("type", Config). + +unevaluatedItems(Config) -> + do_test("unevaluatedItems", Config). + +unevaluatedProperties(Config) -> + do_test("unevaluatedProperties", Config). + +uniqueItems(Config) -> + do_test("uniqueItems", Config). + +unknownKeyword(Config) -> + do_test("unknownKeyword", Config). diff --git a/test/jesse_tests_draft2019_09_SUITE_data/remotes b/test/jesse_tests_draft2019_09_SUITE_data/remotes new file mode 120000 index 00000000..c01b3d38 --- /dev/null +++ b/test/jesse_tests_draft2019_09_SUITE_data/remotes @@ -0,0 +1 @@ +../../test/JSON-Schema-Test-Suite/remotes \ No newline at end of file diff --git a/test/jesse_tests_draft2019_09_SUITE_data/standard b/test/jesse_tests_draft2019_09_SUITE_data/standard new file mode 120000 index 00000000..f80481af --- /dev/null +++ b/test/jesse_tests_draft2019_09_SUITE_data/standard @@ -0,0 +1 @@ +../../test/JSON-Schema-Test-Suite/tests/draft2019-09 \ No newline at end of file diff --git a/test/jesse_tests_draft2020_12_SUITE.erl b/test/jesse_tests_draft2020_12_SUITE.erl new file mode 100644 index 00000000..e932b722 --- /dev/null +++ b/test/jesse_tests_draft2020_12_SUITE.erl @@ -0,0 +1,234 @@ +%%%============================================================================= +%% Copyright (c) 2026 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%% +%% +%% @doc jesse test suite which covers Draft 2020-12. It uses the official +%% JSON-Schema-Test-Suite +%% (https://github.com/json-schema/JSON-Schema-Test-Suite) as the test data. +%% +%% Groups/cases not yet supported by jesse are enumerated in the skip-list +%% below (with the reason) rather than being silently dropped. See the module +%% doc of `jesse_validator_draft2020_12' for the safety rationale. +%% @end +%%%============================================================================= + +-module(jesse_tests_draft2020_12_SUITE). + +-compile([ export_all + , nowarn_export_all + ]). + +-define(EXCLUDED_FUNS, [ module_info + , all + , init_per_suite + , end_per_suite + ]). + +-include_lib("common_test/include/ct.hrl"). +-include_lib("stdlib/include/assert.hrl"). + +-import(jesse_tests_util, [ get_tests/3 + , do_test/2 + ]). + +-define(json_schema_draft2020_12, + <<"https://json-schema.org/draft/2020-12/schema">>). + +all() -> + Exports = ?MODULE:module_info(exports), + %% Test cases are the arity-1 exported functions; helpers like skip_list/0 + %% are excluded by the arity guard. + [F || {F, 1} <- Exports, not lists:member(F, ?EXCLUDED_FUNS)]. + +init_per_suite(Config) -> + {ok, _} = application:ensure_all_started(jesse), + get_tests("standard", ?json_schema_draft2020_12, Config) + ++ [{skip_list, skip_list()}] + ++ Config. + +end_per_suite(_Config) -> + ok. + +%% @doc Cases that jesse does not yet handle for draft 2020-12. +%% `{File, '_'}' skips every case in a file; `{File, Description}' skips one. +%% Each entry is deliberate: silently ignoring an unsupported keyword could +%% false-accept invalid data, so unsupported keywords hard-error and their +%% test groups are skip-listed here instead. +skip_list() -> + %% "$dynamicRef"/"$dynamicAnchor": needs a dynamic scope stack (deferred). + %% "$dynamicRef" hard-errors so it cannot false-accept. + [ {<<"dynamicRef">>, '_'} + %% Remote-schema fetching harness not wired for this dialect yet. + , {<<"refRemote">>, '_'} + , {<<"ref">>, <<"remote ref, containing refs itself">>} + , {<<"ref">>, <<"Recursive references between schemas">>} + %% "$defs" against the metaschema needs the remote 2020-12 metaschema + %% (which itself uses "$dynamicRef"). + , {<<"defs">>, <<"validate definition against metaschema">>} + %% "$anchor" resolution across an "$id" base-URI change (in-document remote + %% scope map) is deferred; the local-anchor cases are supported. + , {<<"anchor">>, <<"Location-independent identifier with absolute URI">>} + , {<<"anchor">>, <<"Location-independent identifier with base URI change" + " in subschema">>} + %% Every id.json case but the last validates a schema document against the + %% remote 2020-12 metaschema; the last needs an in-document "$id" scope map. + , {<<"id">>, '_'} + %% "$id" buried in an unknown keyword: needs in-document "$id" scoping. + , {<<"unknownKeyword">>, <<"$id inside an unknown keyword is not a" + " real identifier">>} + ]. + +%%% Testcases (one per keyword file in tests/draft2020-12) + +additionalProperties(Config) -> + do_test("additionalProperties", Config). + +allOf(Config) -> + do_test("allOf", Config). + +anchor(Config) -> + do_test("anchor", Config). + +anyOf(Config) -> + do_test("anyOf", Config). + +boolean_schema(Config) -> + do_test("boolean_schema", Config). + +const(Config) -> + do_test("const", Config). + +contains(Config) -> + do_test("contains", Config). + +content(Config) -> + do_test("content", Config). + +default(Config) -> + do_test("default", Config). + +defs(Config) -> + do_test("defs", Config). + +dependentRequired(Config) -> + do_test("dependentRequired", Config). + +dependentSchemas(Config) -> + do_test("dependentSchemas", Config). + +dynamicRef(Config) -> + do_test("dynamicRef", Config). + +enum(Config) -> + do_test("enum", Config). + +exclusiveMaximum(Config) -> + do_test("exclusiveMaximum", Config). + +exclusiveMinimum(Config) -> + do_test("exclusiveMinimum", Config). + +format(Config) -> + do_test("format", Config). + +id(Config) -> + do_test("id", Config). + +'if-then-else'(Config) -> + do_test("if-then-else", Config). + +'infinite-loop-detection'(Config) -> + do_test("infinite-loop-detection", Config). + +items(Config) -> + do_test("items", Config). + +maxContains(Config) -> + do_test("maxContains", Config). + +maximum(Config) -> + do_test("maximum", Config). + +maxItems(Config) -> + do_test("maxItems", Config). + +maxLength(Config) -> + do_test("maxLength", Config). + +maxProperties(Config) -> + do_test("maxProperties", Config). + +minContains(Config) -> + do_test("minContains", Config). + +minimum(Config) -> + do_test("minimum", Config). + +minItems(Config) -> + do_test("minItems", Config). + +minLength(Config) -> + do_test("minLength", Config). + +minProperties(Config) -> + do_test("minProperties", Config). + +multipleOf(Config) -> + do_test("multipleOf", Config). + +'not'(Config) -> + do_test("not", Config). + +oneOf(Config) -> + do_test("oneOf", Config). + +pattern(Config) -> + do_test("pattern", Config). + +patternProperties(Config) -> + do_test("patternProperties", Config). + +prefixItems(Config) -> + do_test("prefixItems", Config). + +properties(Config) -> + do_test("properties", Config). + +propertyNames(Config) -> + do_test("propertyNames", Config). + +ref(Config) -> + do_test("ref", Config). + +refRemote(Config) -> + do_test("refRemote", Config). + +required(Config) -> + do_test("required", Config). + +type(Config) -> + do_test("type", Config). + +unevaluatedItems(Config) -> + do_test("unevaluatedItems", Config). + +unevaluatedProperties(Config) -> + do_test("unevaluatedProperties", Config). + +uniqueItems(Config) -> + do_test("uniqueItems", Config). + +unknownKeyword(Config) -> + do_test("unknownKeyword", Config). diff --git a/test/jesse_tests_draft2020_12_SUITE_data/remotes b/test/jesse_tests_draft2020_12_SUITE_data/remotes new file mode 120000 index 00000000..c01b3d38 --- /dev/null +++ b/test/jesse_tests_draft2020_12_SUITE_data/remotes @@ -0,0 +1 @@ +../../test/JSON-Schema-Test-Suite/remotes \ No newline at end of file diff --git a/test/jesse_tests_draft2020_12_SUITE_data/standard b/test/jesse_tests_draft2020_12_SUITE_data/standard new file mode 120000 index 00000000..253156f6 --- /dev/null +++ b/test/jesse_tests_draft2020_12_SUITE_data/standard @@ -0,0 +1 @@ +../../test/JSON-Schema-Test-Suite/tests/draft2020-12 \ No newline at end of file diff --git a/test/jesse_tests_util.erl b/test/jesse_tests_util.erl index 8bc521b0..cec79e15 100644 --- a/test/jesse_tests_util.erl +++ b/test/jesse_tests_util.erl @@ -79,8 +79,12 @@ do_test(Key, Config) -> "** Schema tests: ~p~n" , [Description, Options, Schema, SchemaTests] ), - case lists:member({list_to_binary(Key), Description}, - SkipList) of + KeyBin = list_to_binary(Key), + %% A skip-list entry may target a specific case ({File, Description}) + %% or, with the '_' wildcard, every case in a file ({File, '_'}). + IsSkipped = lists:member({KeyBin, Description}, SkipList) + orelse lists:member({KeyBin, '_'}, SkipList), + case IsSkipped of true -> ct:pal("In skip-list"); false ->