grape-oas 1.2.0 → 1.4.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/README.md +6 -0
  4. data/lib/grape_oas/api_model/api.rb +4 -0
  5. data/lib/grape_oas/api_model/schema.rb +18 -3
  6. data/lib/grape_oas/api_model_builders/concerns/content_type_resolver.rb +21 -0
  7. data/lib/grape_oas/api_model_builders/concerns/type_resolver.rb +3 -6
  8. data/lib/grape_oas/api_model_builders/request.rb +21 -12
  9. data/lib/grape_oas/api_model_builders/request_params_support/param_location_resolver.rb +7 -2
  10. data/lib/grape_oas/api_model_builders/request_params_support/param_schema_builder.rb +15 -1
  11. data/lib/grape_oas/api_model_builders/request_params_support/schema_enhancer.rb +34 -50
  12. data/lib/grape_oas/constants.rb +13 -0
  13. data/lib/grape_oas/doc_key_normalizer.rb +14 -0
  14. data/lib/grape_oas/exporter/oas2/parameter.rb +1 -0
  15. data/lib/grape_oas/exporter/oas2/schema.rb +53 -19
  16. data/lib/grape_oas/exporter/oas3/parameter.rb +5 -2
  17. data/lib/grape_oas/exporter/oas3/schema.rb +81 -46
  18. data/lib/grape_oas/introspectors/dry_introspector_support/argument_extractor.rb +2 -31
  19. data/lib/grape_oas/introspectors/dry_introspector_support/constraint_extractor.rb +10 -19
  20. data/lib/grape_oas/introspectors/dry_introspector_support/predicate_handler.rb +2 -10
  21. data/lib/grape_oas/introspectors/entity_introspector.rb +5 -1
  22. data/lib/grape_oas/introspectors/entity_introspector_support/discriminator_handler.rb +2 -25
  23. data/lib/grape_oas/introspectors/entity_introspector_support/exposure_processor.rb +139 -136
  24. data/lib/grape_oas/introspectors/entity_introspector_support/inheritance_builder.rb +6 -30
  25. data/lib/grape_oas/introspectors/entity_introspector_support/nesting_merger.rb +104 -0
  26. data/lib/grape_oas/introspectors/entity_introspector_support/property_extractor.rb +2 -1
  27. data/lib/grape_oas/introspectors/entity_introspector_support/type_schema_resolver.rb +139 -0
  28. data/lib/grape_oas/introspectors/entity_introspector_support.rb +57 -0
  29. data/lib/grape_oas/range_utils.rb +87 -0
  30. data/lib/grape_oas/schema_constraints.rb +36 -0
  31. data/lib/grape_oas/type_resolvers/array_resolver.rb +3 -5
  32. data/lib/grape_oas/values_normalizer.rb +47 -0
  33. data/lib/grape_oas/version.rb +1 -1
  34. data/lib/grape_oas.rb +27 -0
  35. metadata +9 -2
@@ -29,13 +29,7 @@ module GrapeOAS
29
29
  #
30
30
  # @return [Array] list of entity exposures
31
31
  def exposures
32
- return [] unless @entity_class.respond_to?(:root_exposures)
33
-
34
- root = @entity_class.root_exposures
35
- list = root.instance_variable_get(:@exposures) || []
36
- Array(list)
37
- rescue NoMethodError
38
- []
32
+ EntityIntrospectorSupport.exposures(@entity_class)
39
33
  end
40
34
 
41
35
  # Gets the exposures defined on a parent entity.
@@ -43,38 +37,43 @@ module GrapeOAS
43
37
  # @param parent_entity [Class] the parent entity class
44
38
  # @return [Array] list of parent exposures
45
39
  def parent_exposures(parent_entity)
46
- return [] unless parent_entity.respond_to?(:root_exposures)
47
-
48
- root = parent_entity.root_exposures
49
- list = root.instance_variable_get(:@exposures) || []
50
- Array(list)
51
- rescue NoMethodError
52
- []
40
+ EntityIntrospectorSupport.exposures(parent_entity)
53
41
  end
