verquest 0.6.2 → 0.6.4

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.
@@ -0,0 +1,430 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ module Properties
5
+ # OneOf property type for polymorphic schemas
6
+ #
7
+ # Implements JSON Schema's oneOf keyword for defining polymorphic request structures
8
+ # where exactly one of multiple schemas must match. Supports optional discriminator-based
9
+ # schema selection using a property value to determine which schema applies.
10
+ #
11
+ # According to JSON Schema specification, oneOf validates that the data is valid against
12
+ # exactly one of the subschemas. The discriminator is an OpenAPI extension that helps
13
+ # with efficient schema resolution but is not required for basic oneOf validation.
14
+ #
15
+ # When used at the root level (without a name), it creates a "combination schema"
16
+ # where the entire request body can match one of the defined schemas.
17
+ #
18
+ # @example Root-level oneOf with discriminator
19
+ # one_of = Verquest::Properties::OneOf.new(discriminator: "type")
20
+ # one_of.add(Verquest::Properties::Reference.new(name: "dog", from: DogComponent))
21
+ # one_of.add(Verquest::Properties::Reference.new(name: "cat", from: CatComponent))
22
+ #
23
+ # @example Nested oneOf property
24
+ # one_of = Verquest::Properties::OneOf.new(
25
+ # name: :payment,
26
+ # discriminator: "method",
27
+ # required: true
28
+ # )
29
+ #
30
+ # @example oneOf without discriminator (pure JSON Schema validation)
31
+ # one_of = Verquest::Properties::OneOf.new(name: :value)
32
+ # # Validates that exactly one schema matches
33
+ class OneOf < Base
34
+ # @return [String, nil] The discriminator property name for schema selection
35
+ attr_reader :discriminator
36
+
37
+ # Initialize a new OneOf property
38
+ #
39
+ # @param name [String, Symbol, nil] The property name, or nil for root-level oneOf
40
+ # @param discriminator [String, Symbol, nil] The property name used to discriminate between schemas.
41
+ # When omitted, the transformer infers the variant by validating against each schema.
42
+ # @param required [Boolean, Array<Symbol>] Whether this property is required, or array of dependency names
43
+ # @param nullable [Boolean] Whether this property can be null
44
+ # @param map [String, nil] The mapping path for this property
45
+ def initialize(name: nil, discriminator: nil, required: false, nullable: false, map: nil)
46
+ @name = name&.to_s
47
+ @required = required
48
+ @nullable = nullable
49
+ @map = map
50
+ @discriminator = discriminator&.to_s
51
+ @schemas = {}
52
+ end
53
+
54
+ # Add a schema option to this oneOf
55
+ #
56
+ # Both Reference and Object properties are allowed at any level.
57
+ # Object properties define inline schemas directly within the oneOf.
58
+ #
59
+ # @param schema [Verquest::Properties::Reference, Verquest::Properties::Object] The schema to add
60
+ # @raise [ArgumentError] If schema is neither a Reference nor an Object
61
+ # @return [Verquest::Properties::Base] The added schema
62
+ def add(schema)
63
+ unless schema.is_a?(Verquest::Properties::Reference) || schema.is_a?(Verquest::Properties::Object)
64
+ raise ArgumentError, "Must be a Reference or Object property"
65
+ end
66
+
67
+ schemas[schema.name] = schema
68
+ end
69
+
70
+ # Generate JSON schema definition for this oneOf property
71
+ #
72
+ # @return [Hash] The schema definition with oneOf array and optional discriminator
73
+ def to_schema
74
+ freeze_schemas
75
+ wrap_schema(nullable_schema(build_schema_with_refs))
76
+ end
77
+
78
+ # Generate validation schema for this oneOf property
79
+ #
80
+ # Unlike to_schema which uses $ref, the validation schema includes
81
+ # the full inline schema definitions for each option.
82
+ #
83
+ # @param version [String, nil] The version to generate validation schema for
84
+ # @return [Hash] The validation schema with inline schema definitions
85
+ def to_validation_schema(version: nil)
86
+ freeze_schemas
87
+ wrap_schema(nullable_schema(build_validation_schema(version: version)))
88
+ end
89
+
90
+ # Create mapping for this oneOf property
91
+ #
92
+ # For oneOf schemas, the mapping is keyed by discriminator value so the
93
+ # transformer can select the appropriate mapping based on the input.
94
+ # Each discriminator value maps to a hash of source => target path mappings.
95
+ #
96
+ # For nested oneOf (with a name), the property name is included in the path prefixes.
97
+ # For root-level oneOf (name is nil), paths start from the root.
98
+ #
99
+ # The `map` parameter on oneOf affects the target path prefix for all contained schemas.
100
+ #
101
+ # When no discriminator is set, the transformer will infer the variant by validating
102
+ # the input against each schema and selecting the one that matches.
103
+ #
104
+ # @param key_prefix [Array<String>] Prefix for the source key paths
105
+ # @param value_prefix [Array<String>] Prefix for the target value paths
106
+ # @param mapping [Hash] The mapping hash to be updated (discriminator value => path mappings)
107
+ # @param version [String, nil] The version to create mapping for
108
+ # @return [void]
109
+ def mapping(key_prefix:, value_prefix:, mapping:, version: nil)
110
+ freeze_schemas
111
+ source_prefix = compute_source_prefix(key_prefix)
112
+ target_prefix = compute_target_prefix(value_prefix)
113
+
114
+ build_variant_mappings(mapping, source_prefix, target_prefix, version)
115
+ store_discriminator_path(mapping, source_prefix)
116
+ store_variant_schemas(mapping, version) unless discriminator
117
+ store_nullable_metadata(mapping, target_prefix:) if nullable
118
+ end
119
+
120
+ # Returns validation schemas for all variants
121
+ #
122
+ # Used by the Transformer to infer which variant matches when no discriminator is set.
123
+ #
124
+ # @param version [String, nil] The version for schema resolution
125
+ # @return [Hash<String, Hash>] Variant name => validation schema mapping
126
+ def variant_schemas(version: nil)
127
+ freeze_schemas
128
+ schemas.each_with_object({}) do |(name, schema), result|
129
+ result[name] = schema.to_validation_schema(version: version)[schema.name]
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ attr_reader :schemas
136
+
137
+ # Freezes the schemas hash to prevent further modifications
138
+ # This is called on first read access to ensure immutability after setup
139
+ #
140
+ # @return [void]
141
+ def freeze_schemas
142
+ schemas.freeze unless schemas.frozen?
143
+ end
144
+
145
+ # Check if this is a root-level oneOf (no property name)
146
+ #
147
+ # @return [Boolean] true if this oneOf is at the root level
148
+ def root_level?
149
+ name.nil?
150
+ end
151
+
152
+ # Wraps the schema hash with the property name if present
153
+ #
154
+ # @param schema [Hash] The schema to wrap
155
+ # @return [Hash] The schema, optionally wrapped with the property name
156
+ def wrap_schema(schema)
157
+ root_level? ? schema : {name => schema}
158
+ end
159
+
160
+ # Computes the source path prefix for mapping keys
161
+ #
162
+ # @param key_prefix [Array<String>] The current key prefix
163
+ # @return [Array<String>] The effective key prefix including the property name if present
164
+ def compute_source_prefix(key_prefix)
165
+ root_level? ? key_prefix : key_prefix + [name]
166
+ end
167
+
168
+ # Computes the target path prefix for mapping values
169
+ #
170
+ # @param value_prefix [Array<String>] The current value prefix
171
+ # @return [Array<String>] The effective value prefix based on map or name
172
+ def compute_target_prefix(value_prefix)
173
+ return parse_absolute_path(@map) if absolute_path?(@map)
174
+ return value_prefix + parse_relative_path(@map) if @map
175
+
176
+ root_level? ? value_prefix : value_prefix + [name]
177
+ end
178
+
179
+ # Computes the target prefix for a specific reference's mapping
180
+ #
181
+ # @param reference_map [String, nil] The map parameter from the reference
182
+ # @param base_prefix [Array<String>] The base value prefix
183
+ # @return [Array<String>] The target prefix as an array of path segments
184
+ def compute_reference_target_prefix(reference_map, base_prefix)
185
+ return base_prefix if reference_map.nil?
186
+ return parse_absolute_path(reference_map) if absolute_path?(reference_map)
187
+
188
+ base_prefix + parse_relative_path(reference_map)
189
+ end
190
+
191
+ # Builds variant mappings for each schema option
192
+ #
193
+ # Handles both Reference and Object schemas:
194
+ # - Reference: delegates to the referenced schema's mapping
195
+ # - Object: builds mapping from child properties directly
196
+ #
197
+ # @param mapping [Hash] The mapping hash to populate
198
+ # @param source_prefix [Array<String>] Source path prefix
199
+ # @param target_prefix [Array<String>] Target path prefix
200
+ # @param version [String, nil] The version for schema resolution
201
+ # @return [void]
202
+ def build_variant_mappings(mapping, source_prefix, target_prefix, version)
203
+ schemas.each_value do |schema|
204
+ if schema.is_a?(Verquest::Properties::Reference)
205
+ build_reference_variant_mapping(mapping, schema, source_prefix, target_prefix, version)
206
+ elsif schema.is_a?(Verquest::Properties::Object)
207
+ build_object_variant_mapping(mapping, schema, source_prefix, target_prefix, version)
208
+ end
209
+ end
210
+ end
211
+
212
+ # Builds mapping for a Reference variant
213
+ #
214
+ # @param mapping [Hash] The mapping hash to populate
215
+ # @param schema [Verquest::Properties::Reference] The reference schema
216
+ # @param source_prefix [Array<String>] Source path prefix
217
+ # @param target_prefix [Array<String>] Target path prefix
218
+ # @param version [String, nil] The version for schema resolution
219
+ # @return [void]
220
+ def build_reference_variant_mapping(mapping, schema, source_prefix, target_prefix, version)
221
+ reference_mapping = schema.send(:from).mapping(version: version)
222
+ reference_map = schema.send(:map)
223
+ variant_target_prefix = compute_reference_target_prefix(reference_map, target_prefix)
224
+
225
+ mapping[schema.name] = build_prefixed_mapping(
226
+ reference_mapping,
227
+ source_prefix,
228
+ variant_target_prefix
229
+ )
230
+ end
231
+
232
+ # Builds mapping for an inline Object variant
233
+ #
234
+ # Unlike nested objects, inline oneOf variants map their properties directly
235
+ # under the oneOf property path (not under oneOf/variant_name).
236
+ # This mirrors how Reference variants work.
237
+ #
238
+ # @param mapping [Hash] The mapping hash to populate
239
+ # @param schema [Verquest::Properties::Object] The object schema
240
+ # @param source_prefix [Array<String>] Source path prefix
241
+ # @param target_prefix [Array<String>] Target path prefix
242
+ # @param version [String, nil] The version for schema resolution
243
+ # @return [void]
244
+ def build_object_variant_mapping(mapping, schema, source_prefix, target_prefix, version)
245
+ object_mapping = {}
246
+ object_map = schema.send(:map)
247
+ variant_target_prefix = compute_reference_target_prefix(object_map, target_prefix)
248
+
249
+ # Build mapping from object's child properties
250
+ # Properties are mapped directly under source_prefix (not source_prefix + object_name)
251
+ # to match how Reference variants work
252
+ schema.send(:properties).each_value do |property|
253
+ property.mapping(
254
+ key_prefix: source_prefix,
255
+ value_prefix: variant_target_prefix,
256
+ mapping: object_mapping,
257
+ version: version
258
+ )
259
+ end
260
+
261
+ mapping[schema.name] = object_mapping
262
+ end
263
+
264
+ # Builds a mapping hash with prefixes applied to all keys and values
265
+ #
266
+ # @param base_mapping [Hash] The source mapping from the referenced schema
267
+ # @param source_prefix [Array<String>] Prefix for source keys
268
+ # @param target_prefix [Array<String>] Prefix for target values
269
+ # @return [Hash] The mapping with prefixes applied
270
+ def build_prefixed_mapping(base_mapping, source_prefix, target_prefix)
271
+ base_mapping.each_with_object({}) do |(source_key, target_value), result|
272
+ result[join_path(source_prefix, source_key)] = join_path(target_prefix, target_value)
273
+ end
274
+ end
275
+
276
+ # Stores the discriminator path in the mapping for nested oneOf
277
+ #
278
+ # For nested oneOf (with a name) or oneOf inside a collection (source_prefix is not empty),
279
+ # stores the discriminator path so the transformer knows where to look for the value.
280
+ #
281
+ # @param mapping [Hash] The mapping hash to update
282
+ # @param source_prefix [Array<String>] The source path prefix
283
+ # @return [void]
284
+ def store_discriminator_path(mapping, source_prefix)
285
+ return unless discriminator
286
+ # Skip only for true root-level oneOf (no name AND no prefix from collection)
287
+ return if root_level? && source_prefix.empty?
288
+
289
+ mapping["_discriminator"] = join_path(source_prefix, discriminator)
290
+ end
291
+
292
+ # Stores variant schemas in the mapping for schema-based inference
293
+ #
294
+ # When no discriminator is set, the transformer needs access to the validation
295
+ # schemas to determine which variant matches the input data.
296
+ #
297
+ # @param mapping [Hash] The mapping hash to update
298
+ # @param version [String, nil] The version for schema resolution
299
+ # @return [void]
300
+ def store_variant_schemas(mapping, version)
301
+ mapping["_variant_schemas"] = variant_schemas(version: version)
302
+ mapping["_variant_path"] = name unless root_level?
303
+ end
304
+
305
+ # Stores nullable metadata in the mapping
306
+ #
307
+ # When nullable is true, the transformer needs to know to allow null values
308
+ # without attempting variant resolution.
309
+ #
310
+ # @param mapping [Hash] The mapping hash to update
311
+ # @param target_prefix [Array<String>] The target path prefix for the oneOf property
312
+ # @return [void]
313
+ def store_nullable_metadata(mapping, target_prefix:)
314
+ mapping["_nullable"] = true
315
+ return if root_level?
316
+
317
+ mapping["_nullable_path"] = name
318
+ mapping["_nullable_target_path"] = target_prefix.join("/")
319
+ end
320
+
321
+ # Joins path segments into a slash-separated path string
322
+ #
323
+ # @param prefix [Array<String>] The path prefix segments
324
+ # @param suffix [String] The path suffix
325
+ # @return [String] The combined path
326
+ def join_path(prefix, suffix)
327
+ prefix.empty? ? suffix : "#{prefix.join("/")}/#{suffix}"
328
+ end
329
+
330
+ # Checks if a path is absolute (starts with /)
331
+ #
332
+ # @param path [String, nil] The path to check
333
+ # @return [Boolean] true if the path is absolute
334
+ def absolute_path?(path)
335
+ path&.start_with?("/")
336
+ end
337
+
338
+ # Parses an absolute path into segments
339
+ #
340
+ # @param path [String] The absolute path to parse
341
+ # @return [Array<String>] The path segments
342
+ def parse_absolute_path(path)
343
+ path.delete_prefix("/").split("/").reject(&:empty?)
344
+ end
345
+
346
+ # Parses a relative path into segments
347
+ #
348
+ # @param path [String] The relative path to parse
349
+ # @return [Array<String>] The path segments
350
+ def parse_relative_path(path)
351
+ path.split("/")
352
+ end
353
+
354
+ # Returns the JSON Schema keyword for this property type
355
+ #
356
+ # @return [String] Always returns "oneOf"
357
+ def schema_keyword
358
+ "oneOf"
359
+ end
360
+
361
+ # Builds the JSON schema structure with $ref references
362
+ #
363
+ # @return [Hash] Schema with oneOf array and optional discriminator
364
+ def build_schema_with_refs
365
+ schema = {schema_keyword => collect_schema_refs}
366
+ add_discriminator_to_schema(schema)
367
+ schema
368
+ end
369
+
370
+ # Builds the validation schema structure with inline definitions
371
+ #
372
+ # Unlike the documentation schema, the validation schema omits the discriminator
373
+ # since it's an OpenAPI extension that JSON Schema validators ignore.
374
+ # Validators validate against the oneOf array directly.
375
+ #
376
+ # @param version [String, nil] The version to generate validation schema for
377
+ # @return [Hash] Validation schema with oneOf array (no discriminator)
378
+ def build_validation_schema(version:)
379
+ {schema_keyword => collect_inline_schemas(version)}
380
+ end
381
+
382
+ # Collects $ref schema references for all variants
383
+ #
384
+ # @return [Array<Hash>] Array of schema references
385
+ def collect_schema_refs
386
+ schemas.values.map { |schema| schema.to_schema[schema.name] }
387
+ end
388
+
389
+ # Collects inline schema definitions for all variants
390
+ #
391
+ # @param version [String, nil] The version for schema resolution
392
+ # @return [Array<Hash>] Array of inline schema definitions
393
+ def collect_inline_schemas(version)
394
+ schemas.values.map { |schema| schema.to_validation_schema(version: version)[schema.name] }
395
+ end
396
+
397
+ # Adds discriminator information to the schema if present
398
+ #
399
+ # Only Reference schemas are included in the discriminator mapping since
400
+ # Objects don't have $ref. This follows OpenAPI spec where discriminator
401
+ # mapping contains only $ref strings.
402
+ #
403
+ # @param schema [Hash] The schema to modify
404
+ # @return [void]
405
+ def add_discriminator_to_schema(schema)
406
+ return unless discriminator
407
+
408
+ schema["discriminator"] = {
409
+ "propertyName" => discriminator,
410
+ "mapping" => build_discriminator_mapping
411
+ }
412
+ end
413
+
414
+ # Builds the discriminator mapping with $ref values
415
+ #
416
+ # Only Reference schemas are included since Objects don't have $ref.
417
+ # This follows OpenAPI spec where discriminator mapping contains only $ref strings.
418
+ #
419
+ # @return [Hash] The discriminator value to $ref mapping
420
+ def build_discriminator_mapping
421
+ schemas.each_with_object({}) do |(name, schema), mapping|
422
+ # Only include References in discriminator mapping (Objects don't have $ref)
423
+ next unless schema.is_a?(Verquest::Properties::Reference)
424
+
425
+ mapping[name] = schema.to_schema[name]["$ref"]
426
+ end
427
+ end
428
+ end
429
+ end
430
+ end
@@ -42,20 +42,7 @@ module Verquest
42
42
  #
