verquest 0.6.2 → 0.6.3

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