54
42
 
55
43
  # Builds a schema for an exposure.
56
44
  #
57
45
  # @param exposure the entity exposure
58
- # @param doc [Hash] the documentation hash
59
46
  # @return [ApiModel::Schema] the built schema
60
47
  def schema_for_exposure(exposure, doc)
61
- opts = exposure.instance_variable_get(:@options) || {}
62
- type = opts[:using] || doc[:type] || doc["type"]
48
+ opts = exposure_options(exposure)
49
+ type = opts[:using] || doc[:type]
63
50
 
64
- schema = build_exposure_base_schema(type)
65
- apply_exposure_properties(schema, doc)
66
- apply_exposure_constraints(schema, doc)
51
+ schema = type_resolver.build_exposure_base_schema(type)
52
+ schema = apply_exposure_properties(schema, doc)
53
+ SchemaConstraints.apply(schema, doc)
67
54
  schema
68
55
  end
69
56
 
57
+ # Builds the property schema for an exposure, routing nesting exposures
58
+ # to the inline-object path. Wraps in array if doc[:is_array] is set.
59
+ #
60
+ # @param exposure the entity exposure
61
+ # @param doc [Hash] normalized documentation hash
62
+ # @return [ApiModel::Schema]
63
+ def build_property_schema(exposure, doc)
64
+ prop_schema = if nesting_exposure?(exposure)
65
+ build_nesting_exposure_schema(exposure, doc)
66
+ else
67
+ schema_for_exposure(exposure, doc)
68
+ end
69
+ wrap_in_array_if_needed(prop_schema, doc)
70
+ end
71
+
70
72
  # Checks if an exposure should be included in the schema.
71
73
  #
72
74
  # @param exposure the entity exposure
73
75
  # @return [Boolean] true if exposed
74
- def exposed?(exposure)
75
- exposure.instance_variable_get(:@conditions) || []
76
- true
77
- rescue NoMethodError
76
+ def exposed?(_exposure)
78
77
  true
79
78
  end
80
79
 
@@ -97,14 +96,40 @@ module GrapeOAS
97
96
  # @return [Boolean] true if merge exposure
98
97
  def merge_exposure?(exposure, doc, opts)
99
98
  merge_flag = PropertyExtractor.extract_merge_flag(exposure, doc, opts)
100
- merge_flag && resolve_entity_from_opts(exposure, doc)
99
+ merge_flag && type_resolver.resolve_entity_from_opts(exposure, doc)
100
+ end
101
+
102
+ # Returns the options hash for an exposure.
103
+ #
104
+ # @param exposure the entity exposure
105
+ # @return [Hash]
106
+ def exposure_options(exposure)
107
+ exposure.instance_variable_get(:@options) || {}
108
+ end
109
+
110
+ # Determines whether a property should be marked required.
111
+ # Explicit doc[:required] takes precedence; conditional exposures
112
+ # default to false; unconditional exposures default to true.
113
+ #
114
+ # @param doc [Hash] normalized documentation hash
115
+ # @param exposure the entity exposure
116
+ # @return [Boolean]
117
+ def determine_required(doc, exposure)
118
+ return doc[:required] unless doc[:required].nil?
119
+ return false if conditional?(exposure)
120
+
121
+ true
101
122
  end
102
123
 
103
124
  private
104
125
 
126
+ def type_resolver
127
+ @type_resolver ||= TypeSchemaResolver.new(stack: @stack, registry: @registry)
128
+ end
129
+
105
130
  def add_exposure_to_schema(schema, exposure)
106
- doc = exposure.documentation || {}
107
- opts = exposure.instance_variable_get(:@options) || {}
131
+ doc = normalize_doc_keys(exposure.documentation || {})
132
+ opts = exposure_options(exposure)
108
133
 
