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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/README.md +324 -51
  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 +34 -15
  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 +116 -10
  34. data/lib/verquest/properties/collection.rb +51 -18
  35. data/lib/verquest/properties/const.rb +5 -4
  36. data/lib/verquest/properties/enum.rb +1 -5
  37. data/lib/verquest/properties/field.rb +1 -0
  38. data/lib/verquest/properties/object.rb +38 -4
  39. data/lib/verquest/properties/one_of/discriminated_schema.rb +84 -0
  40. data/lib/verquest/properties/one_of.rb +86 -54
  41. data/lib/verquest/properties/reference.rb +49 -37
  42. data/lib/verquest/query/coercer.rb +165 -0
  43. data/lib/verquest/query/definition_check.rb +91 -0
  44. data/lib/verquest/query/parameters.rb +79 -0
  45. data/lib/verquest/query.rb +25 -0
  46. data/lib/verquest/transformer/collection.rb +32 -0
  47. data/lib/verquest/transformer/const_as_enum.rb +47 -0
  48. data/lib/verquest/transformer/layout.rb +83 -0
  49. data/lib/verquest/transformer/mapping_layout.rb +166 -0
  50. data/lib/verquest/transformer/one_of.rb +111 -0
  51. data/lib/verquest/transformer/path.rb +188 -0
  52. data/lib/verquest/transformer/plan.rb +183 -0
  53. data/lib/verquest/transformer/scope.rb +64 -0
  54. data/lib/verquest/transformer.rb +43 -880
  55. data/lib/verquest/version.rb +152 -79
  56. data/lib/verquest.rb +6 -0
  57. metadata +43 -8
@@ -18,6 +18,9 @@ module Verquest
18
18
  # item: ProductRequest
19
19
  # )
20
20
  class Collection < Base
21
+ include HelperMethods::ConditionalProperties
22
+ include HelperMethods::UnnamedOneOf
23
+
21
24
  # Initialize a new Collection property
22
25
  #
23
26
  # @param name [String, Symbol] The name of the property
@@ -31,6 +34,7 @@ module Verquest
31
34
  raise ArgumentError, "You can not map collection to the root" if map == "/"
32
35
 
33
36
  @properties = {}
37
+ @conditions = []
34
38
 
35
39
  @name = name.to_s
36
40
  @item = item
@@ -51,9 +55,21 @@ module Verquest
51
55
  # @param property [Verquest::Properties::Base] The property to add to the collection items
52
56
  # @return [Verquest::Properties::Base] The added property
53
57
  def add(property)
58
+ check_unnamed_one_of(property)
54
59
  properties[property.name] = property
55
60
  end
56
61
 
62
+ # Add a condition to this collection's item definition
63
+ #
64
+ # @param condition [Verquest::Conditions::Condition] The condition to add
65
+ # @return [Array<Verquest::Conditions::Condition>] The conditions
66
+ # @raise [ArgumentError] If the collection items are defined by `item:`
67
+ def add_condition(condition)
68
+ raise ArgumentError, "condition can not be declared in a collection defined by item:" if has_item?
69
+
70
+ super
71
+ end
72
+
57
73
  # Check if this collection references an external item schema
58
74
  #
59
75
  # @return [Boolean] True if the collection uses an external reference
@@ -61,18 +77,21 @@ module Verquest
61
77
  !item.nil?
62
78
  end
63
79
 
64
- # Check if this collection contains a oneOf property as its item type
80
+ # Check if the items are a oneOf: an unnamed oneOf is the collection's only property
65
81
  #
66
- # @return [Boolean] True if the collection contains a oneOf property
82
+ # @return [Boolean] True if the item schema is the oneOf itself
67
83
  def has_one_of?
68
- properties.values.size == 1 && properties.values.first.is_a?(Verquest::Properties::OneOf)
84
+ # A named oneOf is a property of the item, like any other
85
+ properties.values.size == 1 && properties.values.first.is_a?(Verquest::Properties::OneOf) && properties.values.first.name.nil?
69
86
  end
70
87
 
71
88
  # Returns the oneOf property if this collection contains one
72
89
  #
