rspec-openapi 0.26.0 → 0.27.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/.github/workflows/create_release.yml +1 -1
- data/.rubocop.yml +4 -0
- data/.rubocop_todo.yml +13 -13
- data/lib/rspec/openapi/extractors/hanami.rb +10 -37
- data/lib/rspec/openapi/extractors/rack.rb +7 -32
- data/lib/rspec/openapi/extractors/rails.rb +9 -35
- data/lib/rspec/openapi/extractors/shared_extractor.rb +33 -26
- data/lib/rspec/openapi/record_builder.rb +8 -29
- data/lib/rspec/openapi/schema_builder/build_context.rb +20 -0
- data/lib/rspec/openapi/schema_builder.rb +271 -348
- data/lib/rspec/openapi/schema_cleaner.rb +1 -1
- data/lib/rspec/openapi/schema_file.rb +4 -6
- data/lib/rspec/openapi/schema_merger.rb +79 -49
- data/lib/rspec/openapi/version.rb +1 -1
- data/lib/rspec/openapi.rb +2 -2
- metadata +4 -3
|
@@ -1,45 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
4
|
+
require_relative 'schema_builder/build_context'
|
|
5
|
+
|
|
6
|
+
class << RSpec::OpenAPI::SchemaBuilder
|
|
4
7
|
# @param [RSpec::OpenAPI::Record] record
|
|
5
8
|
# @return [Hash]
|
|
6
9
|
def build(record)
|
|
7
|
-
response = if record.response_example_mode == :none
|
|
8
|
-
# `:none` opts out of recording, so the description is provisional.
|
|
9
|
-
# Stash it under a fallback key; SchemaCleaner promotes it to
|
|
10
|
-
# `description` only if no documented test has set one. This makes
|
|
11
|
-
# the merge result independent of RSpec's random execution order.
|
|
12
|
-
{ _fallback_description: record.description }
|
|
13
|
-
else
|
|
14
|
-
{ description: record.description }
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
response_headers = build_response_headers(record)
|
|
18
|
-
response[:headers] = response_headers unless response_headers.empty?
|
|
19
|
-
|
|
20
|
-
if record.response_body
|
|
21
|
-
disposition = normalize_content_disposition(record.response_content_disposition)
|
|
22
|
-
|
|
23
|
-
has_content = !normalize_content_type(record.response_content_type).nil?
|
|
24
|
-
response[:content] = build_content(disposition, record) if has_content
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
http_method = record.http_method.downcase
|
|
28
10
|
{
|
|
29
11
|
paths: {
|
|
30
12
|
normalize_path(record.path) => {
|
|
31
|
-
http_method =>
|
|
32
|
-
summary: record.summary,
|
|
33
|
-
tags: record.tags,
|
|
34
|
-
operationId: record.operation_id,
|
|
35
|
-
security: record.security,
|
|
36
|
-
deprecated: record.deprecated ? true : nil,
|
|
37
|
-
parameters: build_parameters(record),
|
|
38
|
-
requestBody: include_nil_request_body?(http_method) ? nil : build_request_body(record),
|
|
39
|
-
responses: {
|
|
40
|
-
record.status.to_s => response,
|
|
41
|
-
},
|
|
42
|
-
}.compact,
|
|
13
|
+
record.http_method.downcase => build_operation(record),
|
|
43
14
|
},
|
|
44
15
|
},
|
|
45
16
|
}
|
|
@@ -47,48 +18,64 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
47
18
|
|
|
48
19
|
private
|
|
49
20
|
|
|
50
|
-
def
|
|
51
|
-
|
|
21
|
+
def build_operation(record)
|
|
22
|
+
http_method = record.http_method.downcase
|
|
23
|
+
# GET and DELETE never have a request body in OpenAPI.
|
|
24
|
+
request_body = ['delete', 'get'].include?(http_method) ? nil : build_request_body(record)
|
|
25
|
+
{
|
|
26
|
+
summary: record.summary,
|
|
27
|
+
tags: record.tags,
|
|
28
|
+
operationId: record.operation_id,
|
|
29
|
+
security: record.security,
|
|
30
|
+
deprecated: record.deprecated ? true : nil,
|
|
31
|
+
parameters: build_parameters(record),
|
|
32
|
+
requestBody: request_body,
|
|
33
|
+
responses: { record.status.to_s => build_response(record) },
|
|
34
|
+
}.compact
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def build_response(record)
|
|
38
|
+
# `:none` opts out of recording, so the description is provisional. Stash
|
|
39
|
+
# it under a fallback key; SchemaCleaner promotes it to `description` only
|
|
40
|
+
# if no documented test has set one. This makes the merge result
|
|
41
|
+
# independent of RSpec's random execution order.
|
|
42
|
+
desc_key = record.response_example_mode == :none ? :_fallback_description : :description
|
|
43
|
+
response = { desc_key => record.description }
|
|
44
|
+
|
|
45
|
+
response_headers = build_response_headers(record)
|
|
46
|
+
response[:headers] = response_headers unless response_headers.empty?
|
|
47
|
+
|
|
48
|
+
if record.response_body && !normalize_content_type(record.response_content_type).nil?
|
|
49
|
+
response[:content] = build_content(record)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
response
|
|
52
53
|
end
|
|
53
54
|
|
|
54
|
-
def build_content(
|
|
55
|
+
def build_content(record)
|
|
56
|
+
disposition = normalize_content_disposition(record.response_content_disposition)
|
|
55
57
|
content_type = normalize_content_type(record.response_content_type)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
when :none
|
|
63
|
-
# Only schema, no examples
|
|
64
|
-
{
|
|
65
|
-
content_type => {
|
|
66
|
-
schema: schema,
|
|
67
|
-
}.compact,
|
|
68
|
-
}
|
|
69
|
-
when :multiple
|
|
70
|
-
# Multiple named examples
|
|
71
|
-
{
|
|
72
|
-
content_type => {
|
|
73
|
-
schema: schema,
|
|
74
|
-
examples: { record.example_key => build_example_object(record, disposition: disposition) },
|
|
75
|
-
}.compact,
|
|
76
|
-
}
|
|
77
|
-
else # :single (default)
|
|
78
|
-
# Single example + store name for possible merger conversion
|
|
79
|
-
{
|
|
80
|
-
content_type => {
|
|
81
|
-
schema: schema,
|
|
82
|
-
example: response_example(record, disposition: disposition),
|
|
83
|
-
**example_metadata(record),
|
|
84
|
-
}.compact,
|
|
85
|
-
}
|
|
86
|
-
end
|
|
58
|
+
ctx = BuildContext.new(record: record, context: :response)
|
|
59
|
+
schema = build_property(record.response_body, ctx, disposition: disposition)
|
|
60
|
+
example = response_example(record, disposition: disposition)
|
|
61
|
+
|
|
62
|
+
body = build_example_body(schema, record, mode: record.response_example_mode, example: example)
|
|
63
|
+
{ content_type => body }
|
|
87
64
|
end
|
|
88
65
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
66
|
+
# Returns the per-content-type body (schema + optional example/examples).
|
|
67
|
+
# Shared by response content and request body to keep example_mode handling in one place.
|
|
68
|
+
def build_example_body(schema, record, mode:, example:)
|
|
69
|
+
return { schema: schema } if !example_enabled?(record) || mode == :none
|
|
70
|
+
|
|
71
|
+
case mode
|
|
72
|
+
when :multiple
|
|
73
|
+
{ schema: schema, examples: { record.example_key => build_named_example(record, example) } }
|
|
74
|
+
else
|
|
75
|
+
# :single (default)
|
|
76
|
+
# :single may emit nil example or nil _example_summary; compact strips them.
|
|
77
|
+
{ schema: schema, example: example, **example_metadata(record) }.compact
|
|
78
|
+
end
|
|
92
79
|
end
|
|
93
80
|
|
|
94
81
|
def response_example(record, disposition:)
|
|
@@ -97,16 +84,9 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
97
84
|
record.response_body
|
|
98
85
|
end
|
|
99
86
|
|
|
100
|
-
def build_example_object(record, disposition:)
|
|
101
|
-
build_named_example(record, response_example(record, disposition: disposition))
|
|
102
|
-
end
|
|
103
|
-
|
|
104
87
|
def build_named_example(record, value)
|
|
105
88
|
summary = example_summary(record)
|
|
106
|
-
|
|
107
|
-
example[:summary] = summary if summary
|
|
108
|
-
example[:value] = value
|
|
109
|
-
example
|
|
89
|
+
summary ? { summary: summary, value: value } : { value: value }
|
|
110
90
|
end
|
|
111
91
|
|
|
112
92
|
def example_metadata(record)
|
|
@@ -114,7 +94,7 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
114
94
|
end
|
|
115
95
|
|
|
116
96
|
def example_summary(record)
|
|
117
|
-
return nil unless
|
|
97
|
+
return nil unless RSpec::OpenAPI.enable_example_summary
|
|
118
98
|
return nil if record.example_name.nil? || record.example_name.empty?
|
|
119
99
|
|
|
120
100
|
record.example_name
|
|
@@ -124,58 +104,45 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
124
104
|
record.example_enabled
|
|
125
105
|
end
|
|
126
106
|
|
|
127
|
-
def example_summary_enabled?
|
|
128
|
-
RSpec::OpenAPI.enable_example_summary
|
|
129
|
-
end
|
|
130
|
-
|
|
131
107
|
def build_parameters(record)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
name: build_parameter_name(key, value),
|
|
135
|
-
in: 'path',
|
|
136
|
-
required: true,
|
|
137
|
-
schema: build_property(try_cast(value), key: key, record: record, path: key.to_s, context: :request),
|
|
138
|
-
example: (try_cast(value) if example_enabled?(record)),
|
|
139
|
-
}.compact
|
|
108
|
+
parameters = record.path_params.map do |key, value|
|
|
109
|
+
build_parameter(key, value, location: 'path', record: record)
|
|
140
110
|
end
|
|
141
111
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
name: key,
|
|
145
|
-
in: 'query',
|
|
146
|
-
required: record.required_request_params.include?(key),
|
|
147
|
-
schema: build_property(try_cast(value), key: key, record: record, path: key.to_s, context: :request),
|
|
148
|
-
example: (try_cast(value) if example_enabled?(record)),
|
|
149
|
-
}.compact
|
|
112
|
+
parameters += flatten_query_params(record.query_params).map do |key, value|
|
|
113
|
+
build_parameter(key, value, location: 'query', record: record)
|
|
150
114
|
end
|
|
151
115
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
name: build_parameter_name(key, value),
|
|
155
|
-
in: 'header',
|
|
156
|
-
required: true,
|
|
157
|
-
schema: build_property(try_cast(value), key: key, record: record, path: key.to_s, context: :request),
|
|
158
|
-
example: (try_cast(value) if example_enabled?(record)),
|
|
159
|
-
}.compact
|
|
116
|
+
parameters += record.request_headers.map do |key, value|
|
|
117
|
+
build_parameter(key, value, location: 'header', record: record)
|
|
160
118
|
end
|
|
161
119
|
|
|
162
|
-
parameters
|
|
163
|
-
|
|
164
|
-
return nil if parameters.empty?
|
|
120
|
+
parameters&.empty? ? nil : parameters
|
|
121
|
+
end
|
|
165
122
|
|
|
166
|
-
|
|
123
|
+
# `compound_name` and `required` follow from `location`:
|
|
124
|
+
# path/header params are always required and use bracketed names like
|
|
125
|
+
# `key[subkey]`; query params are pre-flattened and may be optional.
|
|
126
|
+
def build_parameter(key, value, location:, record:)
|
|
127
|
+
is_query = location == 'query'
|
|
128
|
+
compound_name = !is_query
|
|
129
|
+
required = is_query ? record.required_request_params.include?(key) : true
|
|
130
|
+
cast = try_cast(value)
|
|
131
|
+
ctx = BuildContext.new(record: record, context: :request, key: key, path: key.to_s)
|
|
132
|
+
{
|
|
133
|
+
name: compound_name ? build_parameter_name(key, value) : key,
|
|
134
|
+
in: location,
|
|
135
|
+
required: required,
|
|
136
|
+
schema: build_property(cast, ctx),
|
|
137
|
+
example: (cast if example_enabled?(record)),
|
|
138
|
+
}.compact
|
|
167
139
|
end
|
|
168
140
|
|
|
169
141
|
def build_response_headers(record)
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
headers[key] = {
|
|
174
|
-
schema: build_property(try_cast(value), key: key, record: record, path: key.to_s, context: :response),
|
|
175
|
-
}.compact
|
|
142
|
+
record.response_headers.to_h do |key, value|
|
|
143
|
+
ctx = BuildContext.new(record: record, context: :response, key: key, path: key.to_s)
|
|
144
|
+
[key, { schema: build_property(try_cast(value), ctx) }]
|
|
176
145
|
end
|
|
177
|
-
|
|
178
|
-
headers
|
|
179
146
|
end
|
|
180
147
|
|
|
181
148
|
def build_parameter_name(key, value)
|
|
@@ -189,8 +156,7 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
189
156
|
end
|
|
190
157
|
|
|
191
158
|
def flatten_query_params(params, parent_key = nil)
|
|
192
|
-
|
|
193
|
-
params.each do |key, value|
|
|
159
|
+
params.each_with_object({}) do |(key, value), result|
|
|
194
160
|
full_key = parent_key ? "#{parent_key}[#{key}]" : key.to_s
|
|
195
161
|
|
|
196
162
|
if value.is_a?(Hash)
|
|
@@ -199,7 +165,6 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
199
165
|
result[full_key] = value
|
|
200
166
|
end
|
|
201
167
|
end
|
|
202
|
-
result
|
|
203
168
|
end
|
|
204
169
|
|
|
205
170
|
def build_request_body(record)
|
|
@@ -207,99 +172,73 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
207
172
|
return nil if record.status >= 400 && record.request_example_mode != :multiple
|
|
208
173
|
|
|
209
174
|
content_type = normalize_content_type(record.request_content_type)
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
example = build_example(record.request_params)
|
|
215
|
-
|
|
216
|
-
body =
|
|
217
|
-
case record.request_example_mode
|
|
218
|
-
when :none
|
|
219
|
-
{ schema: schema }
|
|
220
|
-
when :multiple
|
|
221
|
-
{
|
|
222
|
-
schema: schema,
|
|
223
|
-
examples: { record.example_key => build_named_example(record, example) },
|
|
224
|
-
}
|
|
225
|
-
else # :single (default)
|
|
226
|
-
{
|
|
227
|
-
schema: schema,
|
|
228
|
-
example: example,
|
|
229
|
-
**example_metadata(record),
|
|
230
|
-
}
|
|
231
|
-
end
|
|
175
|
+
ctx = BuildContext.new(record: record, context: :request)
|
|
176
|
+
schema = build_property(record.request_params, ctx)
|
|
177
|
+
example = example_enabled?(record) ? build_example(record.request_params) : nil
|
|
232
178
|
|
|
233
|
-
|
|
179
|
+
body = build_example_body(schema, record, mode: record.request_example_mode, example: example)
|
|
180
|
+
{ content: { content_type => body } }
|
|
234
181
|
end
|
|
235
182
|
|
|
236
|
-
def build_property(value,
|
|
237
|
-
format = disposition ? 'binary' : infer_format(key, record)
|
|
238
|
-
enum = infer_enum(path, record, context)
|
|
239
|
-
|
|
183
|
+
def build_property(value, ctx, disposition: nil)
|
|
184
|
+
format = disposition ? 'binary' : infer_format(ctx.key, ctx.record)
|
|
185
|
+
enum = infer_enum(ctx.path, ctx.record, ctx.context)
|
|
240
186
|
property = build_type(value, format: format, enum: enum)
|
|
241
187
|
|
|
242
188
|
case value
|
|
243
189
|
when Array
|
|
244
|
-
property[:items] =
|
|
245
|
-
{} # unknown
|
|
246
|
-
else
|
|
247
|
-
build_array_items_schema(value, record: record, path: path, context: context)
|
|
248
|
-
end
|
|
190
|
+
property[:items] = value.empty? ? {} : build_array_items_schema(value, ctx.for_array_items)
|
|
249
191
|
when Hash
|
|
250
|
-
|
|
251
|
-
hybrid_override = infer_hybrid_additional_properties(path, record, context)
|
|
252
|
-
if override.is_a?(Hash) && !override.empty?
|
|
253
|
-
# Schema override: the object's keys are dynamic — replace captured
|
|
254
|
-
# `properties` / `required` with the supplied dictionary value schema.
|
|
255
|
-
property[:additionalProperties] = override
|
|
256
|
-
else
|
|
257
|
-
property[:properties] = {}.tap do |properties|
|
|
258
|
-
value.each do |k, v|
|
|
259
|
-
child_path = path ? "#{path}.#{k}" : k.to_s
|
|
260
|
-
properties[k] = build_property(v, record: record, key: k, path: child_path, context: context)
|
|
261
|
-
end
|
|
262
|
-
end
|
|
263
|
-
property = enrich_with_required_keys(property)
|
|
264
|
-
# Hybrid: keep the observed `properties` / `required` and attach
|
|
265
|
-
# `additionalProperties` alongside.
|
|
266
|
-
# - Boolean values are constraints (`false` forbids extras, `true`
|
|
267
|
-
# explicitly allows them).
|
|
268
|
-
# - Hash schema values come from the dedicated `hybrid_additional_properties`
|
|
269
|
-
# metadata, expressing "known keys + extras of this type".
|
|
270
|
-
if override == true || override == false
|
|
271
|
-
property[:additionalProperties] = override
|
|
272
|
-
elsif hybrid_override.is_a?(Hash) && !hybrid_override.empty?
|
|
273
|
-
property[:additionalProperties] = hybrid_override
|
|
274
|
-
end
|
|
275
|
-
end
|
|
192
|
+
apply_object_schema(property, value, ctx)
|
|
276
193
|
end
|
|
277
194
|
property
|
|
278
195
|
end
|
|
279
196
|
|
|
197
|
+
def apply_object_schema(property, value, ctx)
|
|
198
|
+
override = infer_override(ctx.path, ctx.record, ctx.context, :additional_properties)
|
|
199
|
+
|
|
200
|
+
if override.is_a?(Hash) && !override.empty?
|
|
201
|
+
# Schema override: the object's keys are dynamic — replace captured
|
|
202
|
+
# `properties` / `required` with the supplied dictionary value schema.
|
|
203
|
+
property[:additionalProperties] = override
|
|
204
|
+
return
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
property[:properties] = value.to_h do |k, v|
|
|
208
|
+
[k, build_property(v, ctx.descend(k))]
|
|
209
|
+
end
|
|
210
|
+
property[:required] = property[:properties].keys
|
|
211
|
+
apply_additional_properties(property, override,
|
|
212
|
+
infer_override(ctx.path, ctx.record, ctx.context, :hybrid_additional_properties),)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Hybrid: keep the observed `properties` / `required` and attach
|
|
216
|
+
# `additionalProperties` alongside.
|
|
217
|
+
# - Boolean values are constraints (`false` forbids extras, `true` explicitly allows them).
|
|
218
|
+
# - Hash schema values come from the dedicated `hybrid_additional_properties`
|
|
219
|
+
# metadata, expressing "known keys + extras of this type".
|
|
220
|
+
def apply_additional_properties(property, override, hybrid_override)
|
|
221
|
+
if [true, false].include?(override)
|
|
222
|
+
property[:additionalProperties] = override
|
|
223
|
+
elsif hybrid_override.is_a?(Hash) && !hybrid_override.empty?
|
|
224
|
+
property[:additionalProperties] = hybrid_override
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
280
228
|
def build_type(value, format: nil, enum: nil)
|
|
281
229
|
result = if format
|
|
282
230
|
{ type: 'string', format: format }
|
|
283
231
|
else
|
|
284
232
|
case value
|
|
285
|
-
when String
|
|
286
|
-
|
|
287
|
-
when
|
|
288
|
-
|
|
289
|
-
when
|
|
290
|
-
|
|
291
|
-
when
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
{ type: 'array' }
|
|
295
|
-
when Hash
|
|
296
|
-
{ type: 'object' }
|
|
297
|
-
when ActionDispatch::Http::UploadedFile
|
|
298
|
-
{ type: 'string', format: 'binary' }
|
|
299
|
-
when NilClass
|
|
300
|
-
{ nullable: true }
|
|
301
|
-
else
|
|
302
|
-
raise NotImplementedError, "type detection is not implemented for: #{value.inspect}"
|
|
233
|
+
when String then { type: 'string' }
|
|
234
|
+
when Integer then { type: 'integer' }
|
|
235
|
+
when Float then { type: 'number', format: 'float' }
|
|
236
|
+
when TrueClass, FalseClass then { type: 'boolean' }
|
|
237
|
+
when Array then { type: 'array' }
|
|
238
|
+
when Hash then { type: 'object' }
|
|
239
|
+
when ActionDispatch::Http::UploadedFile then { type: 'string', format: 'binary' }
|
|
240
|
+
when NilClass then { nullable: true }
|
|
241
|
+
else raise NotImplementedError, "type detection is not implemented for: #{value.inspect}"
|
|
303
242
|
end
|
|
304
243
|
end
|
|
305
244
|
|
|
@@ -308,47 +247,31 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
308
247
|
end
|
|
309
248
|
|
|
310
249
|
def infer_format(key, record)
|
|
311
|
-
return nil
|
|
250
|
+
return nil unless key && record
|
|
312
251
|
|
|
313
|
-
record.formats[key
|
|
252
|
+
record.formats&.[](key)
|
|
314
253
|
end
|
|
315
254
|
|
|
316
255
|
def infer_enum(path, record, context)
|
|
317
256
|
return nil if !path || !record
|
|
318
257
|
|
|
319
|
-
enum_hash = context == :request ? record.request_enum : record.response_enum
|
|
320
|
-
return nil unless enum_hash
|
|
321
|
-
|
|
322
258
|
# Keys are already normalized to strings by SharedExtractor.normalize_enum
|
|
323
|
-
|
|
259
|
+
record.send("#{context}_enum")&.[](path.to_s)
|
|
324
260
|
end
|
|
325
261
|
|
|
326
|
-
|
|
262
|
+
# Looks up an override for the current path under one of the per-context
|
|
263
|
+
# override maps on the record (e.g. request_additional_properties).
|
|
264
|
+
# For :additional_properties we use `key?` so a literal `false` is
|
|
265
|
+
# distinguishable from "no override"; for :hybrid_additional_properties
|
|
266
|
+
# plain lookup is enough because only Hash values are meaningful.
|
|
267
|
+
def infer_override(path, record, context, kind)
|
|
327
268
|
return nil unless record
|
|
328
269
|
|
|
329
|
-
overrides =
|
|
330
|
-
record.request_additional_properties
|
|
331
|
-
else
|
|
332
|
-
record.response_additional_properties
|
|
333
|
-
end
|
|
270
|
+
overrides = record.send("#{context}_#{kind}")
|
|
334
271
|
return nil unless overrides
|
|
335
272
|
|
|
336
273
|
# path is nil at the body root; nil.to_s == '' lets users target it via { '' => ... }.
|
|
337
|
-
|
|
338
|
-
return nil unless overrides.key?(path.to_s)
|
|
339
|
-
|
|
340
|
-
overrides[path.to_s]
|
|
341
|
-
end
|
|
342
|
-
|
|
343
|
-
def infer_hybrid_additional_properties(path, record, context)
|
|
344
|
-
return nil unless record
|
|
345
|
-
|
|
346
|
-
overrides = if context == :request
|
|
347
|
-
record.request_hybrid_additional_properties
|
|
348
|
-
else
|
|
349
|
-
record.response_hybrid_additional_properties
|
|
350
|
-
end
|
|
351
|
-
return nil unless overrides
|
|
274
|
+
return nil if kind == :additional_properties && !overrides.key?(path.to_s)
|
|
352
275
|
|
|
353
276
|
overrides[path.to_s]
|
|
354
277
|
end
|
|
@@ -363,30 +286,19 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
363
286
|
def build_example(value)
|
|
364
287
|
return nil if value.nil?
|
|
365
288
|
|
|
366
|
-
value
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
case item
|
|
380
|
-
when ActionDispatch::Http::UploadedFile
|
|
381
|
-
item.original_filename
|
|
382
|
-
when Hash
|
|
383
|
-
adjust_params(item)
|
|
384
|
-
else
|
|
385
|
-
item
|
|
386
|
-
end
|
|
387
|
-
end
|
|
388
|
-
value[key] = result
|
|
389
|
-
end
|
|
289
|
+
adjust_params(value.dup)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def adjust_params(hash)
|
|
293
|
+
hash.transform_values! { |v| adjust_value(v) }
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def adjust_value(value)
|
|
297
|
+
case value
|
|
298
|
+
when ActionDispatch::Http::UploadedFile then value.original_filename
|
|
299
|
+
when Hash then adjust_params(value)
|
|
300
|
+
when Array then value.map { |item| adjust_value(item) }
|
|
301
|
+
else value
|
|
390
302
|
end
|
|
391
303
|
end
|
|
392
304
|
|
|
@@ -401,123 +313,134 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
401
313
|
# Same logic as normalize_content_type – strips header parameters after ';'
|
|
402
314
|
alias normalize_content_disposition normalize_content_type
|
|
403
315
|
|
|
404
|
-
def build_array_items_schema(array,
|
|
316
|
+
def build_array_items_schema(array, ctx)
|
|
405
317
|
return {} if array.empty?
|
|
406
|
-
return build_property(array.first, record: record, path: path, context: context) if array.size == 1
|
|
407
|
-
return build_property(array.first, record: record, path: path, context: context) unless array.all?(Hash)
|
|
408
318
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
319
|
+
schemas = array.map { |item| build_property(item, ctx) }
|
|
320
|
+
return schemas.first if schemas.size == 1 || !array.all?(Hash)
|
|
321
|
+
|
|
322
|
+
merged = schemas.first.dup
|
|
323
|
+
merged[:properties] = merge_property_variations(schemas, allow_recursive_merge: false)
|
|
324
|
+
merged[:required] = schemas.map { |s| s[:required] || [] }.reduce(:&) || []
|
|
325
|
+
merged
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def build_merged_schema_from_variations(variations)
|
|
329
|
+
return {} if variations.empty?
|
|
330
|
+
return variations.first if variations.size == 1
|
|
331
|
+
|
|
332
|
+
types = variations.map { |v| v[:type] }.compact.uniq
|
|
333
|
+
return variations.first unless types.size == 1 && types.first == 'object'
|
|
412
334
|
|
|
413
|
-
|
|
335
|
+
{
|
|
336
|
+
type: 'object',
|
|
337
|
+
properties: merge_property_variations(variations, allow_recursive_merge: true),
|
|
338
|
+
required: variations.map { |v| v[:required] || [] }.reduce(:&) || [],
|
|
339
|
+
}
|
|
340
|
+
end
|
|
414
341
|
|
|
415
|
-
|
|
416
|
-
|
|
342
|
+
# Merge the per-key property schemas of multiple object variations.
|
|
343
|
+
# When `allow_recursive_merge` is true, objects are recursively merged via
|
|
344
|
+
# build_merged_schema_from_variations and existing oneOf entries are flattened.
|
|
345
|
+
# When false (callsite: array-items merging), divergent property variations
|
|
346
|
+
# become oneOf without recursive descent.
|
|
347
|
+
def merge_property_variations(variations, allow_recursive_merge:)
|
|
348
|
+
property_keys(variations).each_with_object({}) do |key, merged_props|
|
|
349
|
+
all = variations.map { |v| v[:properties]&.[](key) }
|
|
350
|
+
prop_variations = all.reject { |p| p.nil? || p.keys == [:nullable] }
|
|
351
|
+
has_nullable = nullable_present?(all, recursive: allow_recursive_merge)
|
|
352
|
+
|
|
353
|
+
next if prop_variations.empty? && !has_nullable
|
|
354
|
+
|
|
355
|
+
merged_props[key] = merge_single_property(prop_variations, has_nullable,
|
|
356
|
+
variations_total: variations.size,
|
|
357
|
+
allow_recursive_merge: allow_recursive_merge,)
|
|
358
|
+
end
|
|
359
|
+
end
|
|
417
360
|
|
|
418
|
-
|
|
419
|
-
|
|
361
|
+
def property_keys(variations)
|
|
362
|
+
variations.flat_map { |v| v[:properties]&.keys || [] }.uniq
|
|
363
|
+
end
|
|
420
364
|
|
|
421
|
-
|
|
365
|
+
# `recursive` mirrors build_merged_schema_from_variations' original rule that
|
|
366
|
+
# also treats `{ ..., nullable: true }` as a nullable signal. Array-items
|
|
367
|
+
# merging only looks at outright nil or `{ nullable: true }` markers.
|
|
368
|
+
def nullable_present?(all_props, recursive:)
|
|
369
|
+
all_props.any? do |p|
|
|
370
|
+
p.nil? || (p.is_a?(Hash) && (p.keys == [:nullable] || (recursive && p[:nullable] == true)))
|
|
371
|
+
end
|
|
372
|
+
end
|
|
422
373
|
|
|
423
|
-
|
|
374
|
+
def merge_single_property(prop_variations, has_nullable, variations_total:, allow_recursive_merge:)
|
|
375
|
+
return { nullable: true } if prop_variations.empty?
|
|
424
376
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
377
|
+
merged =
|
|
378
|
+
if prop_variations.size == 1
|
|
379
|
+
prop_variations.first.dup
|
|
380
|
+
elsif allow_recursive_merge
|
|
381
|
+
merge_multi_recursive(prop_variations)
|
|
429
382
|
else
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
if unique_types.size > 1
|
|
433
|
-
unique_props = property_variations.map { |p| p.reject { |k, _| k == :nullable } }.uniq
|
|
434
|
-
merged_schema[:properties][key] = { oneOf: unique_props }
|
|
435
|
-
else
|
|
436
|
-
case unique_types.first
|
|
437
|
-
when 'array'
|
|
438
|
-
merged_schema[:properties][key] = { type: 'array' }
|
|
439
|
-
items_variations = property_variations.map { |p| p[:items] }.compact
|
|
440
|
-
merged_schema[:properties][key][:items] = build_merged_schema_from_variations(items_variations)
|
|
441
|
-
when 'object'
|
|
442
|
-
merged_schema[:properties][key] = build_merged_schema_from_variations(property_variations)
|
|
443
|
-
else
|
|
444
|
-
merged_schema[:properties][key] = property_variations.first.dup
|
|
445
|
-
end
|
|
446
|
-
end
|
|
383
|
+
merge_multi_array_items(prop_variations)
|
|
447
384
|
end
|
|
448
385
|
|
|
449
|
-
|
|
450
|
-
end
|
|
386
|
+
return merged unless merged.is_a?(Hash)
|
|
451
387
|
|
|
452
|
-
|
|
453
|
-
|
|
388
|
+
# In recursive mode, multi-variation merges also flag nullable when the key
|
|
389
|
+
# only appeared in some of the source variations.
|
|
390
|
+
needs_nullable =
|
|
391
|
+
if allow_recursive_merge && prop_variations.size > 1
|
|
392
|
+
has_nullable || prop_variations.size < variations_total
|
|
393
|
+
else
|
|
394
|
+
has_nullable
|
|
395
|
+
end
|
|
396
|
+
merged[:nullable] = true if needs_nullable
|
|
397
|
+
merged
|
|
398
|
+
end
|
|
454
399
|
|
|
455
|
-
|
|
400
|
+
# Array-items mode: combine multiple variations of the same property without
|
|
401
|
+
# recursing into nested objects/arrays beyond one level.
|
|
402
|
+
def merge_multi_array_items(prop_variations)
|
|
403
|
+
unique_types = prop_variations.map { |p| p[:type] }.compact.uniq
|
|
404
|
+
return one_of_schema(prop_variations) if unique_types.size > 1
|
|
405
|
+
|
|
406
|
+
case unique_types.first
|
|
407
|
+
when 'array'
|
|
408
|
+
items_variations = prop_variations.map { |p| p[:items] }.compact
|
|
409
|
+
{ type: 'array', items: build_merged_schema_from_variations(items_variations) }
|
|
410
|
+
when 'object'
|
|
411
|
+
build_merged_schema_from_variations(prop_variations)
|
|
412
|
+
else
|
|
413
|
+
prop_variations.first.dup
|
|
414
|
+
end
|
|
456
415
|
end
|
|
457
416
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
417
|
+
# Recursive-merge mode (used inside build_merged_schema_from_variations):
|
|
418
|
+
# additionally flattens existing oneOf entries and recurses into objects.
|
|
419
|
+
def merge_multi_recursive(prop_variations)
|
|
420
|
+
return { oneOf: flatten_one_of(prop_variations) } if prop_variations.any? { |p| p.key?(:oneOf) }
|
|
461
421
|
|
|
462
|
-
|
|
422
|
+
prop_types = prop_variations.map { |p| p[:type] }.compact.uniq
|
|
423
|
+
return one_of_schema(prop_variations) if prop_types.size > 1
|
|
463
424
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
has_nullable = all_prop_variations.any? do |v|
|
|
475
|
-
v.nil? || (v.is_a?(Hash) && v[:nullable] == true)
|
|
476
|
-
end || nullable_only.any?
|
|
477
|
-
|
|
478
|
-
if prop_variations.empty? && has_nullable
|
|
479
|
-
merged[:properties][key] = { nullable: true }
|
|
480
|
-
elsif prop_variations.size == 1
|
|
481
|
-
merged[:properties][key] = prop_variations.first.dup
|
|
482
|
-
merged[:properties][key][:nullable] = true if has_nullable
|
|
483
|
-
elsif prop_variations.size > 1
|
|
484
|
-
prop_types = prop_variations.map { |p| p[:type] }.compact.uniq
|
|
485
|
-
has_one_of = prop_variations.any? { |p| p.key?(:oneOf) }
|
|
486
|
-
|
|
487
|
-
if has_one_of
|
|
488
|
-
all_options = []
|
|
489
|
-
prop_variations.each do |prop|
|
|
490
|
-
clean_prop = prop.reject { |k, _| k == :nullable }
|
|
491
|
-
if clean_prop.key?(:oneOf)
|
|
492
|
-
all_options.concat(clean_prop[:oneOf])
|
|
493
|
-
else
|
|
494
|
-
all_options << clean_prop unless clean_prop.empty?
|
|
495
|
-
end
|
|
496
|
-
end
|
|
497
|
-
all_options.uniq!
|
|
498
|
-
merged[:properties][key] = { oneOf: all_options }
|
|
499
|
-
elsif prop_types.size == 1
|
|
500
|
-
# Only recursively merge if it's an object type
|
|
501
|
-
merged[:properties][key] = if prop_types.first == 'object'
|
|
502
|
-
build_merged_schema_from_variations(prop_variations)
|
|
503
|
-
else
|
|
504
|
-
prop_variations.first.dup
|
|
505
|
-
end
|
|
506
|
-
else
|
|
507
|
-
unique_props = prop_variations.map { |p| p.reject { |k, _| k == :nullable } }.uniq
|
|
508
|
-
merged[:properties][key] = { oneOf: unique_props }
|
|
509
|
-
end
|
|
510
|
-
|
|
511
|
-
merged[:properties][key][:nullable] = true if has_nullable || prop_variations.size < variations.size
|
|
512
|
-
end
|
|
425
|
+
prop_types.first == 'object' ? build_merged_schema_from_variations(prop_variations) : prop_variations.first.dup
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def flatten_one_of(prop_variations)
|
|
429
|
+
prop_variations.each_with_object([]) do |prop, options|
|
|
430
|
+
clean = without_nullable(prop)
|
|
431
|
+
if clean.key?(:oneOf)
|
|
432
|
+
options.concat(clean[:oneOf])
|
|
433
|
+
elsif !clean.empty?
|
|
434
|
+
options << clean
|
|
513
435
|
end
|
|
436
|
+
end.uniq
|
|
437
|
+
end
|
|
514
438
|
|
|
515
|
-
|
|
516
|
-
|
|
439
|
+
def without_nullable(prop)
|
|
440
|
+
prop.reject { |k, _| k == :nullable }
|
|
441
|
+
end
|
|
517
442
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
variations.first
|
|
521
|
-
end
|
|
443
|
+
def one_of_schema(variations)
|
|
444
|
+
{ oneOf: variations.map { |p| without_nullable(p) }.uniq }
|
|
522
445
|
end
|
|
523
446
|
end
|