109
134
  if merge_exposure?(exposure, doc, opts)
110
135
  merge_exposure_into_schema(schema, exposure, doc)
@@ -114,149 +139,127 @@ module GrapeOAS
114
139
  end
115
140
 
116
141
  def merge_exposure_into_schema(schema, exposure, doc)
117
- merged_schema = schema_for_merge(exposure, doc)
142
+ merged_schema = type_resolver.schema_for_merge(exposure, doc)
118
143
  merged_schema.properties.each do |n, ps|
119
144
  schema.add_property(n, ps, required: merged_schema.required.include?(n))
120
145
  end
121
146
  end
122
147
 
123
148
  def add_property_from_exposure(schema, exposure, doc)
124
- prop_schema = schema_for_exposure(exposure, doc)
149
+ prop_schema = build_property_schema(exposure, doc)
125
150
  required = determine_required(doc, exposure)
126
- prop_schema = wrap_in_array_if_needed(prop_schema, doc)
127
151
  schema.add_property(exposure.key.to_s, prop_schema, required: required)
128
152
  end
129
153
 
130
- def determine_required(doc, exposure)
131
- # If explicitly set in documentation, use that value
132
- return doc[:required] unless doc[:required].nil?
133
-
134
- # Conditional exposures are not required (may be absent from output)
135
- return false if conditional?(exposure)
136
-
137
- # Unconditional exposures are required by default (always present in output)
138
- true
139
- end
140
-
141
154
  def wrap_in_array_if_needed(prop_schema, doc)
142
- is_array = doc[:is_array] || doc["is_array"]
155
+ is_array = doc[:is_array]
143
156
  return prop_schema unless is_array
144
157
 
145
158
  ApiModel::Schema.new(type: Constants::SchemaTypes::ARRAY, items: prop_schema)
146
159
  end
147
160
 
148
- def build_exposure_base_schema(type)
149
- if type.is_a?(Array)
150
- # Array instance like [String] - extract inner type
151
- inner = schema_for_type(type.first)
152
- ApiModel::Schema.new(type: Constants::SchemaTypes::ARRAY, items: inner)
153
- elsif type == Array
154
- # Array class itself - create array with string items
155
- ApiModel::Schema.new(
156
- type: Constants::SchemaTypes::ARRAY,
157
- items: ApiModel::Schema.new(type: Constants::SchemaTypes::STRING),
158
- )
159
- elsif type.is_a?(Hash) || type == Hash
160
- ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT)
161
- else
162
- schema_for_type(type) || ApiModel::Schema.new(type: Constants::SchemaTypes::STRING)
161
+ # Detects block-based nesting exposures (NestingExposure) that should become
162
+ # inline object schemas. Only triggers when no entity class is via `using:`.
163
+ def nesting_exposure?(exposure)
164
+ return false unless exposure.respond_to?(:nesting?) && exposure.nesting?
165
+
166
+ doc = normalize_doc_keys(exposure.documentation || {})
167
+ opts = exposure_options(exposure)
168
+ # Extra !opts[:using] catches using: set to a non-entity class (e.g. String)
169
+ !type_resolver.resolve_grape_entity_class(opts, doc) && !opts[:using]
170
+ end
171
+
172
+ # Builds an inline object schema from a NestingExposure's child exposures.
173
+ # Duplicate-key children (conditional branches) are merged via NestingMerger.
174
+ def build_nesting_exposure_schema(exposure, doc)
175
+ schema = ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT)
176
+ return schema unless exposure.respond_to?(:nested_exposures)
177
+
178
+ nesting_accum = {}
179
+ nesting_required = Hash.new { |h, k| h[k] = [] }
180
+ Array(exposure.nested_exposures).each do |child_exposure|
181
+ if nesting_exposure?(child_exposure)
182
+ key = child_exposure.key.to_s
183
+ child_doc = normalize_doc_keys(child_exposure.documentation || {})
184
+ child_schema = build_property_schema(child_exposure, child_doc)
185
+ nesting_required[key] << determine_required(child_doc, child_exposure)
186
+ nesting_accum[key] = NestingMerger.merge(nesting_accum[key], child_schema)
187
+ else
188
+ add_exposure_to_schema(schema, child_exposure)
189
+ end
163
190
  end
