grape-oas 1.3.0 → 1.5.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/README.md +27 -10
  4. data/UPGRADING.md +104 -0
  5. data/grape-oas.gemspec +2 -1
  6. data/lib/grape_oas/api_model/api.rb +6 -1
  7. data/lib/grape_oas/api_model/schema.rb +2 -2
  8. data/lib/grape_oas/api_model_builder.rb +3 -2
  9. data/lib/grape_oas/api_model_builders/concerns/content_type_resolver.rb +30 -1
  10. data/lib/grape_oas/api_model_builders/concerns/oas_utilities.rb +13 -0
  11. data/lib/grape_oas/api_model_builders/concerns/route_validations.rb +26 -0
  12. data/lib/grape_oas/api_model_builders/concerns/type_resolver.rb +7 -8
  13. data/lib/grape_oas/api_model_builders/operation.rb +3 -4
  14. data/lib/grape_oas/api_model_builders/path.rb +33 -6
  15. data/lib/grape_oas/api_model_builders/request.rb +29 -22
  16. data/lib/grape_oas/api_model_builders/request_params.rb +83 -3
  17. data/lib/grape_oas/api_model_builders/request_params_support/nested_params_builder.rb +1 -1
  18. data/lib/grape_oas/api_model_builders/request_params_support/param_location_resolver.rb +7 -2
  19. data/lib/grape_oas/api_model_builders/request_params_support/param_schema_builder.rb +4 -0
  20. data/lib/grape_oas/api_model_builders/request_params_support/schema_enhancer.rb +14 -9
  21. data/lib/grape_oas/api_model_builders/response.rb +28 -4
  22. data/lib/grape_oas/constants.rb +22 -1
  23. data/lib/grape_oas/documentation_extension.rb +7 -9
  24. data/lib/grape_oas/exporter/concerns/schema_indexer.rb +14 -3
  25. data/lib/grape_oas/exporter/oas2/operation.rb +4 -2
  26. data/lib/grape_oas/exporter/oas2/parameter.rb +74 -13
  27. data/lib/grape_oas/exporter/oas2/paths.rb +1 -0
  28. data/lib/grape_oas/exporter/oas2/response.rb +6 -4
  29. data/lib/grape_oas/exporter/oas2/schema.rb +85 -38
  30. data/lib/grape_oas/exporter/oas2_schema.rb +7 -5
  31. data/lib/grape_oas/exporter/oas3/operation.rb +5 -3
  32. data/lib/grape_oas/exporter/oas3/parameter.rb +8 -3
  33. data/lib/grape_oas/exporter/oas3/paths.rb +1 -0
  34. data/lib/grape_oas/exporter/oas3/request_body.rb +5 -8
  35. data/lib/grape_oas/exporter/oas3/response.rb +6 -9
  36. data/lib/grape_oas/exporter/oas3/schema.rb +273 -117
  37. data/lib/grape_oas/exporter/oas31/schema.rb +12 -2
  38. data/lib/grape_oas/exporter/oas31_schema.rb +1 -1
  39. data/lib/grape_oas/exporter/oas3_schema.rb +3 -2
  40. data/lib/grape_oas/introspectors/entity_introspector.rb +7 -2
  41. data/lib/grape_oas/introspectors/entity_introspector_support/exposure_processor.rb +55 -20
  42. data/lib/grape_oas/introspectors/entity_introspector_support/inheritance_builder.rb +1 -1
  43. data/lib/grape_oas/introspectors/entity_introspector_support/property_extractor.rb +2 -8
  44. data/lib/grape_oas/introspectors/entity_introspector_support/type_schema_resolver.rb +4 -20
  45. data/lib/grape_oas/introspectors/entity_introspector_support.rb +24 -0
  46. data/lib/grape_oas/range_utils.rb +25 -2
  47. data/lib/grape_oas/type_resolvers/array_resolver.rb +54 -22
  48. data/lib/grape_oas/type_resolvers/base.rb +2 -2
  49. data/lib/grape_oas/type_resolvers/default_resolver.rb +23 -0
  50. data/lib/grape_oas/type_resolvers/dry_type_resolver.rb +1 -1
  51. data/lib/grape_oas/type_resolvers/primitive_resolver.rb +29 -46
  52. data/lib/grape_oas/type_resolvers/registry.rb +31 -15
  53. data/lib/grape_oas/version.rb +1 -1
  54. data/lib/grape_oas.rb +37 -7
  55. metadata +5 -4
  56. data/CONTRIBUTING.md +0 -87
  57. data/RELEASING.md +0 -109