73
90
  # @return [Verquest::Properties::OneOf, nil] The oneOf property or nil
91
+ # @raise [ArgumentError] If the collection also has conditions
74
92
  def one_of_property
75
93
  return nil unless has_one_of?
94
+ raise ArgumentError, "Conditions can not be declared next to a one_of in a collection" if conditions.any?
76
95
 
77
96
  properties.values.first
78
97
  end
@@ -104,10 +123,11 @@ module Verquest
104
123
  "items" => {
105
124
  "type" => "object",
106
125
  "required" => required_properties,
107
- "properties" => properties.transform_values { |property| property.to_schema[property.name] },
126
+ "properties" => all_properties.transform_values { |property| property.to_schema[property.name] },
108
127
  "additionalProperties" => Verquest.configuration.default_additional_properties
109
128
  }.tap do |schema|
110
129
  schema["dependentRequired"] = dependent_required_properties if dependent_required_properties.any?
130
+ add_conditions(schema)
111
131
  end
112
132
  }.merge(schema_options)
113
133
  }
@@ -141,10 +161,11 @@ module Verquest
141
161
  "items" => {
142
162
  "type" => "object",
143
163
  "required" => required_properties,
144
- "properties" => properties.transform_values { |property| property.to_validation_schema(version: version)[property.name] },
164
+ "properties" => all_properties.transform_values { |property| property.to_validation_schema(version: version)[property.name] },
145
165
  "additionalProperties" => Verquest.configuration.default_additional_properties
146
166
  }.tap do |schema|
147
167
  schema["dependentRequired"] = dependent_required_properties if dependent_required_properties.any?
168
+ add_conditions(schema)
148
169
  end
149
170
  }.merge(schema_options)
150
171
  }
@@ -174,24 +195,36 @@ module Verquest
174
195
  # @return [void]
175
196
  def mapping(key_prefix:, value_prefix:, mapping:, version:)
176
197
  if has_item?
177
- value_key_prefix = mapping_value_key(value_prefix: value_prefix, collection: true)
178
-
179
- reference_mapping = item.mapping(version:).dup
180
- reference_mapping.transform_keys! { |k| "#{(key_prefix + [name]).join("/")}[]/#{k}" }
181
- reference_mapping.transform_values! { |v| "#{value_key_prefix}/#{v}" }
182
-
183
- mapping.merge!(reference_mapping)
184
- elsif has_one_of?
185
- one_of_mapping = {}
186
- one_of_property.mapping(key_prefix: key_prefix + ["#{name}[]"], value_prefix: mapping_value_prefix(value_prefix: value_prefix, collection: true), mapping: one_of_mapping, version:)
187
- mapping.merge!(one_of_mapping)
198
+ add_request_mapping(item, key_prefix: key_prefix + ["#{name}[]"], value_prefix: mapping_value_prefix(value_prefix: value_prefix, collection: true), mapping:, version:)
188
199
  else
189
- properties.values.each do |property|
190
- property.mapping(key_prefix: key_prefix + ["#{name}[]"], value_prefix: mapping_value_prefix(value_prefix: value_prefix, collection: true), mapping:, version:)
200
+ children = has_one_of? ? [one_of_property] : all_properties.values
201
+ children.each do |property|
202
+ add_child_mapping(property, key_prefix: key_prefix + ["#{name}[]"], value_prefix: mapping_value_prefix(value_prefix: value_prefix, collection: true), mapping:, version:)
191
203
  end
192
204
  end
193
205
  end
194
206
 