191
+
192
+ # ALL branches must agree for the property to be required.
193
+ nesting_accum.each do |key, merged_schema|
194
+ schema.add_property(key, merged_schema, required: nesting_required[key].all?)
195
+ end
196
+
197
+ schema = apply_exposure_properties(schema, doc)
198
+ SchemaConstraints.apply(schema, doc)
199
+ schema
164
200
  end
165
201
 
166
202
  def apply_exposure_properties(schema, doc)
167
- schema.nullable = doc[:nullable] || doc["nullable"] || false
168
- schema.enum = doc[:values] || doc["values"] if doc[:values] || doc["values"]
169
- schema.description = doc[:desc] || doc["desc"] if doc[:desc] || doc["desc"]
170
- schema.format = doc[:format] || doc["format"] if doc[:format] || doc["format"]
171
- schema.examples = doc[:example] || doc["example"] if schema.respond_to?(:examples=) && (doc[:example] || doc["example"])
203
+ schema.nullable = doc[:nullable] || false
204
+ raw_values = doc[:values]
205
+ if raw_values
206
+ normalized = ValuesNormalizer.normalize(raw_values, context: "entity exposure values")
207
+ if normalized.is_a?(Array) && !normalized.empty?
208
+ schema = apply_enum_to_schema(schema, normalized)
209
+ elsif normalized.is_a?(Range)
210
+ RangeUtils.apply_to_schema(schema, normalized)
211
+ end
212
+ end
213
+ schema.description = doc[:desc] if doc[:desc]
214
+ schema.format = doc[:format] if doc[:format]
215
+ schema.examples = doc[:example] if schema.respond_to?(:examples=) && doc[:example]
172
216
  schema.additional_properties = doc[:additional_properties] if doc.key?(:additional_properties)
173
217
  schema.unevaluated_properties = doc[:unevaluated_properties] if doc.key?(:unevaluated_properties)
174
218
  defs = doc[:defs] || doc[:$defs]
175
219
  schema.defs = defs if defs.is_a?(Hash)
176
220
  x_ext = extract_extensions(doc)
177
221
  schema.extensions = x_ext if x_ext && schema.respond_to?(:extensions=)
222
+ schema
178
223
  end
179
224
 
180
- def apply_exposure_constraints(schema, doc)
181
- schema.minimum = doc[:minimum] if doc.key?(:minimum) && schema.respond_to?(:minimum=)
182
- schema.maximum = doc[:maximum] if doc.key?(:maximum) && schema.respond_to?(:maximum=)
183
- schema.min_length = doc[:min_length] if doc.key?(:min_length) && schema.respond_to?(:min_length=)
184
- schema.max_length = doc[:max_length] if doc.key?(:max_length) && schema.respond_to?(:max_length=)
185
- schema.pattern = doc[:pattern] if doc.key?(:pattern) && schema.respond_to?(:pattern=)
186
- end
187
-
188
- def schema_for_type(type)
189
- case type
190
- when Class
191
- schema_for_class_type(type)
192
- when String, Symbol
193
- schema_for_string_type(type.to_s)
194
- else
195
- default_string_schema
196
- end
197
- end
198
-
199
- def schema_for_class_type(type)
200
- if defined?(Grape::Entity) && type <= Grape::Entity
201
- GrapeOAS.introspectors.build_schema(type, stack: @stack, registry: @registry)
202
- else
203
- build_schema_for_primitive(type) || default_string_schema
225
+ # 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.
228
+ #
229
+ # @return [ApiModel::Schema] the schema (or a dup) with enum applied
230
+ def apply_enum_to_schema(schema, values)
231
+ 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
237
+ schema.enum = values
238
+ return schema
204
239
  end