@@ -19,8 +19,6 @@ module GrapeOAS
19
19
  # @param schema [ApiModel::Schema] the schema to populate
20
20
  def add_exposures_to_schema(schema)
21
21
  exposures.each do |exposure|
22
- next unless exposed?(exposure)
23
-
24
22
  add_exposure_to_schema(schema, exposure)
25
23
  end
26
24
  end
@@ -73,8 +71,11 @@ module GrapeOAS
73
71
  #
74
72
  # @param exposure the entity exposure
75
73
  # @return [Boolean] true if exposed
76
- def exposed?(_exposure)
77
- true
74
+ def exposed?(exposure)
75
+ doc = normalize_doc_keys(exposure.documentation || {})
76
+ hidden = doc[:hidden]
77
+ hidden = hidden.call if hidden.respond_to?(:call)
78
+ !hidden
78
79
  end
79
80
 
80
81
  # Checks if an exposure is conditional.
@@ -128,6 +129,8 @@ module GrapeOAS
128
129
  end
129
130
 
130
131
  def add_exposure_to_schema(schema, exposure)
132
+ return unless exposed?(exposure)
133
+
131
134
  doc = normalize_doc_keys(exposure.documentation || {})
132
135
  opts = exposure_options(exposure)
133
136
 
@@ -155,7 +158,9 @@ module GrapeOAS
155
158
  is_array = doc[:is_array]
156
159
  return prop_schema unless is_array
157
160
 
158
- ApiModel::Schema.new(type: Constants::SchemaTypes::ARRAY, items: prop_schema)
161
+ array_schema = ApiModel::Schema.new(type: Constants::SchemaTypes::ARRAY, items: prop_schema)
162
+ array_schema.examples = doc[:example] if array_valued_example?(doc)
163
+ array_schema
159
164
  end
160
165
 
161
166
  # Detects block-based nesting exposures (NestingExposure) that should become
@@ -178,6 +183,8 @@ module GrapeOAS
178
183
  nesting_accum = {}
179
184
  nesting_required = Hash.new { |h, k| h[k] = [] }
180
185
  Array(exposure.nested_exposures).each do |child_exposure|
186
+ next unless exposed?(child_exposure)
187
+
181
188
  if nesting_exposure?(child_exposure)
182
189
  key = child_exposure.key.to_s
183
190
  child_doc = normalize_doc_keys(child_exposure.documentation || {})
@@ -200,7 +207,16 @@ module GrapeOAS
200
207
  end
201
208
 
202
209
  def apply_exposure_properties(schema, doc)
203
- schema.nullable = doc[:nullable] || false
210
+ nullable = PropertyExtractor.extract_nullable(doc)
211
+ if nullable && schema.canonical_name
212
+ # Don't mutate the shared cached entity schema. Create a wrapper with
213
+ # all_of so the exporter emits { nullable: true, allOf: [{ $ref }] }.
214
+ # collect_refs traverses all_of and registers the original schema as a
215
+ # component, keeping its definition free of this property's nullable.
216
+ schema = ApiModel::Schema.new(nullable: true, all_of: [schema])
217
+ else
218
+ schema.nullable = nullable
219
+ end
204
220
  raw_values = doc[:values]
205
221
  if raw_values
206
222
  normalized = ValuesNormalizer.normalize(raw_values, context: "entity exposure values")
@@ -212,7 +228,7 @@ module GrapeOAS
212
228
  end
213
229
  schema.description = doc[:desc] if doc[:desc]
214
230
  schema.format = doc[:format] if doc[:format]
215
- schema.examples = doc[:example] if schema.respond_to?(:examples=) && doc[:example]
231
+ schema = apply_example_to_schema(schema, doc[:example]) if doc.key?(:example) && !array_valued_example?(doc)
216
232
  schema.additional_properties = doc[:additional_properties] if doc.key?(:additional_properties)
217
233
  schema.unevaluated_properties = doc[:unevaluated_properties] if doc.key?(:unevaluated_properties)
218
234
  defs = doc[:defs] || doc[:$defs]
