verquest 0.6.3 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -0
- data/README.md +324 -51
- data/Rakefile +5 -0
- data/docs/rails.md +169 -0
- data/lib/verquest/base/condition_class_methods.rb +211 -0
- data/lib/verquest/base/private_class_methods.rb +34 -15
- data/lib/verquest/base/public_class_methods.rb +51 -1
- data/lib/verquest/base.rb +1 -0
- data/lib/verquest/conditions/branch.rb +111 -0
- data/lib/verquest/conditions/condition.rb +194 -0
- data/lib/verquest/conditions/conjunction.rb +39 -0
- data/lib/verquest/conditions/fragment_merge.rb +52 -0
- data/lib/verquest/conditions/property_merge.rb +47 -0
- data/lib/verquest/conditions/rule_set.rb +58 -0
- data/lib/verquest/conditions/rules/absent.rb +25 -0
- data/lib/verquest/conditions/rules/alternatives.rb +28 -0
- data/lib/verquest/conditions/rules/any.rb +27 -0
- data/lib/verquest/conditions/rules/equals.rb +22 -0
- data/lib/verquest/conditions/rules/names_rule.rb +23 -0
- data/lib/verquest/conditions/rules/none.rb +24 -0
- data/lib/verquest/conditions/rules/present.rb +22 -0
- data/lib/verquest/conditions/rules/property_rule.rb +48 -0
- data/lib/verquest/conditions/rules/satisfies.rb +22 -0
- data/lib/verquest/conditions/rules/value_in.rb +22 -0
- data/lib/verquest/conditions/rules.rb +17 -0
- data/lib/verquest/conditions.rb +36 -0
- data/lib/verquest/configuration.rb +19 -1
- data/lib/verquest/gem_version.rb +1 -1
- data/lib/verquest/helper_methods/conditional_properties.rb +126 -0
- data/lib/verquest/helper_methods/unnamed_one_of.rb +35 -0
- data/lib/verquest/properties/array.rb +15 -1
- data/lib/verquest/properties/base.rb +116 -10
- data/lib/verquest/properties/collection.rb +51 -18
- data/lib/verquest/properties/const.rb +5 -4
- data/lib/verquest/properties/enum.rb +1 -5
- data/lib/verquest/properties/field.rb +1 -0
- data/lib/verquest/properties/object.rb +38 -4
- data/lib/verquest/properties/one_of/discriminated_schema.rb +84 -0
- data/lib/verquest/properties/one_of.rb +86 -54
- data/lib/verquest/properties/reference.rb +49 -37
- data/lib/verquest/query/coercer.rb +165 -0
- data/lib/verquest/query/definition_check.rb +91 -0
- data/lib/verquest/query/parameters.rb +79 -0
- data/lib/verquest/query.rb +25 -0
- data/lib/verquest/transformer/collection.rb +32 -0
- data/lib/verquest/transformer/const_as_enum.rb +47 -0
- data/lib/verquest/transformer/layout.rb +83 -0
- data/lib/verquest/transformer/mapping_layout.rb +166 -0
- data/lib/verquest/transformer/one_of.rb +111 -0
- data/lib/verquest/transformer/path.rb +188 -0
- data/lib/verquest/transformer/plan.rb +183 -0
- data/lib/verquest/transformer/scope.rb +64 -0
- data/lib/verquest/transformer.rb +43 -880
- data/lib/verquest/version.rb +152 -79
- data/lib/verquest.rb +6 -0
- metadata +43 -8
|
@@ -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
|