207
+ # Adds this collection and its item properties to a transformer layout
208
+ #
209
+ # The collection's source and target paths are added too, so the plan
210
+ # knows where the items go when they hold a oneOf.
211
+ #
212
+ # @param key_prefix [Array<String>] Prefix for the source key
213
+ # @param value_prefix [Array<String>] Prefix for the target value
214
+ # @param layout [Verquest::Transformer::Layout] The layout to add to
215
+ # @param version [String, nil] The version to create the layout for
216
+ # @return [void]
217
+ def layout(key_prefix:, value_prefix:, layout:, version:)
218
+ item_key_prefix = key_prefix + ["#{name}[]"]
219
+ item_value_prefix = mapping_value_prefix(value_prefix:, collection: true)
220
+ layout.add_collection(item_key_prefix.join("/"), item_value_prefix.join("/"))
221
+ return add_request_layout(item, key_prefix: item_key_prefix, value_prefix: item_value_prefix, layout:, version:) if has_item?
222
+
223
+ (has_one_of? ? [one_of_property] : all_properties.values).each do |property|
224
+ property.layout(key_prefix: item_key_prefix, value_prefix: item_value_prefix, layout:, version:)
225
+ end
226
+ end
227
+
195
228
  private
196
229
 
197
230
  attr_reader :item, :schema_options, :properties, :type
@@ -14,12 +14,14 @@ module Verquest
14
14
  # @param value [Object] The fixed value of the constant (can be any scalar value)
15
15
  # @param map [Object, nil] Optional mapping information
16
16
  # @param required [Boolean, Array<Symbol>] Whether this property is required, or array of dependency names (can be overridden by custom type)
17
+ # @param nullable [Boolean] Whether this property can be null
17
18
  # @param schema_options [Hash] Additional JSON schema options for this property
18
- def initialize(name:, value:, map: nil, required: false, **schema_options)
19
+ def initialize(name:, value:, map: nil, required: false, nullable: false, **schema_options)
19
20
  @name = name.to_s
20
21
  @value = value
21
22
  @map = map
22
23
  @required = required
24
+ @nullable = nullable
23
25
  @schema_options = schema_options&.transform_keys(&:to_s)
24
26
  end
25
27
 
@@ -28,9 +30,8 @@ module Verquest
28
30
  # @return [Hash] The schema definition for this constant
29
31
  def to_schema
30
32
  {
31
- name => {
32
- "const" => value
33
- }.merge(schema_options)
33
+ # Draft 4 and OpenAPI 3.0 have no const, but a one-value enum means the same
34
+ name => nullable_schema((Verquest.configuration.const_keyword? ? {"const" => value} : {"enum" => [value]}).merge(schema_options))
34
35
  }
35
36
  end
36
37
 
@@ -26,15 +26,11 @@ module Verquest
26
26
  raise ArgumentError, "Use const for a single value" if values.length == 1
27
27
 
28
28
  @name = name.to_s
29
- @values = values
29
+ @values = nullable ? values | [nil] : values
30
30
  @required = required
31
31
  @nullable = nullable
32
32
  @map = map
33
33
  @schema_options = schema_options&.transform_keys(&:to_s)
34
-
35
- if nullable && !values.include?("null")
36
- values << "null"
37
- end
38
34
  end
39
35
 
40
36
  # Generate JSON schema definition for this enum
@@ -54,6 +54,7 @@ module Verquest
54
54
 
55
55
  if nullable
56
56
  @type = [@type, "null"]
57
+ @schema_options["enum"] = @schema_options["enum"] | [nil] if @schema_options.key?("enum")
57
58
  end
58
59
  end
59
60
 
@@ -12,6 +12,9 @@ module Verquest
12
12
  # address.add(Verquest::Properties::Field.new(name: :street, type: :string))
13
13
  # address.add(Verquest::Properties::Field.new(name: :city, type: :string, required: true))
14
14
  class Object < Base
15
+ include HelperMethods::ConditionalProperties
16
+ include HelperMethods::UnnamedOneOf
17
+
15
18
  # Initialize a new Object property
16
19
  #
17
20
  # @param name [String, Symbol] The name of the property
@@ -21,6 +24,7 @@ module Verquest
21
24
  # @param schema_options [Hash] Additional JSON schema options for this property
22
25
  def initialize(name:, required: false, nullable: false, map: nil, **schema_options)
23
26
  @properties = {}
27
+ @conditions = []
24
28
 
25
29
  @name = name.to_s
26
30
  @required = required
@@ -44,6 +48,7 @@ module Verquest
44
48
  # @param property [Verquest::Properties::Base] The property to add to this object
45
49
  # @return [Verquest::Properties::Base] The added property
46
50
  def add(property)