@@ -223,17 +239,24 @@ module GrapeOAS
223
239
  end
224
240
 
225
241
  # Cached entity schemas (via using:) are shared across all exposures that
226
- # reference the same entity — dup before setting enum to avoid mutating
227
- # the shared schema; emit a warning so users know a dup occurred.
242
+ # reference the same entity — dup before mutating them (enum, example, ...)
243
+ # to avoid corrupting the shared schema; emit a warning so users know a
244
+ # dup occurred.
228
245
  #
246
+ # @return [ApiModel::Schema] a dup of schema with canonical_name cleared
247
+ def dup_cached_schema(schema, reason:, value:)
248
+ GrapeOAS.logger.warn(
249
+ "Duplicating cached schema '#{schema.canonical_name}' to apply #{reason} #{value.inspect}",
250
+ )
251
+ dup = schema.dup
252
+ dup.canonical_name = nil
253
+ dup
254
+ end
255
+
229
256
  # @return [ApiModel::Schema] the schema (or a dup) with enum applied
230
257
  def apply_enum_to_schema(schema, values)
231
258
  if schema.respond_to?(:canonical_name) && schema.canonical_name
232
- GrapeOAS.logger.warn(
233
- "Duplicating cached schema '#{schema.canonical_name}' to apply enum #{values.inspect}",
234
- )
235
- schema = schema.dup
236
- schema.canonical_name = nil
259
+ schema = dup_cached_schema(schema, reason: "enum", value: values)
237
260
  schema.enum = values
238
261
  return schema
239
262
  end
@@ -241,13 +264,9 @@ module GrapeOAS
241
264
  if schema.type == Constants::SchemaTypes::ARRAY &&
242
265
  schema.respond_to?(:items) && schema.items
243
266
  if schema.items.respond_to?(:canonical_name) && schema.items.canonical_name
244
- GrapeOAS.logger.warn(
245
- "Duplicating cached schema '#{schema.items.canonical_name}' to apply enum #{values.inspect}",
246
- )
247
- schema = schema.dup
248
- items_dup = schema.items.dup
249
- items_dup.canonical_name = nil
267
+ items_dup = dup_cached_schema(schema.items, reason: "enum", value: values)
250
268
  items_dup.enum = values
269
+ schema = schema.dup
251
270
  schema.items = items_dup
252
271
  else
253
272
  schema.items.enum = values
@@ -258,6 +277,22 @@ module GrapeOAS
258
277
  schema
259
278
  end
260
279
 
280
+ def apply_example_to_schema(schema, example)
281
+ return schema unless schema.respond_to?(:examples=)
282
+
283
+ if schema.respond_to?(:canonical_name) && schema.canonical_name
284
+ schema = dup_cached_schema(schema, reason: "example", value: example)
285
+ end
286
+ schema.examples = example
287
+ schema
288
+ end
289
+
290
+ # True when doc[:example] describes the whole array (not a single item),
291
+ # i.e. is_array: true and the example is itself an Array.
292
+ def array_valued_example?(doc)
293
+ doc[:is_array] && doc[:example].is_a?(Array)
294
+ end
295
+
261
296
  def normalize_doc_keys(doc)
262
297
  DocKeyNormalizer.normalize(doc)
263
298
  end
@@ -41,7 +41,7 @@ module GrapeOAS
41
41
 
42
42
  # Create allOf schema with ref to parent + child properties
43
43
  schema = ApiModel::Schema.new(
44
- canonical_name: @entity_class.name,
44
+ canonical_name: EntityIntrospectorSupport.resolve_canonical_name(@entity_class),
45
45
  all_of: [parent_schema, child_schema],
46
46
  )
47
47
 
@@ -7,6 +7,8 @@ module GrapeOAS
7
7
  # All methods are stateless and can be called directly on the class.
8
8
  class PropertyExtractor
9
9
  class << self
10
+ include GrapeOAS::ApiModelBuilders::Concerns::OasUtilities
11
+
10
12
  # Extracts description from a documentation hash.
11
13
  #
12
14
  # @param hash [Hash] the documentation hash
@@ -16,14 +18,6 @@ module GrapeOAS
16
18
  desc.is_a?(String) ? desc : nil
17
19
  end
18
20
 
