Author SHA1 Message Date
Ulf Wiger e53c3ff6bb Fix schema default for array path exprs 2026-05-13 21:35:09 +02:00
Ulf Wiger a38d007012 Validator extensions, zomp vsn 0.2.0 2026-05-13 20:43:01 +02:00
3 changed files with 15 additions and 502 deletions
+5 -34
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"`
@@ -128,29 +115,13 @@ given string value.
In the test schema, we can see the following definition:
```json
"properties": {
"from": {
"allOf": [
{ "$ref": "#/components/schemas/Pubkey" },
{ "x-serialization": {
"tags": ["ak"]
}}
]
}
}
...
"Pubkey": {
"type": "string",
"x-serialization": {
"tags": ["ak", "ct"]
}
"Pubkey": {
"type": "string",
"x-serialization": {
"tags": ["ak", "ct"]
}
```
Whenever the validator encounters an `x-...` property mapped to a validator fun,
this fun is called with the value and the schema part of the property. The return
value of the fun is ignored, and any normal return is treated as a validation success.
The example illustrates a common pattern in OpenAPI specs, where entity references are
used extensively. The `Pubkey` data type can have a more general `x-serialization`
definition, where multiple key types are accepted, whereas a specialized use of the
type can narrow the scope by accepting only a subset of the possible types.
+10 -318
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,10 +89,10 @@ 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()).
@@ -115,293 +107,6 @@ 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'}),
@@ -411,7 +116,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 +129,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
@@ -690,7 +396,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 +571,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 +578,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);
@@ -1178,8 +872,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
@@ -1311,8 +1004,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),
-150
View File
@@ -43,146 +43,6 @@ 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">>}.
@@ -443,16 +303,6 @@ 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) ->