From fec725a0fd54be922f662bec25bc89293a765fff Mon Sep 17 00:00:00 2001 From: Javier Garea Date: Wed, 23 Aug 2023 10:09:37 +0200 Subject: [PATCH] chore: move ndto schema operations to restcheck (#36) --- rebar.config | 11 +- rebar.lock | 6 +- src/restcheck_schema.erl | 875 ++++++++++++++++++ src/restcheck_schema.hrl | 35 + src/restcheck_triq.erl | 18 +- .../restcheck_schema_properties.erl | 76 ++ test/restcheck_schema_SUITE.erl | 451 +++++++++ test/restcheck_schema_dom.erl | 189 ++++ 8 files changed, 1643 insertions(+), 18 deletions(-) create mode 100644 src/restcheck_schema.erl create mode 100644 src/restcheck_schema.hrl create mode 100644 test/property_test/restcheck_schema_properties.erl create mode 100644 test/restcheck_schema_SUITE.erl create mode 100644 test/restcheck_schema_dom.erl diff --git a/rebar.config b/rebar.config index cd78ba2..4f9526c 100644 --- a/rebar.config +++ b/rebar.config @@ -49,10 +49,12 @@ {ex_doc, [ {extras, [ {"README.md", #{title => "Overview"}}, + {"CONTRIBUTING.md", #{title => "Contributing"}}, {"LICENSE", #{title => "License"}} ]}, {main, "README.md"}, - {source_url, "https://github.com/nomasystems/restcheck"} + {source_url, "https://github.com/nomasystems/restcheck"}, + {prefix_ref_vsn_with_v, false} ]}. {cover_enabled, true}. @@ -62,11 +64,16 @@ restcheck, restcheck_client, restcheck_pbt, + restcheck_schema, restcheck_suite, restcheck_triq ]}. %% TODO: address this {gradualizer_opts, [ - {exclude, ["src/restcheck.erl", "src/restcheck_triq.erl"]} + {exclude, [ + "src/restcheck.erl", + "src/restcheck_schema.erl", + "src/restcheck_triq.erl" + ]} ]}. diff --git a/rebar.lock b/rebar.lock index 9e81e3f..d634e31 100644 --- a/rebar.lock +++ b/rebar.lock @@ -9,7 +9,7 @@ 1}, {<<"erf">>, {git,"git@github.com:nomasystems/erf.git", - {ref,"66bc59d0d433850d11cfc7efba39eaa9e053b3d8"}}, + {ref,"78aeb10ac17256ef162009ee54ff04247fd34ea5"}}, 0}, {<<"foil">>, {git,"https://github.com/lpgauth/foil.git", @@ -26,7 +26,7 @@ 1}, {<<"ndto">>, {git,"git@github.com:nomasystems/ndto.git", - {ref,"78911a34fc4372fab780e2e4996907a9b80189f1"}}, + {ref,"8cbe7ced7d6d376bdb6edf83b450f894603c83d2"}}, 0}, {<<"njson">>, {git,"git@github.com:nomasystems/njson.git", @@ -38,7 +38,7 @@ 1}, {<<"triq">>, {git,"git@github.com:nomasystems/triq.git", - {ref,"9a2fe2cc44460abb28e48438936adfc5bea4bea9"}}, + {ref,"a0bf2fd60475a621f55d6b769b8240e73a603436"}}, 0}]}. [ {pkg_hash,[ diff --git a/src/restcheck_schema.erl b/src/restcheck_schema.erl new file mode 100644 index 0000000..a405bc2 --- /dev/null +++ b/src/restcheck_schema.erl @@ -0,0 +1,875 @@ +%%% Copyright 2023 Nomasystems, S.L. http://www.nomasystems.com +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License + +%%% @doc A module to reduce complex ndto schemas. +-module(restcheck_schema). + +%%% INCLUDE FILES +-include("restcheck_schema.hrl"). + +%%% EXTERNAL EXPORTS +-export([ + complement/1, + empty_schema/0, + intersection/1, + symmetric_difference/1, + union/1, + universal_schema/0 +]). + +%%% UTIL EXPORTS +-export([ + multiples/3 +]). + +%%%----------------------------------------------------------------------------- +%%% EXTERNAL EXPORTS +%%%----------------------------------------------------------------------------- +-spec complement(Schema) -> Complement when + Schema :: ndto:schema(), + Complement :: ndto:schema(). +complement(#{} = Any) when map_size(Any) =:= 0 -> + false; +complement(true) -> + false; +complement(false) -> + #{}; +complement(#{<<"allOf">> := AllOf}) -> + union([complement(Schema) || Schema <- AllOf]); +complement(#{<<"anyOf">> := AnyOf}) -> + intersection([complement(Schema) || Schema <- AnyOf]); +complement(#{<<"oneOf">> := OneOf}) -> + Schema1 = symmetric_difference(OneOf), + complement(Schema1); +complement(#{<<"not">> := Not}) -> + Not; +complement(#{<<"enum">> := _Values}) -> + %% TODO: mutation + undefined; +complement(#{<<"type">> := <<"boolean">>}) -> + union(lists:delete(#{<<"type">> => <<"boolean">>}, ?BASIC_SCHEMAS)); +complement(#{<<"type">> := <<"number">>} = Schema) -> + Minimum = + case maps:get(<<"minimum">>, Schema, undefined) of + undefined -> + undefined; + Min -> + ExclusiveMin = maps:get(<<"exclusiveMinimum">>, Schema, false), + #{ + <<"type">> => <<"number">>, + <<"maximum">> => Min, + <<"exclusiveMaximum">> => not ExclusiveMin + } + end, + Maximum = + case maps:get(<<"maximum">>, Schema, undefined) of + undefined -> + undefined; + Max -> + ExclusiveMax = maps:get(<<"exclusiveMaximum">>, Schema, false), + #{ + <<"type">> => <<"number">>, + <<"minimum">> => Max, + <<"exclusiveMinimum">> => not ExclusiveMax + } + end, + Schemas = lists:filter(fun(S) -> S =/= undefined end, [Minimum, Maximum]), + union( + Schemas ++ + lists:subtract( + ?BASIC_SCHEMAS, + [ + #{<<"type">> => <<"number">>}, + #{<<"type">> => <<"integer">>} + ] + ) + ); +complement(#{<<"type">> := <<"integer">>} = Schema) -> + Min = maps:get(<<"minimum">>, Schema, undefined), + Max = maps:get(<<"maximum">>, Schema, undefined), + Minimum = + case Min of + undefined -> + undefined; + Min -> + ExclusiveMin = maps:get(<<"exclusiveMinimum">>, Schema, false), + #{ + <<"type">> => <<"number">>, + <<"maximum">> => Min, + <<"exclusiveMaximum">> => not ExclusiveMin + } + end, + Maximum = + case Max of + undefined -> + undefined; + Max -> + ExclusiveMax = maps:get(<<"exclusiveMaximum">>, Schema, false), + #{ + <<"type">> => <<"number">>, + <<"minimum">> => Max, + <<"exclusiveMinimum">> => not ExclusiveMax + } + end, + Intervals = + case maps:get(<<"multipleOf">>, Schema, undefined) of + undefined -> + [Minimum, Maximum]; + Mult -> + Multiples = restcheck_schema:multiples(Mult, Min, Max), + exclude_integers(Multiples) + end, + Schemas = lists:filter(fun(S) -> S =/= undefined end, Intervals), + %% TODO: remove integers in numbers instead of fully removing the numbers domain + union( + Schemas ++ + lists:subtract( + ?BASIC_SCHEMAS, + [ + #{<<"type">> => <<"number">>}, + #{<<"type">> => <<"integer">>} + ] + ) + ); +complement(#{<<"type">> := <<"string">>} = Schema) -> + MinLength = + case maps:get(<<"maxLength">>, Schema, undefined) of + undefined -> + undefined; + Max -> + #{ + <<"type">> => <<"string">>, + <<"minLength">> => Max + 1 + } + end, + MaxLength = + case maps:get(<<"minLength">>, Schema, 0) of + 0 -> + undefined; + Min -> + #{ + <<"type">> => <<"string">>, + <<"maxLength">> => Min - 1 + } + end, + Format = + %% NOTE: mutation + %% TODO: replace mutation with regex + case maps:get(<<"format">>, Schema, undefined) of + undefined -> + undefined; + F -> + Formats = lists:delete(F, ?FORMATS), + Schema#{<<"format">> => random_pick(Formats)} + end, + Pattern = + case maps:get(<<"pattern">>, Schema, undefined) of + undefined -> + undefined; + P -> + Schema#{<<"pattern">> => <<"^(?!.*", P/binary, ").*">>} + end, + Schemas = lists:filter(fun(S) -> S =/= undefined end, [MinLength, MaxLength, Format, Pattern]), + union( + Schemas ++ + lists:delete(#{<<"type">> => <<"string">>}, ?BASIC_SCHEMAS) + ); +complement(#{<<"type">> := <<"array">>} = Schema) -> + Items = + case maps:get(<<"items">>, Schema, undefined) of + undefined -> + undefined; + I -> + Schema#{<<"type">> => <<"array">>, <<"items">> => complement(I)} + end, + MinItems = + case maps:get(<<"minItems">>, Schema, undefined) of + undefined -> + undefined; + Min -> + #{<<"type">> => <<"array">>, <<"maxItems">> => Min - 1} + end, + MaxItems = + case maps:get(<<"maxItems">>, Schema, undefined) of + undefined -> + undefined; + Max -> + #{<<"type">> => <<"array">>, <<"minItems">> => Max + 1} + end, + %% TODO: mutation to enum with repeated items within min and max if max_size is at least 2 + UniqueItems = undefined, + Schemas = lists:filter(fun(S) -> S =/= undefined end, [Items, MinItems, MaxItems, UniqueItems]), + union( + Schemas ++ + lists:delete(#{<<"type">> => <<"array">>}, ?BASIC_SCHEMAS) + ); +complement(#{<<"type">> := <<"object">>} = Schema) -> + Required = maps:get(<<"required">>, Schema, []), + Properties = maps:get(<<"properties">>, Schema, #{}), + PropertiesSchemas = + lists:map( + fun({PropertyName, PropertySchema}) -> + NewRequired = + case lists:member(PropertyName, Required) of + true -> + lists:delete(PropertyName, Required); + false -> + [PropertyName | Required] + end, + Schema#{ + <<"required">> => NewRequired, + <<"properties">> => Properties#{ + PropertyName => complement(PropertySchema) + } + } + end, + maps:to_list(Properties) + ), + MinProperties = + case maps:get(<<"minProperties">>, Schema, undefined) of + undefined -> + undefined; + Min -> + #{ + <<"type">> => <<"object">>, + <<"maxProperties">> => Min - 1 + } + end, + MaxProperties = + case maps:get(<<"maxProperties">>, Schema, undefined) of + undefined -> + undefined; + Max -> + #{ + <<"type">> => <<"object">>, + <<"minProperties">> => Max + 1 + } + end, + AdditionalProperties = + %% NOTE: mutation + case maps:get(<<"additionalProperties">>, Schema, true) of + true -> + undefined; + false -> + PropertyName = new_property_name(maps:keys(Properties)), + Schema#{ + <<"properties">> => Properties#{ + PropertyName => #{} + } + }; + AdditionalSchema -> + PropertyName = new_property_name(maps:keys(Properties)), + Schema#{ + <<"properties">> => Properties#{ + PropertyName => complement(AdditionalSchema) + } + } + end, + Schemas = lists:filter( + fun(S) -> S =/= undefined end, + PropertiesSchemas ++ + [ + MinProperties, + MaxProperties, + AdditionalProperties + ] + ), + union( + Schemas ++ + lists:delete(#{<<"type">> => <<"object">>}, ?BASIC_SCHEMAS) + ). + +-spec empty_schema() -> EmptySchema when + EmptySchema :: ndto:empty_schema(). +empty_schema() -> + false. + +-spec intersection(Schemas) -> Intersection when + Schemas :: [ndto:schema()], + Intersection :: ndto:schema(). +intersection(Schemas) -> + lists:foldl( + fun(S1, Acc) -> + intersection(Acc, S1) + end, + #{}, + Schemas + ). + +-spec intersection(Schema1, Schema2) -> Intersection when + Schema1 :: ndto:schema(), + Schema2 :: ndto:schema(), + Intersection :: ndto:schema(). +intersection(Schema, Schema) -> + Schema; +intersection(#{} = Any, Schema2) when map_size(Any) =:= 0 -> + Schema2; +intersection(Schema1, #{} = Any) when map_size(Any) =:= 0 -> + Schema1; +intersection(true, Schema2) -> + Schema2; +intersection(Schema1, true) -> + Schema1; +intersection(#{<<"allOf">> := AllOf1}, Schema2) -> + Schema1 = intersection(AllOf1), + intersection(Schema1, Schema2); +intersection(Schema1, #{<<"allOf">> := AllOf}) -> + Schema2 = intersection(AllOf), + intersection(Schema1, Schema2); +intersection(#{<<"anyOf">> := AnyOf}, Schema2) -> + union([intersection(Schema2, AnyOfSchema) || AnyOfSchema <- AnyOf]); +intersection(Schema1, #{<<"anyOf">> := AnyOf}) -> + union([intersection(Schema1, AnyOfSchema) || AnyOfSchema <- AnyOf]); +intersection(#{<<"oneOf">> := OneOf}, Schema2) -> + Schema1 = symmetric_difference(OneOf), + intersection(Schema1, Schema2); +intersection(Schema1, #{<<"oneOf">> := OneOf}) -> + Schema2 = symmetric_difference(OneOf), + intersection(Schema1, Schema2); +intersection(#{<<"not">> := Not}, Schema2) -> + Schema1 = complement(Not), + intersection(Schema1, Schema2); +intersection(Schema1, #{<<"not">> := Not}) -> + Schema2 = complement(Not), + intersection(Schema1, Schema2); +intersection(#{<<"enum">> := Enum1}, #{<<"enum">> := Enum2}) -> + NewEnum = sets:to_list( + sets:intersection( + sets:from_list(Enum1), + sets:from_list(Enum2) + ) + ), + #{<<"enum">> => NewEnum}; +intersection(Schema1, #{<<"enum">> := _Enum} = Schema2) -> + intersection(Schema2, Schema1); +intersection(#{<<"enum">> := Enum}, Schema2) -> + Name = erlang:binary_to_atom( + <<"intersection_enum_", (erlang:integer_to_binary(erlang:unique_integer()))/binary>> + ), + DTO = ndto:generate(Name, Schema2), + ndto:load(DTO), + NewEnum = lists:filter(fun Name:is_valid/1, Enum), + #{<<"enum">> => NewEnum}; +intersection(#{<<"type">> := <<"boolean">>} = Schema1, #{<<"type">> := <<"boolean">>}) -> + Schema1; +intersection(#{<<"type">> := <<"integer">>} = Schema1, #{<<"type">> := <<"number">>} = Schema2) -> + intersection(Schema1, Schema2#{<<"type">> => <<"integer">>}); +intersection(#{<<"type">> := <<"number">>} = Schema1, #{<<"type">> := <<"integer">>} = Schema2) -> + intersection(Schema1#{<<"type">> => <<"integer">>}, Schema2); +intersection(#{<<"type">> := Type} = Schema1, #{<<"type">> := Type} = Schema2) when + Type =:= <<"integer">> orelse Type =:= <<"number">> +-> + Minimum1 = maps:get(<<"minimum">>, Schema1, undefined), + Minimum2 = maps:get(<<"minimum">>, Schema2, undefined), + ExclusiveMinimum1 = maps:get(<<"exclusiveMinimum">>, Schema1, undefined), + ExclusiveMinimum2 = maps:get(<<"exclusiveMinimum">>, Schema2, undefined), + {Minimum, ExclusiveMinimum} = + case {Minimum1, Minimum2} of + {Minimum1, undefined} -> + {Minimum1, ExclusiveMinimum1}; + {undefined, Minimum2} -> + {Minimum2, ExclusiveMinimum2}; + {Minimum1, Minimum1} -> + ExcMin = + case {ExclusiveMinimum1, ExclusiveMinimum2} of + {ExclusiveMinimum1, undefined} -> + ExclusiveMinimum1; + {undefined, ExclusiveMinimum2} -> + ExclusiveMinimum2; + {ExclusiveMinimum1, ExclusiveMinimum2} -> + ExclusiveMinimum1 orelse ExclusiveMinimum2 + end, + {Minimum1, ExcMin}; + {Minimum1, Minimum2} when Minimum1 > Minimum2 -> + {Minimum1, ExclusiveMinimum1}; + {Minimum1, Minimum2} -> + {Minimum2, ExclusiveMinimum2} + end, + + Maximum1 = maps:get(<<"maximum">>, Schema1, undefined), + Maximum2 = maps:get(<<"maximum">>, Schema2, undefined), + ExclusiveMaximum1 = maps:get(<<"exclusiveMaximum">>, Schema1, undefined), + ExclusiveMaximum2 = maps:get(<<"exclusiveMaximum">>, Schema2, undefined), + {Maximum, ExclusiveMaximum} = + case {Maximum1, Maximum2} of + {Maximum1, undefined} -> + {Maximum1, ExclusiveMaximum1}; + {undefined, Maximum2} -> + {Maximum2, ExclusiveMaximum2}; + {Maximum1, Maximum1} -> + ExcMax = + case {ExclusiveMaximum1, ExclusiveMaximum2} of + {ExclusiveMaximum1, undefined} -> + ExclusiveMaximum1; + {undefined, ExclusiveMaximum2} -> + ExclusiveMaximum2; + {ExclusiveMaximum1, ExclusiveMaximum2} -> + ExclusiveMaximum1 orelse ExclusiveMaximum2 + end, + {Maximum1, ExcMax}; + {Maximum1, Maximum2} when Maximum1 < Maximum2 -> + {Maximum1, ExclusiveMaximum1}; + {Maximum1, Maximum2} -> + {Maximum2, ExclusiveMaximum2} + end, + MultipleOf1 = maps:get(<<"multipleOf">>, Schema1, undefined), + MultipleOf2 = maps:get(<<"multipleOf">>, Schema2, undefined), + MultipleOf = + case {MultipleOf1, MultipleOf2} of + {MultipleOf1, undefined} -> + MultipleOf1; + {undefined, MultipleOf2} -> + MultipleOf2; + {MultipleOf1, MultipleOf2} -> + lcm(MultipleOf1, MultipleOf2) + end, + clean(#{ + <<"type">> => Type, + <<"minimum">> => Minimum, + <<"exclusiveMinimum">> => ExclusiveMinimum, + <<"maximum">> => Maximum, + <<"exclusiveMaximum">> => ExclusiveMaximum, + <<"multipleOf">> => MultipleOf + }); +intersection(#{<<"type">> := <<"string">>} = Schema1, #{<<"type">> := <<"string">>} = Schema2) -> + MinLength1 = maps:get(<<"minLength">>, Schema1, undefined), + MinLength2 = maps:get(<<"minLength">>, Schema2, undefined), + + MinLength = + case {MinLength1, MinLength2} of + {MinLength1, undefined} -> + MinLength1; + {undefined, MinLength2} -> + MinLength2; + {MinLength1, MinLength2} when MinLength1 > MinLength2 -> + MinLength1; + {_MinLength1, MinLength2} -> + MinLength2 + end, + + MaxLength1 = maps:get(<<"maxLength">>, Schema1, undefined), + MaxLength2 = maps:get(<<"maxLength">>, Schema2, undefined), + + MaxLength = + case {MaxLength1, MaxLength2} of + {MaxLength1, undefined} -> + MaxLength1; + {undefined, MaxLength2} -> + MaxLength2; + {MaxLength1, MaxLength2} when MaxLength1 < MaxLength2 -> + MaxLength1; + {_MaxLength1, MaxLength2} -> + MaxLength2 + end, + + Pattern1 = maps:get(<<"pattern">>, Schema1, undefined), + Pattern2 = maps:get(<<"pattern">>, Schema2, undefined), + + Pattern = + case {Pattern1, Pattern2} of + {Pattern1, undefined} -> + Pattern1; + {undefined, Pattern2} -> + Pattern2; + {Pattern1, Pattern2} -> + <<"^(?=.*", Pattern1/binary, ")(?=.*", Pattern2/binary, ").*">> + end, + + Format1 = maps:get(<<"format">>, Schema1, undefined), + Format2 = maps:get(<<"format">>, Schema2, undefined), + + Format = + case {Format1, Format2} of + {Format1, undefined} -> + Format1; + {_Format1, _Format2} -> + Format2 + end, + + clean(#{ + <<"type">> => <<"string">>, + <<"minLength">> => MinLength, + <<"maxLength">> => MaxLength, + <<"pattern">> => Pattern, + <<"format">> => Format + }); +intersection(#{<<"type">> := <<"array">>} = Schema1, #{<<"type">> := <<"array">>} = Schema2) -> + Items1 = maps:get(<<"items">>, Schema1, undefined), + Items2 = maps:get(<<"items">>, Schema2, undefined), + + Items = + case {Items1, Items2} of + {Items1, undefined} -> + Items1; + {undefined, Items2} -> + Items2; + {Items1, Items2} -> + intersection(Items1, Items2) + end, + + MinItems1 = maps:get(<<"minItems">>, Schema1, undefined), + MinItems2 = maps:get(<<"minItems">>, Schema2, undefined), + + MinItems = + case {MinItems1, MinItems2} of + {MinItems1, undefined} -> + MinItems1; + {undefined, MinItems2} -> + MinItems2; + {MinItems1, MinItems2} when MinItems1 > MinItems2 -> + MinItems1; + {_MinItems1, MinItems2} -> + MinItems2 + end, + + MaxItems1 = maps:get(<<"maxItems">>, Schema1, undefined), + MaxItems2 = maps:get(<<"maxItems">>, Schema2, undefined), + + MaxItems = + case {MaxItems1, MaxItems2} of + {MaxItems1, undefined} -> + MaxItems1; + {undefined, MaxItems2} -> + MaxItems2; + {MaxItems1, MaxItems2} when MaxItems1 < MaxItems2 -> + MaxItems1; + {_MaxItems1, MaxItems2} -> + MaxItems2 + end, + + UniqueItems1 = maps:get(<<"uniqueItems">>, Schema1, undefined), + UniqueItems2 = maps:get(<<"uniqueItems">>, Schema2, undefined), + + UniqueItems = + case {UniqueItems1, UniqueItems2} of + {UniqueItems1, undefined} -> + UniqueItems1; + {undefined, UniqueItems2} -> + UniqueItems2; + {UniqueItems1, UniqueItems2} -> + UniqueItems1 orelse UniqueItems2 + end, + clean(#{ + <<"type">> => <<"array">>, + <<"items">> => Items, + <<"minItems">> => MinItems, + <<"maxItems">> => MaxItems, + <<"uniqueItems">> => UniqueItems + }); +intersection(#{<<"type">> := <<"object">>} = Schema1, #{<<"type">> := <<"object">>} = Schema2) -> + Properties1 = maps:get(<<"properties">>, Schema1, undefined), + Properties2 = maps:get(<<"properties">>, Schema2, undefined), + Properties = + case {Properties1, Properties2} of + {Properties1, undefined} -> + Properties1; + {undefined, Properties2} -> + Properties2; + {Properties1, Properties2} -> + CommonProperties = + sets:to_list( + sets:intersection( + sets:from_list(maps:keys(Properties1)), + sets:from_list(maps:keys(Properties2)) + ) + ), + PropertyList = + lists:map( + fun(PropertyName) -> + PropertySchema1 = maps:get(PropertyName, Properties1), + PropertySchema2 = maps:get(PropertyName, Properties2), + PropertySchema = intersection([PropertySchema1, PropertySchema2]), + {PropertyName, PropertySchema} + end, + CommonProperties + ), + maps:from_list(PropertyList) + end, + + Required1 = maps:get(<<"required">>, Schema1, undefined), + Required2 = maps:get(<<"required">>, Schema2, undefined), + Required = + case {Required1, Required2} of + {Required1, undefined} -> + Required1; + {undefined, Required2} -> + Required2; + {Required1, Required2} -> + lists:uniq(lists:append(Required1, Required2)) + end, + + MinProperties1 = maps:get(<<"minProperties">>, Schema1, undefined), + MinProperties2 = maps:get(<<"minProperties">>, Schema2, undefined), + MinProperties = + case {MinProperties1, MinProperties2} of + {MinProperties1, undefined} -> + MinProperties1; + {undefined, MinProperties2} -> + MinProperties2; + {MinProperties1, MinProperties2} when MinProperties1 > MinProperties2 -> + MinProperties1; + {_MinProperties1, MinProperties2} -> + MinProperties2 + end, + + MaxProperties1 = maps:get(<<"maxProperties">>, Schema1, undefined), + MaxProperties2 = maps:get(<<"maxProperties">>, Schema2, undefined), + MaxProperties = + case {MaxProperties1, MaxProperties2} of + {MaxProperties1, undefined} -> + MaxProperties1; + {undefined, MaxProperties2} -> + MaxProperties2; + {MaxProperties1, MaxProperties2} when MaxProperties1 < MaxProperties2 -> + MaxProperties1; + {_MaxProperties1, MaxProperties2} -> + MaxProperties2 + end, + + AdditionalProperties1 = maps:get(<<"additionalProperties">>, Schema1, undefined), + AdditionalProperties2 = maps:get(<<"additionalProperties">>, Schema2, undefined), + AdditionalProperties = + case {AdditionalProperties1, AdditionalProperties2} of + {AdditionalProperties1, undefined} -> + AdditionalProperties1; + {undefined, AdditionalProperties2} -> + AdditionalProperties2; + {AdditionalProperties1, AdditionalProperties2} -> + intersection(AdditionalProperties1, AdditionalProperties2) + end, + + clean(#{ + <<"type">> => <<"object">>, + <<"properties">> => Properties, + <<"required">> => Required, + <<"minProperties">> => MinProperties, + <<"maxProperties">> => MaxProperties, + <<"additionalProperties">> => AdditionalProperties + }); +intersection(_Schema1, _Schema2) -> + false. + +-spec symmetric_difference(Schemas) -> SymmetricDifference when + Schemas :: [ndto:schema()], + SymmetricDifference :: ndto:schema(). +symmetric_difference(Schemas) -> + lists:foldl( + fun(S1, Acc) -> + symmetric_difference(Acc, S1) + end, + false, + Schemas + ). + +-spec symmetric_difference(Schema1, Schema2) -> SymmetricDifference when + Schema1 :: ndto:schema(), + Schema2 :: ndto:schema(), + SymmetricDifference :: ndto:schema(). +symmetric_difference(Schema1, Schema2) -> + union([ + intersection(Schema1, complement(Schema2)), + intersection(complement(Schema1), Schema2) + ]). + +-spec union(Schemas) -> Union when + Schemas :: [ndto:schema()], + Union :: ndto:schema(). +union(Schemas) -> + lists:foldl( + fun(S1, Acc) -> + union(Acc, S1) + end, + false, + Schemas + ). + +-spec union(Schema1, Schema2) -> Union when + Schema1 :: ndto:schema(), + Schema2 :: ndto:schema(), + Union :: ndto:schema(). +union(Schema, Schema) -> + Schema; +union(_Schema1, #{} = Any) when map_size(Any) =:= 0 -> + universal_schema(); +union(#{} = Any, _Schema2) when map_size(Any) =:= 0 -> + universal_schema(); +union(_Schema1, true) -> + universal_schema(); +union(true, _Schema2) -> + universal_schema(); +union(Schema1, false) -> + Schema1; +union(false, Schema2) -> + Schema2; +union(#{<<"allOf">> := AllOf1}, Schema2) -> + Schema1 = intersection(AllOf1), + union(Schema1, Schema2); +union(Schema1, #{<<"allOf">> := AllOf}) -> + Schema2 = intersection(AllOf), + union(Schema1, Schema2); +union(#{<<"anyOf">> := AnyOf1}, #{<<"anyOf">> := AnyOf2}) -> + case lists:sort(lists:uniq(AnyOf1 ++ AnyOf2)) of + [] -> + false; + [Schema] -> + Schema; + AnyOf -> + #{<<"anyOf">> => AnyOf} + end; +union(#{<<"anyOf">> := AnyOf1}, Schema2) -> + case lists:sort(lists:uniq([Schema2 | AnyOf1])) of + [] -> + false; + [Schema] -> + Schema; + AnyOf -> + #{<<"anyOf">> => AnyOf} + end; +union(Schema1, #{<<"anyOf">> := AnyOf2}) -> + case lists:sort(lists:uniq([Schema1 | AnyOf2])) of + [] -> + false; + [Schema] -> + Schema; + AnyOf -> + #{<<"anyOf">> => AnyOf} + end; +union(#{<<"oneOf">> := OneOf}, Schema2) -> + Schema1 = symmetric_difference(OneOf), + union(Schema1, Schema2); +union(Schema1, #{<<"oneOf">> := OneOf}) -> + Schema2 = symmetric_difference(OneOf), + union(Schema1, Schema2); +union(#{<<"not">> := Not}, Schema2) -> + Schema1 = complement(Not), + union(Schema1, Schema2); +union(Schema1, #{<<"not">> := Not}) -> + Schema2 = complement(Not), + union(Schema1, Schema2); +union(#{<<"enum">> := Enum1}, #{<<"enum">> := Enum2}) -> + NewEnum = lists:sort(lists:uniq(Enum1 ++ Enum2)), + #{<<"enum">> => NewEnum}; +union(#{<<"type">> := <<"boolean">>}, #{<<"type">> := <<"boolean">>}) -> + #{<<"type">> => <<"boolean">>}; +union(Schema1, Schema2) -> + #{<<"anyOf">> => lists:sort([Schema1, Schema2])}. + +-spec universal_schema() -> UniversalSchema when + UniversalSchema :: ndto:universal_schema(). +universal_schema() -> + #{}. + +%%%----------------------------------------------------------------------------- +%%% UTIL EXPORTS +%%%----------------------------------------------------------------------------- +-spec multiples(MultipleOf, Min, Max) -> Multiples when + MultipleOf :: integer(), + Min :: undefined | integer(), + Max :: undefined | integer(), + Multiples :: [integer()]. +multiples(MultipleOf, undefined, Max) -> + multiples(MultipleOf, ?MIN_INT, Max); +multiples(MultipleOf, Min, undefined) -> + multiples(MultipleOf, Min, ?MAX_INT); +multiples(MultipleOf, Min, Max) when MultipleOf =< 0 -> + multiples(MultipleOf * -1, Min, Max); +multiples(MultipleOf, Min, Max) -> + FirstMultiple = MultipleOf * ((Min + MultipleOf - 1) div MultipleOf), + multiples(MultipleOf, Max, FirstMultiple, []). + +multiples(_MultipleOf, Max, Current, Acc) when Current > Max -> + lists:reverse(Acc); +multiples(MultipleOf, Max, Current, Acc) -> + multiples(MultipleOf, Max, Current + MultipleOf, [Current | Acc]). + +%%%----------------------------------------------------------------------------- +%%% INTERNAL FUNCTIONS +%%%----------------------------------------------------------------------------- +-spec clean(Map) -> Clear when + Map :: #{binary() => undefined | term()}, + Clear :: #{binary() => term()}. +clean(Schema) -> + maps:filter( + fun(_K, V) -> V =/= undefined end, + Schema + ). + +-spec exclude_integers(Integers) -> Schemas when + Integers :: [integer()], + Schemas :: [ndto:schema()]. +exclude_integers([]) -> + [#{<<"type">> => <<"integer">>}]; +exclude_integers([Integer | Integers]) -> + Interval = #{ + <<"type">> => <<"integer">>, + <<"maximum">> => Integer, + <<"exclusiveMaximum">> => true + }, + exclude_integers(Integers, [Interval]). + +exclude_integers([], [#{<<"maximum">> := Previous} | _Tl] = Acc) -> + Interval = #{ + <<"type">> => <<"integer">>, + <<"minimum">> => Previous, + <<"exclusiveMinimum">> => true + }, + [Interval | Acc]; +exclude_integers([Next | Rest], [#{<<"maximum">> := Previous} | _Tl] = Acc) -> + Interval = #{ + <<"type">> => <<"integer">>, + <<"minimum">> => Previous, + <<"exclusiveMinimum">> => true, + <<"maximum">> => Next, + <<"exclusiveMaximum">> => true + }, + exclude_integers(Rest, [Interval | Acc]). + +-spec gcd(A, B) -> GCD when + A :: integer(), + B :: integer(), + GCD :: integer(). +gcd(A, 0) -> A; +gcd(A, B) when abs(B) > abs(A) -> gcd(B, A); +gcd(A, B) -> gcd(B, A rem B). + +-spec lcm(A, B) -> LCM when + A :: integer(), + B :: integer(), + LCM :: integer(). +lcm(A, B) -> + case gcd(A, B) of + 0 -> + 0; + GCD -> + LCM = A * (B / GCD), + LCM + end. + +-spec new_property_name(ExcludedNames) -> Name when + ExcludedNames :: [binary()], + Name :: binary(). +new_property_name(ExcludedNames) -> + PropertyName = base64:encode(crypto:strong_rand_bytes(24)), + case lists:member(PropertyName, ExcludedNames) of + true -> + new_property_name(ExcludedNames); + false -> + PropertyName + end. + +-spec random_pick(List) -> Element when + List :: [term(), ...], + Element :: term(). +random_pick(List) -> + lists:nth(rand:uniform(erlang:length(List)), List). diff --git a/src/restcheck_schema.hrl b/src/restcheck_schema.hrl new file mode 100644 index 0000000..afc537d --- /dev/null +++ b/src/restcheck_schema.hrl @@ -0,0 +1,35 @@ +%%% Copyright 2023 Nomasystems, S.L. http://www.nomasystems.com +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License +-ifndef(ndto). +-define(restcheck_schema, true). + +%%% MACROS +-define(BASIC_SCHEMAS, [ + #{<<"type">> => <<"boolean">>}, + #{<<"type">> => <<"integer">>}, + #{<<"type">> => <<"number">>}, + #{<<"type">> => <<"string">>}, + #{<<"type">> => <<"array">>}, + #{<<"type">> => <<"object">>} +]). +-define(FORMATS, [ + <<"base64">>, + <<"iso8601-datetime">> +]). +% https://www.erlang.org/doc/efficiency_guide/advanced.html +-define(MAX_INT, 134217728). +-define(MIN_INT, -134217729). + +% -ifndef(restcheck_schema) +-endif. diff --git a/src/restcheck_triq.erl b/src/restcheck_triq.erl index 044c9c0..2d55279 100644 --- a/src/restcheck_triq.erl +++ b/src/restcheck_triq.erl @@ -14,7 +14,7 @@ -module(restcheck_triq). %%% INCLUDE FILES --include_lib("ndto/include/ndto_schema.hrl"). +-include("restcheck_schema.hrl"). %%% BEHAVIOURS -behaviour(restcheck_backend). @@ -52,8 +52,6 @@ dto(Schema) -> Schema :: restcheck_pbt:schema(), Generator :: restcheck_pbt:generator(). %% @doc Returns a triq generator of DTOs from a given schema and maximum recursion depth. -dto(undefined, _MaxDepth) -> - 'undefined'(); dto(#{<<"enum">> := _Enum} = Schema, _MaxDepth) -> enum(Schema); dto(#{<<"type">> := <<"boolean">>} = Schema, _MaxDepth) -> @@ -134,7 +132,7 @@ report(Subject, Data, false) -> report(Subject, Data). Schema :: ndto:intersection_schema(), Dom :: restcheck_pbt:generator(). all_of(#{<<"allOf">> := Subschemas}, MaxDepth) -> - Schema = ndto_schema:intersection(Subschemas), + Schema = restcheck_schema:intersection(Subschemas), dto(Schema, MaxDepth). -spec any(MaxDepth) -> Dom when @@ -258,7 +256,7 @@ integer(Schema) -> MaxDepth :: recursion_max_depth(), Dom :: restcheck_pbt:generator(). 'not'(#{<<"not">> := Subschema}, MaxDepth) -> - Schema = ndto_schema:complement(Subschema), + Schema = restcheck_schema:complement(Subschema), dto(Schema, MaxDepth). -spec number(Schema) -> Dom when @@ -338,10 +336,6 @@ object([], false, _Missing, MaxDepth, Acc) -> object([], false, 0, MaxDepth, Acc); object([], true, Missing, MaxDepth, Acc) -> object([], #{}, Missing, MaxDepth, Acc); -%%% TODO: remove guard when `gradualizer` supports it -%%% currently it complains: -%%% - The variable on line 232 at column 65 is expected to have type #{binary() => term()} -%%% but it has type false | true | schema() object([], ExtraSchema, Missing, MaxDepth, Acc) -> NewAcc = triq_dom:bind( @@ -366,13 +360,14 @@ object([{PropertyName, PropertySchema} | Properties], ExtraSchema, Missing, MaxD MaxDepth :: recursion_max_depth(), Dom :: restcheck_pbt:generator(). one_of(#{<<"oneOf">> := Subschemas}, MaxDepth) -> - Schema = ndto_schema:symmetric_difference(Subschemas), + Schema = restcheck_schema:symmetric_difference(Subschemas), dto(Schema, MaxDepth). -spec string(Schema) -> Dom when Schema :: ndto:string_schema(), Dom :: restcheck_pbt:generator(). string(#{<<"pattern">> := _Pattern}) -> + %% TODO: implement pattern erlang:throw({restcheck_triq, pattern, not_implemented}); string(Schema) -> MinLength = maps:get(<<"minLength">>, Schema, 1), @@ -448,9 +443,6 @@ string_format(<<"iso8601-datetime">>, _Length) -> end ). -'undefined'() -> - triq_dom:return(undefined). - %%%----------------------------------------------------------------------------- %%% INTERNAL FUNCTIONS %%%----------------------------------------------------------------------------- diff --git a/test/property_test/restcheck_schema_properties.erl b/test/property_test/restcheck_schema_properties.erl new file mode 100644 index 0000000..87b8016 --- /dev/null +++ b/test/property_test/restcheck_schema_properties.erl @@ -0,0 +1,76 @@ +%%% Copyright 2023 Nomasystems, S.L. http://www.nomasystems.com +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +-module(restcheck_schema_properties). + +%%% INCLUDE FILES +-include_lib("stdlib/include/assert.hrl"). +-include_lib("triq/include/triq.hrl"). + +%%% MACROS +-define(NUMTESTS, 3). + +%%%----------------------------------------------------------------------------- +%%% PROPERTIES +%%%----------------------------------------------------------------------------- +prop_conmutative_intersection() -> + triq:numtests( + ?NUMTESTS, + ?FORALL( + {Schema1, Schema2}, + {restcheck_schema_dom:schema(), restcheck_schema_dom:schema()}, + begin + I1_2 = restcheck_schema:intersection([Schema1, Schema2]), + I2_1 = restcheck_schema:intersection([Schema2, Schema1]), + ?assertEqual(I1_2, I2_1), + + true + end + ) + ). + +prop_identity() -> + ?FORALL( + Schema, + restcheck_schema_dom:schema(), + begin + EmptySchema = restcheck_schema:empty_schema(), + ?assertEqual(Schema, restcheck_schema:union([Schema, EmptySchema])), + + UniversalSchema = restcheck_schema:universal_schema(), + ?assertEqual(Schema, restcheck_schema:intersection([Schema, UniversalSchema])), + + true + end + ). + +prop_idempotent() -> + ?FORALL( + Schema, + restcheck_schema_dom:schema(), + begin + ?assertEqual(Schema, restcheck_schema:intersection([Schema, Schema])), + true + end + ). + +prop_domination() -> + ?FORALL( + Schema, + restcheck_schema_dom:schema(), + begin + EmptySchema = restcheck_schema:empty_schema(), + ?assertEqual(EmptySchema, restcheck_schema:intersection([Schema, EmptySchema])), + true + end + ). diff --git a/test/restcheck_schema_SUITE.erl b/test/restcheck_schema_SUITE.erl new file mode 100644 index 0000000..3b7b4f1 --- /dev/null +++ b/test/restcheck_schema_SUITE.erl @@ -0,0 +1,451 @@ +%%% Copyright 2023 Nomasystems, S.L. http://www.nomasystems.com +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License +-module(restcheck_schema_SUITE). + +%%% INCLUDE FILES +-include_lib("stdlib/include/assert.hrl"). + +%%% EXTERNAL EXPORTS +-compile([export_all, nowarn_export_all]). + +%%% MACROS +-define(ENUM_SCHEMA, #{<<"enum">> => [1, <<"string">>, true]}). +-define(BOOLEAN_SCHEMA, #{<<"type">> => <<"boolean">>}). +-define(INTEGER_SCHEMA, #{ + <<"type">> => <<"integer">>, + <<"minimum">> => 2, + <<"exclusiveMinimum">> => true, + <<"maximum">> => 6 +}). +-define(NUMBER_SCHEMA, #{ + <<"type">> => <<"number">>, + <<"minimum">> => 4, + <<"exclusiveMinimum">> => true, + <<"maximum">> => 8, + <<"exclusiveMaximum">> => true +}). +-define(STRING_SCHEMA, #{ + <<"type">> => <<"string">>, + <<"minLength">> => 3, + <<"maxLength">> => 6, + <<"pattern">> => <<"a{3}">> +}). +-define(ARRAY_SCHEMA, #{ + <<"type">> => <<"array">>, + <<"items">> => ?NUMBER_SCHEMA, + <<"minItems">> => 1, + <<"maxItems">> => 3 +}). +-define(OBJECT_SCHEMA, #{ + <<"type">> => <<"object">>, + <<"properties">> => #{ + <<"foo">> => ?INTEGER_SCHEMA, + <<"bar">> => ?STRING_SCHEMA + }, + <<"minProperties">> => 3, + <<"additionalProperties">> => true +}). +-define(INTERSECTION_SCHEMA, #{<<"allOf">> => [?INTEGER_SCHEMA, ?NUMBER_SCHEMA]}). +-define(UNION_SCHEMA, #{<<"anyOf">> => [?BOOLEAN_SCHEMA, ?STRING_SCHEMA]}). +-define(SYMMETRIC_DIFFERENCE_SCHEMA, #{<<"oneOf">> => [?INTEGER_SCHEMA, ?NUMBER_SCHEMA]}). +-define(COMPLEMENT_SCHEMA, #{<<"not">> => ?BOOLEAN_SCHEMA}). + +%%%----------------------------------------------------------------------------- +%%% SUITE EXPORTS +%%%----------------------------------------------------------------------------- +all() -> + [ + {group, properties}, + complement, + intersection, + union + ]. + +groups() -> + [ + {properties, [parallel], [ + prop_conmutative_intersection, + prop_identity, + prop_idempotent, + prop_domination, + prop_empty_schema_complement, + prop_universal_schema_complement + ]} + ]. + +%%%----------------------------------------------------------------------------- +%%% INIT SUITE EXPORTS +%%%----------------------------------------------------------------------------- +init_per_suite(Conf) -> + Config = nct_util:setup_suite(Conf), + ct_property_test:init_per_suite(Config). + +%%%----------------------------------------------------------------------------- +%%% END SUITE EXPORTS +%%%----------------------------------------------------------------------------- +end_per_suite(Conf) -> + nct_util:teardown_suite(Conf). + +%%%----------------------------------------------------------------------------- +%%% INIT CASE EXPORTS +%%%----------------------------------------------------------------------------- +init_per_testcase(Case, Conf) -> + ct:print("Starting test case ~p", [Case]), + nct_util:init_traces(Case), + Conf. + +%%%----------------------------------------------------------------------------- +%%% END CASE EXPORTS +%%%----------------------------------------------------------------------------- +end_per_testcase(Case, Conf) -> + nct_util:end_traces(Case), + ct:print("Test case ~p completed", [Case]), + Conf. + +%%%----------------------------------------------------------------------------- +%%% TEST CASES +%%%----------------------------------------------------------------------------- +prop_conmutative_intersection(Conf) -> + ct_property_test:quickcheck( + restcheck_schema_properties:prop_conmutative_intersection(), + Conf + ). + +prop_identity(Conf) -> + ct_property_test:quickcheck( + restcheck_schema_properties:prop_identity(), + Conf + ). + +prop_idempotent(Conf) -> + ct_property_test:quickcheck( + restcheck_schema_properties:prop_idempotent(), + Conf + ). + +prop_domination(Conf) -> + ct_property_test:quickcheck( + restcheck_schema_properties:prop_domination(), + Conf + ). + +prop_empty_schema_complement(_Conf) -> + EmptySet = false, + UniversalSchema = restcheck_schema:universal_schema(), + ?assertEqual(UniversalSchema, restcheck_schema:complement(EmptySet)), + ok. + +prop_universal_schema_complement(_Conf) -> + UniversalSchema1 = restcheck_schema:universal_schema(), + UniversalSchema2 = true, + EmptySet = false, + ?assertEqual(EmptySet, restcheck_schema:complement(UniversalSchema1)), + ?assertEqual(EmptySet, restcheck_schema:complement(UniversalSchema2)), + ok. + +complement(_Conf) -> + %% TODO: implement enum complement + %% TODO: implement enum validation for non-strings + % EnumComplement = restcheck_schema:complement(?ENUM_SCHEMA), + % ok = generate_and_load(enum_complement, EnumComplement), + % false = enum_complement:is_valid(1), + % true = enum_complement:is_valid(false), + + BooleanComplement = restcheck_schema:complement(?BOOLEAN_SCHEMA), + ok = generate_and_load(boolean_complement, BooleanComplement), + ?assertEqual(false, boolean_complement:is_valid(false)), + ?assertEqual(true, boolean_complement:is_valid(1)), + + IntegerComplement = restcheck_schema:complement(?INTEGER_SCHEMA), + ok = generate_and_load(integer_complement, IntegerComplement), + ?assertEqual(false, integer_complement:is_valid(3)), + ?assertEqual(true, integer_complement:is_valid(1)), + ?assertEqual(true, integer_complement:is_valid(true)), + + NumberComplement = restcheck_schema:complement(?NUMBER_SCHEMA), + ok = generate_and_load(number_complement, NumberComplement), + ?assertEqual(false, number_complement:is_valid(5)), + ?assertEqual(false, number_complement:is_valid(5.5)), + ?assertEqual(true, number_complement:is_valid(9)), + ?assertEqual(true, number_complement:is_valid(true)), + + StringComplement = restcheck_schema:complement(?STRING_SCHEMA), + ok = generate_and_load(string_complement, StringComplement), + ?assertEqual(false, string_complement:is_valid(<<"123aaa">>)), + ?assertEqual(true, string_complement:is_valid(<<"123aa6">>)), + ?assertEqual(true, string_complement:is_valid(<<"aaa4567">>)), + ?assertEqual(true, string_complement:is_valid(true)), + + ArrayComplement = restcheck_schema:complement(?ARRAY_SCHEMA), + ok = generate_and_load(array_complement, ArrayComplement), + ?assertEqual(false, array_complement:is_valid([5])), + ?assertEqual(true, array_complement:is_valid([9])), + ?assertEqual(true, array_complement:is_valid(true)), + + ObjectComplement = restcheck_schema:complement(?OBJECT_SCHEMA), + ok = generate_and_load(object_complement, ObjectComplement), + ?assertEqual( + false, + object_complement:is_valid(#{<<"foo">> => 4, <<"bar">> => <<"aaa">>, <<"baz">> => true}) + ), + ?assertEqual( + true, + object_complement:is_valid(#{<<"foo">> => 1, <<"bar">> => <<"aaa">>, <<"baz">> => true}) + ), + ?assertEqual( + true, + object_complement:is_valid(#{<<"foo">> => 4, <<"bar">> => <<"1aa">>, <<"baz">> => true}) + ), + ?assertEqual(true, object_complement:is_valid(true)), + + IntersectionComplement = restcheck_schema:complement(?INTERSECTION_SCHEMA), + ok = generate_and_load(intersection_complement, IntersectionComplement), + ?assertEqual(false, intersection_complement:is_valid(5)), + ?assertEqual(true, intersection_complement:is_valid(3)), + ?assertEqual(true, intersection_complement:is_valid(true)), + + UnionComplement = restcheck_schema:complement(?UNION_SCHEMA), + ok = generate_and_load(union_complement, UnionComplement), + ?assertEqual(false, union_complement:is_valid(true)), + ?assertEqual(false, union_complement:is_valid(<<"12aaa6">>)), + ?assertEqual(true, union_complement:is_valid(<<"123456">>)), + ?assertEqual(true, union_complement:is_valid(<<"aaa4567">>)), + ?assertEqual(true, union_complement:is_valid([1, 2, 3])), + + SymmetricDifferenceComplement = restcheck_schema:complement(?SYMMETRIC_DIFFERENCE_SCHEMA), + ok = generate_and_load(symmetric_difference_complement, SymmetricDifferenceComplement), + ?assertEqual(false, symmetric_difference_complement:is_valid(3)), + ?assertEqual(false, symmetric_difference_complement:is_valid(7)), + ?assertEqual(true, symmetric_difference_complement:is_valid(5)), + ?assertEqual(true, symmetric_difference_complement:is_valid(true)), + + ComplementComplement = restcheck_schema:complement(?COMPLEMENT_SCHEMA), + ok = generate_and_load(complement_complement, ComplementComplement), + ?assertEqual(false, complement_complement:is_valid(5)), + ?assertEqual(true, complement_complement:is_valid(true)), + + ok. + +intersection(_Conf) -> + %% TODO: implement enum validation for non-strings + % EnumIntersection = restcheck_schema:intersection([?ENUM_SCHEMA, #{<<"enum">> => [true, #{<<"foo">> => <<"bar">>}]}]), + % ok = generate_and_load(enum_intersection, EnumIntersection), + % false = enum_intersection:is_valid(#{<<"foo">> => <<"bar">>}), + % false = enum_intersection:is_valid([1, 2, 3]), + % true = enum_intersection:is_valid(true), + + BooleanIntersection = restcheck_schema:intersection([ + ?BOOLEAN_SCHEMA, #{<<"type">> => <<"boolean">>} + ]), + ok = generate_and_load(boolean_intersection, BooleanIntersection), + ?assertEqual(false, boolean_intersection:is_valid(<<"string">>)), + ?assertEqual(true, boolean_intersection:is_valid(true)), + + IntegerIntersection = restcheck_schema:intersection([ + ?INTEGER_SCHEMA, #{<<"type">> => <<"integer">>, <<"minimum">> => 4} + ]), + ok = generate_and_load(integer_intersection, IntegerIntersection), + ?assertEqual(false, integer_intersection:is_valid(3)), + ?assertEqual(true, integer_intersection:is_valid(4)), + + NumberIntersection = restcheck_schema:intersection([ + ?NUMBER_SCHEMA, #{<<"type">> => <<"number">>, <<"maximum">> => 10} + ]), + ok = generate_and_load(number_intersection, NumberIntersection), + ?assertEqual(false, number_intersection:is_valid(1.0)), + ?assertEqual(true, number_intersection:is_valid(7.0)), + + StringIntersection = restcheck_schema:intersection([ + ?STRING_SCHEMA, #{<<"type">> => <<"string">>, <<"pattern">> => <<"b{3}">>} + ]), + ok = generate_and_load(string_intersection, StringIntersection), + ?assertEqual(false, string_intersection:is_valid(<<"123aaa">>)), + ?assertEqual(false, string_intersection:is_valid(<<"bbb">>)), + ?assertEqual(true, string_intersection:is_valid(<<"aaabbb">>)), + + ArrayIntersection = restcheck_schema:intersection([ + ?ARRAY_SCHEMA, #{<<"type">> => <<"array">>, <<"items">> => ?INTEGER_SCHEMA} + ]), + ok = generate_and_load(array_intersection, ArrayIntersection), + ?assertEqual(false, array_intersection:is_valid([2])), + ?assertEqual(false, array_intersection:is_valid([5.0, 5.1, 5.2])), + ?assertEqual(true, array_intersection:is_valid([5, 5, 5])), + + ObjectIntersection = restcheck_schema:intersection([ + ?OBJECT_SCHEMA, #{<<"type">> => <<"object">>, <<"maxProperties">> => 4} + ]), + ok = generate_and_load(object_intersection, ObjectIntersection), + ?assertEqual( + false, + object_intersection:is_valid(#{ + <<"foo">> => 4, + <<"bar">> => <<"aaa">>, + <<"baz">> => true, + <<"foobar">> => 1, + <<"qux">> => <<"quux">> + }) + ), + ?assertEqual( + false, + object_intersection:is_valid(#{ + <<"foo">> => 4, <<"bar">> => false, <<"baz">> => true, <<"foobar">> => 1 + }) + ), + ?assertEqual( + true, + object_intersection:is_valid(#{<<"foo">> => 4, <<"bar">> => <<"aaa">>, <<"baz">> => true}) + ), + + IntersectionIntersection = restcheck_schema:intersection([ + ?INTERSECTION_SCHEMA, #{<<"allOf">> => [#{<<"type">> => <<"integer">>, <<"minimum">> => 5}]} + ]), + ok = generate_and_load(intersection_intersection, IntersectionIntersection), + ?assertEqual(false, intersection_intersection:is_valid(4)), + ?assertEqual(false, intersection_intersection:is_valid(9)), + ?assertEqual(true, intersection_intersection:is_valid(5)), + + UnionIntersection = restcheck_schema:intersection([ + ?UNION_SCHEMA, #{<<"anyOf">> => [?BOOLEAN_SCHEMA, ?NUMBER_SCHEMA]} + ]), + ok = generate_and_load(union_intersection, UnionIntersection), + ?assertEqual(false, union_intersection:is_valid(5.0)), + ?assertEqual(false, union_intersection:is_valid(<<"foo">>)), + ?assertEqual(true, union_intersection:is_valid(true)), + + SymmetricDifferenceIntersection = restcheck_schema:intersection([ + ?SYMMETRIC_DIFFERENCE_SCHEMA, #{<<"oneOf">> => [?INTEGER_SCHEMA, ?STRING_SCHEMA]} + ]), + ok = generate_and_load(symmetric_difference_intersection, SymmetricDifferenceIntersection), + ?assertEqual(false, symmetric_difference_intersection:is_valid(<<"foo">>)), + ?assertEqual(false, symmetric_difference_intersection:is_valid(5)), + ?assertEqual(true, symmetric_difference_intersection:is_valid(3)), + + ComplementIntersection = restcheck_schema:intersection([ + ?COMPLEMENT_SCHEMA, #{<<"not">> => ?BOOLEAN_SCHEMA} + ]), + ok = generate_and_load(complement_intersection, ComplementIntersection), + ?assertEqual(false, complement_intersection:is_valid(true)), + ?assertEqual(true, complement_intersection:is_valid(<<"foo">>)), + + ok. + +union(_Conf) -> + %% TODO: implement enum validation for non-strings + % EnumUnion = restcheck_schema:union([?ENUM_SCHEMA, #{<<"enum">> => [#{<<"foo">> => <<"bar">>}]}]), + % ok = generate_and_load(enum_union, EnumUnion), + % false = enum_union:is_valid(false), + % true = enum_union:is_valid(#{<<"foo">> => <<"bar">>}), + + BooleanUnion = restcheck_schema:union([?BOOLEAN_SCHEMA, #{<<"type">> => <<"boolean">>}]), + ok = generate_and_load(boolean_union, BooleanUnion), + ?assertEqual(true, boolean_union:is_valid(true)), + ?assertEqual(false, boolean_union:is_valid(1)), + + IntegerUnion = restcheck_schema:union([ + ?INTEGER_SCHEMA, #{<<"type">> => <<"integer">>, <<"minimum">> => 6} + ]), + ok = generate_and_load(integer_union, IntegerUnion), + ?assertEqual(true, integer_union:is_valid(4)), + ?assertEqual(true, integer_union:is_valid(7)), + ?assertEqual(false, integer_union:is_valid(1)), + ?assertEqual(false, integer_union:is_valid(true)), + + NumberUnion = restcheck_schema:union([ + ?NUMBER_SCHEMA, #{<<"type">> => <<"number">>, <<"maximum">> => 5} + ]), + ok = generate_and_load(number_union, NumberUnion), + ?assertEqual(true, number_union:is_valid(1)), + ?assertEqual(true, number_union:is_valid(7)), + ?assertEqual(false, number_union:is_valid(9)), + ?assertEqual(false, number_union:is_valid(true)), + + StringUnion = restcheck_schema:union([ + ?STRING_SCHEMA, #{<<"type">> => <<"string">>, <<"pattern">> => <<"b{3}">>} + ]), + ok = generate_and_load(string_union, StringUnion), + ?assertEqual(true, string_union:is_valid(<<"bbb">>)), + ?assertEqual(true, string_union:is_valid(<<"aaa">>)), + ?assertEqual(false, string_union:is_valid(<<"foo">>)), + ?assertEqual(false, string_union:is_valid(true)), + + ArrayUnion = restcheck_schema:union([ + ?ARRAY_SCHEMA, #{<<"type">> => <<"array">>, <<"items">> => ?BOOLEAN_SCHEMA} + ]), + ok = generate_and_load(array_union, ArrayUnion), + ?assertEqual(true, array_union:is_valid([true])), + ?assertEqual(true, array_union:is_valid([5.0, 6, 7.9999])), + ?assertEqual(false, array_union:is_valid([<<"foo">>, <<"bar">>, <<"baz">>])), + ?assertEqual(false, array_union:is_valid(true)), + + ObjectUnion = restcheck_schema:union([ + ?OBJECT_SCHEMA, + #{ + <<"type">> => <<"object">>, + <<"properties">> => #{ + <<"qux">> => #{<<"type">> => <<"boolean">>} + }, + <<"required">> => [<<"qux">>] + } + ]), + ok = generate_and_load(object_union, ObjectUnion), + ?assertEqual( + true, object_union:is_valid(#{<<"foo">> => 4, <<"bar">> => <<"aaa">>, <<"baz">> => true}) + ), + ?assertEqual(true, object_union:is_valid(#{<<"qux">> => true})), + ?assertEqual(false, object_union:is_valid(#{<<"foo">> => <<"foobar">>})), + ?assertEqual(false, object_union:is_valid(false)), + + IntersectionUnion = restcheck_schema:union([ + ?INTERSECTION_SCHEMA, #{<<"allOf">> => [?BOOLEAN_SCHEMA]} + ]), + ok = generate_and_load(intersection_union, IntersectionUnion), + ?assertEqual(true, intersection_union:is_valid(true)), + ?assertEqual(true, intersection_union:is_valid(5)), + ?assertEqual(false, intersection_union:is_valid(5.1)), + ?assertEqual(false, intersection_union:is_valid(<<"foo">>)), + + UnionUnion = restcheck_schema:union([ + ?UNION_SCHEMA, #{<<"anyOf">> => [?INTEGER_SCHEMA, ?NUMBER_SCHEMA]} + ]), + ok = generate_and_load(union_union, UnionUnion), + ?assertEqual(true, union_union:is_valid(7.9)), + ?assertEqual(true, union_union:is_valid(true)), + ?assertEqual(false, union_union:is_valid(#{<<"foo">> => <<"bar">>})), + + SymmetricDifferenceUnion = restcheck_schema:union([ + ?SYMMETRIC_DIFFERENCE_SCHEMA, #{<<"oneOf">> => [?BOOLEAN_SCHEMA, ?STRING_SCHEMA]} + ]), + ok = generate_and_load(symmetric_difference_union, SymmetricDifferenceUnion), + ?assertEqual(true, symmetric_difference_union:is_valid(4)), + ?assertEqual(true, symmetric_difference_union:is_valid(true)), + ?assertEqual(false, symmetric_difference_union:is_valid(5)), + ?assertEqual(false, symmetric_difference_union:is_valid(#{})), + + ComplementUnion = restcheck_schema:union([?COMPLEMENT_SCHEMA, #{<<"not">> => ?OBJECT_SCHEMA}]), + ok = generate_and_load(complement_union, ComplementUnion), + ?assertEqual(true, complement_union:is_valid(true)), + ?assertEqual( + true, + complement_union:is_valid(#{<<"foo">> => 4, <<"bar">> => <<"aaa">>, <<"baz">> => true}) + ), + ?assertEqual(true, complement_union:is_valid(<<"foo">>)), + + ok. + +%%%----------------------------------------------------------------------------- +%%% INTERNAL FUNCTIONS +%%%----------------------------------------------------------------------------- +generate_and_load(Name, Schema) -> + DTO = ndto:generate(Name, Schema), + ok = ndto:load(DTO). diff --git a/test/restcheck_schema_dom.erl b/test/restcheck_schema_dom.erl new file mode 100644 index 0000000..3ea8712 --- /dev/null +++ b/test/restcheck_schema_dom.erl @@ -0,0 +1,189 @@ +%%% Copyright 2023 Nomasystems, S.L. http://www.nomasystems.com +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License +-module(restcheck_schema_dom). + +%%% GENERATORS +-export([ + schema/0, + any/0, + string/0, + number/0, + integer/0, + boolean/0, + array/0, + object/0 +]). + +%%% MACROS +-define(DEPTH_BOUND, 3). + +%%%----------------------------------------------------------------------------- +%%% GENERATORS +%%%----------------------------------------------------------------------------- +schema() -> + schema(?DEPTH_BOUND). + +schema(0) -> + triq_dom:oneof([ + any(), + string(), + number(), + integer(), + boolean() + ]); +schema(Depth) -> + triq_dom:oneof([ + any(), + string(), + number(), + integer(), + boolean(), + array(Depth), + object(Depth) + ]). + +any() -> + triq_dom:return(#{}). + +string() -> + triq_dom:bind( + triq_dom:int(0, 50), + fun(MaxLength) -> + triq_dom:bind( + triq_dom:int(0, MaxLength), + fun(MinLength) -> + #{ + <<"type">> => <<"string">>, + <<"minLength">> => MinLength, + <<"maxLength">> => MaxLength + } + end + ) + end + ). + +number() -> + triq_dom:bind( + { + triq_dom:oneof([ + triq_dom:int(), + triq_dom:real() + ]), + triq_dom:oneof([ + triq_dom:pos_integer(), + triq_dom:bind( + triq_dom:real(), + fun(Float) -> erlang:abs(Float) end + ) + ]), + triq_dom:bool(), + triq_dom:bool() + }, + fun({Min, Offset, ExclusiveMinimum, ExclusiveMaximum}) -> + Max = Min + Offset, + #{ + <<"type">> => <<"number">>, + <<"minimum">> => Min, + <<"exclusiveMinimum">> => ExclusiveMinimum, + <<"maximum">> => Max, + <<"exclusiveMaximum">> => ExclusiveMaximum + } + end + ). + +integer() -> + triq_dom:bind( + {triq_dom:int(), triq_dom:pos_integer(), triq_dom:bool(), triq_dom:bool()}, + fun({Min, Offset, ExclusiveMinimum, ExclusiveMaximum}) -> + Max = Min + Offset, + triq_dom:bind( + triq_dom:int(Min, Max), + fun(MultipleOf) -> + #{ + <<"type">> => <<"integer">>, + <<"minimum">> => Min, + <<"exclusiveMinimum">> => ExclusiveMinimum, + <<"maximum">> => Max, + <<"exclusiveMaximum">> => ExclusiveMaximum, + <<"multipleOf">> => MultipleOf + } + end + ) + end + ). + +boolean() -> + triq_dom:return(#{<<"type">> => <<"boolean">>}). + +array() -> + array(?DEPTH_BOUND). + +array(Depth) -> + triq_dom:bind( + {schema(Depth - 1), triq_dom:int(), triq_dom:pos_integer(), triq_dom:bool()}, + fun({Schema, MinItems, Offset, UniqueItems}) -> + #{ + <<"type">> => <<"array">>, + <<"items">> => Schema, + <<"minItems">> => MinItems, + <<"maxItems">> => MinItems + Offset, + <<"uniqueItems">> => UniqueItems + } + end + ). + +object() -> + object(?DEPTH_BOUND). + +object(Depth) -> + triq_dom:bind( + { + triq_dom:list({triq_dom:unicode_binary(10), schema(Depth - 1)}), + triq_dom:oneof([triq_dom:bool(), schema(Depth - 1)]) + }, + fun({PropertyList, AdditionalProperties}) -> + case PropertyList of + [] -> + #{ + <<"type">> => <<"object">>, + <<"additionalProperties">> => AdditionalProperties + }; + _PL -> + PropertyNames = proplists:get_keys(PropertyList), + Properties = maps:from_list(PropertyList), + PropertiesNum = erlang:length(PropertyList), + triq_dom:bind( + { + triq_dom:list(triq_dom:elements(PropertyNames)), + triq_dom:int(0, PropertiesNum) + }, + fun({Required, MaxProperties}) -> + triq_dom:bind( + triq_dom:int(0, MaxProperties), + fun(MinProperties) -> + #{ + <<"type">> => <<"object">>, + <<"properties">> => Properties, + <<"required">> => Required, + <<"minProperties">> => MinProperties, + <<"maxProperties">> => MaxProperties, + <<"additionalProperties">> => AdditionalProperties + } + end + ) + end + ) + end + end + ).