19
- # Extracts nullable flag from a documentation hash.
20
- #
21
- # @param doc [Hash] the documentation hash
22
- # @return [Boolean] true if nullable
23
- def extract_nullable(doc)
24
- doc[:nullable] || doc["nullable"] || false
25
- end
26
-
27
21
  # Extracts merge flag from exposure options and documentation.
28
22
  #
29
23
  # @param exposure the entity exposure
@@ -22,7 +22,6 @@ module GrapeOAS
22
22
  # @return [ApiModel::Schema]
23
23
  def build_exposure_base_schema(type)
24
24
  if type.is_a?(Array)
25
- # Array instance like [String] - extract inner type
26
25
  inner = schema_for_type(type.first)
27
26
  ApiModel::Schema.new(type: Constants::SchemaTypes::ARRAY, items: inner)
28
27
  elsif type == Array
@@ -34,7 +33,7 @@ module GrapeOAS
34
33
  elsif type.is_a?(Hash) || type == Hash
35
34
  ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT)
36
35
  else
37
- schema_for_type(type) || ApiModel::Schema.new(type: Constants::SchemaTypes::STRING)
36
+ schema_for_type(type)
38
37
  end
39
38
  end
40
39
 
@@ -87,7 +86,7 @@ module GrapeOAS
87
86
  when String, Symbol
88
87
  schema_for_string_type(type.to_s)
89
88
  else
90
- default_string_schema
89
+ GrapeOAS.type_resolvers.build_schema(type)
91
90
  end
92
91
  end
93
92
 
@@ -95,7 +94,7 @@ module GrapeOAS
95
94
  if defined?(Grape::Entity) && type <= Grape::Entity
96
95
  GrapeOAS.introspectors.build_schema(type, stack: @stack, registry: @registry)
97
96
  else
98
- build_schema_for_primitive(type) || default_string_schema
97
+ GrapeOAS.type_resolvers.build_schema(type)
99
98
  end
100
99
  end
101
100
 
@@ -104,15 +103,10 @@ module GrapeOAS
104
103
  if entity_class
105
104
  GrapeOAS.introspectors.build_schema(entity_class, stack: @stack, registry: @registry)
106
105
  else
107
- schema_type = Constants.primitive_type(type_name) || Constants::SchemaTypes::STRING
108
- ApiModel::Schema.new(type: schema_type)
106
+ GrapeOAS.type_resolvers.build_schema(type_name)
109
107
  end
110
108
  end
111
109
 
112
- def default_string_schema
113
- ApiModel::Schema.new(type: Constants::SchemaTypes::STRING)
114
- end
115
-
116
110
  def resolve_entity_from_string(type_name)
117
111
  return nil unless defined?(Grape::Entity)
118
112
  return nil unless valid_constant_name?(type_name)
@@ -123,16 +117,6 @@ module GrapeOAS
123
117
  rescue NameError
124
118
  nil
125
119
  end
126
-
127
- def build_schema_for_primitive(type)
128
- schema_type = Constants.primitive_type(type)
129
- return nil unless schema_type
130
-
131
- ApiModel::Schema.new(
132
- type: schema_type,
133
- format: Constants.format_for_type(type),
134
- )
135
- end
136
120
  end
137
121
  end
138
122
  end
@@ -19,6 +19,30 @@ module GrapeOAS
19
19
  []
20
20
  end
21
21
 
22
+ # Resolves the canonical name for an entity class, preferring entity_name
23
+ # when defined on the class itself (via def self. or extend) and non-blank,
24
+ # falling back to the Ruby class name. Inherited entity_name is ignored to
25
+ # avoid collisions between parent and child schemas.
26
+ def self.resolve_canonical_name(entity_class)
27
+ if defines_own_entity_name?(entity_class)
28
+ name = entity_class.entity_name
29
+ name.is_a?(String) && !name.strip.empty? ? name : entity_class.name
30
+ else
31
+ entity_class.name
32
+ end
33
+ end
34
+
35
+ def self.defines_own_entity_name?(entity_class)
36
+ return false unless entity_class.respond_to?(:entity_name)
37
+
38
+ parent = find_parent_entity(entity_class)
39
+ return true if parent.nil? || !parent.respond_to?(:entity_name)
40
+
41
+ entity_class.method(:entity_name).owner !=
42
+ parent.method(:entity_name).owner
43
+ end
44
+ private_class_method :defines_own_entity_name?
45
+
22
46
  # Finds the parent entity class if one exists in the Grape::Entity hierarchy.