51
+ check_unnamed_one_of(property)
47
52
  properties[property.name.to_s] = property
48
53
  end
49
54
 
@@ -55,9 +60,10 @@ module Verquest
55
60
  name => {
56
61
  "type" => type,
57
62
  "required" => required_properties,
58
- "properties" => properties.transform_values { |property| property.to_schema[property.name] }
63
+ "properties" => all_properties.transform_values { |property| property.to_schema[property.name] }
59
64
  }.merge(schema_options).tap do |schema|
60
65
  schema["dependentRequired"] = dependent_required_properties if dependent_required_properties.any?
66
+ add_conditions(schema)
61
67
  end
62
68
  }
63
69
  end
@@ -71,23 +77,51 @@ module Verquest
71
77
  name => {
72
78
  "type" => type,
73
79
  "required" => required_properties,
74
- "properties" => properties.transform_values { |property| property.to_validation_schema(version: version)[property.name] }
80
+ "properties" => all_properties.transform_values { |property| property.to_validation_schema(version: version)[property.name] }
75
81
  }.merge(schema_options).tap do |schema|
76
82
  schema["dependentRequired"] = dependent_required_properties if dependent_required_properties.any?
83
+ add_conditions(schema)
77
84
  end
78
85
  }
79
86
  end
80
87
 
81
88
  # Create mapping for this object property and all its children
82
89
  #
90
+ # Each nested oneOf gets its own mapping in the `_oneOfs` array, so its
91
+ # metadata can't clash with another oneOf.
92
+ #
83
93
  # @param key_prefix [Array<String>] Prefix for the source key
84
94
  # @param value_prefix [Array<String>] Prefix for the target value
85
95
  # @param mapping [Hash] The mapping hash to be updated
86
96
  # @param version [String, nil] The version to create mapping for
87
97
  # @return [void]
88
98
  def mapping(key_prefix:, value_prefix:, mapping:, version: nil)
89
- properties.values.each do |property|
90
- property.mapping(key_prefix: key_prefix + [name], value_prefix: mapping_value_prefix(value_prefix:), mapping:, version:)
99
+ all_properties.values.each do |property|
100
+ add_child_mapping(property, key_prefix: key_prefix + [name], value_prefix: mapping_value_prefix(value_prefix:), mapping:, version:)
101
+ end
102
+ end
103
+
104
+ # Adds this object's properties to a transformer layout
105
+ #
106
+ # @param key_prefix [Array<String>] Prefix for the source key
107
+ # @param value_prefix [Array<String>] Prefix for the target value
108
+ # @param layout [Verquest::Transformer::Layout] The layout to add to
109
+ # @param version [String, nil] The version to create the layout for
110
+ # @return [void]
111
+ def layout(key_prefix:, value_prefix:, layout:, version:)
112
+ all_properties.each_value do |property|
113
+ property.layout(key_prefix: key_prefix + [name], value_prefix: mapping_value_prefix(value_prefix:), layout:, version:)
114
+ end
115
+ end
116
+
117
+ # Returns the query parameter options of this object and its properties
118
+ #
119
+ # @param prefix [Array<String>] Path of the parent property
120
+ # @param version [String] The version to resolve references with
121
+ # @return [Hash{Array<String> => Hash}] The options of each property path
122
+ def query_options(prefix:, version:)
123
+ all_properties.each_value.with_object(super) do |property, options|
124
+ options.merge!(property.query_options(prefix: prefix + [name], version:))
91
125
  end
92
126
  end
