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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -0
- data/README.md +6 -0
- data/lib/grape_oas/api_model/api.rb +4 -0
- data/lib/grape_oas/api_model/schema.rb +18 -3
- data/lib/grape_oas/api_model_builders/concerns/content_type_resolver.rb +21 -0
- data/lib/grape_oas/api_model_builders/concerns/type_resolver.rb +3 -6
- data/lib/grape_oas/api_model_builders/request.rb +21 -12
- data/lib/grape_oas/api_model_builders/request_params_support/param_location_resolver.rb +7 -2
- data/lib/grape_oas/api_model_builders/request_params_support/param_schema_builder.rb +15 -1
- data/lib/grape_oas/api_model_builders/request_params_support/schema_enhancer.rb +34 -50
- data/lib/grape_oas/constants.rb +13 -0
- data/lib/grape_oas/doc_key_normalizer.rb +14 -0
- data/lib/grape_oas/exporter/oas2/parameter.rb +1 -0
- data/lib/grape_oas/exporter/oas2/schema.rb +53 -19
- data/lib/grape_oas/exporter/oas3/parameter.rb +5 -2
- data/lib/grape_oas/exporter/oas3/schema.rb +81 -46
- data/lib/grape_oas/introspectors/dry_introspector_support/argument_extractor.rb +2 -31
- data/lib/grape_oas/introspectors/dry_introspector_support/constraint_extractor.rb +10 -19
- data/lib/grape_oas/introspectors/dry_introspector_support/predicate_handler.rb +2 -10
- data/lib/grape_oas/introspectors/entity_introspector.rb +5 -1
- data/lib/grape_oas/introspectors/entity_introspector_support/discriminator_handler.rb +2 -25
- data/lib/grape_oas/introspectors/entity_introspector_support/exposure_processor.rb +139 -136
- data/lib/grape_oas/introspectors/entity_introspector_support/inheritance_builder.rb +6 -30
- data/lib/grape_oas/introspectors/entity_introspector_support/nesting_merger.rb +104 -0
- data/lib/grape_oas/introspectors/entity_introspector_support/property_extractor.rb +2 -1
- data/lib/grape_oas/introspectors/entity_introspector_support/type_schema_resolver.rb +139 -0
- data/lib/grape_oas/introspectors/entity_introspector_support.rb +57 -0
- data/lib/grape_oas/range_utils.rb +87 -0
- data/lib/grape_oas/schema_constraints.rb +36 -0
- data/lib/grape_oas/type_resolvers/array_resolver.rb +3 -5
- data/lib/grape_oas/values_normalizer.rb +47 -0
- data/lib/grape_oas/version.rb +1 -1
- data/lib/grape_oas.rb +27 -0
- metadata +9 -2
|
@@ -29,13 +29,7 @@ module GrapeOAS
|
|
|
29
29
|
#
|
|
30
30
|
# @return [Array] list of entity exposures
|
|
31
31
|
def exposures
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
62
|
-
type = opts[:using] || 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
|
-
|
|
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?(
|
|
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
|
|
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 =
|
|
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]
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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] ||
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
213
|
-
ApiModel::Schema.new(type: schema_type)
|
|
256
|
+
schema.enum = values
|
|
214
257
|
end
|
|
258
|
+
schema
|
|
215
259
|
end
|
|
216
260
|
|
|
217
|
-
def
|
|
218
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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.
|
|
86
|
-
required = determine_required(doc, exposure
|
|
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
|