205
- end
206
240
 
207
- def schema_for_string_type(type_name)
208
- entity_class = resolve_entity_from_string(type_name)
209
- if entity_class
210
- GrapeOAS.introspectors.build_schema(entity_class, stack: @stack, registry: @registry)
241
+ if schema.type == Constants::SchemaTypes::ARRAY &&
242
+ schema.respond_to?(:items) && schema.items
243
+ 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
250
+ items_dup.enum = values
251
+ schema.items = items_dup
252
+ else
253
+ schema.items.enum = values
254
+ end
211
255
  else
212
- schema_type = Constants.primitive_type(type_name) || Constants::SchemaTypes::STRING
213
- ApiModel::Schema.new(type: schema_type)
256
+ schema.enum = values
214
257
  end
258
+ schema
215
259
  end
216
260
 
217
- def default_string_schema
218
- ApiModel::Schema.new(type: Constants::SchemaTypes::STRING)
219
- end
220
-
221
- def resolve_entity_from_string(type_name)
222
- return nil unless defined?(Grape::Entity)
223
- return nil unless valid_constant_name?(type_name)
224
- return nil unless Object.const_defined?(type_name, false)
225
-
226
- klass = Object.const_get(type_name, false)
227
- klass if klass.is_a?(Class) && klass <= Grape::Entity
228
- rescue NameError
229
- nil
230
- end
231
-
232
- def schema_for_merge(exposure, doc)
233
- using_class = resolve_entity_from_opts(exposure, doc)
234
- return ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT) unless using_class
235
-
236
- child = GrapeOAS.introspectors.build_schema(using_class, stack: @stack, registry: @registry)
237
- merged = ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT)
238
- child.properties.each do |n, ps|
239
- merged.add_property(n, ps, required: child.required.include?(n))
240
- end
241
- merged
242
- end
243
-
244
- def resolve_entity_from_opts(exposure, doc)
245
- opts = exposure.instance_variable_get(:@options) || {}
246
- type = opts[:using] || doc[:type] || doc["type"]
247
- return type if defined?(Grape::Entity) && type.is_a?(Class) && type <= Grape::Entity
248
-
249
- nil
250
- end
251
-
252
- def build_schema_for_primitive(type)
253
- schema_type = Constants.primitive_type(type)
254
- return nil unless schema_type
255
-
256
- ApiModel::Schema.new(
257
- type: schema_type,
258
- format: Constants.format_for_type(type),
259
- )
261
+ def normalize_doc_keys(doc)
262
+ DocKeyNormalizer.normalize(doc)
260
263
  end
261
264
  end
262
265
  end
@@ -16,12 +16,7 @@ module GrapeOAS
16
16
  # @param entity_class [Class] the entity class to check
17
17
  # @return [Class, nil] the parent entity class or nil
18
18
  def self.find_parent_entity(entity_class)
19
- return nil unless defined?(Grape::Entity)
20
-
21
- parent = entity_class.superclass
22
- return nil unless parent && parent < Grape::Entity && parent != Grape::Entity
23
-
24
- parent
19
+ EntityIntrospectorSupport.find_parent_entity(entity_class)
25
20
  end
26
21
 
27
22
  # Checks if an entity inherits from a parent that uses discriminator.
@@ -46,7 +41,7 @@ module GrapeOAS
46
41
 
47
42
  # Create allOf schema with ref to parent + child properties
48
43
  schema = ApiModel::Schema.new(
49
- canonical_name: @entity_class.name,
44
+ canonical_name: EntityIntrospectorSupport.resolve_canonical_name(@entity_class),
50
45
  all_of: [parent_schema, child_schema],
51
46
  )
52
47
 