93
127
 
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ module Properties
5
+ class OneOf < Base
6
+ # Builds a self-contained oneOf validation schema that keeps the discriminator
7
+ #
8
+ # OpenAPI validators (json_schemer's OpenAPI 3.0 and 3.1 dialects) select the
9
+ # discriminated variant through each oneOf entry's `$ref`, so the variants move
10
+ # into local definitions. The content-based identifier makes those `$ref`s resolve
11
+ # against this schema wherever it is embedded, and identical content always gets
12
+ # the same identifier. Other dialects ignore the discriminator and validate every variant.
13
+ #
14
+ # The identifier is set as both `$id` (draft 6 and later, OpenAPI 3.1) and `id`
15
+ # (draft 4, OpenAPI 3.0), and the variants live under `definitions`, which every
16
+ # dialect can reference. The output is the same whatever `json_schema_version` is.
17
+ #
18
+ # @example
19
+ # DiscriminatedSchema.new(definitions: {"dog" => dog_schema, "cat" => cat_schema}, discriminator: "type").to_h
20
+ # # => {
21
+ # # "$id" => "https://verquest.local/one_of/3f1c9a0b2d4e5f67",
22
+ # # "id" => "https://verquest.local/one_of/3f1c9a0b2d4e5f67",
23
+ # # "definitions" => {"dog" => dog_schema, "cat" => cat_schema},
24
+ # # "oneOf" => [{"$ref" => "#/definitions/dog"}, {"$ref" => "#/definitions/cat"}],
25
+ # # "discriminator" => {"propertyName" => "type", "mapping" => {"dog" => "#/definitions/dog", "cat" => "#/definitions/cat"}}
26
+ # # }
27
+ class DiscriminatedSchema
28
+ # Base URI for the schema identifier
29
+ ID_PREFIX = "https://verquest.local/one_of/"
30
+
31
+ # Initialize a new DiscriminatedSchema
32
+ #
33
+ # @param definitions [Hash<String, Hash>] Variant name (discriminator value) => inline validation schema
34
+ # @param discriminator [String] The discriminator property name
35
+ def initialize(definitions:, discriminator:)
36
+ @definitions = definitions
37
+ @discriminator = discriminator
38
+ end
39
+
40
+ # Builds the validation schema
41
+ #
42
+ # @return [Hash] Schema with identifiers, definitions, oneOf refs, and discriminator
43
+ def to_h
44
+ {"$id" => id, "id" => id}.merge(body)
45
+ end
46
+
47
+ private
48
+
49
+ # @!attribute [r] definitions
50
+ # @return [Hash<String, Hash>] Variant name => inline validation schema
51
+ # @!attribute [r] discriminator
52
+ # @return [String] The discriminator property name
53
+ attr_reader :definitions, :discriminator
54
+
55
+ # The schema without the identifiers
56
+ #
57
+ # @return [Hash] Definitions, oneOf refs, and discriminator
58
+ def body
59
+ @body ||= {
60
+ "definitions" => definitions,
61
+ "oneOf" => refs.values.map { |ref| {"$ref" => ref} },
62
+ "discriminator" => {"propertyName" => discriminator, "mapping" => refs}
63
+ }
64
+ end
65
+
66
+ # Content-based identifier, so identical schemas share it
67
+ #
68
+ # `allow_nan` keeps schemas with infinite or NaN numbers (e.g. `maximum: Float::INFINITY`) hashable.
69
+ #
70
+ # @return [String] The identifier URI
71
+ def id
72
+ @id ||= "#{ID_PREFIX}#{Digest::SHA256.hexdigest(JSON.generate(body, allow_nan: true))[0, 16]}"
73
+ end
74
+
75
+ # Local `$ref` for each variant, keyed by variant name
76
+ #
77
+ # @return [Hash<String, String>] Variant name => `$ref` string
78
+ def refs
79
+ @refs ||= definitions.keys.to_h { |name| [name, "#/definitions/#{name}"] }
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -30,10 +30,10 @@ module Verquest
30
30
  # @example oneOf without discriminator (pure JSON Schema validation)
31
31
  # one_of = Verquest::Properties::OneOf.new(name: :value)
32
32
  # # Validates that exactly one schema matches
33
+ #
34
+ # With a discriminator, the validation schema keeps the discriminator (see DiscriminatedSchema),
35
+ # so OpenAPI 3.0 and 3.1 validators validate only the variant the discriminator selects.
33
36
  class OneOf < Base
34
- # JSON Schema for null type, used when nullable is true
35
- NULL_TYPE_SCHEMA = {"type" => "null"}.freeze
36
-
37
37
  # @return [String, nil] The discriminator property name for schema selection
38
38
  attr_reader :discriminator
39
39
 