23
47
  def self.find_parent_entity(entity_class)
24
48
  return nil unless defined?(Grape::Entity)
@@ -34,10 +34,17 @@ module GrapeOAS
34
34
 
35
35
  return if descending?(first_val, last_val)
36
36
 
37
- target.minimum = first_val if finite_numeric?(first_val) && target.respond_to?(:minimum=)
37
+ if finite_numeric?(first_val) && target.respond_to?(:minimum=)
38
+ coerced_min = coerce_for_json(first_val)
39
+ target.minimum = coerced_min unless coerced_min.nil?
40
+ end
41
+
38
42
  return unless finite_numeric?(last_val)
39
43
 
40
- target.maximum = last_val if target.respond_to?(:maximum=)
44
+ coerced_max = coerce_for_json(last_val)
45
+ return if coerced_max.nil?
46
+
47
+ target.maximum = coerced_max if target.respond_to?(:maximum=)
41
48
  target.exclusive_maximum = range.exclude_end? if target.respond_to?(:exclusive_maximum=)
42
49
  end
43
50
 
@@ -79,6 +86,22 @@ module GrapeOAS
79
86
  val.is_a?(Numeric) && val.finite?
80
87
  end
81
88
 
89
+ # Coerce BigDecimal to Float so min/max render as JSON numbers, not strings.
90
+ # Returns nil when the result overflows to Infinity.
91
+ def coerce_for_json(val)
92
+ return val unless defined?(BigDecimal) && val.is_a?(BigDecimal)
93
+
94
+ coerced = val.to_f
95
+ unless coerced.finite?
96
+ GrapeOAS.logger.warn("BigDecimal value #{val} overflows to Float::INFINITY and cannot be represented in JSON; skipping bound")
97
+ return nil
98
+ end
99
+ if val != BigDecimal(coerced, Float::DIG + 1)
100
+ GrapeOAS.logger.debug("BigDecimal value #{val} lost precision when coerced to Float #{coerced}")
101
+ end
102
+ coerced
103
+ end
104
+
82
105
  def descending?(first_val, last_val)
83
106
  first_val.is_a?(Numeric) && last_val.is_a?(Numeric) && first_val > last_val
84
107
  end
@@ -2,13 +2,17 @@
2
2
 
3
3
  module GrapeOAS
4
4
  module TypeResolvers
5
- # Resolves array types like "[String]", "[Integer]", "[MyApp::Types::UUID]".
5
+ # Resolves array types like "[String]", "Array[Integer]", "Set[String, Integer]".
6
6
  #
7
7
  # Grape converts `type: [SomeClass]` to the string "[SomeClass]" for documentation.
8
+ # Grape 3.3+ VariantCollectionCoercer#to_s emits "Array[Type, ...]" / "Set[Type, ...]"
9
+ # for `type: Array[Integer, String]` so documentation tools can tell a collection
10
+ # of variant members from `types: [Integer, String]` (a scalar oneOf, grape#2758).
11
+ #
8
12
  # This resolver:
9
13
  # 1. Detects the array pattern via regex
10
- # 2. Extracts the inner type name
11
- # 3. Attempts to resolve it back to the actual class via Object.const_get
14
+ # 2. Extracts the inner type name(s)
15
+ # 3. Attempts to resolve each name back to the actual class via Object.const_get
12
16
  # 4. If resolved, extracts rich metadata (Dry::Types format, primitive, etc.)
13
17
  # 5. Falls back to string-based inference if class not available
14
18
  #
@@ -20,41 +24,69 @@ module GrapeOAS
20
24
  class ArrayResolver
21
25
  extend Base
22
26
 
23
- # Pattern to match Grape's array notation: "[Type]" or "[Module::Type]"
24
- # Intentionally narrow: avoid treating multi-type strings like "[String, Integer]" as arrays.
25
- ARRAY_PATTERN = /\A\[(?<inner>(?:::)?[A-Z]\w*(?:::[A-Z]\w*)*)\]\z/
27
+ TYPED_ARRAY_PATTERN = Constants::TypePatterns::TYPED_ARRAY
28
+ VARIANT_COLLECTION_PATTERN = Constants::TypePatterns::VARIANT_COLLECTION
26
29
 