@@ -77,35 +72,16 @@ module GrapeOAS
77
72
  end
78
73
 
79
74
  def add_child_property(child_schema, exposure, processor)
80
- doc = exposure.documentation || {}
81
- opts = exposure.instance_variable_get(:@options) || {}
75
+ doc = DocKeyNormalizer.normalize(exposure.documentation || {})
76
+ opts = processor.exposure_options(exposure)
82
77
 
83
78
  return if processor.merge_exposure?(exposure, doc, opts)
84
79
 
85
- prop_schema = processor.schema_for_exposure(exposure, doc)
86
- required = determine_required(doc, exposure, processor)
87
- prop_schema = wrap_in_array_if_needed(prop_schema, doc)
80
+ prop_schema = processor.build_property_schema(exposure, doc)
81
+ required = processor.determine_required(doc, exposure)
88
82
 
89
83
  child_schema.add_property(exposure.key.to_s, prop_schema, required: required)
90
84
  end
91
-
92
- def determine_required(doc, exposure, processor)
93
- # If explicitly set in documentation, use that value
94
- return doc[:required] unless doc[:required].nil?
95
-
96
- # Conditional exposures are not required (may be absent from output)
97
- return false if processor.conditional?(exposure)
98
-
99
- # Unconditional exposures are required by default (always present in output)
100
- true
101
- end
102
-
103
- def wrap_in_array_if_needed(prop_schema, doc)
104
- is_array = doc[:is_array] || doc["is_array"]
105
- return prop_schema unless is_array
106
-
107
- ApiModel::Schema.new(type: Constants::SchemaTypes::ARRAY, items: prop_schema)
108
- end
109
85
  end
110
86
  end
111
87
  end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Introspectors
