verquest 0.6.4 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -1
  3. data/README.md +299 -45
  4. data/Rakefile +5 -0
  5. data/docs/rails.md +169 -0
  6. data/lib/verquest/base/condition_class_methods.rb +211 -0
  7. data/lib/verquest/base/private_class_methods.rb +31 -14
  8. data/lib/verquest/base/public_class_methods.rb +51 -1
  9. data/lib/verquest/base.rb +1 -0
  10. data/lib/verquest/conditions/branch.rb +111 -0
  11. data/lib/verquest/conditions/condition.rb +194 -0
  12. data/lib/verquest/conditions/conjunction.rb +39 -0
  13. data/lib/verquest/conditions/fragment_merge.rb +52 -0
  14. data/lib/verquest/conditions/property_merge.rb +47 -0
  15. data/lib/verquest/conditions/rule_set.rb +58 -0
  16. data/lib/verquest/conditions/rules/absent.rb +25 -0
  17. data/lib/verquest/conditions/rules/alternatives.rb +28 -0
  18. data/lib/verquest/conditions/rules/any.rb +27 -0
  19. data/lib/verquest/conditions/rules/equals.rb +22 -0
  20. data/lib/verquest/conditions/rules/names_rule.rb +23 -0
  21. data/lib/verquest/conditions/rules/none.rb +24 -0
  22. data/lib/verquest/conditions/rules/present.rb +22 -0
  23. data/lib/verquest/conditions/rules/property_rule.rb +48 -0
  24. data/lib/verquest/conditions/rules/satisfies.rb +22 -0
  25. data/lib/verquest/conditions/rules/value_in.rb +22 -0
  26. data/lib/verquest/conditions/rules.rb +17 -0
  27. data/lib/verquest/conditions.rb +36 -0
  28. data/lib/verquest/configuration.rb +19 -1
  29. data/lib/verquest/gem_version.rb +1 -1
  30. data/lib/verquest/helper_methods/conditional_properties.rb +126 -0
  31. data/lib/verquest/helper_methods/unnamed_one_of.rb +35 -0
  32. data/lib/verquest/properties/array.rb +15 -1
  33. data/lib/verquest/properties/base.rb +104 -10
  34. data/lib/verquest/properties/collection.rb +51 -18
  35. data/lib/verquest/properties/const.rb +2 -1
  36. data/lib/verquest/properties/object.rb +38 -4
  37. data/lib/verquest/properties/one_of/discriminated_schema.rb +84 -0
  38. data/lib/verquest/properties/one_of.rb +83 -44
  39. data/lib/verquest/properties/reference.rb +47 -16
  40. data/lib/verquest/query/coercer.rb +165 -0
  41. data/lib/verquest/query/definition_check.rb +91 -0
  42. data/lib/verquest/query/parameters.rb +79 -0
  43. data/lib/verquest/query.rb +25 -0
  44. data/lib/verquest/transformer/collection.rb +32 -0
  45. data/lib/verquest/transformer/const_as_enum.rb +47 -0
  46. data/lib/verquest/transformer/layout.rb +83 -0
  47. data/lib/verquest/transformer/mapping_layout.rb +166 -0
  48. data/lib/verquest/transformer/one_of.rb +111 -0
  49. data/lib/verquest/transformer/path.rb +188 -0
  50. data/lib/verquest/transformer/plan.rb +183 -0
  51. data/lib/verquest/transformer/scope.rb +64 -0
  52. data/lib/verquest/transformer.rb +43 -880
  53. data/lib/verquest/version.rb +152 -79
  54. data/lib/verquest.rb +6 -0
  55. metadata +42 -7
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ module Conditions
5
+ # The `then` or `else` part of a condition
6
+ #
7
+ # A branch is a DSL scope: `field`, `object` and the rest add properties
8
+ # through {#add}, and rule methods add rules through {#add_rule}. The
9
+ # properties are listed at the scope level by
10
+ # {Verquest::HelperMethods::ConditionalProperties}, so the branch schema
11
+ # only holds rules: the required names, the rule fragments and `false`
12
+ # for the names the other branch declares.
13
+ class Branch
14
+ include HelperMethods::RequiredProperties
15
+
16
+ # @!attribute [r] properties
17
+ # @return [Hash<String, Verquest::Properties::Base>] The properties declared in the branch
18
+ #
19
+ # @!attribute [r] documentation
20
+ # @return [Hash<String, Object>] The title, description and deprecated flag of the branch's oneOf variant
21
+ attr_reader :properties, :documentation
22
+
23
+ # Initialize an empty branch
24
+ #
25
+ # The documentation is used only when the condition is documented as a oneOf.
26
+ #
27
+ # @param title [String, nil] Title of the branch's oneOf variant
28
+ # @param description [String, nil] Description of the branch's oneOf variant
29
+ # @param deprecated [Boolean, nil] Whether the branch's oneOf variant is deprecated
30
+ def initialize(title: nil, description: nil, deprecated: nil)
31
+ @properties = {}
32
+ @rules = RuleSet.new
33
+ @documentation = {"title" => title, "description" => description, "deprecated" => deprecated}.compact
34
+ end
35
+
36
+ # Add a property declared in the branch
37
+ #
38
+ # @param property [Verquest::Properties::Base] The property to add
39
+ # @return [Verquest::Properties::Base] The added property
40
+ # @raise [ArgumentError] If the property is a oneOf
41
+ def add(property)
42
+ raise ArgumentError, "one_of can not be declared inside a condition" if property.is_a?(Properties::OneOf)
43
+
44
+ properties[property.name] = property
45
+ end
46
+
47
+ # Add a rule
48
+ #
49
+ # @param rule [#to_schema, #property_names] The rule to add
50
+ # @return [Array] The rules
51
+ def add_rule(rule)
52
+ rules.add_rule(rule)
53
+ end
54
+
55
+ # Returns the names of the properties the rules use
56
+ #
57
+ # @return [Array<String>] The property names, without duplicates
58
+ def rule_property_names
59
+ rules.property_names
60
+ end
61
+
62
+ # Builds the branch schema fragment
63
+ #
64
+ # @param forbidden [Array<String>] Property names the branch forbids (declared by the other branch only)
65
+ # @return [Hash] The schema fragment
66
+ # @raise [ArgumentError] If two rules conflict or the branch requires a forbidden name
67
+ def to_schema(forbidden: [])
68
+ FragmentMerge.call([required_schema, rules.to_schema, forbidden_schema(forbidden)]).tap do |schema|
69
+ check_satisfiable!(schema, forbidden)
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ # @!attribute [r] rules
76
+ # @return [Verquest::Conditions::RuleSet] The rules of the branch
77
+ attr_reader :rules
78
+
79
+ # Builds the fragment for the branch properties' requiredness
80
+ #
81
+ # @return [Hash] The `required` and `dependentRequired` fragment
82
+ def required_schema
83
+ {}.tap do |schema|
84
+ schema["required"] = required_properties if required_properties.any?
85
+ schema["dependentRequired"] = dependent_required_properties if dependent_required_properties.any?
86
+ end
87
+ end
88
+
89
+ # Checks that the branch doesn't require a name it forbids
90
+ #
91
+ # @param schema [Hash] The branch schema fragment
92
+ # @param forbidden [Array<String>] The forbidden property names
93
+ # @return [void]
94
+ # @raise [ArgumentError] If a required name is forbidden, which no payload could satisfy
95
+ def check_satisfiable!(schema, forbidden)
96
+ conflict = schema.fetch("required", []) & forbidden
97
+ raise ArgumentError, "Branch requires #{conflict.join(", ")}, which only the other branch declares" if conflict.any?
98
+ end
99
+
100
+ # Builds the fragment that forbids properties
101
+ #
102
+ # @param names [Array<String>] The forbidden property names
103
+ # @return [Hash] The `properties` fragment with a `false` schema per name
104
+ def forbidden_schema(names)
105
+ return {} if names.empty?
106
+
107
+ {"properties" => names.to_h { |name| [name, false] }}
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ module Conditions
5
+ # One `if` / `then` / `else`
6
+ #
7
+ # Holds the `given` rule set (the `if`) and up to two branches. A property
8
+ # declared in one branch only is forbidden in the other, so when only one
9
+ # branch declares properties, the other one is generated.
10
+ #
11
+ # @example
12
+ # condition = Condition.new(name: :pricing)
13
+ # condition.open(:given).add_rule(Rules::Present.new(%i[percentages]))
14
+ # condition.open(:then).add(Properties::Field.new(name: :percentages, type: :number, required: true))
15
+ # condition.to_schema
16
+ # # => {
17
+ # # "if" => {"required" => ["percentages"]},
18
+ # # "then" => {"required" => ["percentages"]},
19
+ # # "else" => {"properties" => {"percentages" => false}}
20
+ # # }
21
+ class Condition
22
+ # The scope class for each part of a condition
23
+ PARTS = {given: RuleSet, then: Branch, else: Branch}.freeze
24
+
25
+ # The ways a condition can be rendered in `to_schema` instead of `if` / `then` / `else`
26
+ DOCUMENT_AS = %i[one_of].freeze
27
+
28
+ # @!attribute [r] name
29
+ # @return [String, nil] The condition name, used to exclude it in a later version
30
+ #
31
+ # @!attribute [r] document_as
32
+ # @return [Symbol, nil] `:one_of` when `to_schema` renders the condition as a oneOf
33
+ attr_reader :name, :document_as
34
+
35
+ # Initialize a new Condition
36
+ #
37
+ # @param name [Symbol, String, nil] Optional name
38
+ # @param document_as [Symbol, nil] `:one_of` to render the condition as a oneOf in `to_schema`
39
+ # @raise [ArgumentError] If document_as is not supported
40
+ def initialize(name: nil, document_as: nil)
41
+ raise ArgumentError, "document_as must be :one_of (got #{document_as.inspect})" unless document_as.nil? || DOCUMENT_AS.include?(document_as)
42
+
43
+ @name = name&.to_s
44
+ @document_as = document_as
45
+ @parts = {}
46
+ end
47
+
48
+ # Starts one part of the condition
49
+ #
50
+ # @param part [Symbol] :given, :then or :else
51
+ # @param options [Hash] Branch documentation (title, description, deprecated), for :then and :else only
52
+ # @return [Verquest::Conditions::RuleSet, Verquest::Conditions::Branch] The new part, used as the DSL scope
53
+ # @raise [ArgumentError] If the part was already declared
54
+ def open(part, **options)
55
+ raise ArgumentError, "#{part} can be declared only once in #{label}" if parts.key?(part)
56
+
57
+ parts[part] = PARTS.fetch(part).new(**options)
58
+ end
59
+
60
+ # Rejects properties declared outside a branch
61
+ #
62
+ # @param _property [Verquest::Properties::Base] The property that was declared
63
+ # @raise [ArgumentError] Always
64
+ def add(_property)
65
+ raise ArgumentError, "Properties of #{label} must be declared inside then_apply or else_apply"
66
+ end
67
+
68
+ # Checks the shape of the condition and the names its rules use
69
+ #
70
+ # @param property_names [Array<String>] The names declared in the scope and in its branches
71
+ # @return [nil]
72
+ # @raise [ArgumentError] If `given` has no rules, neither branch is declared, or a rule uses an unknown name
73
+ def validate!(property_names)
74
+ raise ArgumentError, "#{label} needs a given block with at least one rule" if given.rules.empty?
75
+ raise ArgumentError, "#{label} needs a then_apply or else_apply block" unless parts.key?(:then) || parts.key?(:else)
76
+
77
+ unknown = rule_property_names - property_names
78
+ raise ArgumentError, "#{label} uses unknown properties: #{unknown.join(", ")}" if unknown.any?
79
+ end
80
+
81
+ # Returns the branch properties to list at the scope level
82
+ #
83
+ # @return [Hash<String, Verquest::Properties::Base>] The properties of both branches
84
+ # @raise [ArgumentError] If the branches declare the same name differently
85
+ def properties
86
+ PropertyMerge.call([branch(:then).properties, branch(:else).properties])
87
+ end
88
+
89
+ # Returns the names of the properties the rules use
90
+ #
91
+ # @return [Array<String>] The property names, without duplicates
92
+ def rule_property_names
93
+ (given.property_names + branch(:then).rule_property_names + branch(:else).rule_property_names).uniq
94
+ end
95
+
96
+ # Builds the `if` / `then` / `else` keywords
97
+ #
98
+ # @return [Hash] The keywords, without empty branches
99
+ def to_schema
100
+ {
101
+ "if" => given.to_schema(in_given: true),
102
+ "then" => branch_schema(:then, other: :else),
103
+ "else" => branch_schema(:else, other: :then)
104
+ }.reject { |_, fragment| fragment.empty? }
105
+ end
106
+
107
+ # Builds a oneOf with one variant per branch, for documentation
108
+ #
109
+ # Each variant holds the scope's properties without the ones only the
110
+ # other branch declares, plus its branch's rules. The `then` variant also
111
+ # holds the `given` rules, and the `else` variant their negation, unless
112
+ # the variant already rules `given` out. So exactly one variant matches
113
+ # every payload the `if` / `then` / `else` schema accepts.
114
+ #
115
+ # @param scope_schema [Hash] The scope schema with every property listed
116
+ # @return [Hash] The scope description and the `oneOf` variants
117
+ def to_one_of_schema(scope_schema)
118
+ {"description" => scope_schema["description"], "oneOf" => [variant_schema(scope_schema, :then, other: :else), variant_schema(scope_schema, :else, other: :then)]}.compact
119
+ end
120
+
121
+ private
122
+
123
+ # @!attribute [r] parts
124
+ # @return [Hash<Symbol, Object>] The declared parts, keyed by :given, :then and :else
125
+ attr_reader :parts
126
+
127
+ # @return [Verquest::Conditions::RuleSet] The `given` rules, empty if not declared
128
+ def given
129
+ parts.fetch(:given) { RuleSet.new }
130
+ end
131
+
132
+ # @param part [Symbol] :then or :else
133
+ # @return [Verquest::Conditions::Branch] The branch, empty if not declared
134
+ def branch(part)
135
+ parts.fetch(part) { Branch.new }
136
+ end
137
+
138
+ # Builds one branch, forbidding the names only the other branch declares
139
+ #
140
+ # @param part [Symbol] :then or :else
141
+ # @param other [Symbol] The other branch
142
+ # @return [Hash] The branch schema fragment
143
+ def branch_schema(part, other:)
144
+ branch(part).to_schema(forbidden: branch(other).properties.keys - branch(part).properties.keys)
145
+ end
146
+
147
+ # Builds the oneOf variant for one branch
148
+ #
149
+ # @param scope_schema [Hash] The scope schema with every property listed
150
+ # @param part [Symbol] :then or :else
151
+ # @param other [Symbol] The other branch
152
+ # @return [Hash] The variant schema
153
+ def variant_schema(scope_schema, part, other:)
154
+ excluded = branch(other).properties.keys - branch(part).properties.keys
155
+ base = {"type" => scope_schema["type"]}.merge(branch(part).documentation, scope_schema.except("description"))
156
+ base["properties"] = variant_properties(scope_schema, excluded)
157
+
158
+ Conjunction.call([base, branch(part).to_schema, (part == :then) ? given.to_schema(in_given: true) : {}]).tap do |variant|
159
+ variant["not"] = given.to_schema(in_given: true) if part == :else && given_possible?(variant)
160
+ end
161
+ end
162
+
163
+ # Lists the properties of a variant
164
+ #
165
+ # The excluded names are dropped. When the scope allows additional
166
+ # properties, they are forbidden with a `false` schema instead.
167
+ #
168
+ # @param scope_schema [Hash] The scope schema with every property listed
169
+ # @param excluded [Array<String>] The names only the other branch declares
170
+ # @return [Hash] The variant properties
171
+ def variant_properties(scope_schema, excluded)
172
+ properties = scope_schema["properties"].except(*excluded)
173
+ return properties if scope_schema["additionalProperties"] == false
174
+
175
+ properties.merge(excluded.to_h { |property_name| [property_name, false] })
176
+ end
177
+
178
+ # Checks whether a payload matching the variant could still match `given`
179
+ #
180
+ # @param variant [Hash] The variant schema
181
+ # @return [Boolean] false when the variant forbids a name `given` requires
182
+ def given_possible?(variant)
183
+ given.to_schema(in_given: true).fetch("required", []).none? do |property_name|
184
+ variant["properties"][property_name] == false || (!variant["properties"].key?(property_name) && variant["additionalProperties"] == false)
185
+ end
186
+ end
187
+
188
+ # @return [String] The condition name for error messages
189
+ def label
190
+ name ? "Condition '#{name}'" : "Condition"
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ module Conditions
5
+ # Combines schema fragments that must all hold
6
+ #
7
+ # Unlike FragmentMerge, a clash is not an error here: the fragments are
8
+ # requirements on the same payload, so a fragment that can't be merged
9
+ # (for example `note: false` against `note: {type: string}`) is added to
10
+ # `allOf` instead. Used for the documented oneOf variants, which join the
11
+ # scope schema, the branch rules and the `given` rules.
12
+ #
13
+ # @example
14
+ # Conjunction.call([{"properties" => {"note" => {"type" => "string"}}}, {"properties" => {"note" => false}}])
15
+ # # => {"properties" => {"note" => {"type" => "string"}}, "allOf" => [{"properties" => {"note" => false}}]}
16
+ module Conjunction
17
+ module_function
18
+
19
+ # Combines the fragments in order
20
+ #
21
+ # @param fragments [Array<Hash>] The schema fragments that must all hold
22
+ # @return [Hash] The combined fragment
23
+ def call(fragments)
24
+ fragments.reduce({}) { |combined, fragment| conjoin(combined, fragment) }
25
+ end
26
+
27
+ # Merges two fragments, or adds the second to `allOf` when they clash
28
+ #
29
+ # @param left [Hash] The fragment combined so far
30
+ # @param right [Hash] The fragment to add
31
+ # @return [Hash] The combined fragment
32
+ def conjoin(left, right)
33
+ FragmentMerge.merge(left, right)
34
+ rescue ArgumentError
35
+ left.merge("allOf" => left.fetch("allOf", []) + [right])
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ module Conditions
5
+ # Deep-merges the schema fragments of several rules into one fragment
6
+ #
7
+ # `required` lists are concatenated without duplicates and nested hashes
8
+ # (like `properties`) are merged. Two different values for the same keyword
9
+ # raise, so `equals :kind, "a"` and `equals :kind, "b"` in one block fail
10
+ # instead of silently keeping one of them.
11
+ #
12
+ # @example
13
+ # FragmentMerge.call([{"required" => ["a"]}, {"required" => ["a", "b"]}])
14
+ # # => {"required" => ["a", "b"]}
15
+ module FragmentMerge
16
+ module_function
17
+
18
+ # Merges the fragments in order
19
+ #
20
+ # @param fragments [Array<Hash>] The schema fragments to merge
21
+ # @return [Hash] The merged fragment
22
+ # @raise [ArgumentError] If two fragments set the same keyword to different values
23
+ def call(fragments)
24
+ fragments.reduce({}) { |merged, fragment| merge(merged, fragment) }
25
+ end
26
+
27
+ # Merges two fragments
28
+ #
29
+ # @param left [Hash] The fragment merged so far
30
+ # @param right [Hash] The fragment to merge into it
31
+ # @return [Hash] The merged fragment
32
+ def merge(left, right)
33
+ left.merge(right) { |key, left_value, right_value| merge_value(key, left_value, right_value) }
34
+ end
35
+
36
+ # Merges two values of the same keyword
37
+ #
38
+ # @param key [String] The keyword
39
+ # @param left_value [Object] The value merged so far
40
+ # @param right_value [Object] The value to merge into it
41
+ # @return [Object] The merged value
42
+ # @raise [ArgumentError] If the values differ and can't be combined
43
+ def merge_value(key, left_value, right_value)
44
+ return left_value | right_value if key == "required" && left_value.is_a?(::Array)
45
+ return merge(left_value, right_value) if left_value.is_a?(Hash) && right_value.is_a?(Hash)
46
+ return left_value if left_value == right_value
47
+
48
+ raise ArgumentError, "Conflicting rules for '#{key}': #{left_value.inspect} and #{right_value.inspect}"
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ module Conditions
5
+ # Merges the properties declared in condition branches into one hash
6
+ #
7
+ # A name may be declared in several branches only if every declaration
8
+ # produces the same schema and has the same `map:`. That keeps the flat
9
+ # mapping unambiguous.
10
+ module PropertyMerge
11
+ module_function
12
+
13
+ # Merges property hashes in order, keeping the first declaration of each name
14
+ #
15
+ # @param property_hashes [Array<Hash<String, Verquest::Properties::Base>>] The properties to merge
16
+ # @return [Hash<String, Verquest::Properties::Base>] The merged properties
17
+ # @raise [ArgumentError] If a name is declared differently
18
+ def call(property_hashes)
19
+ property_hashes.reduce({}) do |merged, properties|
20
+ merged.merge(properties) { |name, declared, other| pick(name, declared, other) }
21
+ end
22
+ end
23
+
24
+ # Returns the first declaration if both declarations are the same
25
+ #
26
+ # @param name [String] The property name
27
+ # @param declared [Verquest::Properties::Base] The declaration merged so far
28
+ # @param other [Verquest::Properties::Base] Another declaration of the same name
29
+ # @return [Verquest::Properties::Base] The first declaration
30
+ # @raise [ArgumentError] If the declarations differ
31
+ def pick(name, declared, other)
32
+ return declared if same?(declared, other)
33
+
34
+ raise ArgumentError, "Property '#{name}' is declared differently in several condition branches"
35
+ end
36
+
37
+ # Checks whether two declarations produce the same schema and mapping
38
+ #
39
+ # @param declared [Verquest::Properties::Base] The first declaration
40
+ # @param other [Verquest::Properties::Base] The second declaration
41
+ # @return [Boolean] true if class, `map:` and schema are the same
42
+ def same?(declared, other)
43
+ declared.instance_of?(other.class) && declared.map == other.map && declared.to_schema == other.to_schema
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ module Conditions
5
+ # The rules of one block, combined with AND
6
+ #
7
+ # Used as the DSL scope of `given`, so rule methods add to it and property
8
+ # declarations raise. {Rules::Any} and {Rules::None} build on it.
9
+ #
10
+ # @example
11
+ # rule_set = RuleSet.new
12
+ # rule_set.add_rule(Rules::Present.new(%i[percentages]))
13
+ # rule_set.to_schema(in_given: true)
14
+ # # => {"required" => ["percentages"]}
15
+ class RuleSet
16
+ # @!attribute [r] rules
17
+ # @return [Array<#to_schema>] The rules in declaration order
18
+ attr_reader :rules
19
+
20
+ # Initialize an empty rule set
21
+ def initialize
22
+ @rules = []
23
+ end
24
+
25
+ # Add a rule
26
+ #
27
+ # @param rule [#to_schema, #property_names] The rule to add
28
+ # @return [Array] The rules
29
+ def add_rule(rule)
30
+ rules << rule
31
+ end
32
+
33
+ # Rejects property declarations, which are only allowed in branches
34
+ #
35
+ # @param _property [Verquest::Properties::Base] The property that was declared
36
+ # @raise [ArgumentError] Always
37
+ def add(_property)
38
+ raise ArgumentError, "Properties can not be declared inside given, any or none"
39
+ end
40
+
41
+ # Returns the names of the properties the rules use
42
+ #
43
+ # @return [Array<String>] The property names, without duplicates
44
+ def property_names
45
+ rules.flat_map(&:property_names).uniq
46
+ end
47
+
48
+ # Builds the merged schema fragment of all rules
49
+ #
50
+ # @param in_given [Boolean] Whether the rules are used inside `given`
51
+ # @return [Hash] The schema fragment
52
+ # @raise [ArgumentError] If two rules conflict
53
+ def to_schema(in_given: false)
54
+ FragmentMerge.call(rules.map { |rule| rule.to_schema(in_given:) })
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ module Conditions
5
+ module Rules
6
+ # Forbids the named properties
7
+ #
8
+ # A forbidden property gets the `false` schema, so a payload that sends it
9
+ # is reported with a single error at the property's pointer.
10
+ #
11
+ # @example
12
+ # Absent.new(%i[a b]).to_schema(in_given: false)
13
+ # # => {"properties" => {"a" => false, "b" => false}}
14
+ class Absent < NamesRule
15
+ # Builds the schema fragment for this rule
16
+ #
17
+ # @param in_given [Boolean] Whether the rule is used inside `given` (no effect for this rule)
18
+ # @return [Hash] The schema fragment
19
+ def to_schema(in_given: false)
20
+ {"properties" => property_names.to_h { |name| [name, false] }}
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ module Conditions
5
+ module Rules
6
+ # Base class for rules whose nested rules are alternatives
7
+ #
8
+ # Each rule inside the block becomes one `anyOf` entry, so an empty block
9
+ # would produce an invalid (`anyOf: []`) or meaningless schema and raises.
10
+ #
11
+ # @abstract Subclass and implement `#to_schema(in_given:)` with {#alternatives}
12
+ class Alternatives < RuleSet
13
+ private
14
+
15
+ # Builds one schema fragment per nested rule
16
+ #
17
+ # @param in_given [Boolean] Whether the rule is used inside `given`
18
+ # @return [Array<Hash>] The fragments, one per alternative
19
+ # @raise [ArgumentError] If the block declared no rules
20
+ def alternatives(in_given:)
21
+ raise ArgumentError, "any and none need at least one rule" if rules.empty?
22
+
23
+ rules.map { |rule| rule.to_schema(in_given:) }
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ module Conditions
5
+ module Rules
6
+ # Requires at least one of its rules to hold
7
+ #
8
+ # Each rule inside the block is one alternative.
9
+ #
10
+ # @example
11
+ # any = Any.new
12
+ # any.add_rule(Present.new(%i[email]))
13
+ # any.add_rule(Present.new(%i[phone]))
14
+ # any.to_schema(in_given: false)
15
+ # # => {"anyOf" => [{"required" => ["email"]}, {"required" => ["phone"]}]}
16
+ class Any < Alternatives
17
+ # Builds the schema fragment with one `anyOf` entry per rule
18
+ #
19
+ # @param in_given [Boolean] Whether the rule is used inside `given`
20
+ # @return [Hash] The schema fragment
21
+ def to_schema(in_given: false)
22
+ {"anyOf" => alternatives(in_given:)}
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ module Conditions
5
+ module Rules
6
+ # Requires a property to hold one value
7
+ #
8
+ # @example
9
+ # Equals.new(:kind, "company").to_schema(in_given: false)
10
+ # # => {"properties" => {"kind" => {"const" => "company"}}}
11
+ class Equals < PropertyRule
12
+ # Initialize a new Equals rule
13
+ #
14
+ # @param name [Symbol, String] The property the rule constrains
15
+ # @param value [Object] The value the property must hold
16
+ def initialize(name, value)
17
+ super(name, {"const" => value})
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ module Conditions
5
+ module Rules
6
+ # Base class for rules that name one or more properties
7
+ #
8
+ # @abstract Subclass and implement `#to_schema(in_given:)`
9
+ class NamesRule
10
+ # @!attribute [r] property_names
11
+ # @return [Array<String>] The names of the properties the rule uses
12
+ attr_reader :property_names
13
+
14
+ # Initialize a new rule
15
+ #
16
+ # @param names [Array<Symbol, String>] The properties the rule names
17
+ def initialize(names)
18
+ @property_names = names.map(&:to_s)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ module Conditions
5
+ module Rules
6
+ # Requires none of its rules to hold
7
+ #
8
+ # @example
9
+ # none = None.new
10
+ # none.add_rule(Present.new(%i[email]))
11
+ # none.to_schema(in_given: false)
12
+ # # => {"not" => {"anyOf" => [{"required" => ["email"]}]}}
13
+ class None < Alternatives
14
+ # Builds the schema fragment that negates the alternatives
15
+ #
16
+ # @param in_given [Boolean] Whether the rule is used inside `given`
17
+ # @return [Hash] The schema fragment
18
+ def to_schema(in_given: false)
19
+ {"not" => {"anyOf" => alternatives(in_given:)}}
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ module Conditions
5
+ module Rules
6
+ # Requires the named properties to be present
7
+ #
8
+ # @example
9
+ # Present.new(%i[a b]).to_schema(in_given: false)
10
+ # # => {"required" => ["a", "b"]}
11
+ class Present < NamesRule
12
+ # Builds the schema fragment for this rule
13
+ #
14
+ # @param in_given [Boolean] Whether the rule is used inside `given` (no effect for this rule)
15
+ # @return [Hash] The schema fragment
16
+ def to_schema(in_given: false)
17
+ {"required" => property_names}
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end