27
30
  class << self
28
31
  def handles?(type)
29
32
  return false unless type.is_a?(String)
30
33
 
31
- type.match?(ARRAY_PATTERN)
34
+ type.match?(TYPED_ARRAY_PATTERN) || type.match?(VARIANT_COLLECTION_PATTERN)
32
35
  end
33
36
 
34
37
  def build_schema(type)
35
- inner_type_name = extract_inner_type(type)
36
- return nil unless inner_type_name
37
-
38
- # Try to resolve the string to an actual class
39
- resolved_class = resolve_class(inner_type_name)
40
-
41
- items_schema = if resolved_class
42
- build_schema_from_class(resolved_class)
43
- else
44
- build_schema_from_string(inner_type_name)
45
- end
38
+ container, type_names = extract_collection(type)
39
+ return nil unless type_names
46
40
 
47
- ApiModel::Schema.new(
41
+ items_schema = build_items_schema(type_names)
42
+ schema = ApiModel::Schema.new(
48
43
  type: Constants::SchemaTypes::ARRAY,
49
44
  items: items_schema,
50
45
  )
46
+ schema.unique_items = true if container == "Set"
47
+ schema
51
48
  end
52
49
 
53
50
  private
54
51
 
55
- def extract_inner_type(type)
56
- match = type.match(ARRAY_PATTERN)
57
- match[:inner] if match
52
+ def extract_collection(type)
53
+ if (match = type.match(VARIANT_COLLECTION_PATTERN))
54
+ [match[:container], match[:inner].split(/,\s*/)]
55
+ elsif (match = type.match(TYPED_ARRAY_PATTERN))
56
+ [match[:container], [match[:inner]]]
57
+ end
58
+ end
59
+
60
+ def build_items_schema(type_names)
61
+ if nullable_type_pair?(type_names)
62
+ schema = build_items_for_name(type_names.find { |name| !nil_type_name?(name) })
63
+ schema.nullable = true
64
+ return schema
65
+ end
66
+
67
+ return build_items_for_name(type_names.first) if type_names.size == 1
68
+
69
+ has_nil_type = type_names.any? { |name| nil_type_name?(name) }
70
+ variants = type_names.reject { |name| nil_type_name?(name) }.map { |name| build_items_for_name(name) }
71
+ ApiModel::Schema.new(one_of: variants, nullable: has_nil_type ? true : nil)
72
+ end
73
+
74
+ def build_items_for_name(type_name)
75
+ resolved_class = resolve_class(type_name)
76
+ if resolved_class
77
+ build_schema_from_class(resolved_class)
78
+ else
79
+ build_schema_from_string(type_name)
80
+ end
81
+ end
82
+
83
+ def nullable_type_pair?(type_names)
84
+ type_names.size == 2 && type_names.one? { |name| nil_type_name?(name) }
85
+ end
86
+
87
+ def nil_type_name?(type_name)
88
+ normalized = type_name.to_s
89
+ normalized == "NilClass" || normalized == "Nil" || normalized.end_with?("::Nil")
58
90
  end
59
91
 
60
92
  def build_schema_from_class(klass)
@@ -49,7 +49,7 @@ module GrapeOAS
49
49
  # Checks if this resolver can handle the given type.
50
50
  #
51
51
  # @param type [String, Class, Object] The type to check (stringified or actual)
52
- # @return [Boolean] true if this resolver can handle the type
52
+ # @return [Boolean]
53
53
  def handles?(type)
54
54
  raise NotImplementedError, "#{self} must implement .handles?(type)"
55
55
  end
@@ -57,7 +57,7 @@ module GrapeOAS
57
57
  # Builds an OpenAPI schema from the given type.
58
58
  #
59
59
  # @param type [String, Class, Object] The type to build schema for
60
- # @return [ApiModel::Schema] The built schema
60
+ # @return [ApiModel::Schema, nil]
61
61
  def build_schema(type)
62
62
  raise NotImplementedError, "#{self} must implement .build_schema(type)"
63
63
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module TypeResolvers
5
+ # Catch-all used by Registry#build_schema when no registered resolver
6
+ # matches. Returns a plain string schema for any type. Not registered
7
+ # in the chain — called automatically by the registry as a built-in
8
+ # fallback.
9
+ class DefaultResolver
10
+ class << self
11
+ def handles?(_type)
12
+ true
13
+ end
14
+
15
+ def build_schema(type)
16
+ logger = GrapeOAS.logger
17
+ logger.debug { "No type resolver matched #{type.inspect}, falling back to string schema" } if logger.respond_to?(:debug)
18
+ ApiModel::Schema.new(type: Constants::SchemaTypes::STRING)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -26,7 +26,7 @@ module GrapeOAS
26
26
 
27
27
  # Handle strings that resolve to Dry::Types
28
28
  return false unless type.is_a?(String)
29
- return false if type.match?(/\A\[.+\]\z/) # Skip arrays, handled by ArrayResolver
29
+ return false if type.match?(/\A(?:Array|Set)?\[.+\]\z/) # Skip arrays, handled by ArrayResolver
30
30
 
31
31
  resolved = resolve_class(type)
32
32
  dry_type?(resolved)
@@ -4,8 +4,9 @@ module GrapeOAS
4
4
  module TypeResolvers
5
5
  # Resolves primitive types like "Integer", "String", "Boolean", "Float".
6
6
  #
7
- # This is the fallback resolver that handles basic Ruby types and their
8
- # string representations. It's registered last in the resolver chain.
7
+ # Handles basic Ruby types and their string representations, including
8
+ # OpenAPI type name aliases via Constants. Registered before the
9
+ # catch-all DefaultResolver in the resolver chain.
9
10
  #
10
11
  class PrimitiveResolver
11
12
  extend Base
@@ -13,7 +14,7 @@ module GrapeOAS
13
14
  # Known primitive type mappings
14
15
  PRIMITIVES = {
15
16
  "String" => { type: Constants::SchemaTypes::STRING },
16
- "Integer" => { type: Constants::SchemaTypes::INTEGER, format: "int32" },
17
+ "Integer" => { type: Constants::SchemaTypes::INTEGER },
17
18
  "Float" => { type: Constants::SchemaTypes::NUMBER, format: "float" },
18
19
  "BigDecimal" => { type: Constants::SchemaTypes::NUMBER, format: "double" },
19
20
  "Numeric" => { type: Constants::SchemaTypes::NUMBER },
@@ -33,33 +34,33 @@ module GrapeOAS
33
34
 
34
35
  class << self
35
36
  def handles?(type)
36
- type_str = normalize_type(type)
37
- PRIMITIVES.key?(type_str) || resolvable_to_primitive?(type)
37
+ !find_mapping(type).nil?
38
38
  end
39
39
 
40
40
  def build_schema(type)
41
+ schema_type, format = find_mapping(type)
42
+ return nil unless schema_type
43
+
44
+ ApiModel::Schema.new(type: schema_type, format: format)
45
+ end
46
+
47
+ private
48
+
49
+ # Returns [type, format] or nil.
50
+ def find_mapping(type)
41
51
  type_str = normalize_type(type)
42
52
 
43
- # Check direct mapping first
44
- if PRIMITIVES.key?(type_str)
45
- mapping = PRIMITIVES[type_str]
46
- return ApiModel::Schema.new(
47
- type: mapping[:type],
48
- format: mapping[:format],
49
- )
53
+ if (mapping = PRIMITIVES[type_str])
54
+ return [mapping[:type], mapping[:format]]
50
55
  end
51
56
 
52
- # Try to resolve and build schema
53
- resolved = resolve_class(type_str)
54
- if resolved
55
- build_from_resolved(resolved)
56
- else
57
- # Default fallback
58
- ApiModel::Schema.new(type: Constants::SchemaTypes::STRING)
59
- end
60
- end
57
+ # OpenAPI type name aliases ("object", "number", "boolean")
58
+ schema_type = Constants.primitive_type(type_str)
59
+ return [schema_type, Constants.format_for_type(type_str)] if schema_type
61
60
 
62
- private
61
+ mapping = resolved_primitive_mapping(resolve_class(type_str))
62
+ [mapping[:type], mapping[:format]] if mapping
63
+ end
63
64
 
64
65
  def normalize_type(type)
65
66
  case type
@@ -72,32 +73,14 @@ module GrapeOAS
72
73
  end
73
74
  end
74
75
 
75
- def resolvable_to_primitive?(type)
76
- resolved = resolve_class(normalize_type(type))
77
- return false unless resolved
78
-
79
- # Dry::Types should be handled by DryTypeResolver.
80
- return false if resolved.respond_to?(:primitive)
81
-
82
- resolved_name = resolved.respond_to?(:name) ? resolved.name : resolved.to_s
83
- return false if resolved_name.nil? || resolved_name.empty?
76
+ def resolved_primitive_mapping(resolved)
77
+ return nil unless resolved
78
+ return nil if resolved.respond_to?(:primitive) # Dry::Types handled by DryTypeResolver
84
79
 
85
- PRIMITIVES.key?(resolved_name)
86
- end
87
-
88
- def build_from_resolved(klass)
89
- # Find mapping by class name
90
- type_str = klass.name
91
- mapping = PRIMITIVES[type_str]
80
+ resolved_name = resolved.respond_to?(:name) ? resolved.name : nil
81
+ return nil if resolved_name.nil? || resolved_name.empty?
92
82
 
93
- if mapping
94
- ApiModel::Schema.new(
95
- type: mapping[:type],
96
- format: mapping[:format],
97
- )
98
- else
99
- ApiModel::Schema.new(type: Constants::SchemaTypes::STRING)
100
- end
83
+ PRIMITIVES[resolved_name]
101
84
  end
102
85
  end
103
86
  end
@@ -50,31 +50,38 @@ module GrapeOAS
50
50
  self
51
51
  end
52
52
 
53
- # Finds the first resolver that can handle the given type.
54
- #
55
- # @param type [String, Class, Object] The type to resolve
56
- # @return [Class, nil] The resolver class, or nil if none found
57
- def find(type)
58
- @resolvers.find { |resolver| resolver.handles?(type) }
59
- end
60
-
61
- # Builds a schema using the appropriate resolver.
53
+ # Builds a schema using the first resolver that returns non-nil.
54
+ # Falls back to DefaultResolver when no registered resolver produces
55
+ # a schema, guaranteeing a non-nil return.
62
56
  #
63
57
  # @param type [String, Class, Object] The type to build schema for
64
- # @return [ApiModel::Schema, nil] The built schema, or nil if no handler found
58
+ # @return [ApiModel::Schema] The built schema
65
59
  def build_schema(type)
66
- resolver = find(type)
67
- return nil unless resolver
60
+ @resolvers.each do |resolver|
61
+ next unless resolver.handles?(type)
62
+
63
+ schema = resolver.build_schema(type)
64
+ return schema if schema
65
+ end
68
66
 
69
- resolver.build_schema(type)
67
+ DefaultResolver.build_schema(type)
70
68
  end
71
69
 
72
- # Checks if any resolver can handle the given type.
70
+ # Checks if any registered resolver produces a schema for this type.
71
+ # Does not account for the built-in DefaultResolver fallback, so
72
+ # returning false does not mean build_schema will return nil — it
73
+ # will still produce a string schema via DefaultResolver.
73
74
  #
74
75
  # @param type [String, Class, Object] The type to check
75
76
  # @return [Boolean]
77
+ def registered_resolver_for?(type)
78
+ !find(type).nil?
79
+ end
80
+
81
+ # @deprecated Use {#registered_resolver_for?} instead.
76
82
  def handles?(type)
77
- @resolvers.any? { |resolver| resolver.handles?(type) }
83
+ warn "GrapeOAS::TypeResolvers::Registry#handles? is deprecated, use #registered_resolver_for? instead", uplevel: 1
84
+ registered_resolver_for?(type)
78
85
  end
79
86
 
80
87
  # Iterates over all registered resolvers.
@@ -108,7 +115,16 @@ module GrapeOAS
108
115
 
109
116
  private
110
117
 
118
+ def find(type)
119
+ @resolvers.find { |resolver| resolver.handles?(type) }
120
+ end
121
+
111
122
  def validate_resolver!(resolver)
123
+ if resolver == DefaultResolver
124
+ raise ArgumentError,
125
+ "DefaultResolver is the built-in fallback and must not be registered in the chain"
126
+ end
127
+
112
128
  return if resolver.respond_to?(:handles?) && resolver.respond_to?(:build_schema)
113
129
 
114
130
  raise ArgumentError,