5
+ module EntityIntrospectorSupport
6
+ # Merges duplicate-key nesting exposure branches into a single schema,
7
+ # preserving properties from all branches.
8
+ module NestingMerger
9
+ MAX_MERGE_DEPTH = 10 # Grape nesting rarely exceeds 3-4 levels
10
+
11
+ class << self
12
+ # @param accum [ApiModel::Schema, nil] accumulated schema from previous branches
13
+ # @param current [ApiModel::Schema] schema from the current branch
14
+ # @param depth [Integer] current recursion depth (guarded by MAX_MERGE_DEPTH)
15
+ # @return [ApiModel::Schema] merged schema
16
+ def merge(accum, current, depth = 0)
17
+ return current unless accum
18
+ return accum if current.equal?(accum)
19
+
20
+ # Unwrap array schemas to merge their items, then re-wrap
21
+ if array_of_objects?(accum) && array_of_objects?(current)
22
+ merged_items = merge(accum.items, current.items, depth + 1)
23
+ merged_array = ApiModel::Schema.new(type: Constants::SchemaTypes::ARRAY, items: merged_items)
24
+ copy_branch_metadata(merged_array, accum)
25
+ copy_branch_metadata(merged_array, current)
26
+ return merged_array
27
+ end
28
+
29
+ return accum unless current&.type == Constants::SchemaTypes::OBJECT
30
+ return current unless accum.type == Constants::SchemaTypes::OBJECT
31
+
32
+ merge_object_schemas(accum, current, depth)
33
+ end
34
+
35
+ private
36
+
37
+ def merge_object_schemas(accum, current, depth)
38
+ shared_required = accum.required & current.required
39
+ merged = ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT)
40
+ copy_branch_metadata(merged, accum)
41
+ copy_branch_metadata(merged, current)
42
+
43
+ accum.properties.each do |n, s|
44
+ merged.add_property(n, s, required: shared_required.include?(n))
45
+ end
46
+
47
+ current.properties.each do |n, s|
48
+ existing = merged.properties[n]
49
+ if existing && mergeable_schemas?(existing, s)
50
+ if depth < MAX_MERGE_DEPTH
51
+ merged.properties[n] = merge(existing, s, depth + 1)
52
+ else
53
+ GrapeOAS.logger.warn(
54
+ "NestingMerger: property '#{n}' exceeds maximum merge depth " \
55
+ "(#{MAX_MERGE_DEPTH}); using current branch value instead of merging",
56
+ )
57
+ merged.add_property(n, s, required: shared_required.include?(n))
58
+ end
59
+ else
60
+ merged.add_property(n, s, required: shared_required.include?(n))
61
+ end
62
+ end
63
+ merged
64
+ end
65
+
66
+ # Copies scalar metadata. First non-nil wins for description/format/examples;
67
+ # nullable uses OR; extensions are merged (last branch wins for overlapping keys).
68
+ def copy_branch_metadata(merged, source)
69
+ merged.description ||= source.description
70
+ merged.nullable = true if source.nullable
71
+ merged.format ||= source.format
72
+ merged.examples ||= source.examples if source.respond_to?(:examples)
73
+ return unless source.respond_to?(:extensions) && source.extensions
74
+
75
+ existing = merged.extensions || {}
76
+ merged.extensions = existing.merge(dup_hash_recursive(source.extensions))
77
+ end
78
+
79
+ def mergeable_schemas?(left, right)
80
+ return true if left.type == Constants::SchemaTypes::OBJECT && right.type == Constants::SchemaTypes::OBJECT
81
+
82
+ array_of_objects?(left) && array_of_objects?(right)
83
+ end
84
+
85
+ def array_of_objects?(schema)
86
+ schema&.type == Constants::SchemaTypes::ARRAY &&
87
+ schema.items&.type == Constants::SchemaTypes::OBJECT
88
+ end
89
+
90
+ # Recursive dup for extension hashes. Non-collection values are shared (safe for frozen literals).
91
+ def dup_hash_recursive(hash)
92
+ hash.each_with_object({}) do |(k, v), result|
93
+ result[k] = case v
94
+ when Hash then dup_hash_recursive(v)
95
+ when Array then v.map { |e| e.is_a?(Hash) ? dup_hash_recursive(e) : e }
96
+ else v
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -12,7 +12,8 @@ module GrapeOAS
12
12
  # @param hash [Hash] the documentation hash
13
13
  # @return [String, nil] the description value
14
14
  def extract_description(hash)
15
- hash[:description] || hash[:desc]
15
+ desc = hash[:description] || hash[:desc]
16
+ desc.is_a?(String) ? desc : nil
16
17
  end
17
18
 