43
43
  # @return [Hash] The schema definition with a $ref pointer
44
44
  def to_schema
45
- if nullable
46
- {
47
- name => {
48
- "oneOf" => [
49
- {"$ref" => from.to_ref(property: property)},
50
- {"type" => "null"}
51
- ]
52
- }
53
- }
54
- else
55
- {
56
- name => {"$ref" => from.to_ref(property: property)}
57
- }
58
- end
45
+ {name => nullable_schema({"$ref" => from.to_ref(property: property)})}
59
46
  end
60
47
 
61
48
  # Generate validation schema for this reference property
@@ -65,13 +52,7 @@ module Verquest
65
52
  def to_validation_schema(version: nil)
66
53
  schema = from.to_validation_schema(version:, property: property).dup
67
54
 
68
- if nullable
69
- schema["type"] = [schema["type"], "null"] unless schema["type"].include?("null")
70
- end
71
-
72
- {
73
- name => schema
74
- }
55
+ {name => nullable_schema(schema)}
75
56
  end
76
57
 
77
58
  # Create mapping for this reference property
@@ -80,8 +61,8 @@ module Verquest
80
61
  # @param key_prefix [Array<String>] Prefix for the source key
81
62
  # @param value_prefix [Array<String>] Prefix for the target value
82
63
  # @param mapping [Hash] The mapping hash to be updated
83
- # @param version [String, nil] The version to create mapping for
84
- # @return [Hash] The updated mapping hash
64
+ # @param version [String] The version to create mapping for
65
+ # @return [void]
85
66
  def mapping(key_prefix:, value_prefix:, mapping:, version:)
86
67
  reference_mapping = from.mapping(version:, property:).dup
87
68
  value_key_prefix = mapping_value_key(value_prefix:)