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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a4868bbd147183e51c6f9221695daf82a9bcafb6a8622b723b67fe79bc3b72c3
4
- data.tar.gz: d80029b08f825959f68fe88e94c323a0f334c0279b360ca1489b3b9bd4d169f8
3
+ metadata.gz: 5be764c988f4ea26a04112ed68684fcc7d292e25c37c6c2105f1c62ba7c92bc8
4
+ data.tar.gz: 15cead4ea24fc972317e15157fd14194098f2f543ae7d2402ae81a443ad198d9
5
5
  SHA512:
6
- metadata.gz: 0037ac0f502c551f6a76328c8d53ea9fbc7e78ea57598c3eec1e2e387f475ef5a0c3b46e1f058034ce246f5d7b6ff54395e98ca963d7058cc4a338b1e8709d42
7
- data.tar.gz: 8da38ed81e49880c62b998967dce5d7c4caf863ff83e08e6eba2de5a182d9257ef24e1a5aa1c0d65f074c95ae94a997dc4198badcb210df5452241aba176ae7b
6
+ metadata.gz: 898d20e10c9ea20ccacd10fb8711778444f7cc096c09152e6ba48a167e363a19e60a2d8ed481205d2331b3b5a076ccc888a279ba44121342e698ade944ff9c8b
7
+ data.tar.gz: 23dba503f13f33a571f65008eaa13f2f94347dd974cfa45e9e8bb2f76e9e2599e0ab19fed299b2ea43144641e642c54a30d636ffe0d332967cb7ec84c521f3d6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.6.3] - 2026-09-23
4
+
5
+ ### Fixed
6
+ - Handling `nullable: true` ([#18](https://github.com/verquest/verquest/pull/18), [@CiTroNaK](https://github.com/CiTroNaK))
7
+
8
+ ## [0.6.3] - 2026-03-17
9
+
10
+ ### Fixed
11
+ - Transformer fixes: fix null propagation, unknown discriminator, and null variant path.
12
+
3
13
  ## [0.6.2] - 2025-12-09
4
14
 
5
15
  ### Fixed
data/README.md CHANGED
@@ -319,6 +319,20 @@ Will produce this validation schema:
319
319
 
320
320
  You can define nullable properties in your request schema by setting the `nullable` option to `true`. This feature is based on the latest JSON Schema specification, which is also used in OpenAPI 3.1.
321
321
 
322
+ Nullable enums include Ruby `nil` in their allowed values, which exports as JSON `null`, not the string `"null"`. This also applies to fields with an `enum:` constraint (including custom field types). The supplied values array is not modified.
323
+
324
+ ```ruby
325
+ enum :role, values: %w[member admin], nullable: true
326
+ # => {"role" => {"enum" => ["member", "admin", nil]}}
327
+
328
+ const :kind, value: "user", nullable: true
329
+ # => {"kind" => {"anyOf" => [{"const" => "user"}, {"type" => "null"}]}}
330
+ ```
331
+
332
+ Nullable constants and references use `anyOf` with a null alternative in both exported and validation schemas. Nullable `one_of` uses an outer `anyOf` around the original `oneOf` and the null alternative. This allows null even when a referenced schema or multiple variants already accept it, while preserving all restrictions on non-null values. A discriminator, when present, stays alongside the inner `oneOf`.
333
+
334
+ `required: true` still requires the key to be present; `nullable: true` only permits its value to be null. Defaults stay on the outer nullable schema so missing properties still receive them when default insertion is enabled. Explicit null values are not replaced by defaults.
335
+
322
336
  ```ruby
323
337
  class NullableRequest < Verquest::Base
324
338
  description "This is a simple request with nullable properties for testing purposes."
@@ -365,13 +379,18 @@ Will produce this validation schema:
365
379
  "additionalProperties" => false
366
380
  },
367
381
  "referenced_object" => {
368
- "type" => %w[object null],
369
- "description" => "This is an another example for testing purposes.",
370
- "required" => %w[simple_field nested],
371
- "properties" => {"simple_field" => {"type" => "string", "description" => "The simple field"}, "nested" => {"type" => "object", "required" => %w[nested_field_1 nested_field_2], "properties" => {"nested_field_1" => {"type" => "string", "description" => "This is a nested field"}, "nested_field_2" => {"type" => "string", "description" => "This is another nested field"}}, "additionalProperties" => false}},
372
- "additionalProperties" => false
382
+ "anyOf" => [
383
+ {
384
+ "type" => "object",
385
+ "description" => "This is an another example for testing purposes.",
386
+ "required" => %w[simple_field nested],
387
+ "properties" => {"simple_field" => {"type" => "string", "description" => "The simple field"}, "nested" => {"type" => "object", "required" => %w[nested_field_1 nested_field_2], "properties" => {"nested_field_1" => {"type" => "string", "description" => "This is a nested field"}, "nested_field_2" => {"type" => "string", "description" => "This is another nested field"}}, "additionalProperties" => false}},
388
+ "additionalProperties" => false
389
+ },
390
+ {"type" => "null"}
391
+ ]
373
392
  },
374
- "referenced_field" => {"type" => %w[string null], "description" => "The simple field"}
393
+ "referenced_field" => {"anyOf" => [{"type" => "string", "description" => "The simple field"}, {"type" => "null"}]}
375
394
  },
376
395
  "additionalProperties" => false
377
396
  }
data/Rakefile CHANGED
@@ -1,12 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bundler/gem_tasks"
4
- require "minitest/test_task"
4
+ require "rake/testtask"
5
5
 
6
- Minitest::TestTask.create
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ t.warning = true
11
+ end
7
12
 
8
13
  require "rubocop/rake_task"
9
14
 
10
15
  RuboCop::RakeTask.new
11
16
 
12
- task default: %i[test rubocop]
17
+ desc "Check YARD documentation coverage (must be 100%)"
18
+ task :yard do
19
+ require "yard"
20
+
21
+ # Capture the stats output
22
+ stats = YARD::CLI::Stats.new
23
+ stats.run("--list-undoc")
24
+
25
+ # Check if there are any undocumented objects
26
+ undocumented = stats.instance_variable_get(:@undoc_list) || []
27
+
28
+ unless undocumented.empty?
29
+ abort "\nYARD documentation check failed: #{undocumented.size} undocumented objects found."
30
+ end
31
+
32
+ puts "\nYARD documentation: 100% documented"
33
+ end
34
+
35
+ task default: %i[test rubocop yard]
@@ -164,13 +164,15 @@ module Verquest
164
164
  # @param value [Object] The value of the constant
165
165
  # @param map [String, nil] An optional mapping to another constant
166
166
  # @param required [Boolean, Array<Symbol>] Whether the constant is required
167
+ # @param nullable [Boolean] Whether the constant can be null
167
168
  # @param schema_options [Hash] Additional schema options for the constant
168
169
  # @return [void]
169
- def const(name, value:, map: nil, required: nil, **schema_options)
170
+ def const(name, value:, map: nil, required: nil, nullable: nil, **schema_options)
170
171
  camelize(schema_options)
171
172
  required = default_options.fetch(:required, false) if required.nil?
173
+ nullable = default_options.fetch(:nullable, false) if nullable.nil?
172
174
 
173
- const = Properties::Const.new(name:, value:, map:, required:, **schema_options)
175
+ const = Properties::Const.new(name:, value:, map:, required:, nullable:, **schema_options)
174
176
  current_scope.add(const)
175
177
  end
176
178
 
@@ -280,6 +282,36 @@ module Verquest
280
282
  current_scope.add(array)
281
283
  end
282
284
 
285
+ # Defines a oneOf property for polymorphic schemas
286
+ #
287
+ # Creates a JSON Schema oneOf structure where exactly one of the defined
288
+ # schemas must match. When used at the root level (without a name), it creates
289
+ # a "combination schema" where the entire request body matches one of the options.
290
+ #
291
+ # @param name [Symbol, nil] The property name, or nil for root-level oneOf
292
+ # @param discriminator [Symbol, nil] The property used to discriminate between schemas
293
+ # @param required [Boolean, Array<Symbol>, nil] Whether this property is required
294
+ # @param nullable [Boolean] Whether this property can be null
295
+ # @param map [String, nil] An optional mapping to another property
296
+ # @yield Block defining the schema options using reference declarations
297
+ # @return [void]
298
+ def one_of(name: nil, discriminator: nil, required: nil, nullable: nil, map: nil, &block)
299
+ required = default_options.fetch(:required, false) if required.nil?
300
+ nullable = default_options.fetch(:nullable, false) if nullable.nil?
301
+
302
+ one_of = Properties::OneOf.new(name:, discriminator:, required:, nullable:, map:)
303
+ current_scope.add(one_of)
304
+
305
+ if block_given?
306
+ previous_scope = current_scope
307
+ @current_scope = one_of
308
+
309
+ instance_exec(&block)
310
+ end
311
+ ensure
312
+ @current_scope = previous_scope if block_given?
313
+ end
314
+
283
315
  # Excludes specified properties from the current scope by removing them
284
316
  # from the version's property set
285
317
  #
@@ -12,7 +12,7 @@ module Verquest
12
12
  # @param version [String, nil] Specific version to use, defaults to configuration setting
13
13
  # @param validate [Boolean, nil] Whether to validate the params, defaults to configuration setting
14
14
  # @param remove_extra_root_keys [Boolean, nil] Whether to remove extra keys at the root level, defaults to configuration setting
15
- # @return [Verquest::Result, Hash, Exception] When validation_error_handling is :result, returns a Success result with mapped params or Failure result with validation errors.
15
+ # @return [Verquest::Result, Hash] When validation_error_handling is :result, returns a Success result with mapped params or Failure result with validation errors.
16
16
  # When validation_error_handling is :raise, returns mapped params directly or raises InvalidParamsError with validation errors.
17
17
  def process(params, version: nil, validate: nil, remove_extra_root_keys: nil)
18
18
  validate = Verquest.configuration.validate_params if validate.nil?
@@ -22,7 +22,7 @@ module Verquest
22
22
 
23
23
  params = params.dup
24
24
  params = params.to_unsafe_h if params.respond_to?(:to_unsafe_h)
25
- params = params.slice(*version_class.properties.keys) if remove_extra_root_keys
25
+ params = params.slice(*version_class.properties.keys) if remove_extra_root_keys && !version_class.combination?
26
26
 
27
27
  if validate && (validation_result = version_class.validate_params(params: params)) && validation_result.any?
28
28
  case Verquest.configuration.validation_error_handling
@@ -32,13 +32,22 @@ module Verquest
32
32
  Result.failure(validation_result)
33
33
  end
34
34
  else
35
- mapped_params = version_class.map_params(params)
36
-
37
- case Verquest.configuration.validation_error_handling
38
- when :raise
39
- mapped_params
40
- when :result
41
- Result.success(mapped_params)
35
+ begin
36
+ mapped_params = version_class.map_params(params)
37
+
38
+ case Verquest.configuration.validation_error_handling
39
+ when :raise
40
+ mapped_params
41
+ when :result
42
+ Result.success(mapped_params)
43
+ end
44
+ rescue MappingError => e
45
+ case Verquest.configuration.validation_error_handling
46
+ when :raise
47
+ raise
48
+ when :result
49
+ Result.failure([{message: e.message, type: "mapping_error"}])
50
+ end
42
51
  end
43
52
  end
44
53
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Verquest
2
4
  # Configuration for the Verquest gem
3
5
  #
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Verquest
4
- GEM_VERSION = "0.6.2"
4
+ GEM_VERSION = "0.6.4"
5
5
  end
@@ -20,7 +20,7 @@ module Verquest
20
20
  # by selecting those with their required attribute set to true (boolean).
21
21
  # Results are memoized to avoid recalculating on subsequent calls.
22
22
  #
23
- # @return [Array<Symbol>] Names of properties marked as unconditionally required (required == true)
23
+ # @return [Array<String>] Names of properties marked as unconditionally required (required == true)
24
24
  def required_properties
25
25
  @_required_properties ||= properties.values.select { _1.required == true }.map(&:name)
26
26
  end
@@ -72,8 +72,8 @@ module Verquest
72
72
  # @param key_prefix [Array<String>] Prefix for the source key
73
73
  # @param value_prefix [Array<String>] Prefix for the target value
74
74
  # @param mapping [Hash] The mapping hash to be updated
75
- # @param version [String, nil] The version to create mapping for, defaults to configuration setting
76
- # @return [Hash] The updated mapping hash
75
+ # @param version [String, nil] The version to create mapping for
76
+ # @return [void]
77
77
  def mapping(key_prefix:, value_prefix:, mapping:, version: nil)
78
78
  mapping[(key_prefix + [name]).join("/")] = mapping_value_key(value_prefix:)
79
79
  end
@@ -12,6 +12,9 @@ module Verquest
12
12
  class Base
13
13
  include HelperMethods::RequiredProperties
14
14
 
15
+ # JSON Schema for null type, used when nullable is true
16
+ NULL_TYPE_SCHEMA = {"type" => "null"}.freeze
17
+
15
18
  # @!attribute [rw] name
16
19
  # @return [String] The name of the property
17
20
  # @!attribute [rw] required
@@ -49,7 +52,7 @@ module Verquest
49
52
  # @param value_prefix [Array<String>] Prefix for the target value
50
53
  # @param mapping [Hash] The mapping hash to be updated
51
54
  # @param version [String, nil] The version to create mapping for
52
- # @return [Hash] The updated mapping hash
55
+ # @return [void]
53
56
  # @raise [NoMethodError] This is an abstract method that must be overridden
54
57
  def mapping(key_prefix:, value_prefix:, mapping:, version:)
55
58
  raise NoMethodError
@@ -61,6 +64,15 @@ module Verquest
61
64
  # @return [Boolean] Whether this property can be null
62
65
  attr_reader :nullable
63
66
 
67
+ # Allows null without weakening constraints, keeping defaults available for insertion
68
+ # @param schema [Hash] The original property schema
69
+ # @return [Hash] The schema, optionally wrapped in a nullable union
70
+ def nullable_schema(schema)
71
+ return schema unless nullable
72
+
73
+ schema.slice("default").merge("anyOf" => [schema.except("default"), NULL_TYPE_SCHEMA])
74
+ end
75
+
64
76
  # Determines the mapping target key based on mapping configuration
65
77
  # @param value_prefix [Array<String>] Prefix for the target value
66
78
  # @param collection [Boolean] Whether this is a collection mapping
@@ -61,6 +61,22 @@ module Verquest
61
61
  !item.nil?
62
62
  end
63
63
 
64
+ # Check if this collection contains a oneOf property as its item type
65
+ #
66
+ # @return [Boolean] True if the collection contains a oneOf property
67
+ def has_one_of?
68
+ properties.values.size == 1 && properties.values.first.is_a?(Verquest::Properties::OneOf)
69
+ end
70
+
71
+ # Returns the oneOf property if this collection contains one
72
+ #
73
+ # @return [Verquest::Properties::OneOf, nil] The oneOf property or nil
74
+ def one_of_property
75
+ return nil unless has_one_of?
76
+
77
+ properties.values.first
78
+ end
79
+
64
80
  # Generate JSON schema definition for this collection property
65
81
  #
66
82
  # @return [Hash] The schema definition for this collection property
@@ -74,6 +90,13 @@ module Verquest
74
90
  }
75
91
  }.merge(schema_options)
76
92
  }
93
+ elsif has_one_of?
94
+ {
95
+ name => {
96
+ "type" => type,
97
+ "items" => one_of_property.to_schema[one_of_property.name] || one_of_property.to_schema
98
+ }.merge(schema_options)
99
+ }
77
100
  else
78
101
  {
79
102
  name => {
@@ -103,6 +126,14 @@ module Verquest
103
126
  "items" => item.to_validation_schema(version: version)
104
127
  }.merge(schema_options)
105
128
  }
129
+ elsif has_one_of?
130
+ one_of_schema = one_of_property.to_validation_schema(version: version)
131
+ {
132
+ name => {
133
+ "type" => type,
134
+ "items" => one_of_schema[one_of_property.name] || one_of_schema
135
+ }.merge(schema_options)
136
+ }
106
137
  else
107
138
  {
108
139
  name => {
@@ -122,13 +153,17 @@ module Verquest
122
153
 
123
154
  # Create mapping for this collection property and all its children
124
155
  #
125
- # This method handles two different scenarios:
156
+ # This method handles three different scenarios:
126
157
  # 1. When the collection references an external item schema (`has_item?` returns true)
127
158
  # - Creates mappings by transforming keys from the referenced item schema
128
159
  # - Adds array notation ([]) to indicate this is a collection
129
160
  # - Prefixes all keys and values with the appropriate paths
130
161
  #
131
- # 2. When the collection has inline item properties
162
+ # 2. When the collection contains a oneOf property (`has_one_of?` returns true)
163
+ # - Creates variant-keyed mappings for discriminator-less oneOf support
164
+ # - Each variant gets array notation applied to its paths
165
+ #
166
+ # 3. When the collection has inline item properties
132
167
  # - Creates mappings for each property in the collection items
133
168
  # - Each property gets mapped with array notation and appropriate prefixes
134
169
  #
@@ -136,16 +171,20 @@ module Verquest
136
171
  # @param value_prefix [Array<String>] Prefix for the target value
137
172
  # @param mapping [Hash] The mapping hash to be updated
138
173
  # @param version [String, nil] The version to create mapping for
139
- # @return [Hash] The updated mapping hash
174
+ # @return [void]
140
175
  def mapping(key_prefix:, value_prefix:, mapping:, version:)
141
176
  if has_item?
142
177
  value_key_prefix = mapping_value_key(value_prefix: value_prefix, collection: true)
143
178
 
144
179
  reference_mapping = item.mapping(version:).dup
145
- reference_mapping.transform_keys! { "#{(key_prefix + [name]).join("/")}[]/#{_1}" }
146
- reference_mapping.transform_values! { "#{value_key_prefix}/#{_1}" }
180
+ reference_mapping.transform_keys! { |k| "#{(key_prefix + [name]).join("/")}[]/#{k}" }
181
+ reference_mapping.transform_values! { |v| "#{value_key_prefix}/#{v}" }
147
182
 
148
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)
149
188
  else
150
189
  properties.values.each do |property|
151
190
  property.mapping(key_prefix: key_prefix + ["#{name}[]"], value_prefix: mapping_value_prefix(value_prefix: value_prefix, collection: true), mapping:, version:)
@@ -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,19 +30,17 @@ 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
+ name => nullable_schema({"const" => value}.merge(schema_options))
34
34
  }
35
35
  end
36
36
 
37
37
  # Create mapping for this const property
38
38
  #
39
- # @param key_prefix [Array<Symbol>] Prefix for the source key
39
+ # @param key_prefix [Array<String>] Prefix for the source key
40
40
  # @param value_prefix [Array<String>] Prefix for the target value
41
41
  # @param mapping [Hash] The mapping hash to be updated
42
42
  # @param version [String, nil] The version to create mapping for
43
- # @return [Hash] The updated mapping hash
43
+ # @return [void]
44
44
  def mapping(key_prefix:, value_prefix:, mapping:, version: nil)
45
45
  mapping[(key_prefix + [name]).join("/")] = mapping_value_key(value_prefix:)
46
46
  end
@@ -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
@@ -48,11 +44,11 @@ module Verquest
48
44
 
49
45
  # Create mapping for this enum property
50
46
  #
51
- # @param key_prefix [Array<Symbol>] Prefix for the source key
47
+ # @param key_prefix [Array<String>] Prefix for the source key
52
48
  # @param value_prefix [Array<String>] Prefix for the target value
53
49
  # @param mapping [Hash] The mapping hash to be updated
54
50
  # @param version [String, nil] The version to create mapping for
55
- # @return [Hash] The updated mapping hash
51
+ # @return [void]
56
52
  def mapping(key_prefix:, value_prefix:, mapping:, version: nil)
57
53
  mapping[(key_prefix + [name]).join("/")] = mapping_value_key(value_prefix:)
58
54
  end
@@ -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
 
@@ -68,11 +69,11 @@ module Verquest
68
69
 
69
70
  # Create mapping for this field property
70
71
  #
71
- # @param key_prefix [Array<Symbol>] Prefix for the source key
72
+ # @param key_prefix [Array<String>] Prefix for the source key
72
73
  # @param value_prefix [Array<String>] Prefix for the target value
73
74
  # @param mapping [Hash] The mapping hash to be updated
74
75
  # @param version [String, nil] The version to create mapping for
75
- # @return [Hash] The updated mapping hash
76
+ # @return [void]
76
77
  def mapping(key_prefix:, value_prefix:, mapping:, version: nil)
77
78
  mapping[(key_prefix + [name]).join("/")] = mapping_value_key(value_prefix:)
78
79
  end
@@ -84,7 +84,7 @@ module Verquest
84
84
  # @param value_prefix [Array<String>] Prefix for the target value
85
85
  # @param mapping [Hash] The mapping hash to be updated
86
86
  # @param version [String, nil] The version to create mapping for
87
- # @return [Hash] The updated mapping hash
87
+ # @return [void]
88
88
  def mapping(key_prefix:, value_prefix:, mapping:, version: nil)
89
89
  properties.values.each do |property|
90
90
  property.mapping(key_prefix: key_prefix + [name], value_prefix: mapping_value_prefix(value_prefix:), mapping:, version:)