-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()) ]}. 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)). 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.