From 25f81ae339468af1e1668d5290c01eaa17d2b4e4 Mon Sep 17 00:00:00 2001 From: Ulf Wiger Date: Sat, 29 Aug 2026 11:30:01 +0200 Subject: [PATCH] Add JSON Schema draft discrimination Track the declared JSON Schema dialect in the validator state and validate dialect-sensitive constructs before applying a schema. Recognize draft-04, draft-06, draft-07, 2019-09, and 2020-12, while preserving the existing compatibility behavior for schemas without a $schema declaration. Reject unknown dialects and known keywords or forms that do not belong to the declared draft. Implement draft-specific exclusive bound semantics, boolean-schema rules, and array vocabulary differences. Document the behavior and add coverage for all supported drafts. The generated Gajumaru draft-04 configuration schema passes the new checks. --- README.md | 13 ++ src/gmconfig_schema_utils.erl | 328 ++++++++++++++++++++++++++- test/gmconfig_schema_utils_tests.erl | 150 ++++++++++++ 3 files changed, 481 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 534bbce..8e92c5e 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,19 @@ is still incomplete. `"definitions"` and `"$ref"` properties are recognized. As almost anything is theoretically possible with JSON-Schema, there are surely other things that are unsupported. +### Schema dialects + +If a schema declares `"$schema"`, the validator recognizes draft-04, +draft-06, draft-07, 2019-09 and 2020-12. It rejects unknown dialects and +known constructs that do not belong to the declared draft. This currently +includes boolean-schema availability, keyword introduction dates, +draft-specific exclusive-bound syntax and the 2020-12 array vocabulary. + +Schemas without `"$schema"` retain the historical compatibility behavior, +which accepts the supported keyword subset without assigning it to one +particular JSON-Schema draft. Dialect recognition does not yet imply full +conformance with every keyword in that draft. + ### All standard data types * `"null"` diff --git a/src/gmconfig_schema_utils.erl b/src/gmconfig_schema_utils.erl index e64a79a..db33230 100644 --- a/src/gmconfig_schema_utils.erl +++ b/src/gmconfig_schema_utils.erl @@ -46,6 +46,13 @@ -type schema() :: json(). +-type draft() :: compatibility + | draft4 + | draft6 + | draft7 + | draft2019_09 + | draft2020_12. + -type ext_fun() :: fun( (json(), schema()) -> any() | no_return() ). -type extensions() :: #{ binary() => ext_fun() }. -type options() :: #{coerce => boolean(), @@ -54,6 +61,7 @@ -record(st, { s :: schema() %% schema , r :: schema() %% root schema + , draft = compatibility :: draft() , p = [] , a = [] %% annotations , v :: json() | undefined %% value @@ -89,10 +97,10 @@ get_schema(Default) -> -spec use_schema(schema() | st()) -> st(). use_schema(#st{} = St) -> St; -use_schema(S) -> #st{s = S, r = S}. +use_schema(S) -> new_state(S, S). use_schema(Schema, RootSchema) -> - #st{s = Schema, r = RootSchema}. + new_state(Schema, RootSchema). normalize() -> normalize(get_schema()). @@ -107,6 +115,293 @@ normalize(S) -> bin_key(A) when is_atom(A) -> atom_to_binary(A, utf8); bin_key(B) when is_binary(B) -> B. +new_state(Schema, RootSchema) -> + Draft = schema_draft(RootSchema, compatibility, []), + case Draft of + compatibility -> ok; + _ -> check_schema(normalize(RootSchema), Draft, []) + end, + #st{s = Schema, r = RootSchema, draft = Draft}. + +schema_draft(Schema, Inherited, Path) when is_map(Schema) -> + case schema_value(<<"$schema">>, '$schema', Schema) of + undefined -> + Inherited; + URI -> + draft_from_uri(URI, Path ++ [<<"$schema">>]) + end; +schema_draft(_, Inherited, _) -> + Inherited. + +schema_value(BinaryKey, AtomKey, Schema) -> + case maps:find(BinaryKey, Schema) of + {ok, Value} -> Value; + error -> maps:get(AtomKey, Schema, undefined) + end. + +draft_from_uri(URI, _) when URI =:= <<"http://json-schema.org/draft-04/schema#">>; + URI =:= <<"https://json-schema.org/draft-04/schema#">>; + URI =:= <<"http://json-schema.org/draft-04/schema">>; + URI =:= <<"https://json-schema.org/draft-04/schema">> -> + draft4; +draft_from_uri(URI, _) when URI =:= <<"http://json-schema.org/draft-06/schema#">>; + URI =:= <<"https://json-schema.org/draft-06/schema#">>; + URI =:= <<"http://json-schema.org/draft-06/schema">>; + URI =:= <<"https://json-schema.org/draft-06/schema">> -> + draft6; +draft_from_uri(URI, _) when URI =:= <<"http://json-schema.org/draft-07/schema#">>; + URI =:= <<"https://json-schema.org/draft-07/schema#">>; + URI =:= <<"http://json-schema.org/draft-07/schema">>; + URI =:= <<"https://json-schema.org/draft-07/schema">> -> + draft7; +draft_from_uri(URI, _) when URI =:= <<"http://json-schema.org/draft/2019-09/schema#">>; + URI =:= <<"https://json-schema.org/draft/2019-09/schema#">>; + URI =:= <<"http://json-schema.org/draft/2019-09/schema">>; + URI =:= <<"https://json-schema.org/draft/2019-09/schema">> -> + draft2019_09; +draft_from_uri(URI, _) when URI =:= <<"http://json-schema.org/draft/2020-12/schema#">>; + URI =:= <<"https://json-schema.org/draft/2020-12/schema#">>; + URI =:= <<"http://json-schema.org/draft/2020-12/schema">>; + URI =:= <<"https://json-schema.org/draft/2020-12/schema">> -> + draft2020_12; +draft_from_uri(URI, Path) -> + schema_error(unsupported_schema_draft, + #{draft_uri => URI, p => Path}). + +check_schema(_, compatibility, _) -> + ok; +check_schema(Schema, draft4 = Draft, Path) when is_boolean(Schema) -> + case lists:reverse(Path) of + [<<"additionalProperties">> | _] -> ok; + [<<"additionalItems">> | _] -> ok; + _ -> + schema_error(boolean_schema_not_in_draft, + #{draft => Draft, p => Path}) + end; +check_schema(Schema, _Draft, _Path) when is_boolean(Schema) -> + ok; +check_schema(Schema, Draft0, Path) when is_map(Schema) -> + Draft = schema_draft(Schema, Draft0, Path), + maps:foreach( + fun(Keyword0, Value) -> + Keyword = bin_key(Keyword0), + check_keyword(Keyword, Value, Draft, Path ++ [Keyword]) + end, Schema), + check_schema_shape(Schema, Draft, Path), + check_subschemas(Schema, Draft, Path); +check_schema(_, Draft, Path) -> + schema_error(invalid_schema, + #{draft => Draft, p => Path}). + +check_keyword(Keyword, Value, Draft, Path) -> + case minimum_draft(Keyword) of + undefined -> ok; + Minimum -> + case draft_rank(Draft) >= draft_rank(Minimum) of + true -> ok; + false -> + schema_error(keyword_not_in_draft, + #{draft => Draft, + keyword => Keyword, + introduced_in => Minimum, + p => Path}) + end + end, + case maximum_draft(Keyword) of + undefined -> ok; + Maximum -> + case draft_rank(Draft) =< draft_rank(Maximum) of + true -> ok; + false -> + schema_error(keyword_not_in_draft, + #{draft => Draft, + keyword => Keyword, + last_supported_in => Maximum, + p => Path}) + end + end, + check_keyword_shape(Keyword, Value, Draft, Path). + +minimum_draft(<<"$id">>) -> draft6; +minimum_draft(<<"const">>) -> draft6; +minimum_draft(<<"contains">>) -> draft6; +minimum_draft(<<"propertyNames">>) -> draft6; +minimum_draft(<<"examples">>) -> draft6; +minimum_draft(<<"$comment">>) -> draft7; +minimum_draft(<<"if">>) -> draft7; +minimum_draft(<<"then">>) -> draft7; +minimum_draft(<<"else">>) -> draft7; +minimum_draft(<<"writeOnly">>) -> draft7; +minimum_draft(<<"contentMediaType">>) -> draft7; +minimum_draft(<<"contentEncoding">>) -> draft7; +minimum_draft(<<"$defs">>) -> draft2019_09; +minimum_draft(<<"$anchor">>) -> draft2019_09; +minimum_draft(<<"$recursiveAnchor">>) -> draft2019_09; +minimum_draft(<<"$recursiveRef">>) -> draft2019_09; +minimum_draft(<<"dependentRequired">>) -> draft2019_09; +minimum_draft(<<"dependentSchemas">>) -> draft2019_09; +minimum_draft(<<"unevaluatedItems">>) -> draft2019_09; +minimum_draft(<<"unevaluatedProperties">>) -> draft2019_09; +minimum_draft(<<"minContains">>) -> draft2019_09; +minimum_draft(<<"maxContains">>) -> draft2019_09; +minimum_draft(<<"contentSchema">>) -> draft2019_09; +minimum_draft(<<"$dynamicAnchor">>) -> draft2020_12; +minimum_draft(<<"$dynamicRef">>) -> draft2020_12; +minimum_draft(<<"prefixItems">>) -> draft2020_12; +minimum_draft(_) -> undefined. + +maximum_draft(<<"additionalItems">>) -> draft2019_09; +maximum_draft(_) -> undefined. + +draft_rank(draft4) -> 4; +draft_rank(draft6) -> 6; +draft_rank(draft7) -> 7; +draft_rank(draft2019_09) -> 8; +draft_rank(draft2020_12) -> 9. + +check_keyword_shape(<<"exclusiveMinimum">>, Value, draft4, Path) -> + require_type(fun is_boolean/1, Value, draft4, Path); +check_keyword_shape(<<"exclusiveMaximum">>, Value, draft4, Path) -> + require_type(fun is_boolean/1, Value, draft4, Path); +check_keyword_shape(<<"exclusiveMinimum">>, Value, Draft, Path) -> + require_type(fun is_number/1, Value, Draft, Path); +check_keyword_shape(<<"exclusiveMaximum">>, Value, Draft, Path) -> + require_type(fun is_number/1, Value, Draft, Path); +check_keyword_shape(<<"items">>, Value, draft2020_12, Path) + when is_list(Value) -> + schema_error(keyword_value_not_in_draft, + #{draft => draft2020_12, + keyword => <<"items">>, + p => Path}); +check_keyword_shape(_, _, _, _) -> + ok. + +check_schema_shape(Schema, draft4, Path) -> + check_exclusive_bound(Schema, <<"exclusiveMinimum">>, <<"minimum">>, + draft4, Path), + check_exclusive_bound(Schema, <<"exclusiveMaximum">>, <<"maximum">>, + draft4, Path); +check_schema_shape(_, _, _) -> + ok. + +check_exclusive_bound(Schema, Exclusive, Bound, Draft, Path) -> + case {maps:find(Exclusive, Schema), maps:is_key(Bound, Schema)} of + {{ok, _}, false} -> + schema_error(missing_keyword_for_draft, + #{draft => Draft, + keyword => Exclusive, + required_keyword => Bound, + p => Path ++ [Exclusive]}); + _ -> + ok + end. + +require_type(Predicate, Value, Draft, Path) -> + case Predicate(Value) of + true -> ok; + false -> + schema_error(keyword_value_not_in_draft, + #{draft => Draft, p => Path, value => Value}) + end. + +check_subschemas(Schema, Draft, Path) -> + check_schema_values( + [<<"additionalProperties">>, <<"additionalItems">>, <<"contains">>, + <<"propertyNames">>, <<"not">>, <<"if">>, <<"then">>, <<"else">>, + <<"unevaluatedProperties">>, <<"unevaluatedItems">>, + <<"contentSchema">>], + Schema, Draft, Path), + check_schema_arrays( + [<<"allOf">>, <<"anyOf">>, <<"oneOf">>, <<"prefixItems">>], + Schema, Draft, Path), + check_schema_maps( + [<<"properties">>, <<"patternProperties">>, <<"definitions">>, + <<"$defs">>, <<"dependentSchemas">>], + Schema, Draft, Path), + check_schema_items(Schema, Draft, Path), + check_dependencies(Schema, Draft, Path). + +check_schema_values(Keys, Schema, Draft, Path) -> + lists:foreach( + fun(Key) -> + case maps:find(Key, Schema) of + {ok, SubSchema} -> + check_schema(SubSchema, Draft, Path ++ [Key]); + error -> + ok + end + end, Keys). + +check_schema_arrays(Keys, Schema, Draft, Path) -> + lists:foreach( + fun(Key) -> + case maps:find(Key, Schema) of + {ok, SubSchemas} when is_list(SubSchemas) -> + check_schema_list(SubSchemas, Draft, Path ++ [Key], 0); + {ok, Value} -> + schema_error(invalid_schema, + #{draft => Draft, + keyword => Key, + value => Value, + p => Path ++ [Key]}); + error -> + ok + end + end, Keys). + +check_schema_list([Schema | Schemas], Draft, Path, Index) -> + check_schema(Schema, Draft, Path ++ [Index]), + check_schema_list(Schemas, Draft, Path, Index + 1); +check_schema_list([], _, _, _) -> + ok. + +check_schema_maps(Keys, Schema, Draft, Path) -> + lists:foreach( + fun(Key) -> + case maps:find(Key, Schema) of + {ok, SubSchemas} when is_map(SubSchemas) -> + maps:foreach( + fun(Name, SubSchema) -> + check_schema(SubSchema, Draft, + Path ++ [Key, Name]) + end, SubSchemas); + {ok, Value} -> + schema_error(invalid_schema, + #{draft => Draft, + keyword => Key, + value => Value, + p => Path ++ [Key]}); + error -> + ok + end + end, Keys). + +check_schema_items(Schema, Draft, Path) -> + case maps:find(<<"items">>, Schema) of + {ok, Items} when is_list(Items) -> + check_schema_list(Items, Draft, Path ++ [<<"items">>], 0); + {ok, Items} -> + check_schema(Items, Draft, Path ++ [<<"items">>]); + error -> + ok + end. + +check_dependencies(Schema, Draft, Path) -> + case maps:find(<<"dependencies">>, Schema) of + {ok, Dependencies} when is_map(Dependencies) -> + maps:foreach( + fun(_, Dependency) when is_list(Dependency) -> ok; + (Name, Dependency) -> + check_schema(Dependency, Draft, + Path ++ [<<"dependencies">>, Name]) + end, Dependencies); + _ -> + ok + end. + +schema_error(Reason, Details) -> + error(Details#{e => Reason}). + clear() -> persistent_term:erase({?MODULE,'$schema'}), persistent_term:erase({?MODULE,'$config'}), @@ -116,8 +411,7 @@ clear() -> update_config(Cfg) -> OldCfg = get_config(), Schema = get_schema(), - Res = merge(Cfg, OldCfg, #st{s = Schema, - r = Schema }), + Res = merge(Cfg, OldCfg, Schema), set_config(Res), Res. @@ -129,7 +423,7 @@ merge(A, B) -> merge(A, B, #st{} = St) -> merge_(A, B, St); merge(A, B, Schema) -> - merge_(A, B, #st{s = Schema, r = Schema}). + merge_(A, B, new_state(Schema, Schema)). %% Neither the JSON spec or the JSON-Schema spec are very helpful %% regarding what takes precedence if dynamically evaluated parts @@ -396,7 +690,7 @@ valid(V) -> valid(V, #st{} = St) -> valid_(V, St#st{v = V}); valid(V, Schema) -> - valid(V, #st{p = [], s = Schema, r = Schema, v = V}). + valid(V, (new_state(Schema, Schema))#st{v = V}). valid_(V, #st{s = true}) -> V; valid_(_, #st{s = false} = St) -> fail(invalid, St); @@ -571,6 +865,19 @@ valid_number_(I, Sub, St, Ss) when is_number(I) -> fail(not_a_multiple, St1) end end, + validate_number_bounds(I, St, Ss), + I. + +validate_number_bounds(I, #st{draft = draft4} = St, Ss) -> + Min = schema_prop(<<"minimum">>, St, Ss, I), + Max = schema_prop(<<"maximum">>, St, Ss, I), + EMin = schema_prop(<<"exclusiveMinimum">>, St, Ss, false), + EMax = schema_prop(<<"exclusiveMaximum">>, St, Ss, false), + MinOp = case EMin of true -> '<'; false -> '=<' end, + MaxOp = case EMax of true -> '>'; false -> '>=' end, + test_range(MaxOp, Max, I, add_anno(Max, push_path(maximum, St))), + test_range(MinOp, Min, I, add_anno(Min, push_path(minimum, St))); +validate_number_bounds(I, St, Ss) -> Min = schema_prop(<<"minimum">>, St, Ss, I), Max = schema_prop(<<"maximum">>, St, Ss, I), test_range('>=', Max, I, add_anno(Max, push_path(maximum, St))), @@ -578,8 +885,7 @@ valid_number_(I, Sub, St, Ss) when is_number(I) -> EMin = schema_prop(<<"exclusiveMinimum">>, St, Ss, I-1), EMax = schema_prop(<<"exclusiveMaximum">>, St, Ss, I+1), test_range('>', EMax, I, add_anno(EMax, push_path(exclusiveMaximum, St))), - test_range('<', EMin, I, add_anno(EMin, push_path(exclusiveMinimum, St))), - I. + test_range('<', EMin, I, add_anno(EMin, push_path(exclusiveMinimum, St))). coerce_num(integer, I) when is_binary(I) -> binary_to_integer(I); @@ -872,7 +1178,8 @@ acc_props(Ss) -> end, #{}, Ss). s(S, #st{} = St) -> - St#st{s = S, d = undefined}. + Draft = schema_draft(S, St#st.draft, lists:reverse(St#st.p)), + St#st{s = S, d = undefined, draft = Draft}. push_path(Ps, #st{p = P0} = St) when is_list(Ps) -> %% Assume Ps is in reverse order @@ -1004,7 +1311,8 @@ schema(Path, Schema) -> schema(Path, #st{s = Schema, r = RootSchema}, Opts) -> schema_(Path, Schema, RootSchema, Opts); schema(Path, Schema, Opts) -> - schema_(Path, Schema, Schema, Opts). + St = new_state(Schema, Schema), + schema(Path, St, Opts). schema_([H|T], Schema, RootSchema0, Opts) -> RootSchema = set_rootschema(Schema, RootSchema0), diff --git a/test/gmconfig_schema_utils_tests.erl b/test/gmconfig_schema_utils_tests.erl index 3ebba8f..1e18e43 100644 --- a/test/gmconfig_schema_utils_tests.erl +++ b/test/gmconfig_schema_utils_tests.erl @@ -43,6 +43,146 @@ schema_spec_examples_test_() -> , ?t(t_nested_refs()) ]}. +draft_discrimination_test_() -> + {"JSON Schema draft discrimination", + [ + ?t(t_draft4_exclusive_bounds()) + , ?t(t_draft4_rejects_boolean_schema()) + , ?t(t_draft4_rejects_const()) + , ?t(t_draft6_accepts_const()) + , ?t(t_draft6_rejects_conditionals()) + , ?t(t_draft7_accepts_conditionals()) + , ?t(t_draft7_rejects_prefix_items()) + , ?t(t_draft2020_accepts_prefix_items()) + , ?t(t_draft2020_rejects_tuple_items()) + , ?t(t_unspecified_draft_remains_compatible()) + , ?t(t_unknown_draft_is_rejected()) + ]}. + +-define(DRAFT4, <<"http://json-schema.org/draft-04/schema#">>). +-define(DRAFT6, <<"http://json-schema.org/draft-06/schema#">>). +-define(DRAFT7, <<"http://json-schema.org/draft-07/schema#">>). +-define(DRAFT2020, <<"https://json-schema.org/draft/2020-12/schema">>). + +with_draft(Draft, Schema) -> + Schema#{<<"$schema">> => Draft}. + +t_draft4_exclusive_bounds() -> + S = with_draft(?DRAFT4, + #{<<"type">> => <<"number">>, + <<"minimum">> => 1, + <<"exclusiveMinimum">> => true, + <<"maximum">> => 3, + <<"exclusiveMaximum">> => false}), + all_valid([2, 3], S), + all_fail([1, 4], S, not_in_range), + schema_fails( + with_draft(?DRAFT4, + #{<<"type">> => <<"number">>, + <<"exclusiveMinimum">> => 1}), + #{e => keyword_value_not_in_draft, + draft => draft4, + p => [<<"exclusiveMinimum">>]}), + schema_fails( + with_draft(?DRAFT4, + #{<<"type">> => <<"number">>, + <<"exclusiveMinimum">> => true}), + #{e => missing_keyword_for_draft, + draft => draft4, + keyword => <<"exclusiveMinimum">>, + required_keyword => <<"minimum">>}). + +t_draft4_rejects_boolean_schema() -> + S = with_draft(?DRAFT4, + #{<<"properties">> => #{<<"disabled">> => false}}), + schema_fails(S, #{e => boolean_schema_not_in_draft, + draft => draft4, + p => [<<"properties">>, <<"disabled">>]}), + ObjectSchema = with_draft(?DRAFT4, + #{<<"type">> => <<"object">>, + <<"additionalProperties">> => false}), + is_valid(#{}, ObjectSchema), + fails(#{<<"unexpected">> => true}, ObjectSchema, invalid). + +t_draft4_rejects_const() -> + S = with_draft(?DRAFT4, + #{<<"properties">> => + #{<<"mode">> => #{<<"const">> => <<"on">>}}}), + schema_fails(S, #{e => keyword_not_in_draft, + draft => draft4, + keyword => <<"const">>, + introduced_in => draft6, + p => [<<"properties">>, <<"mode">>, <<"const">>]}). + +t_draft6_accepts_const() -> + S = with_draft(?DRAFT6, #{<<"const">> => <<"on">>}), + is_valid(<<"on">>, S), + fails(<<"off">>, S, not_in_enum). + +t_draft6_rejects_conditionals() -> + S = with_draft(?DRAFT6, + #{<<"if">> => #{<<"const">> => 1}, + <<"then">> => false}), + schema_fails(S, #{e => keyword_not_in_draft, + draft => draft6, + keyword => <<"if">>, + introduced_in => draft7, + p => [<<"if">>]}). + +t_draft7_accepts_conditionals() -> + S = with_draft(?DRAFT7, + #{<<"if">> => #{<<"const">> => 1}, + <<"then">> => #{<<"minimum">> => 1}, + <<"else">> => #{<<"minimum">> => 2}}), + all_valid([1, 2], S), + fails(0, S, not_in_range). + +t_draft7_rejects_prefix_items() -> + S = with_draft(?DRAFT7, + #{<<"type">> => <<"array">>, + <<"prefixItems">> => [#{<<"type">> => <<"integer">>}]}), + schema_fails(S, #{e => keyword_not_in_draft, + draft => draft7, + keyword => <<"prefixItems">>, + introduced_in => draft2020_12, + p => [<<"prefixItems">>]}). + +t_draft2020_accepts_prefix_items() -> + S = with_draft(?DRAFT2020, + #{<<"type">> => <<"array">>, + <<"prefixItems">> => [#{<<"type">> => <<"integer">>}], + <<"items">> => false}), + is_valid([1], S), + fails([1, 2], S, invalid). + +t_draft2020_rejects_tuple_items() -> + S = with_draft(?DRAFT2020, + #{<<"type">> => <<"array">>, + <<"items">> => [#{<<"type">> => <<"integer">>}]}), + schema_fails(S, #{e => keyword_value_not_in_draft, + draft => draft2020_12, + keyword => <<"items">>, + p => [<<"items">>]}), + Additional = with_draft(?DRAFT2020, + #{<<"type">> => <<"array">>, + <<"additionalItems">> => false}), + schema_fails(Additional, #{e => keyword_not_in_draft, + draft => draft2020_12, + keyword => <<"additionalItems">>, + last_supported_in => draft2019_09, + p => [<<"additionalItems">>]}). + +t_unspecified_draft_remains_compatible() -> + S = #{<<"type">> => <<"array">>, + <<"prefixItems">> => [#{<<"type">> => <<"integer">>}]}, + is_valid([1], S). + +t_unknown_draft_is_rejected() -> + S = with_draft(<<"https://example.test/schema-dialect">>, #{}), + schema_fails(S, #{e => unsupported_schema_draft, + draft_uri => <<"https://example.test/schema-dialect">>, + p => [<<"$schema">>]}). + array() -> #{<<"type">> => <<"array">>}. int() -> #{<<"type">> => <<"integer">>}. str() -> #{<<"type">> => <<"string">>}. @@ -303,6 +443,16 @@ fails(V, S, Opts, Expect) -> end. %% ?assertError({Reason, [], V}, valid(V, S)). +schema_fails(Schema, Expect) -> + try valid(null, Schema) of + _ -> + error({expected_schema_exception, + #{s => Schema, e => Expect}}) + catch + error:Reason -> + match_expected(Expect, Reason) + end. + match_expected(E, R) -> case maps:fold( fun(K, V, Acc) ->