@@ -75,7 +75,7 @@ module Verquest
75
75
  # @return [Hash] The schema definition with oneOf array and optional discriminator
76
76
  def to_schema
77
77
  freeze_schemas
78
- wrap_schema(build_schema_with_refs)
78
+ wrap_schema(nullable_schema(build_schema_with_refs))
79
79
  end
80
80
 
81
81
  # Generate validation schema for this oneOf property
@@ -87,7 +87,7 @@ module Verquest
87
87
  # @return [Hash] The validation schema with inline schema definitions
88
88
  def to_validation_schema(version: nil)
89
89
  freeze_schemas
90
- wrap_schema(build_validation_schema(version: version))
90
+ wrap_schema(nullable_schema(build_validation_schema(version: version)))
91
91
  end
92
92
 
93
93
  # Create mapping for this oneOf property
@@ -116,8 +116,33 @@ module Verquest
116
116
 
117
117
  build_variant_mappings(mapping, source_prefix, target_prefix, version)
118
118
  store_discriminator_path(mapping, source_prefix)
119
- store_variant_schemas(mapping, version) unless discriminator
120
- store_nullable_metadata(mapping, target_prefix:) if nullable
119
+ store_variant_schemas(mapping, source_prefix, version) unless discriminator
120
+ store_nullable_metadata(mapping, source_prefix:, target_prefix:) if nullable
121
+ end
122
+
123
+ # Adds this oneOf to a transformer layout
124
+ #
125
+ # Each variant gets its own layout, so it can hold oneOfs and collections.
126
+ # Like in the mapping, variant properties sit directly under the oneOf
127
+ # path, not under the variant name.
128
+ #
129
+ # @param key_prefix [Array<String>] Prefix for the source key
130
+ # @param value_prefix [Array<String>] Prefix for the target value
131
+ # @param layout [Verquest::Transformer::Layout] The layout to add to
132
+ # @param version [String, nil] The version to create the layout for
133
+ # @return [void]
134
+ def layout(key_prefix:, value_prefix:, layout:, version:)
135
+ freeze_schemas
136
+ source_prefix = compute_source_prefix(key_prefix)
137
+ target_prefix = compute_target_prefix(value_prefix)
138
+
139
+ layout.add_one_of(Transformer::Layout::OneOfLayout.new(
140
+ path: source_prefix.join("/"),
141
+ discriminator: discriminator && join_path(source_prefix, discriminator),
142
+ variants: schemas.each_value.to_h { |schema| [schema.name, variant_layout(schema, source_prefix, target_prefix, layout, version)] },
143
+ variant_schemas: (variant_schemas(version:) unless discriminator),
144
+ null_target: (target_prefix.join("/") if nullable && !source_prefix.empty?)
145
+ ))
121
146
  end
122
147
 
123
148
  # Returns validation schemas for all variants
@@ -194,7 +219,7 @@ module Verquest
194
219
  # Builds variant mappings for each schema option
195
220
  #
196
221
  # Handles both Reference and Object schemas:
197
- # - Reference: delegates to the referenced schema's mapping
222
+ # - Reference: maps the referenced request's properties under the oneOf path
198
223
  # - Object: builds mapping from child properties directly
199
224
  #
200
225
  # @param mapping [Hash] The mapping hash to populate
@@ -214,6 +239,9 @@ module Verquest
214
239
 
215
240
  # Builds mapping for a Reference variant
216
241
  #
242
+ # Maps the referenced request's properties under the oneOf path, so its
243
+ # oneOfs land in the variant's `_oneOfs` with full paths.
244
+ #
217
245
  # @param mapping [Hash] The mapping hash to populate
218
246
  # @param schema [Verquest::Properties::Reference] The reference schema
219
247
  # @param source_prefix [Array<String>] Source path prefix
@@ -221,15 +249,10 @@ module Verquest
221
249
  # @param version [String, nil] The version for schema resolution
222
250
  # @return [void]
223
251
  def build_reference_variant_mapping(mapping, schema, source_prefix, target_prefix, version)
