Add JSON Schema draft discrimination #7

Open
uwiger wants to merge 1 commits from uw-schema-dialects into master
3 changed files with 481 additions and 10 deletions
+13
View File
@@ -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"`
+318 -10
View File
@@ -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),
+150
View File
@@ -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) ->