Author SHA1 Message Date
Ulf Wiger 25f81ae339 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.
2026-08-29 11:30:01 +02:00
uwiger 73944804c1 Merge pull request 'Clarify validation example in README' (#6) from uw-clarify-readme into master
Reviewed-on: #6
2026-05-14 19:10:20 +09:00
Ulf Wiger ffa189b885 Clarify validation example in README 2026-05-14 11:32:17 +02:00
uwigerandUlf Wiger 08287da7b7 Validator extensions, zomp vsn 0.2.0 (#5)
Co-authored-by: Ulf Wiger <ulf@wiger.net>
Reviewed-on: #5
2026-05-14 17:00:02 +09:00
uwigerandUlf Wiger 09093f729f Add gmconfig:pure_update_config/3 (#4)
The function `pure_update_config/3` updates the config and simply returns the new config, without any side-effects.

Co-authored-by: Ulf Wiger <ulf@wiger.net>
Reviewed-on: #4
2026-04-28 17:20:08 +09:00
14 changed files with 1270 additions and 61 deletions
+76
View File
@@ -11,6 +11,9 @@ management subsystem. It is based on JSON-Schema, and includes, among other thin
* Caching of the user config (and schema) as persistent terms
* Fast config lookups using key paths
* Lookups can handle both schema defaults and user-provided defaults
* Optional type coercion during validation
* Optional conversion of enums to atoms
* Optional extensions with custom validator funs
## JSON-Schema validator
@@ -22,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"`
@@ -78,3 +94,63 @@ there are surely other things that are unsupported.
* `"merge"` (for objects, keeps and/or updates existing values)
* `"suggest"` (adds value if not already present)
### Custom validation options
Using `gmconfig_schema_utils:validate(Json, Schema, Opts)`, a few options are supported
to further enhance the validation (`Opts` is of type `map()`):
`coerce => boolean()` converts strings to integers, null and booleans when needed.
`enums_to_atoms => boolean()` converts enum strings to atoms
`extensions => map()` supports mapping `x-...` properties to custom validators.
#### Validator extensions
See the following test case:
```erlang
t_nested_refs() ->
S = read("data/nested_refs_schema.json"),
F = fun(Str, #{<<"tags">> := Tags}) ->
true = lists:any(
fun(T) ->
nomatch =/= string:prefix(Str, T)
end, Tags)
end,
Opts = #{extensions => #{<<"x-serialization">> => F}},
Vs = #{<<"tx">> => #{<<"from">> => <<"ak_good">>}},
Vf = #{<<"tx">> => #{<<"from">> => <<"ac_bad">>}},
validate(Vs, S, Opts),
fails(Vf, S, Opts, #{e => failing_schemas}),
ok.
```
This simulates an encoding extension, where the example fun here simply checks
if tags specified under the `x-serialization` property are prefixes of the
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"]
}
```
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.
+1 -1
View File
@@ -1,6 +1,6 @@
{application,gmconfig,
[{description,"Configuration management support"},
{vsn,"0.1.0"},
{vsn,"0.2.0"},
{registered,[]},
{applications,[kernel,stdlib,setup]},
{env,[]},
+1 -1
View File
@@ -1,7 +1,7 @@
%% -*- mode: erlang; erlang-indent-level: 4; indent-tabs-mode: nil -*-
{application, gmconfig,
[{description, "Gajumaru configuration management support"},
{vsn, "0.1.0"},
{vsn, "zomp"},
{registered, []},
{applications,
[
+14
View File
@@ -0,0 +1,14 @@
%% -*- erlang-mode; erlang-indent-level: 4; indent-tabs-mode: nil -*-
[{application, Name, Opts}] = CONFIG.
case lists:keyfind(vsn, 1, Opts) of
{vsn, "zomp"} ->
ZompMetaF = filename:join(filename:dirname(filename:dirname(SCRIPT)), "zomp.meta"),
{ok, ZMeta} = file:consult(ZompMetaF),
{_, {_, _, {Vmaj,Vmin,Vpatch}}} = lists:keyfind(package_id, 1, ZMeta),
VsnStr = unicode:characters_to_list(io_lib:fwrite("~w.~w.~w", [Vmaj, Vmin, Vpatch])),
Opts1 = lists:keyreplace(vsn, 1, Opts, {vsn, VsnStr}),
[{application, Name, Opts1}];
_ ->
CONFIG
end.
+23 -2
View File
@@ -7,6 +7,7 @@
%%% @end
%%%-------------------------------------------------------------------
-module(gmconfig).
-vsn("0.2.0").
-export([get_env/2, get_env/3]).
@@ -38,6 +39,7 @@
-export([update_config/1,
update_config/2,
update_config/3,
pure_update_config/3,
silent_update_config/1,
delete_config_value/1,
suggest_config/2,
@@ -99,7 +101,7 @@ mock_config() ->
mock_config(Cfg) ->
ensure_schema_loaded(),
store(Cfg, _Notify = false, _Mode = silent).
store(Cfg, _Mode = silent).
unmock_config() ->
gmconfig_schema_utils:clear(),
@@ -443,7 +445,20 @@ just_schema_keys(_) ->
schema_default(Path) when is_list(Path) ->
schema_default(Path, schema()).
schema_default(Path, Schema) ->
schema_default(Path0, Schema) ->
%% There is a way to navigate through an array of objects
%% essentially locating the desired object in the array, and then
%% continuing into one of its properties. This is specified in
%% a path exression as `{PropName, Value, ThenProp}`.
%% If the query expects us to fall back to the schema default, then
%% we must adapt the path accordingly.
%%
%% find_config([<<"system">>, <<"plugins">>,
%% {<<"name">>, PluginName, <<"config">>} | Key],
%% [user_config, schema_default]);
Path = lists:flatmap(fun({_, _,P}) -> [<<"items">>, P];
(P) -> [P]
end, Path0),
case schema(Path, Schema) of
undefined -> undefined;
{ok, Tree} ->
@@ -878,6 +893,12 @@ update_config(Map, ConfigMap, Mode) ->
pt_set_config(ConfigMap1),
ok.
pure_update_config(Map, ConfigMap, Schema) ->
NewCfg = gmconfig_schema_utils:merge(Map, ConfigMap, Schema),
check_validation([validate_(NewCfg, Schema)],
[NewCfg], pure_update_config, silent),
NewCfg.
export_config() ->
Config = pt_get_config(),
JSON = json:format(Config, #{indent => 2}),
+1
View File
@@ -1,4 +1,5 @@
-module(gmconfig_schema_helpers).
-vsn("0.2.0").
-export(
[
+514 -56
View File
@@ -1,5 +1,6 @@
%% -*- mode: erlang; erlang-indent-level: 4; indent-tabs-mode: nil -*-
-module(gmconfig_schema_utils).
-vsn("0.2.0").
-export([get_config/0,
@@ -7,20 +8,28 @@
get_schema/0,
get_schema/1, %% (Default)
set_schema/1,
use_schema/1,
use_schema/2,
schema/1, %% (Path)
schema/2, %% (Path, Schema)
schema/3, %% (Path, Schema, Opts)
clear/0,
expand_schema/1]).
expand_ref/2,
expand_schema/1, %% (Schema) %% expand whole schema
expand_schema/2]). %% (SubSchema, RootSchema)
-export([ update_config/1 %% (Map) -> ok
, merge/2 %% (Item1, Item2) -> Item3
, merge/3 %% (Item1, Item2, Schema) -> Item3
, valid/1 %% (Item) -> Item | error()
, valid/2 %% (Item, Schema) -> Item | error()
, validate/3 %% (Item, Schema, Opts) -> Item | error().
]).
-export([in_properties/2]).
-export([normalize/0,
normalize/1]).
-type json_string() :: binary().
-type json_int() :: integer().
-type json_num() :: number().
@@ -37,15 +46,34 @@
-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(),
enum_to_atom => boolean(),
extensions => extensions() }.
-record(st, { s :: schema() %% schema
, r :: schema() %% root schema
, draft = compatibility :: draft()
, p = []
, a = [] %% annotations
, v :: json() | undefined %% value
, d = undefined :: list() | 'undefined' %% dynamic eval
, opts = #{} :: options()
}).
-type st() :: #st{}.
-export_type([ schema/0, json/0 ]).
-include_lib("kernel/include/logger.hrl").
-spec set_schema(schema()) -> ok.
set_schema(Schema) ->
@@ -67,6 +95,313 @@ get_schema() ->
get_schema(Default) ->
persistent_term:get({?MODULE, '$schema'}, Default).
-spec use_schema(schema() | st()) -> st().
use_schema(#st{} = St) -> St;
use_schema(S) -> new_state(S, S).
use_schema(Schema, RootSchema) ->
new_state(Schema, 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) ->
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'}),
@@ -76,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.
@@ -89,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
@@ -138,12 +472,12 @@ schema_get(_, _, Default) ->
%% let us pattern-match on a schema map
%% all schemas that are not a map are converted to the empty map.
schema_map(Map) when is_map(Map) -> Map;
schema_map(_) -> #{}.
schema_map(Map, _) when is_map(Map) -> Map;
schema_map(_, _) -> #{}.
-spec merge_(json(), json(), st()) -> json().
merge_(A, B, #st{} = St) ->
Ss = schemas_from_dynamic_eval(A, St),
merge_(A, B, #st{} = St0) ->
{Ss, St} = schemas_from_dynamic_eval(A, St0#st{d = undefined}),
case schema_prop(<<"readOnly">>, St, Ss, false) of
true when B == null ->
valid(A, St);
@@ -163,7 +497,7 @@ merge_(A, B, St, Ss) ->
end.
update_semantics(A, St, Ss) ->
case maps:find(<<"$updateSemantics">>, schema_map(A)) of
case maps:find(<<"$updateSemantics">>, schema_map(A, St)) of
{ok, _} = Ok ->
{Ok, object};
error ->
@@ -190,9 +524,9 @@ remove_props(O, Keys, Recurse) when is_map(O) ->
remove_props(Other, _, _) ->
Other.
get_type(#st{} = St, Value) ->
Ss = schemas_from_dynamic_eval(Value, St),
get_type(St, Ss, Value).
get_type(#st{} = St0, Value) ->
{Ss, St} = schemas_from_dynamic_eval(Value, St0),
{get_type(St, Ss, Value), St}.
get_type(#st{} = St, Ss, Value) ->
case any_schema_prop(<<"type">>, St, Ss) of
@@ -272,7 +606,7 @@ update_object(A0, B, St, Ss) ->
update_object_(New, Old, St, Ss) ->
Dyn = acc_props(Ss),
SsOld = schemas_from_dynamic_eval(Old, St),
{SsOld, _} = schemas_from_dynamic_eval(Old, St#st{d = undefined}),
PropSchemas = [{P, prop_schema(P, Dyn, St)} || P <- maps:keys(New)],
try do_update_object(New, Old, St, PropSchemas)
catch
@@ -300,6 +634,55 @@ do_update_object(New, Old, St, PropSchemas) ->
end, Old, PropSchemas),
valid(Res, object, St).
validate(V, Schema, Opts) when is_map(Opts) ->
St0 = use_schema(Schema),
St = St0#st{opts = Opts},
V1 = valid(V, St),
case Opts of
#{enum_to_atom := true} ->
convert_enums(V1, St);
_ ->
V1
end.
convert_enums(V, St0) when is_binary(V) ->
case get_type(St0, V) of
{string, St} ->
{Ss, St1} = schemas_from_dynamic_eval(V, St),
case any_schema_prop(<<"enum">>, St1, Ss) of
{ok, _} ->
binary_to_atom(V, utf8);
_ ->
V
end;
_ ->
V
end;
convert_enums(V, St0) when is_map(V) ->
{Ss, St} = schemas_from_dynamic_eval(V, St0),
Dyn = acc_props(Ss),
maps:map(
fun(P, Vp) ->
PSchema = prop_schema(P, Dyn, St),
convert_enums(Vp, push_path(P, s(PSchema, St)))
end, V);
convert_enums(V, St0) when is_list(V) ->
{Ss,St} = schemas_from_dynamic_eval(V, St0),
case any_schema_prop(<<"items">>, St, Ss) of
{ok, Is} ->
[convert_enums(Vi, push_path(items, s(Is, St)))
|| Vi <- V];
error ->
case any_schema_prop(<<"prefixItems">>, St, Ss) of
{ok, PfxIs} ->
[convert_enums(Vi, push_path(prefixItems, s(PfxIs, St)))
|| Vi <- V];
error ->
V
end
end;
convert_enums(V, _) ->
V.
valid(V) ->
valid(V, get_schema()).
@@ -307,25 +690,25 @@ 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);
valid_(V, St) ->
Type = get_type(St, V),
valid_(V, St0) ->
{Type, St} = get_type(St0, V),
valid(V, Type, St).
valid(V, _, #st{s = true}) -> V;
valid(_, _, #st{s = false} = St) -> fail(invalid, St);
valid(V, Type, St) ->
valid(V, Type, St0) ->
%% We run dynamic eval to find conditional parts of the schema.
%% we keep these in a separate list.
Ss = schemas_from_dynamic_eval(V, St),
{Ss,St} = schemas_from_dynamic_eval(V, St0),
valid(V, Type, St, Ss).
valid(V, Type, St, Ss) ->
valid_const(V, Type, St, Ss),
valid_enum(V, Type, St, Ss),
_ = valid_const(V, Type, St, Ss),
_ = valid_enum(V, Type, St, Ss),
%% Dynamic eval returns a list of matching schemas
%% We pass them along as they may contain properties,
%% but `V` has already been validated against them.
@@ -342,7 +725,7 @@ valid(V, Type, St, Ss) ->
split_valid(V, St, Ss) ->
split_valid(V, 0, St, Ss, [], []).
split_valid(V, Ix, St, [S|Ss], Yes, No) ->
try valid(V, push_path(Ix, St#st{s = S})) of
try valid(V, push_path(Ix, s(S, St))) of
_ -> split_valid(V, Ix+1, St, Ss, [{Ix,S}|Yes], No)
catch
error:Err ->
@@ -370,7 +753,8 @@ valid_enum(V, Type, St, Ss) ->
case lists:any(fun(X) ->
is_equal(Type, V, X)
end, En) of
true -> V;
true ->
V;
false ->
fail(not_in_enum, push_path(enum, St))
end
@@ -396,7 +780,7 @@ valid_object(O, St, Ss) when is_map(O) ->
end,
lists:foreach(
fun({P, #st{} = S}) ->
valid(maps:get(P, O), push_path(P, S))
valid(maps:get(P, O), push_path(P, S#st{d = undefined}))
end, PropSchemas),
O;
valid_object(_, St, _) ->
@@ -405,12 +789,15 @@ valid_object(_, St, _) ->
-spec valid_boolean(json(), st(), [st()]) -> json_bool().
valid_boolean(V, #st{s = true}, []) -> V;
valid_boolean(_, #st{s = false} = St, []) -> fail(invalid, St);
valid_boolean(<<"true">> , #st{opts = #{coerce := true}}, _) -> true;
valid_boolean(<<"false">>, #st{opts = #{coerce := true}}, _) -> false;
valid_boolean(V, St, _) ->
assert_type(fun is_boolean/1, V, St),
V.
valid_null(N, #st{s = true}, []) -> N;
valid_null(_, #st{s = false} = St, []) -> fail(invalid, St);
valid_null(<<"null">>, #st{s = null, opts = #{coerce := true}}, _) -> null;
valid_null(null, #st{s = null}, _) ->
null;
valid_null(_, St, _) ->
@@ -432,15 +819,28 @@ valid_string(S, St, Ss) when is_binary(S) ->
Lmax = schema_prop(<<"maxLength">>, St, Ss, Sz),
assert_min(Sz, Lmin, min_length, St),
assert_max(Sz, Lmax, max_length, St),
S;
valid_enum(S, string, St, Ss);
valid_string(_, St, _) ->
fail(wrong_type, St).
valid_number(N, _, #st{s = true}, []) -> N;
valid_number(_, _, #st{s = false} = St, []) -> fail(invalid, St);
valid_number(I, Sub, #st{opts = #{coerce := true}} = St, Ss) when is_binary(I) ->
try coerce_num(Sub, I) of
I1 ->
valid_number_(I1, Sub, St#st{v = I1}, Ss)
catch
error:_ ->
fail(wrong_type, St)
end;
valid_number(I, Sub, St, Ss) when is_number(I) ->
[assert_type(fun is_integer/1, I, St) || Sub == integer],
valid_number_(I, Sub, St, Ss);
valid_number(_, _, St, _) ->
fail(wrong_type, St).
valid_number_(I, Sub, St, Ss) when is_number(I) ->
[assert_type(fun is_integer/1, I, St) || Sub == integer],
case any_schema_prop(<<"multipleOf">>, St, Ss) of
error -> ok;
{ok, X} when is_integer(X), X > 0 ->
@@ -465,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))),
@@ -472,10 +885,16 @@ 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;
valid_number(_, _, St, _) ->
fail(wrong_type, St).
test_range('<', EMin, I, add_anno(EMin, push_path(exclusiveMinimum, St))).
coerce_num(integer, I) when is_binary(I) ->
binary_to_integer(I);
coerce_num(number, I) when is_binary(I) ->
try binary_to_integer(I)
catch
error:_ ->
binary_to_float(I)
end.
valid_array(A, #st{s = true}, []) -> A;
valid_array(_, #st{s = false} = St, []) -> fail(invalid, St);
@@ -497,23 +916,23 @@ valid_array(A, #st{} = St, Ss) when is_list(A) ->
{ok, PfxIs} ->
assert_schema(fun is_list/1, PfxIs, push_path(prefixItems, St)),
check_prefix_items(
PfxIs, A, Is, push_path(prefixItems, St#st{s = PfxIs}));
PfxIs, A, Is, push_path(prefixItems, s(PfxIs, St)));
error ->
check_items(A, push_path(items, St#st{s = Is}))
check_items(A, push_path(items, s(Is, St)))
end;
error ->
case PfxItems of
{ok, PfxIs} ->
assert_schema(fun is_list/1, PfxIs, push_path(prefixItems, St)),
check_prefix_items(
PfxIs, A, true, push_path(prefixItems, St#st{s = PfxIs}));
PfxIs, A, true, push_path(prefixItems, s(PfxIs, St)));
error ->
ok
end
end,
case any_schema_prop(<<"contains">>, St, Ss) of
{ok, Cs} ->
check_contains(A, push_path(contains, St#st{s = Cs}),
check_contains(A, push_path(contains, s(Cs, St)),
schema_prop(<<"minContains">>, St, Ss, null),
schema_prop(<<"maxContains">>, St, Ss, null));
error ->
@@ -540,12 +959,12 @@ check_prefix_items(_, _, _, St) ->
fail(invalid, St).
check_prefix_items([I|Is], [H|T], Ix, Items, St) ->
_ = valid(H, push_path(Ix, St#st{s = I})),
_ = valid(H, push_path(Ix, s(I, St))),
check_prefix_items(Is, T, Ix+1, Items, St);
check_prefix_items(_, [], _, _, _) ->
ok;
check_prefix_items([], Rest, Ix, Items, St) ->
check_items(Rest, Ix, push_path(items, St#st{s = Items})).
check_items(Rest, Ix, push_path(items, s(Items, St))).
check_items(A, St) ->
check_items(A, 0, St).
@@ -577,13 +996,13 @@ check_contains([], _, St, Min, Max, Yes, _No) ->
if is_integer(Max) ->
_ = valid(YesLen,
push_path(max,
St#st{s = #{<<"maximum">> => Max}}));
s(#{<<"maximum">> => Max}, St)));
true -> ok
end,
if is_integer(Min) ->
_ = valid(YesLen,
push_path(min,
St#st{s = #{<<"minimum">> => Min}}));
s(#{<<"minimum">> => Min}, St)));
true ->
ok
end
@@ -660,14 +1079,26 @@ any_pattern_({Pat, Schema, I}, P) ->
any_pattern_(maps:next(I), P)
end.
schemas_from_dynamic_eval(Obj, #st{s = Schema} = St) ->
SMap = schema_map(Schema),
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)};
_ ->
St
end.
schemas_from_dynamic_eval(_, #st{d = Ss} = St) when Ss =/= undefined ->
{Ss, St};
schemas_from_dynamic_eval(Obj, #st{s = Schema} = St0) ->
St = maybe_expand_ref(St0),
SMap = schema_map(Schema, St),
Ss =
maps:fold(
fun(<<"allOf">>, Ss, Acc) ->
St1 = push_path(allOf, St),
case split_valid(Obj, St, Ss) of
{ValidSs, []} ->
Acc ++ [St1#st{s = S} || {_, S} <- ValidSs];
Acc ++ [s(S, St1) || {_, S} <- ValidSs];
{_, FailedSs} ->
fail(failing_schemas, add_anno(FailedSs, St1))
end;
@@ -675,7 +1106,7 @@ schemas_from_dynamic_eval(Obj, #st{s = Schema} = St) ->
St1 = push_path(anyOf, St),
case split_valid(Obj, St1, Ss) of
{[_|_] = ValidSs, _} ->
Acc ++ [St1#st{s = S} || {_, S} <- ValidSs];
Acc ++ [s(S, St1) || {_, S} <- ValidSs];
{[], FailedSs} ->
fail(no_matching_schema, add_anno(FailedSs, St1))
end;
@@ -683,7 +1114,7 @@ schemas_from_dynamic_eval(Obj, #st{s = Schema} = St) ->
St1 = push_path(oneOf, St),
case split_valid(Obj, St1, Ss) of
{[{_, S}], _} ->
Acc ++ [St1#st{s = S}];
Acc ++ [s(S, St1)];
{[_|_] = MoreValid, _} ->
ValidIxs = [I || {I,_} <- MoreValid],
fail(more_than_one, add_anno({valid, ValidIxs}, St1));
@@ -692,31 +1123,49 @@ schemas_from_dynamic_eval(Obj, #st{s = Schema} = St) ->
end;
(<<"if">>, S, Acc) ->
St1 = push_path('if', St),
try valid(Obj, St1#st{s = S}) of
try valid(Obj, s(S, St1)) of
_ ->
Sthen =
push_path(
'then', St1#st{s = maps:get(<<"then">>, SMap, #{})}),
'then', s(maps:get(<<"then">>, SMap, #{}), St1)),
_ = valid(Obj, Sthen),
Acc ++ [Sthen]
catch
error:_ ->
Selse =
push_path(
'else', St1#st{s = maps:get(<<"else">>, SMap, #{})}),
'else', s(maps:get(<<"else">>, SMap, #{}), St1)),
_ = valid(Obj, Selse),
Acc ++ [Selse]
end;
(<<"not">>, S, Acc) ->
Snot = push_path('not', St#st{s = S}),
Snot = push_path('not', s(S, St)),
try valid(Obj, Snot) of
_ -> fail(invalid, Snot)
catch
error:_ ->
Acc
end;
(_, _, Acc) -> Acc
end, [], SMap).
(<<"x-", _/binary>> = Prop, SExt, Acc) ->
case St#st.opts of
#{extensions := #{Prop := ExtF}} ->
St1 = push_path(Prop, St),
call_extension(ExtF, Obj, SExt, Prop, St1),
Acc;
_ ->
Acc
end;
(_, _, Acc) ->
Acc
end, [], SMap),
{Ss, St#st{d = Ss}}.
call_extension(F, Obj, S, Prop, St) ->
try F(Obj, S)
catch
error:E ->
fail(extended_check, add_anno({Prop, E}, St))
end.
acc_props(Ss) ->
lists:foldl(
@@ -728,6 +1177,10 @@ acc_props(Ss) ->
end
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}.
push_path(Ps, #st{p = P0} = St) when is_list(Ps) ->
%% Assume Ps is in reverse order
St#st{p = Ps ++ P0};
@@ -780,13 +1233,15 @@ uniqueItems(L) ->
USorted = lists:usort(L),
[] == L -- USorted.
expand_schema(S0) ->
S = expand_definitions(S0),
expand_schema(S) ->
%% S = expand_definitions(S0),
expand_schema(S, S).
expand_definitions(#{<<"definitions">> := D} = S) ->
S#{<<"definitions">> := expand_schema(D, S)}.
%% expand_definitions(#{<<"definitions">> := D} = 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(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.
@@ -802,9 +1257,9 @@ expand_schema(S, S0) when is_map(S) ->
S0
end,
maps:fold(fun(K, V, Acc) -> expand_schema_(K, V, Acc, S1) end, #{}, S);
expand_schema([#{<<"$ref">> := Path} = V], S0) when map_size(V) == 1 ->
D = expand_ref(Path, S0),
[D];
%% expand_schema([#{<<"$ref">> := Path} = V], S0) when map_size(V) == 1 ->
%% D = expand_ref(Path, S0),
%% [expand_schema(D, S0)];
expand_schema(S, S0) when is_list(S) ->
[expand_schema(E, S0) || E <- S];
expand_schema(S, _) ->
@@ -853,8 +1308,11 @@ schema(Path) ->
schema(Path, Schema) ->
schema(Path, Schema, #{follow_refs => true}).
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),
+29
View File
@@ -0,0 +1,29 @@
{
"type": "object",
"properties": {
"tx" : { "$ref" : "#/components/schemas/Tx" }
},
"components": {
"schemas": {
"Tx": {
"type": "object",
"properties": {
"from": {
"allOf": [
{ "$ref": "#/components/schemas/Pubkey" },
{ "x-serialization": {
"tags": ["ak"]
}}
]
}
}
},
"Pubkey": {
"type": "string",
"x-serialization": {
"tags": ["ak", "ct"]
}
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "Elizabeth",
"children": [
{
"name": "Charles",
"children": [
{
"name": "William",
"children": [
{ "name": "George" },
{ "name": "Charlotte" }
]
},
{
"name": "Harry"
}
]
}
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "Elizabeth",
"children": [
{
"name": "Charles",
"children": [
{
"name": "William",
"children": [
{ "name": [1] },
{ "name": "Charlotte" }
]
},
{
"name": "Harry"
}
]
}
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"type": "object",
"properties": {
"name": { "type": "string" },
"children": {
"type": "array",
"items": { "$ref": "#" }
}
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"$defs": {
"alice": { "$ref": "#/$defs/bob" },
"bob": { "$ref": "#/$defs/alice" }
}
}
+554
View File
@@ -0,0 +1,554 @@
-module(gmconfig_schema_utils_tests).
-export([tr/0]).
-include_lib("eunit/include/eunit.hrl").
-import(gmconfig_schema_utils, [ valid/2
, validate/3 ]).
%%-define(t(Expr), ?_test(?debugVal(Expr))).
-define(t(Expr), ?_test(Expr)).
tr() ->
dbg:tracer(),
dbg:tpl(?MODULE, x),
dbg:tpl(gmconfig_schema_utils,x),
dbg:p(all,[c]),
eunit:test(?MODULE).
simple_type_test_() ->
{"Simple type tests",
[
?t(t_integer())
, ?t(t_number())
, ?t(t_boolean())
, ?t(t_string())
, ?t(t_array())
, ?t(t_object())
, ?t(t_shortcut_schema())
]}.
update_test_() ->
{"Validated update tests",
[
?t(t_update_objects())
]}.
schema_spec_examples_test_() ->
{"Examples from JSON-Schema docs",
[
?t(t_ref_loop())
, ?t(t_recursive_def())
, ?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">>}.
obj(Ps) -> #{<<"type">> => <<"object">>,
<<"properties">> => Ps}.
t_update_objects() ->
S0 = obj(
#{<<"a">> => int(),
<<"b">> => obj(#{<<"b1">> => array(),
<<"b2">> =>
obj(#{<<"b21">> => str()})
})
}),
%%
%% Only objects are merged; other types are replaced.
A0 = #{<<"a">> => 1,
<<"b">> => #{<<"b1">> => [1,2],
<<"b2">> => #{}}},
B0 = #{<<"a">> => 2,
<<"b">> => #{<<"b1">> => [4,5],
<<"b2">> => #{<<"b21">> => <<"foo">>}}},
#{<<"a">> := 1,
<<"b">> := #{<<"b1">> := [1,2],
<<"b2">> := #{<<"b21">> := <<"foo">>}}} =
gmconfig_schema_utils:merge(A0, B0, S0),
%%
%% Modified update semantics - replace instead of merge
S0Ps = maps:get(<<"properties">>, S0),
S01Ps = S0Ps#{<<"c">> => #{<<"type">> => <<"object">>,
<<"updateSemantics">> => <<"replace">>,
<<"properties">> =>
#{<<"c1">> => #{<<"type">> => <<"integer">>},
<<"c2">> => #{<<"type">> => <<"integer">>}
}}},
S01 = S0#{<<"properties">> => S01Ps},
A01 = A0#{<<"c">> => #{<<"c1">> => 1}},
B01 = B0#{<<"c">> => #{<<"c2">> => 2}},
#{<<"a">> := 1,
<<"b">> := #{<<"b1">> := [1,2],
<<"b2">> := #{<<"b21">> := <<"foo">>}},
<<"c">> := #{<<"c1">> := 1}} =
gmconfig_schema_utils:merge(A01, B01, S01),
A0r = #{<<"a">> => 1,
<<"b">> => #{<<"$updateSemantics">> => <<"replace">>,
<<"b1">> => [1,2],
<<"b2">> => #{<<"$updateSemantics">> => <<"merge">>}
}},
#{<<"a">> := 1,
<<"b">> := #{<<"b1">> := [1,2],
<<"b2">> := B2M}} =
gmconfig_schema_utils:merge(A0r, B0, S0),
%% B2M is the empty map (updateSemantics prop removed recursively)
?assertEqual(#{}, B2M),
A0r1 = #{<<"a">> => 1,
<<"b">> => #{<<"$updateSemantics">> => <<"merge">>,
<<"b1">> => [1,2],
<<"b2">> => #{<<"$updateSemantics">> => <<"replace">>}
}},
#{<<"a">> := 1,
<<"b">> := #{<<"b1">> := [1,2],
<<"b2">> := B2M1}} =
gmconfig_schema_utils:merge(A0r1, B0, S0),
%% B2M is the empty map (updateSemantics prop removed recursively)
?assertEqual(#{}, B2M1),
%%
%% With updateSemantics : suggest, the offered data is accepted if
%% the existing is missing or 'null'.
S02Ps = S0Ps#{<<"c">> => #{<<"type">> => <<"object">>,
<<"updateSemantics">> => <<"suggest">>,
<<"properties">> =>
#{<<"c1">> => #{<<"type">> => <<"integer">>},
<<"c2">> => #{<<"type">> => <<"integer">>}
}}},
S02 = S0#{<<"properties">> => S02Ps},
A02 = A0#{<<"c">> => #{<<"c1">> => 1}},
B02 = B0#{<<"c">> => #{<<"c2">> => 2}},
#{<<"a">> := 1,
<<"b">> := #{<<"b1">> := [1,2],
<<"b2">> := #{<<"b21">> := <<"foo">>}},
<<"c">> := #{<<"c2">> := 2}} =
gmconfig_schema_utils:merge(A02, B02, S02),
B03 = B0#{<<"c">> => null},
#{<<"a">> := 1,
<<"b">> := #{<<"b1">> := [1,2],
<<"b2">> := #{<<"b21">> := <<"foo">>}},
<<"c">> := #{<<"c1">> := 1}} =
gmconfig_schema_utils:merge(A02, B0, S02),
#{<<"a">> := 1,
<<"b">> := #{<<"b1">> := [1,2],
<<"b2">> := #{<<"b21">> := <<"foo">>}},
<<"c">> := #{<<"c1">> := 1}} =
gmconfig_schema_utils:merge(A02, B03, S02),
ok.
t_integer() ->
S0 = #{<<"type">> => <<"integer">>},
is_valid(17, S0),
all_fail([17.0, [], #{}, <<>>], S0, wrong_type),
S1 = S0#{<<"minimum">> => 1, <<"maximum">> => 4},
all_valid([1, 2, 4], S1),
all_fail([0, 5], S1, not_in_range),
S2 = S0#{<<"exclusiveMinimum">> => 1, <<"exclusiveMaximum">> => 3},
is_valid(2, S2),
all_fail([1, 3], S2, not_in_range),
S3 = S0#{<<"multipleOf">> => 2},
is_valid(4, S3),
fails(3, S3, not_a_multiple),
ok.
t_number() ->
S0 = #{<<"type">> => <<"number">>},
all_valid([17, 17.0], S0),
all_fail([#{}, [], <<>>, true], S0, wrong_type),
S1 = S0#{<<"minimum">> => 1, <<"maximum">> => 4.0},
all_valid([1, 1.0, 2, 2.3, 3.999, 4, 4.0], S1),
all_fail([0, 5], S1, not_in_range),
S2 = S0#{<<"exclusiveMinimum">> => 1, <<"exclusiveMaximum">> => 3},
is_valid(2, S2),
all_fail([1, 3], S2, not_in_range),
S3 = S0#{<<"multipleOf">> => 2},
is_valid(4, S3),
all_fail([3, 4.0], S3, not_a_multiple),
ok.
t_boolean() ->
S0 = #{<<"type">> => <<"boolean">>},
all_valid([true, false], S0),
%% truthy and falsy types not accepted
all_fail([1, 0, null, [], <<>>, #{}], S0, wrong_type),
ok.
t_string() ->
S0 = #{<<"type">> => <<"string">>},
all_valid([<<>>, <<"foo">>], S0),
fails("foo", S0, wrong_type),
ok.
t_array() ->
S0 = #{<<"type">> => <<"array">>},
all_valid([ [], [1,1], [1,2,3], [#{}] ], S0),
all_fail([1, false, #{}, <<"foo">>], S0, wrong_type),
S1 = S0#{<<"minItems">> => 2},
all_valid([[1,2], [1,2,3,4]], S1),
all_fail([[], [1]], S1, not_in_range),
S2 = S0#{<<"maxItems">> => 4},
all_valid([[], [1], [1,2], [1,2,3,4]], S2),
fails([1,2,3,4,5], S2, not_in_range),
S3 = maps:merge(S1, S2),
all_valid([ [1,2], [1,2,3], [1,2,3,4] ], S3),
all_fail([ [1], [1,2,3,4,5] ], S3, not_in_range),
S4 = S0#{<<"uniqueItems">> => true},
all_valid([ [], [1], [1,2] ], S4),
all_fail([ [1,1], [2,1,2] ], S4, not_unique),
S5 = S0#{<<"items">> => #{<<"type">> => <<"integer">>}},
all_valid([ [], [1], [1,2,3] ], S5),
fails([<<"foo">>], S5, #{e => wrong_type, p => [array,items,0,integer]}),
fails([1,2,false], S5, #{e => wrong_type, p => [array,items,2,integer]}),
S6 = S0#{<<"prefixItems">> => [#{<<"type">> => <<"string">>},
#{<<"type">> => <<"integer">>},
#{<<"type">> => <<"integer">>}]},
is_valid([<<"foo">>, 1], S6),
is_valid([<<"foo">>, 1, 2], S6),
is_valid([<<"foo">>, 1, 2, 3], S6),
fails([1,2,3], S6, #{e => wrong_type, p => [array,prefixItems,0,string]}),
S7 = S6#{<<"items">> => false},
fails([<<"foo">>, 1, 2, 3], S7, #{e => invalid,
p => [array,prefixItems,items,3]}),
S71 = S6#{<<"items">> => #{<<"type">> => <<"integer">>}},
is_valid([<<>>, 1,2,3,4,5,6], S71),
fails([<<>>, 1,2,3,4,5,[]], S71,
#{e => wrong_type,
p => [array,prefixItems,items,6,integer]}),
S8 = S0#{<<"contains">> => #{<<"type">> => <<"integer">>}},
is_valid([1,2,3], S8),
is_valid([true,true,1], S8),
fails([true], S8, contains),
fails([], S8, contains), % not sure where specifed, but makes sense
S81 = S8#{<<"minContains">> => 2},
is_valid([1,2,3], S81),
is_valid([true,1,2], S81),
fails([1], S81, #{e => not_in_range, p => [array, contains, min,
integer, minimum]}),
S82 = S8#{<<"maxContains">> => 4},
all_valid([ [1], [1,2,3], [true,1,2,3,4] ], S82),
S83 = maps:merge(S81, S82),
is_valid([1,2,3], S83),
fails([1], S83, #{e => not_in_range, p => [array, contains, min,
integer, minimum]}),
fails([1,2,3,4,5], S83, #{e => not_in_range, p => [array, contains, max,
integer, maximum]}),
is_valid([], S8#{<<"minContains">> => 0}),
ok.
t_object() ->
S0 = #{<<"type">> => <<"object">>},
all_valid([ #{}, #{<<"a">> => 1} ], S0),
all_fail([ true, 17, 17.0, [], <<"foo">> ], S0, #{e => wrong_type}),
S1 = S0#{<<"properties">> => #{<<"a">> => #{<<"type">> => <<"integer">>}}},
all_valid([ #{<<"a">> => 1}, #{}, #{<<"b">> => 1} ], S1),
fails(#{<<"a">> => true}, S1, #{e => wrong_type,
p => [object, <<"a">>, integer]}),
is_valid(#{<<"b">> => 1}, S1#{<<"additionalProperties">> => true}),
fails(#{<<"b">> => 1}, S1#{<<"additionalProperties">> => false},
#{e => invalid,
p => [object, <<"b">>],
a => [additionalProperties]}),
fails(#{<<"b">> => 1}, S1#{<<"required">> => [<<"a">>]},
#{e => required, p => [object,required], a => [[<<"a">>]]}),
all_valid([ #{<<"a">> => 1}, #{<<"a">> => true} ],
S0#{<<"properties">> =>
#{<<"a">> => #{<<"oneOf">> =>
[#{<<"type">> => <<"integer">>},
#{<<"type">> => <<"boolean">>}]}
}}),
S2 = S0#{<<"properties">> =>
#{<<"a">> => #{<<"type">> => <<"integer">>,
<<"allOf">> =>
[#{<<"minimum">> => 2},
#{<<"maximum">> => 5},
#{<<"multipleOf">> => 2}
]}}
},
is_valid(#{<<"a">> => 4}, S2),
fails(#{<<"a">> => 3}, S2, #{e => failing_schemas,
a => [[{2,'_'}]|'_'],
p => [object, <<"a">>, allOf]}),
ok.
t_shortcut_schema() ->
Vs = [<<"foo">>,
#{<<"a">> => 1},
17],
all_valid(Vs, #{}),
all_valid(Vs, true),
all_fail(Vs, false, invalid),
ok.
is_valid(V, S) ->
{V, V} = {valid(V, S), V}.
%% ?assertEqual(V, valid(V, S)).
fails(V, S, Reason) ->
fails(V, S, #{}, Reason).
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
_ ->
error({expected_exception, #{v => V,
s => S,
e => Expect}})
catch
error:R ->
match_expected(Expect, R)
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) ->
case maps:find(K, R) of
{ok, V} -> Acc;
{ok, Other} ->
case match_other(V, Other) of
true -> Acc;
false ->
Acc#{K => Other}
end;
error ->
Acc#{K => '$not_found'}
end
end, #{}, E) of
M when map_size(M) == 0 ->
ok;
Unexpected ->
error({expected, E, Unexpected})
end.
match_other(V, V) -> true;
match_other([H1|T1], [H2|T2]) ->
case match_other(H1, H2) of
true ->
match_other(T1, T2);
false ->
false
end;
match_other(T1, T2) when tuple_size(T1) =:= tuple_size(T2) ->
lists:all(fun({A,B}) -> match_other(A, B) end,
lists:zip(tuple_to_list(T1),
tuple_to_list(T2)));
match_other('_', _) ->
true;
match_other(_, _) ->
false.
all_valid(Vs, S) ->
[is_valid(V, S) || V <- Vs].
all_fail(Vs, S, Reason) ->
[fails(V, S, Reason) || V <- Vs].
read(F) ->
FullF = filename:join(
filename:dirname(code:which(?MODULE)), F),
?debugFmt("FullF = ~s~n", [FullF]),
{ok, Bin} = file:read_file(FullF),
dec(Bin).
dec(Str) ->
try json:decode(Str)
catch
error:E ->
error(#{type => json_decode_error,
error => E})
end.
t_ref_loop() ->
S = read("data/ref_loop_schema.json"),
try gmconfig_schema_utils:schema([<<"$defs">>,<<"alice">>], S) of
Unexpected ->
error({unexpected, Unexpected})
catch
error:nested_references ->
ok
end.
t_recursive_def() ->
S = read("data/recursion_schema.json"),
D = read("data/recursion_data.json"),
Df = read("data/recursion_data_faulty.json"),
is_valid(D, S),
fails(Df, S, #{e => wrong_type, p => [object, <<"children">>,
array, items, 0,
object, <<"children">>,
array, items, 0,
object,<<"children">>,
array, items, 0,
object, <<"name">>, string
]}),
ok.
t_nested_refs() ->
S = read("data/nested_refs_schema.json"),
F = fun(Str, #{<<"tags">> := Tags}) ->
true = lists:any(
fun(T) ->
nomatch =/= string:prefix(Str, T)
end, Tags)
end,
Opts = #{extensions => #{<<"x-serialization">> => F}},
Vs = #{<<"tx">> => #{<<"from">> => <<"ak_good">>}},
Vf = #{<<"tx">> => #{<<"from">> => <<"ac_bad">>}},
validate(Vs, S, Opts),
fails(Vf, S, Opts, #{e => failing_schemas}),
ok.
+1 -1
View File
@@ -4,7 +4,7 @@
{prefix,"gmconfig"}.
{author,"Ulf Wiger"}.
{desc,"Configuration management support"}.
{package_id,{"uwiger","gmconfig",{0,1,2}}}.
{package_id,{"uwiger","gmconfig",{0,2,0}}}.
{deps,[{"uwiger","setup",{2,2,4}}]}.
{key_name,none}.
{a_email,"ulf@wiger.net"}.