grape-oas 1.1.0 → 1.2.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/lib/grape_oas/api_model/api.rb +2 -1
  4. data/lib/grape_oas/api_model_builder.rb +1 -0
  5. data/lib/grape_oas/api_model_builders/concerns/type_resolver.rb +10 -5
  6. data/lib/grape_oas/api_model_builders/request.rb +3 -158
  7. data/lib/grape_oas/api_model_builders/request_params_support/nested_params_builder.rb +2 -0
  8. data/lib/grape_oas/api_model_builders/request_params_support/param_schema_builder.rb +5 -19
  9. data/lib/grape_oas/api_model_builders/request_params_support/schema_enhancer.rb +7 -7
  10. data/lib/grape_oas/constants.rb +17 -2
  11. data/lib/grape_oas/exporter/oas2/operation.rb +4 -2
  12. data/lib/grape_oas/exporter/oas2/parameter.rb +3 -2
  13. data/lib/grape_oas/exporter/oas2/paths.rb +1 -0
  14. data/lib/grape_oas/exporter/oas2/response.rb +3 -2
  15. data/lib/grape_oas/exporter/oas2/schema.rb +20 -3
  16. data/lib/grape_oas/exporter/oas2_schema.rb +7 -2
  17. data/lib/grape_oas/exporter/oas3/operation.rb +4 -4
  18. data/lib/grape_oas/exporter/oas3/parameter.rb +3 -3
  19. data/lib/grape_oas/exporter/oas3/paths.rb +2 -3
  20. data/lib/grape_oas/exporter/oas3/request_body.rb +3 -3
  21. data/lib/grape_oas/exporter/oas3/response.rb +3 -3
  22. data/lib/grape_oas/exporter/oas3/schema.rb +52 -21
  23. data/lib/grape_oas/exporter/oas31_schema.rb +4 -2
  24. data/lib/grape_oas/exporter/oas3_schema.rb +4 -4
  25. data/lib/grape_oas/introspectors/dry_introspector_support/type_unwrapper.rb +15 -0
  26. data/lib/grape_oas/type_resolvers/array_resolver.rb +108 -0
  27. data/lib/grape_oas/type_resolvers/base.rb +110 -0
  28. data/lib/grape_oas/type_resolvers/dry_type_resolver.rb +135 -0
  29. data/lib/grape_oas/type_resolvers/primitive_resolver.rb +105 -0
  30. data/lib/grape_oas/type_resolvers/registry.rb +137 -0
  31. data/lib/grape_oas/version.rb +1 -1
  32. data/lib/grape_oas.rb +41 -0
  33. metadata +7 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d956b648ef9686c13b90db7a16bba4038bf505ed069634131ade6cac17b3f8bf
4
- data.tar.gz: 740148b7d4a9cb0d09675d53465a6e4b798b5c042245070b7de145401ac47820
3
+ metadata.gz: 6f97a967ef31212399ee7765f184a358e755b4364578cbc43d3fdaf75a919638
4
+ data.tar.gz: c56afb98329feaa1227d6ee252cbf5866ddc0144eac15dc6db8e34f9718d4731
5
5
  SHA512:
