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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -0
- data/README.md +27 -10
- data/UPGRADING.md +104 -0
- data/grape-oas.gemspec +2 -1
- data/lib/grape_oas/api_model/api.rb +6 -1
- data/lib/grape_oas/api_model/schema.rb +2 -2
- data/lib/grape_oas/api_model_builder.rb +3 -2
- data/lib/grape_oas/api_model_builders/concerns/content_type_resolver.rb +30 -1
- data/lib/grape_oas/api_model_builders/concerns/oas_utilities.rb +13 -0
- data/lib/grape_oas/api_model_builders/concerns/route_validations.rb +26 -0
- data/lib/grape_oas/api_model_builders/concerns/type_resolver.rb +7 -8
- data/lib/grape_oas/api_model_builders/operation.rb +3 -4
- data/lib/grape_oas/api_model_builders/path.rb +33 -6
- data/lib/grape_oas/api_model_builders/request.rb +29 -22
- data/lib/grape_oas/api_model_builders/request_params.rb +83 -3
- data/lib/grape_oas/api_model_builders/request_params_support/nested_params_builder.rb +1 -1
- 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 +4 -0
- data/lib/grape_oas/api_model_builders/request_params_support/schema_enhancer.rb +14 -9
- data/lib/grape_oas/api_model_builders/response.rb +28 -4
- data/lib/grape_oas/constants.rb +22 -1
- data/lib/grape_oas/documentation_extension.rb +7 -9
- data/lib/grape_oas/exporter/concerns/schema_indexer.rb +14 -3
- data/lib/grape_oas/exporter/oas2/operation.rb +4 -2
- data/lib/grape_oas/exporter/oas2/parameter.rb +74 -13
- data/lib/grape_oas/exporter/oas2/paths.rb +1 -0
- data/lib/grape_oas/exporter/oas2/response.rb +6 -4
- data/lib/grape_oas/exporter/oas2/schema.rb +85 -38
- data/lib/grape_oas/exporter/oas2_schema.rb +7 -5
- data/lib/grape_oas/exporter/oas3/operation.rb +5 -3
- data/lib/grape_oas/exporter/oas3/parameter.rb +8 -3
- data/lib/grape_oas/exporter/oas3/paths.rb +1 -0
- data/lib/grape_oas/exporter/oas3/request_body.rb +5 -8
- data/lib/grape_oas/exporter/oas3/response.rb +6 -9
- data/lib/grape_oas/exporter/oas3/schema.rb +273 -117
- data/lib/grape_oas/exporter/oas31/schema.rb +12 -2
- data/lib/grape_oas/exporter/oas31_schema.rb +1 -1
- data/lib/grape_oas/exporter/oas3_schema.rb +3 -2
- data/lib/grape_oas/introspectors/entity_introspector.rb +7 -2
- data/lib/grape_oas/introspectors/entity_introspector_support/exposure_processor.rb +55 -20
- data/lib/grape_oas/introspectors/entity_introspector_support/inheritance_builder.rb +1 -1
- data/lib/grape_oas/introspectors/entity_introspector_support/property_extractor.rb +2 -8
- data/lib/grape_oas/introspectors/entity_introspector_support/type_schema_resolver.rb +4 -20
- data/lib/grape_oas/introspectors/entity_introspector_support.rb +24 -0
- data/lib/grape_oas/range_utils.rb +25 -2
- data/lib/grape_oas/type_resolvers/array_resolver.rb +54 -22
- data/lib/grape_oas/type_resolvers/base.rb +2 -2
- data/lib/grape_oas/type_resolvers/default_resolver.rb +23 -0
- data/lib/grape_oas/type_resolvers/dry_type_resolver.rb +1 -1
- data/lib/grape_oas/type_resolvers/primitive_resolver.rb +29 -46
- data/lib/grape_oas/type_resolvers/registry.rb +31 -15
- data/lib/grape_oas/version.rb +1 -1
- data/lib/grape_oas.rb +37 -7
- metadata +5 -4
- data/CONTRIBUTING.md +0 -87
- 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?(
|
|
77
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
227
|
-
# the shared schema; emit a warning so users know a
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]", "[
|
|
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
|
|
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
|
-
|
|
24
|
-
|
|
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?(
|
|
34
|
+
type.match?(TYPED_ARRAY_PATTERN) || type.match?(VARIANT_COLLECTION_PATTERN)
|
|
32
35
|
end
|
|
33
36
|
|
|
34
37
|
def build_schema(type)
|
|
35
|
-
|
|
36
|
-
return nil unless
|
|
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
|
-
|
|
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
|
|
56
|
-
match = type.match(
|
|
57
|
-
|
|
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]
|
|
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]
|
|
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
|
|
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
|
-
#
|
|
8
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
#
|
|
53
|
-
|
|
54
|
-
if
|
|
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
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
return
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
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
|
|
58
|
+
# @return [ApiModel::Schema] The built schema
|
|
65
59
|
def build_schema(type)
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
67
|
+
DefaultResolver.build_schema(type)
|
|
70
68
|
end
|
|
71
69
|
|
|
72
|
-
# Checks if any resolver
|
|
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
|
-
|
|
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,
|