grape-oas 1.1.0 → 1.3.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -0
  3. data/README.md +6 -0
  4. data/lib/grape_oas/api_model/api.rb +2 -1
  5. data/lib/grape_oas/api_model/schema.rb +17 -2
  6. data/lib/grape_oas/api_model_builder.rb +1 -0
  7. data/lib/grape_oas/api_model_builders/concerns/type_resolver.rb +10 -5
  8. data/lib/grape_oas/api_model_builders/request.rb +3 -158
  9. data/lib/grape_oas/api_model_builders/request_params_support/nested_params_builder.rb +2 -0
  10. data/lib/grape_oas/api_model_builders/request_params_support/param_schema_builder.rb +16 -20
  11. data/lib/grape_oas/api_model_builders/request_params_support/schema_enhancer.rb +26 -53
  12. data/lib/grape_oas/constants.rb +21 -2
  13. data/lib/grape_oas/doc_key_normalizer.rb +14 -0
  14. data/lib/grape_oas/exporter/oas2/operation.rb +4 -2
  15. data/lib/grape_oas/exporter/oas2/parameter.rb +3 -2
  16. data/lib/grape_oas/exporter/oas2/paths.rb +1 -0
  17. data/lib/grape_oas/exporter/oas2/response.rb +3 -2
  18. data/lib/grape_oas/exporter/oas2/schema.rb +37 -5
  19. data/lib/grape_oas/exporter/oas2_schema.rb +7 -2
  20. data/lib/grape_oas/exporter/oas3/operation.rb +4 -4
  21. data/lib/grape_oas/exporter/oas3/parameter.rb +3 -3
  22. data/lib/grape_oas/exporter/oas3/paths.rb +2 -3
  23. data/lib/grape_oas/exporter/oas3/request_body.rb +3 -3
  24. data/lib/grape_oas/exporter/oas3/response.rb +3 -3
  25. data/lib/grape_oas/exporter/oas3/schema.rb +84 -26
  26. data/lib/grape_oas/exporter/oas31_schema.rb +4 -2
  27. data/lib/grape_oas/exporter/oas3_schema.rb +4 -4
  28. data/lib/grape_oas/introspectors/dry_introspector_support/argument_extractor.rb +2 -31
  29. data/lib/grape_oas/introspectors/dry_introspector_support/constraint_extractor.rb +10 -19
  30. data/lib/grape_oas/introspectors/dry_introspector_support/predicate_handler.rb +2 -10
  31. data/lib/grape_oas/introspectors/dry_introspector_support/type_unwrapper.rb +15 -0
  32. data/lib/grape_oas/introspectors/entity_introspector_support/discriminator_handler.rb +2 -25
  33. data/lib/grape_oas/introspectors/entity_introspector_support/exposure_processor.rb +139 -136
  34. data/lib/grape_oas/introspectors/entity_introspector_support/inheritance_builder.rb +5 -29
  35. data/lib/grape_oas/introspectors/entity_introspector_support/nesting_merger.rb +104 -0
  36. data/lib/grape_oas/introspectors/entity_introspector_support/property_extractor.rb +2 -1
  37. data/lib/grape_oas/introspectors/entity_introspector_support/type_schema_resolver.rb +139 -0
  38. data/lib/grape_oas/introspectors/entity_introspector_support.rb +33 -0
  39. data/lib/grape_oas/range_utils.rb +87 -0
  40. data/lib/grape_oas/schema_constraints.rb +36 -0
  41. data/lib/grape_oas/type_resolvers/array_resolver.rb +108 -0
  42. data/lib/grape_oas/type_resolvers/base.rb +110 -0
  43. data/lib/grape_oas/type_resolvers/dry_type_resolver.rb +135 -0
  44. data/lib/grape_oas/type_resolvers/primitive_resolver.rb +105 -0
  45. data/lib/grape_oas/type_resolvers/registry.rb +137 -0
  46. data/lib/grape_oas/values_normalizer.rb +47 -0
  47. data/lib/grape_oas/version.rb +1 -1
  48. data/lib/grape_oas.rb +68 -0
  49. metadata +14 -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: e5289691e338b77f7408ed384429c297bfcac143962085e4b02edc1b980b35ff