18
19
  # Extracts nullable flag from a documentation hash.
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Introspectors
5
+ module EntityIntrospectorSupport
6
+ # Resolves OpenAPI schemas from Grape Entity exposure types.
7
+ # Handles primitives, Grape::Entity subclasses (via recursive introspection),
8
+ # and merge exposures. Extracted from ExposureProcessor so the type-resolution
9
+ # concern can be read and tested in isolation.
10
+ class TypeSchemaResolver
11
+ include GrapeOAS::ApiModelBuilders::Concerns::OasUtilities
12
+
13
+ def initialize(stack:, registry:)
14
+ @stack = stack
15
+ @registry = registry
16
+ end
17
+
18
+ # Builds the base schema for an exposure's type annotation.
19
+ # Handles array literals, the Array class, Hash, and all scalar types.
20
+ #
21
+ # @param type [Class, Array, String, Symbol, nil] the type annotation
22
+ # @return [ApiModel::Schema]
23
+ def build_exposure_base_schema(type)
24
+ if type.is_a?(Array)
25
+ # Array instance like [String] - extract inner type
26
+ inner = schema_for_type(type.first)
27
+ ApiModel::Schema.new(type: Constants::SchemaTypes::ARRAY, items: inner)
28
+ elsif type == Array
29
+ # Array class itself - create array with string items
30
+ ApiModel::Schema.new(
31
+ type: Constants::SchemaTypes::ARRAY,
32
+ items: ApiModel::Schema.new(type: Constants::SchemaTypes::STRING),
33
+ )
34
+ elsif type.is_a?(Hash) || type == Hash
35
+ ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT)
36
+ else
37
+ schema_for_type(type) || ApiModel::Schema.new(type: Constants::SchemaTypes::STRING)
38
+ end
39
+ end
40
+
41
+ # Builds and returns a flattened object schema from the merge-target entity.
42
+ # Returns an empty object schema when no entity can be resolved.
43
+ #
44
+ # @param exposure the entity exposure
45
+ # @param doc [Hash] normalized documentation hash
46
+ # @return [ApiModel::Schema]
47
+ def schema_for_merge(exposure, doc)
48
+ using_class = resolve_entity_from_opts(exposure, doc)
49
+ return ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT) unless using_class
50
+
51
+ child = GrapeOAS.introspectors.build_schema(using_class, stack: @stack, registry: @registry)
52
+ merged = ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT)
53
+ child.properties.each do |n, ps|
54
+ merged.add_property(n, ps, required: child.required.include?(n))
55
+ end
56
+ merged
57
+ end
58
+
59
+ # Resolves the Grape::Entity class referenced by an exposure's options or doc.
60
+ #
61
+ # @param exposure the entity exposure
62
+ # @param doc [Hash] normalized documentation hash
63
+ # @return [Class, nil] the entity class or nil
64
+ def resolve_entity_from_opts(exposure, doc)
65
+ opts = exposure.instance_variable_get(:@options) || {}
66
+ resolve_grape_entity_class(opts, doc)
67
+ end
68
+
69
+ # Checks if opts or doc point to a Grape::Entity subclass.
70
+ #
71
+ # @param opts [Hash] exposure options
72
+ # @param doc [Hash] normalized documentation hash
73
+ # @return [Class, nil] the entity class or nil
74
+ def resolve_grape_entity_class(opts, doc)
75
+ type = opts[:using] || doc[:type]
76
+ return type if defined?(Grape::Entity) && type.is_a?(Class) && type <= Grape::Entity
77
+
78
+ nil
79
+ end
80
+
81
+ private
82
+
83
+ def schema_for_type(type)
84
+ case type
85
+ when Class
86
+ schema_for_class_type(type)
87
+ when String, Symbol
88
+ schema_for_string_type(type.to_s)
89
+ else
90
+ default_string_schema
91
+ end
92
+ end
93
+
94
+ def schema_for_class_type(type)
95
+ if defined?(Grape::Entity) && type <= Grape::Entity
96
+ GrapeOAS.introspectors.build_schema(type, stack: @stack, registry: @registry)
97
+ else
98
+ build_schema_for_primitive(type) || default_string_schema
99
+ end
100
+ end
101
+
102
+ def schema_for_string_type(type_name)
103
+ entity_class = resolve_entity_from_string(type_name)
104
+ if entity_class
105
+ GrapeOAS.introspectors.build_schema(entity_class, stack: @stack, registry: @registry)
106
+ else
107
+ schema_type = Constants.primitive_type(type_name) || Constants::SchemaTypes::STRING
108
+ ApiModel::Schema.new(type: schema_type)
109
+ end
110
+ end
111
+
112
+ def default_string_schema
113
+ ApiModel::Schema.new(type: Constants::SchemaTypes::STRING)
114
+ end
115
+
116
+ def resolve_entity_from_string(type_name)
117
+ return nil unless defined?(Grape::Entity)
118
+ return nil unless valid_constant_name?(type_name)
119
+ return nil unless Object.const_defined?(type_name, false)
120
+
121
+ klass = Object.const_get(type_name, false)
122
+ klass if klass.is_a?(Class) && klass <= Grape::Entity
123
+ rescue NameError
124
+ nil
125
+ 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
+ end
137
+ end
138
+ end
139
+ end