Author SHA1 Message Date
Ulf Wiger ea50e9e61a Improve normalization, add anchor support 2026-05-16 16:13:55 +02:00
3 changed files with 139 additions and 501 deletions
-13
View File
@@ -25,19 +25,6 @@ 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"`
+126 -336
View File
@@ -46,13 +46,6 @@
-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(),
@@ -61,7 +54,6 @@
-record(st, { s :: schema() %% schema
, r :: schema() %% root schema
, draft = compatibility :: draft()
, p = []
, a = [] %% annotations
, v :: json() | undefined %% value
@@ -97,311 +89,67 @@ get_schema(Default) ->
-spec use_schema(schema() | st()) -> st().
use_schema(#st{} = St) -> St;
use_schema(S) -> new_state(S, S).
use_schema(S) -> #st{s = S, r = S}.
use_schema(Schema, RootSchema) ->
new_state(Schema, RootSchema).
#st{s = Schema, r = RootSchema}.
normalize() ->
normalize(get_schema()).
normalize(S) when is_map(S) ->
#{bin_key(K) => normalize(V) || K := V <- S};
normalize(S) when is_list(S) ->
[normalize(Sx) || Sx <- S];
normalize(S) ->
normalize(Schema) ->
Schema1 = normalize_map_keys(Schema),
normalize_values(Schema1).
normalize_map_keys(S) when is_map(S) ->
#{bin_key(K) => normalize_map_keys(V) || K := V <- S};
normalize_map_keys(L) when is_list(L) ->
[normalize_map_keys(S) || S <- L];
normalize_map_keys(S) ->
S.
normalize_values(S) when is_map(S) ->
#{K => normalize_value(K, V) || K := V <- S};
normalize_values(L) when is_list(L) ->
[normalize_values(S) || S <- L];
normalize_values(S) ->
S.
normalize_value(<<"type">>, [C|_] = T) when is_integer(C) ->
bin_key(T);
normalize_value(K, L) when is_list(L) ->
%% In some cases, the spec tells us what to do
if K == <<"allOf">>; %% 10.2.1.1
K == <<"anyOf">>; %% 10.2.1.2
K == <<"oneOf">>; %% 10.2.1.3
K == <<"prefixItems">> -> %% 10.3.1.1
%% These MUST refer to arrays
[normalize_values(S) || S <- L];
K == <<"contains">> ->
%% 10.3.1.3 Value MUST be a valid schema
normalize_values(L);
true ->
try unicode:characters_to_binary(L)
catch
error:_ ->
[normalize_values(S) || S <- L]
end
end;
normalize_value(_, V) when is_atom(V) ->
atom_to_binary(V, utf8);
normalize_value(_, V) when is_list(V) ->
try unicode:characters_to_binary(V)
catch
error:_ ->
[normalize_values(S) || S <- V]
end;
normalize_value(_, V) ->
V.
bin_key(A) when is_atom(A) -> atom_to_binary(A, utf8);
bin_key(L) when is_list(L) -> unicode:characters_to_binary(L);
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'}),
@@ -411,7 +159,8 @@ clear() ->
update_config(Cfg) ->
OldCfg = get_config(),
Schema = get_schema(),
Res = merge(Cfg, OldCfg, Schema),
Res = merge(Cfg, OldCfg, #st{s = Schema,
r = Schema }),
set_config(Res),
Res.
@@ -423,7 +172,7 @@ merge(A, B) ->
merge(A, B, #st{} = St) ->
merge_(A, B, St);
merge(A, B, Schema) ->
merge_(A, B, new_state(Schema, Schema)).
merge_(A, B, #st{s = Schema, r = Schema}).
%% Neither the JSON spec or the JSON-Schema spec are very helpful
%% regarding what takes precedence if dynamically evaluated parts
@@ -453,10 +202,10 @@ any_schema_prop(P, S0, [S|Ss]) ->
any_schema_prop(P, S, []) ->
schema_prop_find(P, S).
schema_prop_find(P, #st{s = S, r = RS}) when is_map(S) ->
schema_prop_find(P, #st{s = S} = St) when is_map(S) ->
case maps:find(P, S) of
{ok, #{<<"$ref">> := Sub} = M} when map_size(M) == 1 ->
D = expand_ref(Sub, RS),
D = expand_ref(Sub, St),
{ok, D};
Other -> Other
end;
@@ -530,8 +279,8 @@ get_type(#st{} = St0, Value) ->
get_type(#st{} = St, Ss, Value) ->
case any_schema_prop(<<"type">>, St, Ss) of
{ok, TBin} ->
select_type(TBin, Value, St);
{ok, Type} when is_binary(Type); is_list(Type) ->
select_type(Type, Value, St);
error ->
try infer_type(Value)
catch
@@ -651,7 +400,7 @@ convert_enums(V, St0) when is_binary(V) ->
{Ss, St1} = schemas_from_dynamic_eval(V, St),
case any_schema_prop(<<"enum">>, St1, Ss) of
{ok, _} ->
binary_to_atom(V, utf8);
binary_to_atom(V, unicode);
_ ->
V
end;
@@ -690,7 +439,7 @@ valid(V) ->
valid(V, #st{} = St) ->
valid_(V, St#st{v = V});
valid(V, Schema) ->
valid(V, (new_state(Schema, Schema))#st{v = V}).
valid(V, #st{p = [], s = Schema, r = Schema, v = V}).
valid_(V, #st{s = true}) -> V;
valid_(_, #st{s = false} = St) -> fail(invalid, St);
@@ -865,19 +614,6 @@ 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))),
@@ -885,7 +621,8 @@ validate_number_bounds(I, St, Ss) ->
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))).
test_range('<', EMin, I, add_anno(EMin, push_path(exclusiveMinimum, St))),
I.
coerce_num(integer, I) when is_binary(I) ->
binary_to_integer(I);
@@ -1081,8 +818,8 @@ any_pattern_({Pat, Schema, I}, P) ->
maybe_expand_ref(#st{s = S} = St) ->
case S of
#{<<"$ref">> := Ref} = R when map_size(R) == 1 ->
St#st{s = expand_ref(Ref, St#st.r)};
#{<<"$ref">> := Ref} ->
St#st{s = expand_ref(Ref, St)};
_ ->
St
end.
@@ -1178,8 +915,7 @@ acc_props(Ss) ->
end, #{}, Ss).
s(S, #st{} = St) ->
Draft = schema_draft(S, St#st.draft, lists:reverse(St#st.p)),
St#st{s = S, d = undefined, draft = Draft}.
St#st{s = S, d = undefined}.
push_path(Ps, #st{p = P0} = St) when is_list(Ps) ->
%% Assume Ps is in reverse order
@@ -1241,7 +977,7 @@ expand_schema(S) ->
%% S#{<<"definitions">> := expand_schema(D, S)}.
expand_schema(#{<<"$ref">> := Path} = V, S0) when map_size(V) == 1 ->
expand_schema(expand_ref(Path, S0), S0);
expand_schema(expand_ref(Path, use_schema(S0)), S0);
expand_schema(S, S0) when is_map(S) ->
%% https://json-schema.org/understanding-json-schema/structuring#dollarref
%% When $id is used in a subschema, it indicates an embedded schema.
@@ -1266,7 +1002,7 @@ expand_schema(S, _) ->
S.
expand_schema_(K, #{<<"$ref">> := Path} = V, Acc, S0) when map_size(V) == 1 ->
D = expand_ref(Path, S0),
D = expand_ref(Path, use_schema(S0)),
Acc#{K => D};
expand_schema_(K, V, Acc, S0) ->
Acc#{K => expand_schema(V, S0)}.
@@ -1274,13 +1010,13 @@ expand_schema_(K, V, Acc, S0) ->
expand_ref(R, _, #{follow_refs := false}) ->
R;
expand_ref(R, S, _) ->
expand_ref(R, S).
expand_ref(R, use_schema(S)).
expand_ref(<<"#">>, S) ->
expand_ref(<<"#">>, #st{r = R}) ->
%% The $ref keyword may be used to create recursive schemas that refer to themselves.
%% This done by using `{"$ref" : "#"}`
S;
expand_ref(<<"#/", Path/binary>>, S) ->
R;
expand_ref(<<"#/", Path/binary>>, #st{r = S}) ->
Key = filename:split(Path),
case schema(Key, S, #{follow_refs => false}) of
{ok, #{<<"$ref">> := _}} ->
@@ -1300,8 +1036,63 @@ expand_ref(<<"#/", Path/binary>>, S) ->
Def;
undefined ->
error(unknown_ref, [Path])
end;
expand_ref(<<"#", Anchor/binary>>, #st{r = S}) ->
case find_anchor(Anchor, S) of
{ok, Ss} ->
Ss;
error ->
error({unknown_anchor, Anchor})
end.
%% get_schema_by_path([T|P], #{<<"type">> := Ts} = S) when is_atom(T) ->
%% case atom_to_binary(T, utf8) of
%% Ts ->
%% get_schema_by_path(P, S);
%% Prop when is_map_key(Prop, S) ->
%% get_schema_by_path(P, maps:get(Prop, S));
%% _ ->
%% error(invalid_schema_path)
%% end;
%% get_schema_by_path([Property|P], #{<<"properties">> := Ps} = S) when is_binary(Property) ->
%% get_schema_by_path(P, maps:get(Property, Ps));
%% get_schema_by_path([], S) ->
%% S.
%% == Anchor search (unoptimized - must search whole root schema)
find_anchor(Anchor, S) when map_get(<<"$anchor">>, S) =:= Anchor ->
{ok, S};
find_anchor(Anchor, S) when is_map(S) ->
Iter = maps:iterator(S),
map_search_anchor(maps:next(Iter), Anchor);
find_anchor(Anchor, S) when is_list(S) ->
list_search_anchor(S, Anchor);
find_anchor(_, _) ->
error.
map_search_anchor({_K, V, I}, Anchor) ->
case find_anchor(Anchor, V) of
{ok, _} = Ok ->
Ok;
error ->
map_search_anchor(maps:next(I), Anchor)
end;
map_search_anchor(none, _) ->
error.
list_search_anchor([H | T], Anchor) ->
case find_anchor(Anchor, H) of
{ok, _} = Ok ->
Ok;
error ->
list_search_anchor(T, Anchor)
end;
list_search_anchor([], _) ->
error.
%% ==
schema(Path) ->
schema(Path, get_schema()).
@@ -1311,8 +1102,7 @@ schema(Path, Schema) ->
schema(Path, #st{s = Schema, r = RootSchema}, Opts) ->
schema_(Path, Schema, RootSchema, Opts);
schema(Path, Schema, Opts) ->
St = new_state(Schema, Schema),
schema(Path, St, Opts).
schema_(Path, Schema, Schema, Opts).
schema_([H|T], Schema, RootSchema0, Opts) ->
RootSchema = set_rootschema(Schema, RootSchema0),
+13 -152
View File
@@ -41,148 +41,9 @@ schema_spec_examples_test_() ->
?t(t_ref_loop())
, ?t(t_recursive_def())
, ?t(t_nested_refs())
, ?t(t_anchors())
]}.
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">>}.
@@ -433,7 +294,8 @@ fails(V, S, Opts, Reason) when is_atom(Reason) ->
fails(V, S, Opts, #{e => Reason});
fails(V, S, Opts, Expect) ->
try validate(V, S, Opts) of
_ ->
Other ->
?debugFmt("Expected failure, Other = ~p", [Other]),
error({expected_exception, #{v => V,
s => S,
e => Expect}})
@@ -443,16 +305,8 @@ 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('_', _) ->
ok;
match_expected(E, R) ->
case maps:fold(
fun(K, V, Acc) ->
@@ -500,7 +354,6 @@ all_fail(Vs, S, Reason) ->
read(F) ->
FullF = filename:join(
filename:dirname(code:which(?MODULE)), F),
?debugFmt("FullF = ~s~n", [FullF]),
{ok, Bin} = file:read_file(FullF),
dec(Bin).
@@ -552,3 +405,11 @@ t_nested_refs() ->
validate(Vs, S, Opts),
fails(Vf, S, Opts, #{e => failing_schemas}),
ok.
t_anchors() ->
S = read("data/anchors.json"),
validate(#{<<"person">> => #{ <<"name">> => <<"Ulf">>
, <<"age">> => 29 }}, S, #{}),
fails(#{<<"person">> => #{ <<"name">> => <<"Ulf">>
, <<"age">> => -17 }}, S, #{}, not_in_range),
ok.