224
- reference_mapping = schema.send(:from).mapping(version: version)
225
- reference_map = schema.send(:map)
226
- variant_target_prefix = compute_reference_target_prefix(reference_map, target_prefix)
252
+ variant_target_prefix = compute_reference_target_prefix(schema.send(:map), target_prefix)
253
+ variant_mapping = mapping[schema.name] = {}
227
254
 
228
- mapping[schema.name] = build_prefixed_mapping(
229
- reference_mapping,
230
- source_prefix,
231
- variant_target_prefix
232
- )
255
+ add_request_mapping(schema.send(:from), key_prefix: source_prefix, value_prefix: variant_target_prefix, mapping: variant_mapping, version:)
233
256
  end
234
257
 
235
258
  # Builds mapping for an inline Object variant
@@ -245,34 +268,34 @@ module Verquest
245
268
  # @param version [String, nil] The version for schema resolution
246
269
  # @return [void]
247
270
  def build_object_variant_mapping(mapping, schema, source_prefix, target_prefix, version)
248
- object_mapping = {}
249
- object_map = schema.send(:map)
250
- variant_target_prefix = compute_reference_target_prefix(object_map, target_prefix)
271
+ variant_target_prefix = compute_reference_target_prefix(schema.send(:map), target_prefix)
272
+ variant_mapping = mapping[schema.name] = {}
251
273
 
252
- # Build mapping from object's child properties
253
274
  # Properties are mapped directly under source_prefix (not source_prefix + object_name)
254
- # to match how Reference variants work
255
- schema.send(:properties).each_value do |property|
256
- property.mapping(
257
- key_prefix: source_prefix,
258
- value_prefix: variant_target_prefix,
259
- mapping: object_mapping,
260
- version: version
261
- )
275
+ # to match how Reference variants work; a nested oneOf lands in the variant's _oneOfs
276
+ schema.all_properties.each_value do |property|
277
+ add_child_mapping(property, key_prefix: source_prefix, value_prefix: variant_target_prefix, mapping: variant_mapping, version:)
262
278
  end
263
-
264
- mapping[schema.name] = object_mapping
265
279
  end
266
280
 
267
- # Builds a mapping hash with prefixes applied to all keys and values
281
+ # Adds a variant's properties to its layout, directly under the oneOf path
268
282
  #
269
- # @param base_mapping [Hash] The source mapping from the referenced schema
270
- # @param source_prefix [Array<String>] Prefix for source keys
271
- # @param target_prefix [Array<String>] Prefix for target values
272
- # @return [Hash] The mapping with prefixes applied
273
- def build_prefixed_mapping(base_mapping, source_prefix, target_prefix)
274
- base_mapping.each_with_object({}) do |(source_key, target_value), result|
275
- result[join_path(source_prefix, source_key)] = join_path(target_prefix, target_value)
283
+ # @param schema [Verquest::Properties::Reference, Verquest::Properties::Object] The variant
284
+ # @param source_prefix [Array<String>] The oneOf source path
285
+ # @param target_prefix [Array<String>] The oneOf target path
286
+ # @param layout [Verquest::Transformer::Layout] The layout holding the oneOf
287
+ # @param version [String, nil] The version for schema resolution
288
+ # @return [Verquest::Transformer::Layout] The variant layout
289
+ def variant_layout(schema, source_prefix, target_prefix, layout, version)
290
+ variant_target_prefix = compute_reference_target_prefix(schema.send(:map), target_prefix)
291
+ layout.variant.tap do |variant|
292
+ if schema.is_a?(Verquest::Properties::Reference)
293
+ add_request_layout(schema.send(:from), key_prefix: source_prefix, value_prefix: variant_target_prefix, layout: variant, version:)
294
+ else
295
+ schema.all_properties.each_value do |property|
296
+ property.layout(key_prefix: source_prefix, value_prefix: variant_target_prefix, layout: variant, version:)
297
+ end
298
+ end
276
299
  end
277
300
  end
278
301
 
@@ -298,11 +321,13 @@ module Verquest
298
321
  # schemas to determine which variant matches the input data.
299
322
  #
300
323
  # @param mapping [Hash] The mapping hash to update