4
+ data.tar.gz: 1d03adce8e12922b769ce6679013d14cb4e9bb4bc3ceaa2a6d4fdc2a26d34625
5
5
  SHA512:
6
- metadata.gz: a9e134fc9ad362b0b2aa505d7dcab8b24cf35697356365fda1d0bc20f081893b675811b2205aca46507aeb6da66ea547cee8bb3a01a5c6de301f097bb7990948
7
- data.tar.gz: 3a2c13601c299600272d5ff3e5cccb648f658b61b0a3d1a8cf43c66b0bb35cadae8979f74bffd820ec217ccbd18e8eb65b25c28de9f6b4b67a1972914b524f92
6
+ metadata.gz: 850eee859637b8e5cab7606b8819931c5b59479aeab6247907fde7578c43b613694a0ab7b2ffb24c1530ac1e878982f555810a7afb83b34cf378d2f6b35cc020
7
+ data.tar.gz: 1298c51a115ec42502e011e50b41ae3b9bf9a553c834c49b7c3b628d285b7e2853ee8251cf55c742766d951f2ecfc525252aee1950a61ffe8fbd064a84550110
data/CHANGELOG.md CHANGED
@@ -5,6 +5,47 @@ 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
+
9
+ ## [1.3.0] - 2026-03-27
10
+
11
+ ### Added
12
+
13
+ - [#48](https://github.com/numbata/grape-oas/pull/48): Add configurable `GrapeOAS.logger` for schema generation warnings - [@numbata](https://github.com/numbata).
14
+ - [#43](https://github.com/numbata/grape-oas/pull/43): Bump actions/upload-artifact from 6 to 7 - [@dependabot[bot]](https://github.com/dependabot[bot]).
15
+ - [#51](https://github.com/numbata/grape-oas/pull/51): Add inline nesting exposure support — block-based `expose :key do ... end` now produces inline object schemas with preserved enum values, min/max constraints, and metadata - [@numbata](https://github.com/numbata).
16
+
17
+ ### Changed
18
+
19
+ - [#52](https://github.com/numbata/grape-oas/pull/52): Extract `SchemaConstraints` — centralizes numeric/string constraint application (min/max, exclusive flags, length, pattern) and adds `exclusive_minimum`/`exclusive_maximum` support - [@numbata](https://github.com/numbata).
20
+ - [#50](https://github.com/numbata/grape-oas/pull/50): Extract `ValuesNormalizer` — consolidates Proc/Set/Hash value normalization into a single module used by both request params and entity exposures - [@numbata](https://github.com/numbata).
21
+
22
+ ### Fixed
23
+
24
+ - [#55](https://github.com/numbata/grape-oas/pull/55): Preserve enum values on cached entity schemas — dup the shared schema before applying enum instead of silently discarding the constraint; emit a warning naming the entity - [@numbata](https://github.com/numbata).
25
+ - [#56](https://github.com/numbata/grape-oas/pull/56): Make `NestingMerger` depth-cap warning actionable — include property name and depth cap value in the message - [@numbata](https://github.com/numbata).
26
+ - [#50](https://github.com/numbata/grape-oas/pull/50): Fix `[false]` enum silently dropped — `[false].any?` returns `false` in Ruby, causing boolean-only enum constraints to be discarded - [@numbata](https://github.com/numbata).
27
+
28
+ - [#49](https://github.com/numbata/grape-oas/pull/49): Fix OOM on wide string range expansion in `values:` — replace unbounded `range.to_a` with bounded enumeration; non-numeric ranges on numeric schemas and numeric ranges on non-numeric schemas now warn and are ignored instead of silently producing invalid output - [@numbata](https://github.com/numbata).
29
+ - [#46](https://github.com/numbata/grape-oas/pull/46): Fix `is_array: true` in request param documentation being ignored for primitive types — only entity types were wrapped in array schema - [@numbata](https://github.com/numbata).
30
+ - [#42](https://github.com/numbata/grape-oas/pull/42): Fix array items `description` and `nullable` placement — hoist to outer array schema instead of wrapping `items` in `allOf`; fix `:description` field naming collision in `PropertyExtractor` - [@numbata](https://github.com/numbata).
31
+ - [#44](https://github.com/numbata/grape-oas/pull/44): Fix RuboCop 1.85 offenses - [@numbata](https://github.com/numbata).
32
+ - [#47](https://github.com/numbata/grape-oas/pull/47): Fix duplicate entries in `Schema#required` array when the same property is added multiple times with `required: true` - [@numbata](https://github.com/numbata).
33
+
34
+ ## [1.2.0] - 2026-03-02
35
+
36
+ ### Added
37
+
38
+ - [#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).
39
+ - [#37](https://github.com/numbata/grape-oas/pull/37): Replace boolean `nullable_keyword` with configurable `nullable_strategy` - [@numbata](https://github.com/numbata).
40
+
41
+ ### Fixed
42
+
43
+ - [#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).
44
+ - [#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).
45
+ - [#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).
46
+ - [#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).
47
+ - [#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).
48
+
8
49
  ## [1.1.0] - 2026-01-23
9
50
 
10
51
  ### Added
data/README.md CHANGED
@@ -150,6 +150,12 @@ class Entity::User < Grape::Entity
150
150
  expose :id, documentation: { type: Integer }
151
151
  expose :name, documentation: { type: String }
152
152
  expose :posts, using: Entity::Post, documentation: { is_array: true }
153
+
154
+ # Block-based nesting produces an inline object schema
155
+ expose :meta do
156
+ expose :role, documentation: { type: String, values: %w[admin user guest] }
157
+ expose :verified, documentation: { type: 'Boolean' }
158
+ end
153
159
  end
154
160
  ```
155
161
 
@@ -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)
@@ -51,10 +51,25 @@ module GrapeOAS
51
51
  end
52
52
 
53
53
  def add_property(name, schema, required: false)
54
- @properties[name.to_s] = schema
55
- @required << name.to_s if required
54
+ key = name.to_s
55
+ @properties[key] = schema
56
+ @required << key if required && !@required.include?(key)
56
57
  schema
57
58
  end
59
+
60
+ protected
61
+
62
+ # Ensure dup produces an independent copy — without this, the shallow
63
+ # copy shares @properties, @required, and @defs with the original.
64
+ # Property values are duped one level deep (via transform_values(&:dup)),
65
+ # which triggers initialize_copy recursively on each nested Schema,
66
+ # producing a full deep copy of the property tree.
67
+ def initialize_copy(source)
68
+ super
69
+ @properties = source.properties.transform_values(&:dup)
70
+ @required = source.required.dup
71
+ @defs = source.defs.dup
72
+ end
58
73
  end
59
74
  end
60
75
  end
@@ -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
@@ -33,13 +33,18 @@ module GrapeOAS
33
33
 
34
34
  return build_entity_array_schema(spec, raw_type, doc_type) if entity_array_type?(type_source, doc_type, spec)
35
35
  return build_doc_entity_array_schema(doc_type) if doc[:is_array] && grape_entity?(doc_type)
36
+ return build_primitive_array_schema(doc_type, raw_type) if doc[:is_array]
36
37
  return build_entity_schema(doc_type) if grape_entity?(doc_type)
37
38
  return build_entity_schema(raw_type) if grape_entity?(raw_type)
38
39
  return build_elements_array_schema(spec) if array_with_elements?(raw_type, spec)
39
40
  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
41
  return build_simple_array_schema if simple_array?(raw_type)
42
42
 
43
+ # Use TypeResolvers registry for arrays, Dry::Types, and primitives
44
+ # This resolves stringified types back to actual classes and extracts rich metadata
45
+ resolved_schema = GrapeOAS.type_resolvers.build_schema(raw_type)
46
+ return resolved_schema if resolved_schema
47
+
43
48
  build_primitive_schema(raw_type, doc)
44
49
  end
45
50
 
@@ -88,6 +93,15 @@ module GrapeOAS
88
93
  ApiModel::Schema.new(type: Constants::SchemaTypes::ARRAY, items: items_schema)
89
94
  end
90
95
 
96
+ def build_primitive_array_schema(doc_type, raw_type)
97
+ type_source = doc_type || raw_type
98
+ item_type = resolve_schema_type(type_source)
99
+ ApiModel::Schema.new(
100
+ type: Constants::SchemaTypes::ARRAY,
101
+ items: ApiModel::Schema.new(type: item_type, format: Constants.format_for_type(type_source)),
102
+ )
103
+ end
104
+
91
105
  def build_simple_array_schema
92
106
  ApiModel::Schema.new(
93
107
  type: Constants::SchemaTypes::ARRAY,
@@ -170,29 +184,11 @@ module GrapeOAS
170
184
  !!resolve_entity_class(type)
171
185
  end
172
186
 
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
187
  # Checks if type is a simple Array (class or string)
179
188
  def simple_array?(type)
180
189
  type == Array || type.to_s == "Array"
181
190
  end
182
191
 
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
192
  def resolve_entity_class(type)
197
193
  return nil unless defined?(Grape::Entity)
198
194
  return type if type.is_a?(Class) && type <= Grape::Entity
@@ -205,7 +201,7 @@ module GrapeOAS
205
201
  klass = Object.const_get(const_name, false)
206
202
  klass if klass.is_a?(Class) && klass <= Grape::Entity
207
203
  rescue NameError => e
208
- warn "[grape-oas] Could not resolve entity constant '#{const_name}': #{e.message}"
204
+ GrapeOAS.logger.warn("Could not resolve entity constant '#{const_name}': #{e.message}")
209
205
  nil
210
206
  end
211
207
  end
@@ -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)
@@ -19,17 +19,16 @@ module GrapeOAS
19
19
 
20
20
  apply_additional_properties(schema, doc)
21
21
  apply_format_and_example(schema, doc)
22
- apply_constraints(schema, doc)
22
+ SchemaConstraints.apply(schema, doc)
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
@@ -51,38 +50,24 @@ module GrapeOAS
51
50
  schema.examples = doc[:example] if doc[:example] && schema.respond_to?(:examples=)
52
51
  end
53
52
 
54
- def apply_constraints(schema, doc)
55
- schema.minimum = doc[:minimum] if doc.key?(:minimum) && schema.respond_to?(:minimum=)
56
- schema.maximum = doc[:maximum] if doc.key?(:maximum) && schema.respond_to?(:maximum=)
57
- schema.min_length = doc[:min_length] if doc.key?(:min_length) && schema.respond_to?(:min_length=)
58
- schema.max_length = doc[:max_length] if doc.key?(:max_length) && schema.respond_to?(:max_length=)
59
- schema.pattern = doc[:pattern] if doc.key?(:pattern) && schema.respond_to?(:pattern=)
60
- end
61
-
62
- # Applies values from spec[:values] - converts Range to min/max,
63
- # evaluates Proc (arity 0), and sets enum for arrays.
64
- # Skips Proc/Lambda validators (arity > 0) used for custom validation.
65
- # For array schemas, applies enum to items (since values constrain array elements).
66
- # For oneOf schemas, applies enum to each non-null variant.
67
53
  def apply_values(schema, spec)
68
- values = spec[:values]
54
+ values = ValuesNormalizer.normalize(spec[:values], context: "parameter values")
69
55
  return unless values
70
56
 
71
- # Handle Hash format { value: ..., message: ... } - extract the value
72
- values = values[:value] if values.is_a?(Hash) && values.key?(:value)
73
-
74
- # Handle Proc/Lambda
75
- if values.respond_to?(:call)
76
- # Skip validators (arity > 0) - they validate individual values
77
- return if values.arity != 0
78
-
79
- # Evaluate arity-0 procs - they return enum arrays
80
- values = values.call
81
- end
82
-
83
57
  if values.is_a?(Range)
84
- apply_range_values(schema, values)
85
- elsif values.is_a?(Array) && values.any?
58
+ if one_of_schema?(schema)
59
+ schema.one_of.each do |variant|
60
+ next if null_type_schema?(variant)
61
+ next unless range_compatible_with_schema?(values, variant)
62
+
63
+ RangeUtils.apply_to_schema(variant, values)
64
+ end
65
+ elsif array_schema_with_items?(schema)
66
+ RangeUtils.apply_to_schema(schema.items, values)
67
+ else
68
+ RangeUtils.apply_to_schema(schema, values)
69
+ end
70
+ elsif values.is_a?(Array) && !values.empty?
86
71
  apply_enum_values(schema, values)
87
72
  end
88
73
  end
@@ -98,7 +83,7 @@ module GrapeOAS
98
83
  compatible_values = filter_compatible_values(variant, values)
99
84
 
100
85
  # Only apply enum if there are compatible values
101
- variant.enum = compatible_values if compatible_values.any? && variant.respond_to?(:enum=)
86
+ variant.enum = compatible_values if !compatible_values.empty? && variant.respond_to?(:enum=)
102
87
  end
103
88
  elsif array_schema_with_items?(schema)
104
89
  # For array schemas, apply enum to items (values constrain array elements)
@@ -110,7 +95,7 @@ module GrapeOAS
110
95
  end
111
96
 
112
97
  def one_of_schema?(schema)
113
- schema.respond_to?(:one_of) && schema.one_of.is_a?(Array) && schema.one_of.any?
98
+ schema.respond_to?(:one_of) && schema.one_of.is_a?(Array) && !schema.one_of.empty?
114
99
  end
115
100
 
116
101
  def null_type_schema?(schema)
@@ -159,26 +144,14 @@ module GrapeOAS
159
144
  end
160
145
  end
161
146
 
162
- # Converts a Range to minimum/maximum constraints.
163
- # For numeric ranges (Integer, Float), uses min/max.
164
- # For other ranges (e.g., 'a'..'z'), expands to enum array.
165
- # Handles endless/beginless ranges (e.g., 1.., ..10).
166
- def apply_range_values(schema, range)
167
- first_val = range.begin
168
- last_val = range.end
169
-
170
- if first_val.is_a?(Numeric) || last_val.is_a?(Numeric)
171
- schema.minimum = first_val if first_val && schema.respond_to?(:minimum=)
172
- schema.maximum = last_val if last_val && schema.respond_to?(:maximum=)
173
- elsif first_val && last_val && schema.respond_to?(:enum=)
174
- # Non-numeric bounded range (e.g., 'a'..'z') - expand to enum
175
- schema.enum = range.to_a
176
- end
177
- end
178
-
179
147
  def extract_defs(doc)
180
148
  doc[:defs] || doc[:$defs]
181
149
  end
150
+
151
+ def range_compatible_with_schema?(range, schema)
152
+ numeric_type = RangeUtils::NUMERIC_TYPES.include?(schema.type)
153
+ RangeUtils.numeric_range?(range) ? numeric_type : !numeric_type
154
+ end
182
155
  end
183
156
  end
184
157
  end
@@ -35,6 +35,22 @@ 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
+
50
+ # Maximum number of elements to expand from a non-numeric Range into an enum array.
51
+ # Prevents OOM on wide string ranges (e.g. "a".."zzzzzz").
52
+ MAX_ENUM_RANGE_SIZE = 100
53
+
38
54
  # Default values for OpenAPI spec when not provided by user
39
55
  module Defaults
40
56
  LICENSE_NAME = "Proprietary"
@@ -49,13 +65,16 @@ module GrapeOAS
49
65
  RUBY_TYPE_MAPPING = {
50
66
  Integer => SchemaTypes::INTEGER,
51
67
  Float => SchemaTypes::NUMBER,
52
- BigDecimal => SchemaTypes::NUMBER,
53
68
  TrueClass => SchemaTypes::BOOLEAN,
54
69
  FalseClass => SchemaTypes::BOOLEAN,
55
70
  Array => SchemaTypes::ARRAY,
56
71
  Hash => SchemaTypes::OBJECT,
57
72
  File => SchemaTypes::FILE
58
- }.freeze
73
+ }.tap do |mapping|
74
+ mapping.default_proc = lambda do |_hash, key|
75
+ key.is_a?(Class) && key.to_s == "BigDecimal" ? SchemaTypes::NUMBER : nil
76
+ end
77
+ end.freeze
59
78
 
60
79
  # String type name to schema type and format mapping (lowercase).
61
80
  # Supports lookup with any case via primitive_type helper.
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ # Normalizes documentation hash keys so callers can use symbol access
5
+ # uniformly. String keys that look like OpenAPI extensions ("x-*") are
6
+ # kept as strings; all other keys are converted to symbols.
7
+ module DocKeyNormalizer
8
+ def self.normalize(doc)
9
+ return doc if doc.empty?
10
+
11
+ doc.transform_keys { |k| k.to_s.start_with?("x-") ? k.to_s : k.to_sym }
12
+ end
13
+ end
14
+ end
@@ -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