6
- metadata.gz: a9e134fc9ad362b0b2aa505d7dcab8b24cf35697356365fda1d0bc20f081893b675811b2205aca46507aeb6da66ea547cee8bb3a01a5c6de301f097bb7990948
7
- data.tar.gz: 3a2c13601c299600272d5ff3e5cccb648f658b61b0a3d1a8cf43c66b0bb35cadae8979f74bffd820ec217ccbd18e8eb65b25c28de9f6b4b67a1972914b524f92
6
+ metadata.gz: efed55aaa9f34d9045e495b2c8cbccea7b4da8381b4d2c58628e20785947319f42cc3a1e9952bac87219cb290cdbb81ac31bca7107acc3be3db13e027087c1ac
7
+ data.tar.gz: aa0eebcf7dd0b7122346150adfdf6e5dbfc8ed0287470ecedfe18ff9a95afca3376479899e0dc1e6298be3e65da3711238988b329839b4af68d48dd622bcd1f0
data/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.0] - 2026-03-02
9
+
10
+ ### Added
11
+
12
+ - [#36](https://github.com/numbata/grape-oas/pull/36): Add extensible TypeResolvers for resolving Grape's stringified parameter types to OpenAPI schemas with rich metadata (format, enum, nullable) - [@numbata](https://github.com/numbata).
13
+ - [#37](https://github.com/numbata/grape-oas/pull/37): Replace boolean `nullable_keyword` with configurable `nullable_strategy` - [@numbata](https://github.com/numbata).
14
+
15
+ ### Fixed
16
+
17
+ - [#41](https://github.com/numbata/grape-oas/pull/41): Fix `Set` enum values being silently dropped and `maybe(Coercible::Integer)` resolving to `string` instead of `integer` - [@numbata](https://github.com/numbata).
18
+ - [#40](https://github.com/numbata/grape-oas/pull/40): Remove dead `spec[:allow_nil]` and `spec[:nullable]` checks from `extract_nullable` — these values were never set by Grape or grape-swagger - [@numbata](https://github.com/numbata).
19
+ - [#39](https://github.com/numbata/grape-oas/pull/39): Support `documentation: { x: { nullable: true } }` on nested Hash params — nullable flag was ignored for object container schemas - [@numbata](https://github.com/numbata).
20
+ - [#38](https://github.com/numbata/grape-oas/pull/38): Wrap `$ref` in `allOf` when `description` or `nullable` is present — fixes sibling properties being ignored per OpenAPI spec - [@numbata](https://github.com/numbata).
21
+ - [#37](https://github.com/numbata/grape-oas/pull/37): Fix OAS 3.0 `nullable` keyword being constructed but not emitted in the generated output - [@numbata](https://github.com/numbata).
22
+
8
23
  ## [1.1.0] - 2026-01-23
9
24
 
10
25
  ### Added
@@ -11,7 +11,7 @@ module GrapeOAS
11
11
  class API < Node
12
12
  attr_accessor :title, :version, :paths, :servers, :tag_defs, :components,
13
13
  :host, :base_path, :schemes, :security_definitions, :security,
14
- :registered_schemas, :suppress_default_error_response
14
+ :registered_schemas, :suppress_default_error_response, :nullable_strategy
15
15
 
16
16
  def initialize(title:, version:)
17
17
  super()
@@ -28,6 +28,7 @@ module GrapeOAS
28
28
  @security = []
29
29
  @registered_schemas = []
30
30
  @suppress_default_error_response = false
31
+ @nullable_strategy = nil
31
32
  end
32
33
 
33
34
  def add_path(path)
@@ -19,6 +19,7 @@ module GrapeOAS
19
19
  @api.servers = build_servers(options)
20
20
  @api.registered_schemas = build_registered_schemas(options[:models])
21
21
  @api.suppress_default_error_response = options[:suppress_default_error_response] || false
22
+ @api.nullable_strategy = options[:nullable_strategy]
22
23
 
23
24
  @namespace_filter = options[:namespace]
24
25
  @apis = []
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bigdecimal"
3
+ begin
4
+ require "bigdecimal"
5
+ rescue LoadError
6
+ # BigDecimal is an optional default gem dependency.
7
+ end
4
8
 
5
9
  module GrapeOAS
6
10
  module ApiModelBuilders
@@ -8,11 +12,11 @@ module GrapeOAS
8
12
  # Centralizes Ruby type to OpenAPI schema type resolution.
9
13
  # Used by request builders and introspectors to avoid duplicated type switching logic.
10
14
  module TypeResolver
11
- # Regex to match Grape's typed array notation like "[String]", "[Integer]"
12
- TYPED_ARRAY_PATTERN = /^\[(\w+)\]$/
15
+ # Regex to match Grape's typed array notation like "[String]", "[Integer]", "[MyModule::MyType]"
16
+ TYPED_ARRAY_PATTERN = /\A\[(\w+(?:::\w+)*)\]\z/
13
17
 
14
18
  # Regex to match Grape's multi-type notation like "[String, Integer]", "[String, Float]"
15
- MULTI_TYPE_PATTERN = /^\[(\w+(?:::\w+)*(?:,\s*\w+(?:::\w+)*)+)\]$/
19
+ MULTI_TYPE_PATTERN = /\A\[(\w+(?:::\w+)*(?:,\s*\w+(?:::\w+)*)+)\]\z/
16
20
 
17
21
  # Resolves a Ruby class or type name to its OpenAPI schema type string.
18
22
  # Handles both Ruby classes (Integer, Float) and string type names ("integer", "float").
@@ -27,7 +31,8 @@ module GrapeOAS
27
31
  # Handle Ruby classes directly
28
32
  if type.is_a?(Class)
29
33
  # Check static mapping first
30
- return Constants::RUBY_TYPE_MAPPING[type] if Constants::RUBY_TYPE_MAPPING.key?(type)
34
+ mapped = Constants::RUBY_TYPE_MAPPING[type]
35
+ return mapped if mapped
31
36
 
32
37
  # Handle Grape::API::Boolean dynamically (may not be loaded at constant definition time)
33
38
  return Constants::SchemaTypes::BOOLEAN if grape_boolean_type?(type)
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bigdecimal"
4
-
5
3
  module GrapeOAS
6
4
  module ApiModelBuilders
7
5
  class Request
@@ -202,162 +200,9 @@ module GrapeOAS
202
200
 
203
201
  # First try direct lookup (for Ruby class values like String, Integer)
204
202
  # Then try class-based lookup (for actual runtime values like "hello", 123)
205
- Constants::RUBY_TYPE_MAPPING.fetch(value) do
206
- Constants::RUBY_TYPE_MAPPING.fetch(value.class, Constants::SchemaTypes::STRING)
207
- end
208
- end
209
-
210
- def schema_from_types(types_hash, rule_constraints)
211
- schema = GrapeOAS::ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT)
212
- types_hash.each do |name, dry_type|
213
- prop_schema = schema_for_type(dry_type)
214
- merge_rule_constraints(prop_schema, rule_constraints[name]) if rule_constraints[name]
215
- required = true
216
- required = false if dry_type.respond_to?(:optional?) && dry_type.optional?
217
- required = false if dry_type.respond_to?(:meta) && dry_type.meta[:omittable]
218
- schema.add_property(name, prop_schema, required: required)
219
- end
220
- schema
221
- end
222
-
223
- def schema_for_type(dry_type)
224
- if dry_type.respond_to?(:primitive) && dry_type.primitive == Array && dry_type.respond_to?(:member)
225
- items_schema = schema_for_type(dry_type.member)
226
- schema = GrapeOAS::ApiModel::Schema.new(type: Constants::SchemaTypes::ARRAY, items: items_schema)
227
- apply_array_meta_constraints(schema, dry_type.respond_to?(:meta) ? dry_type.meta : {})
228
- return schema
229
- end
230
-
231
- primitive, member = derive_primitive_and_member(dry_type)
232
- if dry_type.respond_to?(:primitive) && dry_type.primitive == Array
233
- member ||= dry_type.respond_to?(:member) ? dry_type.member : nil
234
- primitive = Array
235
- end
236
- meta = dry_type.respond_to?(:meta) ? dry_type.meta : {}
237
- nullable = dry_type.respond_to?(:optional?) && dry_type.optional?
238
- enum_vals = dry_type.respond_to?(:values) ? dry_type.values : nil
239
-
240
- schema = if primitive == Array
241
- items_schema = member ? schema_for_type(member) : default_string_schema
242
- s = GrapeOAS::ApiModel::Schema.new(type: Constants::SchemaTypes::ARRAY, items: items_schema)
243
- apply_array_meta_constraints(s, meta)
244
- s
245
- else
246
- build_schema_for_primitive(primitive)
247
- end
248
-
249
- schema.nullable = nullable
250
- schema.enum = enum_vals if enum_vals
251
- apply_string_meta_constraints(schema, meta) if primitive == String
252
- apply_numeric_meta_constraints(schema, meta) if [Integer, Float, BigDecimal].include?(primitive)
253
- schema
254
- end
255
-
256
- def derive_primitive_and_member(dry_type)
257
- if defined?(Dry::Types::Array::Member) && dry_type.respond_to?(:type) && dry_type.type.is_a?(Dry::Types::Array::Member)
258
- return [Array, dry_type.type.member]
259
- end
260
-
261
- return [Array, dry_type.member] if dry_type.respond_to?(:member)
262
-
263
- primitive = dry_type.respond_to?(:primitive) ? dry_type.primitive : nil
264
- [primitive, nil]
265
- end
266
-
267
- def apply_string_meta_constraints(schema, meta)
268
- min_length = extract_min_constraint(meta)
269
- max_length = extract_max_constraint(meta)
270
- schema.min_length = min_length if min_length
271
- schema.max_length = max_length if max_length
272
- schema.pattern = meta[:pattern] if meta[:pattern]
273
- end
274
-
275
- def apply_array_meta_constraints(schema, meta)
276
- min_items = extract_min_constraint(meta, :min_items)
277
- max_items = extract_max_constraint(meta, :max_items)
278
- schema.min_items = min_items if min_items
279
- schema.max_items = max_items if max_items
280
- end
281
-
282
- # Extract minimum constraint, supporting multiple key names
283
- def extract_min_constraint(meta, specific_key = :min_length)
284
- meta[:min_size] || meta[specific_key]
285
- end
286
-
287
- # Extract maximum constraint, supporting multiple key names
288
- def extract_max_constraint(meta, specific_key = :max_length)
289
- meta[:max_size] || meta[specific_key]
290
- end
291
-
292
- def apply_numeric_meta_constraints(schema, meta)
293
- if meta[:gt]
294
- schema.minimum = meta[:gt]
295
- schema.exclusive_minimum = true
296
- elsif meta[:gteq]
297
- schema.minimum = meta[:gteq]
298
- end
299
- if meta[:lt]
300
- schema.maximum = meta[:lt]
301
- schema.exclusive_maximum = true
302
- elsif meta[:lteq]
303
- schema.maximum = meta[:lteq]
304
- end
305
- end
306
-
307
- def merge_rule_constraints(schema, rule_constraints)
308
- return unless rule_constraints
309
-
310
- schema.enum ||= rule_constraints[:enum]
311
- schema.nullable ||= rule_constraints[:nullable]
312
- schema.min_length ||= rule_constraints[:min] if rule_constraints[:min]
313
- schema.max_length ||= rule_constraints[:max] if rule_constraints[:max]
314
- schema.minimum ||= rule_constraints[:minimum] if rule_constraints[:minimum]
315
- schema.maximum ||= rule_constraints[:maximum] if rule_constraints[:maximum]
316
- schema.exclusive_minimum ||= rule_constraints[:exclusive_minimum]
317
- schema.exclusive_maximum ||= rule_constraints[:exclusive_maximum]
318
- end
319
-
320
- # Very small parser for FakeType rule_ast used in tests
321
- def extract_rule_constraints(schema_obj)
322
- return {} unless schema_obj.respond_to?(:rules)
323
-
324
- # Only supports FakeSchema/FakeType used in tests
325
- constraints = Hash.new { |h, k| h[k] = {} }
326
- if schema_obj.respond_to?(:types)
327
- schema_obj.types.each do |name, dry_type|
328
- next unless dry_type.respond_to?(:rule_ast)
329
-
330
- rules = dry_type.rule_ast
331
- Array(rules).each do |rule|
332
- next unless rule.is_a?(Array)
333
-
334
- _, pred = rule
335
- next unless pred.is_a?(Array)
336
-
337
- pname, pargs = pred
338
- case pname
339
- when :size?
340
- min, max = Array(pargs).first
341
- constraints[name][:min] = min
342
- constraints[name][:max] = max
343
- when :maybe
344
- constraints[name][:nullable] = true
345
- end
346
- end
347
- end
348
- end
349
- constraints
350
- rescue NoMethodError, TypeError
351
- {}
352
- end
353
-
354
- def extract_enum_from_core_values(core)
355
- return unless core.respond_to?(:values)
356
-
357
- vals = core.values
358
- vals if vals.is_a?(Array)
359
- rescue NoMethodError
360
- nil
203
+ Constants::RUBY_TYPE_MAPPING[value] ||
204
+ Constants::RUBY_TYPE_MAPPING[value.class] ||
205
+ Constants::SchemaTypes::STRING
361
206
  end
362
207
 
363
208
  def convert_contract_schema_to_params(schema)
@@ -148,6 +148,8 @@ module GrapeOAS
148
148
  schema.unevaluated_properties = doc[:unevaluated_properties] if doc.key?(:unevaluated_properties)
149
149
  schema.format = doc[:format] if doc[:format]
150
150
  schema.examples = doc[:example] if doc[:example]
151
+ nullable = SchemaEnhancer.extract_nullable(doc)
152
+ schema.nullable = (schema.nullable || nullable) if schema.respond_to?(:nullable=)
151
153
  end
152
154
  end
153
155
  end
@@ -37,9 +37,13 @@ module GrapeOAS
37
37
  return build_entity_schema(raw_type) if grape_entity?(raw_type)
38
38
  return build_elements_array_schema(spec) if array_with_elements?(raw_type, spec)
39
39
  return build_multi_type_schema(raw_type) if multi_type?(raw_type)
40
- return build_typed_array_schema(raw_type) if typed_array?(raw_type)
41
40
  return build_simple_array_schema if simple_array?(raw_type)
42
41
 
42
+ # Use TypeResolvers registry for arrays, Dry::Types, and primitives
43
+ # This resolves stringified types back to actual classes and extracts rich metadata
44
+ resolved_schema = GrapeOAS.type_resolvers.build_schema(raw_type)
45
+ return resolved_schema if resolved_schema
46
+
43
47
  build_primitive_schema(raw_type, doc)
44
48
  end
45
49
 
@@ -170,29 +174,11 @@ module GrapeOAS
170
174
  !!resolve_entity_class(type)
171
175
  end
172
176
 
173
- # Checks if type is a Grape typed array notation like "[String]"
174
- def typed_array?(type)
175
- type.is_a?(String) && type.match?(TYPED_ARRAY_PATTERN)
176
- end
177
-
178
177
  # Checks if type is a simple Array (class or string)
179
178
  def simple_array?(type)
180
179
  type == Array || type.to_s == "Array"
181
180
  end
182
181
 
183
- # Builds schema for Grape's typed array notation like "[String]", "[Integer]"
184
- def build_typed_array_schema(type)
185
- member_type = extract_typed_array_member(type)
186
- items_type = resolve_schema_type(member_type)
187
- ApiModel::Schema.new(
188
- type: Constants::SchemaTypes::ARRAY,
189
- items: ApiModel::Schema.new(
190
- type: items_type,
191
- format: Constants.format_for_type(member_type),
192
- ),
193
- )
194
- end
195
-
196
182
  def resolve_entity_class(type)
197
183
  return nil unless defined?(Grape::Entity)
198
184
  return type if type.is_a?(Class) && type <= Grape::Entity
@@ -11,7 +11,7 @@ module GrapeOAS
11
11
  # @param spec [Hash] the parameter specification
12
12
  # @param doc [Hash] the documentation hash
13
13
  def self.apply(schema, spec, doc)
14
- nullable = extract_nullable(spec, doc)
14
+ nullable = extract_nullable(doc)
15
15
 
16
16
  schema.description ||= doc[:desc]
17
17
  # Preserve existing nullable: true (e.g., from [Type, Nil] optimization)
@@ -23,13 +23,12 @@ module GrapeOAS
23
23
  apply_values(schema, spec)
24
24
  end
25
25
 
26
- # Extracts nullable flag from spec and documentation.
26
+ # Extracts nullable flag from a documentation hash.
27
27
  #
28
- # @param spec [Hash] the parameter specification
29
28
  # @param doc [Hash] the documentation hash
30
29
  # @return [Boolean] true if nullable
31
- def self.extract_nullable(spec, doc)
32
- spec[:allow_nil] || spec[:nullable] || doc[:nullable] || false
30
+ def self.extract_nullable(doc)
31
+ doc[:nullable] || (doc[:x].is_a?(Hash) && doc[:x][:nullable]) || false
33
32
  end
34
33
 
35
34
  class << self
@@ -82,8 +81,9 @@ module GrapeOAS
82
81
 
83
82
  if values.is_a?(Range)
84
83
  apply_range_values(schema, values)
85
- elsif values.is_a?(Array) && values.any?
86
- apply_enum_values(schema, values)
84
+ else
85
+ enum_values = defined?(Set) && values.is_a?(Set) ? values.to_a : values
86
+ apply_enum_values(schema, enum_values) if enum_values.is_a?(Array) && enum_values.any?
87
87
  end
88
88
  end
89
89
 
@@ -35,6 +35,18 @@ module GrapeOAS
35
35
  ALL = [JSON, XML, FORM_URLENCODED, MULTIPART_FORM].freeze
36
36
  end
37
37
 
38
+ # Nullable representation strategies for different OpenAPI versions.
39
+ # Passed via `nullable_strategy:` option to control how nullable fields
40
+ # are represented in the generated schema.
41
+ module NullableStrategy
42
+ # OAS 3.0: emits `"nullable": true` alongside the type
43
+ KEYWORD = :keyword
44
+ # OAS 3.1: emits `"type": ["string", "null"]` (JSON Schema style)
45
+ TYPE_ARRAY = :type_array
46
+ # OAS 2.0: emits `"x-nullable": true` extension
47
+ EXTENSION = :extension
48
+ end
49
+
38
50
  # Default values for OpenAPI spec when not provided by user
39
51
  module Defaults
40
52
  LICENSE_NAME = "Proprietary"
@@ -49,13 +61,16 @@ module GrapeOAS
49
61
  RUBY_TYPE_MAPPING = {
50
62
  Integer => SchemaTypes::INTEGER,
51
63
  Float => SchemaTypes::NUMBER,
52
- BigDecimal => SchemaTypes::NUMBER,
53
64
  TrueClass => SchemaTypes::BOOLEAN,
54
65
  FalseClass => SchemaTypes::BOOLEAN,
55
66
  Array => SchemaTypes::ARRAY,
56
67
  Hash => SchemaTypes::OBJECT,
57
68
  File => SchemaTypes::FILE
58
- }.freeze
69
+ }.tap do |mapping|
70
+ mapping.default_proc = lambda do |_hash, key|
71
+ key.is_a?(Class) && key.to_s == "BigDecimal" ? SchemaTypes::NUMBER : nil
72
+ end
73
+ end.freeze
59
74
 
60
75
  # String type name to schema type and format mapping (lowercase).
61
76
  # Supports lookup with any case via primitive_type helper.
@@ -10,11 +10,13 @@ module GrapeOAS
10
10
 
11
11
  # OAS2-specific fields: consumes, produces, parameters (including body)
12
12
  def build_version_specific_fields
13
+ strategy = @options[:nullable_strategy]
14
+
13
15
  {
14
16
  "consumes" => consumes,
15
17
  "produces" => produces,
16
- "parameters" => Parameter.new(@op, @ref_tracker).build,
17
- "responses" => Response.new(@op.responses, @ref_tracker).build
18
+ "parameters" => Parameter.new(@op, @ref_tracker, nullable_strategy: strategy).build,
19
+ "responses" => Response.new(@op.responses, @ref_tracker, nullable_strategy: strategy).build
18
20
  }
19
21
  end
20
22
 
@@ -18,9 +18,10 @@ module GrapeOAS
18
18
  "uuid" => { type: Constants::SchemaTypes::STRING, format: "uuid" }
19
19
  }.freeze
20
20
 
21
- def initialize(operation, ref_tracker = nil)
21
+ def initialize(operation, ref_tracker = nil, nullable_strategy: nil)
22
22
  @op = operation
23
23
  @ref_tracker = ref_tracker
24
+ @nullable_strategy = nullable_strategy
24
25
  end
25
26
 
26
27
  def build
@@ -140,7 +141,7 @@ module GrapeOAS
140
141
  ref_name = schema.canonical_name.gsub("::", "_")
141
142
  { "$ref" => "#/definitions/#{ref_name}" }
142
143
  else
143
- Schema.new(schema, @ref_tracker).build
144
+ Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
144
145
  end
145
146
  end
146
147
  end
@@ -11,6 +11,7 @@ module GrapeOAS
11
11
  # Build OAS2-specific operation
12
12
  def build_operation(operation)
13
13
  Operation.new(operation, @ref_tracker,
14
+ nullable_strategy: @options[:nullable_strategy],
14
15
  suppress_default_error_response: @options[:suppress_default_error_response],).build
15
16
  end
16
17
  end
@@ -4,9 +4,10 @@ module GrapeOAS
4
4
  module Exporter
5
5
  module OAS2
6
6
  class Response
7
- def initialize(responses, ref_tracker = nil)
7
+ def initialize(responses, ref_tracker = nil, nullable_strategy: nil)
8
8
  @responses = responses
9
9
  @ref_tracker = ref_tracker
10
+ @nullable_strategy = nullable_strategy
10
11
  end
11
12
 
12
13
  def build
@@ -36,7 +37,7 @@ module GrapeOAS
36
37
  ref_name = schema.canonical_name.gsub("::", "_")
37
38
  { "$ref" => "#/definitions/#{ref_name}" }
38
39
  else
39
- Schema.new(schema, @ref_tracker).build
40
+ Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
40
41
  end
41
42
  end
42
43
 
@@ -4,9 +4,10 @@ module GrapeOAS
4
4
  module Exporter
5
5
  module OAS2
6
6
  class Schema
7
- def initialize(schema, ref_tracker = nil)
7
+ def initialize(schema, ref_tracker = nil, nullable_strategy: nil)
8
8
  @schema = schema
9
9
  @ref_tracker = ref_tracker
10
+ @nullable_strategy = nullable_strategy
10
11
  end
11
12
 
12
13
  def build
@@ -57,11 +58,16 @@ module GrapeOAS
57
58
  end
58
59
 
59
60
  def apply_extensions(schema_hash)
61
+ schema_hash["x-nullable"] = true if @nullable_strategy == Constants::NullableStrategy::EXTENSION && nullable?
60
62
  schema_hash.merge!(@schema.extensions) if @schema.extensions
61
63
  end
62
64
 
63
65
  private
64
66
 
67
+ def nullable?
68
+ @schema.respond_to?(:nullable) && @schema.nullable
69
+ end
70
+
65
71
  # Build schema from oneOf/anyOf by using first type (OAS2 doesn't support these)
66
72
  # Extensions are merged to allow x-anyOf/x-oneOf for consumers that support them
67
73
  def build_first_of_schema(composition_type)
@@ -100,9 +106,20 @@ module GrapeOAS
100
106
  if schema.respond_to?(:canonical_name) && schema.canonical_name
101
107
  @ref_tracker << schema.canonical_name if @ref_tracker
102
108
  ref_name = schema.canonical_name.gsub("::", "_")
103
- { "$ref" => "#/definitions/#{ref_name}" }
109
+ ref_hash = { "$ref" => "#/definitions/#{ref_name}" }
110
+ result = {}
111
+ if @nullable_strategy == Constants::NullableStrategy::EXTENSION && schema.respond_to?(:nullable) && schema.nullable
112
+ result["x-nullable"] = true
113
+ end
114
+ result["description"] = schema.description.to_s if schema.description
115
+ if result.empty?
116
+ ref_hash
117
+ else
118
+ result["allOf"] = [ref_hash]
119
+ result
120
+ end
104
121
  else
105
- Schema.new(schema, @ref_tracker).build
122
+ Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
106
123
  end
107
124
  end
108
125
 
@@ -66,8 +66,13 @@ module GrapeOAS
66
66
  media_types.empty? ? [Constants::MimeTypes::JSON] : media_types
67
67
  end
68
68
 
69
+ def nullable_strategy
70
+ @api.nullable_strategy
71
+ end
72
+
69
73
  def build_paths
70
74
  OAS2::Paths.new(@api, @ref_tracker,
75
+ nullable_strategy: nullable_strategy,
71
76
  suppress_default_error_response: @api.suppress_default_error_response,).build
72
77
  end
73
78
 
@@ -82,7 +87,7 @@ module GrapeOAS
82
87
  end
83
88
 
84
89
  def build_schema(schema)
85
- OAS2::Schema.new(schema, @ref_tracker).build
90
+ OAS2::Schema.new(schema, @ref_tracker, nullable_strategy: nullable_strategy).build
86
91
  end
87
92
 
88
93
  def build_definitions
@@ -103,7 +108,7 @@ module GrapeOAS
103
108
 
104
109
  ref_name = canonical_name.gsub("::", "_")
105
110
  schema = find_schema_by_canonical_name(canonical_name)
106
- definitions[ref_name] = OAS2::Schema.new(schema, @ref_tracker).build if schema
111
+ definitions[ref_name] = OAS2::Schema.new(schema, @ref_tracker, nullable_strategy: nullable_strategy).build if schema
107
112
  collect_refs(schema, pending) if schema
108
113
 
109
114
  @ref_tracker.to_a.each do |cn|
@@ -10,12 +10,12 @@ module GrapeOAS
10
10
 
11
11
  # OAS3-specific fields: parameters (no body), requestBody, responses
12
12
  def build_version_specific_fields
13
- nullable_keyword = @options.key?(:nullable_keyword) ? @options[:nullable_keyword] : true
13
+ strategy = @options[:nullable_strategy] || Constants::NullableStrategy::KEYWORD
14
14
 
15
15
  {
16
- "parameters" => Parameter.new(@op, @ref_tracker, nullable_keyword: nullable_keyword).build,
17
- "requestBody" => RequestBody.new(@op.request_body, @ref_tracker, nullable_keyword: nullable_keyword).build,
18
- "responses" => Response.new(@op.responses, @ref_tracker, nullable_keyword: nullable_keyword).build
16
+ "parameters" => Parameter.new(@op, @ref_tracker, nullable_strategy: strategy).build,
17
+ "requestBody" => RequestBody.new(@op.request_body, @ref_tracker, nullable_strategy: strategy).build,
18
+ "responses" => Response.new(@op.responses, @ref_tracker, nullable_strategy: strategy).build
19
19
  }
20
20
  end
21
21
  end
@@ -4,10 +4,10 @@ module GrapeOAS
4
4
  module Exporter
5
5
  module OAS3
6
6
  class Parameter
7
- def initialize(operation, ref_tracker = nil, nullable_keyword: true)
7
+ def initialize(operation, ref_tracker = nil, nullable_strategy: Constants::NullableStrategy::KEYWORD)
8
8
  @op = operation
9
9
  @ref_tracker = ref_tracker
10
- @nullable_keyword = nullable_keyword
10
+ @nullable_strategy = nullable_strategy
11
11
  end
12
12
 
13
13
  def build
@@ -19,7 +19,7 @@ module GrapeOAS
19
19
  "description" => param.description,
20
20
  "style" => param.style,
21
21
  "explode" => param.explode,
22
- "schema" => Schema.new(param.schema, @ref_tracker, nullable_keyword: @nullable_keyword).build
22
+ "schema" => Schema.new(param.schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
23
23
  }.compact
24
24
  end.presence
25
25
  end
@@ -8,11 +8,10 @@ module GrapeOAS
8
8
  class Paths < Base::Paths
9
9
  private
10
10
 
11
- # Build OAS3-specific operation with nullable_keyword option
11
+ # Build OAS3-specific operation with nullable_strategy option
12
12
  def build_operation(operation)
13
- nullable_keyword = @options.key?(:nullable_keyword) ? @options[:nullable_keyword] : true
14
13
  Operation.new(operation, @ref_tracker,
15
- nullable_keyword: nullable_keyword,
14
+ nullable_strategy: @options[:nullable_strategy] || Constants::NullableStrategy::KEYWORD,
16
15
  suppress_default_error_response: @options[:suppress_default_error_response],).build
17
16
  end
18
17
  end
@@ -4,10 +4,10 @@ module GrapeOAS
4
4
  module Exporter
5
5
  module OAS3
6
6
  class RequestBody
7
- def initialize(request_body, ref_tracker = nil, nullable_keyword: true)
7
+ def initialize(request_body, ref_tracker = nil, nullable_strategy: Constants::NullableStrategy::KEYWORD)
8
8
  @request_body = request_body
9
9
  @ref_tracker = ref_tracker
10
- @nullable_keyword = nullable_keyword
10
+ @nullable_strategy = nullable_strategy
11
11
  end
12
12
 
13
13
  def build
@@ -45,7 +45,7 @@ module GrapeOAS
45
45
  ref_name = schema.canonical_name.gsub("::", "_")
46
46
  { "$ref" => "#/components/schemas/#{ref_name}" }
47
47
  else
48
- Schema.new(schema, @ref_tracker, nullable_keyword: @nullable_keyword).build
48
+ Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
49
49
  end
50
50
  end
51
51
  end
@@ -4,10 +4,10 @@ module GrapeOAS
4
4
  module Exporter
5
5
  module OAS3
6
6
  class Response
7
- def initialize(responses, ref_tracker = nil, nullable_keyword: true)
7
+ def initialize(responses, ref_tracker = nil, nullable_strategy: Constants::NullableStrategy::KEYWORD)
8
8
  @responses = responses
9
9
  @ref_tracker = ref_tracker
10
- @nullable_keyword = nullable_keyword
10
+ @nullable_strategy = nullable_strategy
11
11
  end
12
12
 
13
13
  def build
@@ -76,7 +76,7 @@ module GrapeOAS
76
76
  ref_name = schema.canonical_name.gsub("::", "_")
77
77
  { "$ref" => "#/components/schemas/#{ref_name}" }
78
78
  else
79
- Schema.new(schema, @ref_tracker, nullable_keyword: @nullable_keyword).build
79
+ Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
80
80
  end
81
81
  end
82
82
  end