324
+ # @param source_prefix [Array<String>] The source path prefix, ending with the oneOf name
301
325
  # @param version [String, nil] The version for schema resolution
302
326
  # @return [void]
303
- def store_variant_schemas(mapping, version)
327
+ def store_variant_schemas(mapping, source_prefix, version)
304
328
  mapping["_variant_schemas"] = variant_schemas(version: version)
305
- mapping["_variant_path"] = name unless root_level?
329
+ # An unnamed oneOf is only at the root when nothing references it
330
+ mapping["_variant_path"] = source_prefix.join("/") unless source_prefix.empty?
306
331
  end
307
332
 
308
333
  # Stores nullable metadata in the mapping
@@ -311,13 +336,14 @@ module Verquest
311
336
  # without attempting variant resolution.
312
337
  #
313
338
  # @param mapping [Hash] The mapping hash to update
339
+ # @param source_prefix [Array<String>] The source path prefix, ending with the oneOf name
314
340
  # @param target_prefix [Array<String>] The target path prefix for the oneOf property
315
341
  # @return [void]
316
- def store_nullable_metadata(mapping, target_prefix:)
342
+ def store_nullable_metadata(mapping, source_prefix:, target_prefix:)
317
343
  mapping["_nullable"] = true
318
- return if root_level?
344
+ return if source_prefix.empty?
319
345
 
320
- mapping["_nullable_path"] = name
346
+ mapping["_nullable_path"] = source_prefix.join("/")
321
347
  mapping["_nullable_target_path"] = target_prefix.join("/")
322
348
  end
323
349
 
@@ -372,23 +398,23 @@ module Verquest
372
398
 
373
399
  # Builds the validation schema structure with inline definitions
374
400
  #
375
- # Unlike the documentation schema, the validation schema omits the discriminator
376
- # since it's an OpenAPI extension that JSON Schema validators ignore.
377
- # Validators validate against the oneOf array directly.
401
+ # Without a discriminator, the variants are inlined in the oneOf array.
402
+ # With a discriminator, DiscriminatedSchema builds a self-contained schema
403
+ # that keeps the discriminator.
378
404
  #
379
405
  # @param version [String, nil] The version to generate validation schema for
380
- # @return [Hash] Validation schema with oneOf array (no discriminator)
406
+ # @return [Hash] Validation schema with oneOf array
381
407
  def build_validation_schema(version:)
382
- {schema_keyword => collect_inline_schemas(version)}
408
+ return {schema_keyword => collect_inline_schemas(version)} unless discriminator
409
+
410
+ DiscriminatedSchema.new(definitions: collect_inline_definitions(version), discriminator: discriminator).to_h
383
411
  end
384
412
 
385
413
  # Collects $ref schema references for all variants
386
414
  #
387
415
  # @return [Array<Hash>] Array of schema references
388
416
  def collect_schema_refs
389
- refs = schemas.values.map { |schema| schema.to_schema[schema.name] }
390
- refs << NULL_TYPE_SCHEMA if nullable
391
- refs
417
+ schemas.values.map { |schema| schema.to_schema[schema.name] }
392
418
  end
393
419
 
394
420
  # Collects inline schema definitions for all variants
@@ -396,9 +422,15 @@ module Verquest
396
422
  # @param version [String, nil] The version for schema resolution
397
423
  # @return [Array<Hash>] Array of inline schema definitions
398
424
  def collect_inline_schemas(version)
399
- inline_schemas = schemas.values.map { |schema| schema.to_validation_schema(version: version)[schema.name] }
400
- inline_schemas << NULL_TYPE_SCHEMA if nullable
401
- inline_schemas
425
+ collect_inline_definitions(version).values
426
+ end
427
+
428
+ # Collects inline schema definitions keyed by variant name
429
+ #
430
+ # @param version [String, nil] The version for schema resolution
431
+ # @return [Hash<String, Hash>] Variant name => inline schema definition
432
+ def collect_inline_definitions(version)
433
+ schemas.transform_values { |schema| schema.to_validation_schema(version: version)[schema.name] }
402
434
  end
403
435
 
404
436
  # Adds discriminator information to the schema if present