rspec-openapi 0.25.1 → 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/.github/workflows/publish.yml +1 -1
- data/.github/workflows/validate-openapi.yml +21 -0
- data/.gitignore +2 -0
- data/.rubocop.yml +4 -0
- data/.rubocop_todo.yml +13 -13
- data/README.md +337 -36
- data/lib/rspec/openapi/extractors/hanami.rb +10 -29
- data/lib/rspec/openapi/extractors/rack.rb +7 -24
- data/lib/rspec/openapi/extractors/rails.rb +14 -27
- data/lib/rspec/openapi/extractors/shared_extractor.rb +102 -18
- data/lib/rspec/openapi/record.rb +6 -1
- data/lib/rspec/openapi/record_builder.rb +8 -22
- data/lib/rspec/openapi/schema_builder/build_context.rb +20 -0
- data/lib/rspec/openapi/schema_builder.rb +288 -286
- data/lib/rspec/openapi/schema_cleaner.rb +4 -1
- data/lib/rspec/openapi/schema_file.rb +4 -6
- data/lib/rspec/openapi/schema_merger.rb +81 -40
- data/lib/rspec/openapi/version.rb +1 -1
- data/lib/rspec/openapi.rb +2 -2
- data/redocly.yaml +31 -0
- metadata +6 -3
|
@@ -1,39 +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 = {
|
|
8
|
-
description: record.description,
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
response_headers = build_response_headers(record)
|
|
12
|
-
response[:headers] = response_headers unless response_headers.empty?
|
|
13
|
-
|
|
14
|
-
if record.response_body
|
|
15
|
-
disposition = normalize_content_disposition(record.response_content_disposition)
|
|
16
|
-
|
|
17
|
-
has_content = !normalize_content_type(record.response_content_type).nil?
|
|
18
|
-
response[:content] = build_content(disposition, record) if has_content
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
http_method = record.http_method.downcase
|
|
22
10
|
{
|
|
23
11
|
paths: {
|
|
24
12
|
normalize_path(record.path) => {
|
|
25
|
-
http_method =>
|
|
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: include_nil_request_body?(http_method) ? nil : build_request_body(record),
|
|
33
|
-
responses: {
|
|
34
|
-
record.status.to_s => response,
|
|
35
|
-
},
|
|
36
|
-
}.compact,
|
|
13
|
+
record.http_method.downcase => build_operation(record),
|
|
37
14
|
},
|
|
38
15
|
},
|
|
39
16
|
}
|
|
@@ -41,49 +18,64 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
41
18
|
|
|
42
19
|
private
|
|
43
20
|
|
|
44
|
-
def
|
|
45
|
-
|
|
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
|
|
46
53
|
end
|
|
47
54
|
|
|
48
|
-
def build_content(
|
|
55
|
+
def build_content(record)
|
|
56
|
+
disposition = normalize_content_disposition(record.response_content_disposition)
|
|
49
57
|
content_type = normalize_content_type(record.response_content_type)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
when :none
|
|
57
|
-
# Only schema, no examples
|
|
58
|
-
{
|
|
59
|
-
content_type => {
|
|
60
|
-
schema: schema,
|
|
61
|
-
}.compact,
|
|
62
|
-
}
|
|
63
|
-
when :multiple
|
|
64
|
-
# Multiple named examples
|
|
65
|
-
{
|
|
66
|
-
content_type => {
|
|
67
|
-
schema: schema,
|
|
68
|
-
examples: { record.example_key => build_example_object(record, disposition: disposition) },
|
|
69
|
-
}.compact,
|
|
70
|
-
}
|
|
71
|
-
else # :single (default)
|
|
72
|
-
# Single example + store name for possible merger conversion
|
|
73
|
-
{
|
|
74
|
-
content_type => {
|
|
75
|
-
schema: schema,
|
|
76
|
-
example: response_example(record, disposition: disposition),
|
|
77
|
-
_example_key: record.example_key,
|
|
78
|
-
_example_summary: example_summary(record),
|
|
79
|
-
}.compact,
|
|
80
|
-
}
|
|
81
|
-
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 }
|
|
82
64
|
end
|
|
83
65
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
87
79
|
end
|
|
88
80
|
|
|
89
81
|
def response_example(record, disposition:)
|
|
@@ -92,16 +84,17 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
92
84
|
record.response_body
|
|
93
85
|
end
|
|
94
86
|
|
|
95
|
-
def
|
|
87
|
+
def build_named_example(record, value)
|
|
96
88
|
summary = example_summary(record)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
89
|
+
summary ? { summary: summary, value: value } : { value: value }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def example_metadata(record)
|
|
93
|
+
{ _example_key: record.example_key, _example_summary: example_summary(record) }
|
|
101
94
|
end
|
|
102
95
|
|
|
103
96
|
def example_summary(record)
|
|
104
|
-
return nil unless
|
|
97
|
+
return nil unless RSpec::OpenAPI.enable_example_summary
|
|
105
98
|
return nil if record.example_name.nil? || record.example_name.empty?
|
|
106
99
|
|
|
107
100
|
record.example_name
|
|
@@ -111,58 +104,45 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
111
104
|
record.example_enabled
|
|
112
105
|
end
|
|
113
106
|
|
|
114
|
-
def example_summary_enabled?
|
|
115
|
-
RSpec::OpenAPI.enable_example_summary
|
|
116
|
-
end
|
|
117
|
-
|
|
118
107
|
def build_parameters(record)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
name: build_parameter_name(key, value),
|
|
122
|
-
in: 'path',
|
|
123
|
-
required: true,
|
|
124
|
-
schema: build_property(try_cast(value), key: key, record: record, path: key.to_s, context: :request),
|
|
125
|
-
example: (try_cast(value) if example_enabled?(record)),
|
|
126
|
-
}.compact
|
|
108
|
+
parameters = record.path_params.map do |key, value|
|
|
109
|
+
build_parameter(key, value, location: 'path', record: record)
|
|
127
110
|
end
|
|
128
111
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
name: key,
|
|
132
|
-
in: 'query',
|
|
133
|
-
required: record.required_request_params.include?(key),
|
|
134
|
-
schema: build_property(try_cast(value), key: key, record: record, path: key.to_s, context: :request),
|
|
135
|
-
example: (try_cast(value) if example_enabled?(record)),
|
|
136
|
-
}.compact
|
|
112
|
+
parameters += flatten_query_params(record.query_params).map do |key, value|
|
|
113
|
+
build_parameter(key, value, location: 'query', record: record)
|
|
137
114
|
end
|
|
138
115
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
name: build_parameter_name(key, value),
|
|
142
|
-
in: 'header',
|
|
143
|
-
required: true,
|
|
144
|
-
schema: build_property(try_cast(value), key: key, record: record, path: key.to_s, context: :request),
|
|
145
|
-
example: (try_cast(value) if example_enabled?(record)),
|
|
146
|
-
}.compact
|
|
116
|
+
parameters += record.request_headers.map do |key, value|
|
|
117
|
+
build_parameter(key, value, location: 'header', record: record)
|
|
147
118
|
end
|
|
148
119
|
|
|
149
|
-
parameters
|
|
150
|
-
|
|
151
|
-
return nil if parameters.empty?
|
|
120
|
+
parameters&.empty? ? nil : parameters
|
|
121
|
+
end
|
|
152
122
|
|
|
153
|
-
|
|
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
|
|
154
139
|
end
|
|
155
140
|
|
|
156
141
|
def build_response_headers(record)
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
headers[key] = {
|
|
161
|
-
schema: build_property(try_cast(value), key: key, record: record, path: key.to_s, context: :response),
|
|
162
|
-
}.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) }]
|
|
163
145
|
end
|
|
164
|
-
|
|
165
|
-
headers
|
|
166
146
|
end
|
|
167
147
|
|
|
168
148
|
def build_parameter_name(key, value)
|
|
@@ -176,8 +156,7 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
176
156
|
end
|
|
177
157
|
|
|
178
158
|
def flatten_query_params(params, parent_key = nil)
|
|
179
|
-
|
|
180
|
-
params.each do |key, value|
|
|
159
|
+
params.each_with_object({}) do |(key, value), result|
|
|
181
160
|
full_key = parent_key ? "#{parent_key}[#{key}]" : key.to_s
|
|
182
161
|
|
|
183
162
|
if value.is_a?(Hash)
|
|
@@ -186,71 +165,80 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
186
165
|
result[full_key] = value
|
|
187
166
|
end
|
|
188
167
|
end
|
|
189
|
-
result
|
|
190
168
|
end
|
|
191
169
|
|
|
192
170
|
def build_request_body(record)
|
|
193
171
|
return nil if record.request_content_type.nil?
|
|
194
|
-
return nil if record.status >= 400
|
|
172
|
+
return nil if record.status >= 400 && record.request_example_mode != :multiple
|
|
195
173
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
example: (build_example(record.request_params) if example_enabled?(record)),
|
|
201
|
-
}.compact,
|
|
202
|
-
},
|
|
203
|
-
}
|
|
204
|
-
end
|
|
174
|
+
content_type = normalize_content_type(record.request_content_type)
|
|
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
|
|
205
178
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
179
|
+
body = build_example_body(schema, record, mode: record.request_example_mode, example: example)
|
|
180
|
+
{ content: { content_type => body } }
|
|
181
|
+
end
|
|
209
182
|
|
|
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)
|
|
210
186
|
property = build_type(value, format: format, enum: enum)
|
|
211
187
|
|
|
212
188
|
case value
|
|
213
189
|
when Array
|
|
214
|
-
property[:items] =
|
|
215
|
-
{} # unknown
|
|
216
|
-
else
|
|
217
|
-
build_array_items_schema(value, record: record, path: path, context: context)
|
|
218
|
-
end
|
|
190
|
+
property[:items] = value.empty? ? {} : build_array_items_schema(value, ctx.for_array_items)
|
|
219
191
|
when Hash
|
|
220
|
-
property
|
|
221
|
-
value.each do |k, v|
|
|
222
|
-
child_path = path ? "#{path}.#{k}" : k.to_s
|
|
223
|
-
properties[k] = build_property(v, record: record, key: k, path: child_path, context: context)
|
|
224
|
-
end
|
|
225
|
-
end
|
|
226
|
-
property = enrich_with_required_keys(property)
|
|
192
|
+
apply_object_schema(property, value, ctx)
|
|
227
193
|
end
|
|
228
194
|
property
|
|
229
195
|
end
|
|
230
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
|
+
|
|
231
228
|
def build_type(value, format: nil, enum: nil)
|
|
232
229
|
result = if format
|
|
233
230
|
{ type: 'string', format: format }
|
|
234
231
|
else
|
|
235
232
|
case value
|
|
236
|
-
when String
|
|
237
|
-
|
|
238
|
-
when
|
|
239
|
-
|
|
240
|
-
when
|
|
241
|
-
|
|
242
|
-
when
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
{ type: 'array' }
|
|
246
|
-
when Hash
|
|
247
|
-
{ type: 'object' }
|
|
248
|
-
when ActionDispatch::Http::UploadedFile
|
|
249
|
-
{ type: 'string', format: 'binary' }
|
|
250
|
-
when NilClass
|
|
251
|
-
{ nullable: true }
|
|
252
|
-
else
|
|
253
|
-
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}"
|
|
254
242
|
end
|
|
255
243
|
end
|
|
256
244
|
|
|
@@ -259,19 +247,33 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
259
247
|
end
|
|
260
248
|
|
|
261
249
|
def infer_format(key, record)
|
|
262
|
-
return nil
|
|
250
|
+
return nil unless key && record
|
|
263
251
|
|
|
264
|
-
record.formats[key
|
|
252
|
+
record.formats&.[](key)
|
|
265
253
|
end
|
|
266
254
|
|
|
267
255
|
def infer_enum(path, record, context)
|
|
268
256
|
return nil if !path || !record
|
|
269
257
|
|
|
270
|
-
enum_hash = context == :request ? record.request_enum : record.response_enum
|
|
271
|
-
return nil unless enum_hash
|
|
272
|
-
|
|
273
258
|
# Keys are already normalized to strings by SharedExtractor.normalize_enum
|
|
274
|
-
|
|
259
|
+
record.send("#{context}_enum")&.[](path.to_s)
|
|
260
|
+
end
|
|
261
|
+
|
|
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)
|
|
268
|
+
return nil unless record
|
|
269
|
+
|
|
270
|
+
overrides = record.send("#{context}_#{kind}")
|
|
271
|
+
return nil unless overrides
|
|
272
|
+
|
|
273
|
+
# path is nil at the body root; nil.to_s == '' lets users target it via { '' => ... }.
|
|
274
|
+
return nil if kind == :additional_properties && !overrides.key?(path.to_s)
|
|
275
|
+
|
|
276
|
+
overrides[path.to_s]
|
|
275
277
|
end
|
|
276
278
|
|
|
277
279
|
# Convert an always-String param to an appropriate type
|
|
@@ -284,30 +286,19 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
284
286
|
def build_example(value)
|
|
285
287
|
return nil if value.nil?
|
|
286
288
|
|
|
287
|
-
value
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
case item
|
|
301
|
-
when ActionDispatch::Http::UploadedFile
|
|
302
|
-
item.original_filename
|
|
303
|
-
when Hash
|
|
304
|
-
adjust_params(item)
|
|
305
|
-
else
|
|
306
|
-
item
|
|
307
|
-
end
|
|
308
|
-
end
|
|
309
|
-
value[key] = result
|
|
310
|
-
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
|
|
311
302
|
end
|
|
312
303
|
end
|
|
313
304
|
|
|
@@ -322,123 +313,134 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
322
313
|
# Same logic as normalize_content_type – strips header parameters after ';'
|
|
323
314
|
alias normalize_content_disposition normalize_content_type
|
|
324
315
|
|
|
325
|
-
def build_array_items_schema(array,
|
|
316
|
+
def build_array_items_schema(array, ctx)
|
|
326
317
|
return {} if array.empty?
|
|
327
|
-
return build_property(array.first, record: record, path: path, context: context) if array.size == 1
|
|
328
|
-
return build_property(array.first, record: record, path: path, context: context) unless array.all?(Hash)
|
|
329
318
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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'
|
|
333
334
|
|
|
334
|
-
|
|
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
|
|
335
341
|
|
|
336
|
-
|
|
337
|
-
|
|
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
|
|
338
360
|
|
|
339
|
-
|
|
340
|
-
|
|
361
|
+
def property_keys(variations)
|
|
362
|
+
variations.flat_map { |v| v[:properties]&.keys || [] }.uniq
|
|
363
|
+
end
|
|
341
364
|
|
|
342
|
-
|
|
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
|
|
343
373
|
|
|
344
|
-
|
|
374
|
+
def merge_single_property(prop_variations, has_nullable, variations_total:, allow_recursive_merge:)
|
|
375
|
+
return { nullable: true } if prop_variations.empty?
|
|
345
376
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
377
|
+
merged =
|
|
378
|
+
if prop_variations.size == 1
|
|
379
|
+
prop_variations.first.dup
|
|
380
|
+
elsif allow_recursive_merge
|
|
381
|
+
merge_multi_recursive(prop_variations)
|
|
350
382
|
else
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
if unique_types.size > 1
|
|
354
|
-
unique_props = property_variations.map { |p| p.reject { |k, _| k == :nullable } }.uniq
|
|
355
|
-
merged_schema[:properties][key] = { oneOf: unique_props }
|
|
356
|
-
else
|
|
357
|
-
case unique_types.first
|
|
358
|
-
when 'array'
|
|
359
|
-
merged_schema[:properties][key] = { type: 'array' }
|
|
360
|
-
items_variations = property_variations.map { |p| p[:items] }.compact
|
|
361
|
-
merged_schema[:properties][key][:items] = build_merged_schema_from_variations(items_variations)
|
|
362
|
-
when 'object'
|
|
363
|
-
merged_schema[:properties][key] = build_merged_schema_from_variations(property_variations)
|
|
364
|
-
else
|
|
365
|
-
merged_schema[:properties][key] = property_variations.first.dup
|
|
366
|
-
end
|
|
367
|
-
end
|
|
383
|
+
merge_multi_array_items(prop_variations)
|
|
368
384
|
end
|
|
369
385
|
|
|
370
|
-
|
|
371
|
-
end
|
|
386
|
+
return merged unless merged.is_a?(Hash)
|
|
372
387
|
|
|
373
|
-
|
|
374
|
-
|
|
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
|
|
375
399
|
|
|
376
|
-
|
|
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
|
|
377
415
|
end
|
|
378
416
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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) }
|
|
382
421
|
|
|
383
|
-
|
|
422
|
+
prop_types = prop_variations.map { |p| p[:type] }.compact.uniq
|
|
423
|
+
return one_of_schema(prop_variations) if prop_types.size > 1
|
|
384
424
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
has_nullable = all_prop_variations.any? do |v|
|
|
396
|
-
v.nil? || (v.is_a?(Hash) && v[:nullable] == true)
|
|
397
|
-
end || nullable_only.any?
|
|
398
|
-
|
|
399
|
-
if prop_variations.empty? && has_nullable
|
|
400
|
-
merged[:properties][key] = { nullable: true }
|
|
401
|
-
elsif prop_variations.size == 1
|
|
402
|
-
merged[:properties][key] = prop_variations.first.dup
|
|
403
|
-
merged[:properties][key][:nullable] = true if has_nullable
|
|
404
|
-
elsif prop_variations.size > 1
|
|
405
|
-
prop_types = prop_variations.map { |p| p[:type] }.compact.uniq
|
|
406
|
-
has_one_of = prop_variations.any? { |p| p.key?(:oneOf) }
|
|
407
|
-
|
|
408
|
-
if has_one_of
|
|
409
|
-
all_options = []
|
|
410
|
-
prop_variations.each do |prop|
|
|
411
|
-
clean_prop = prop.reject { |k, _| k == :nullable }
|
|
412
|
-
if clean_prop.key?(:oneOf)
|
|
413
|
-
all_options.concat(clean_prop[:oneOf])
|
|
414
|
-
else
|
|
415
|
-
all_options << clean_prop unless clean_prop.empty?
|
|
416
|
-
end
|
|
417
|
-
end
|
|
418
|
-
all_options.uniq!
|
|
419
|
-
merged[:properties][key] = { oneOf: all_options }
|
|
420
|
-
elsif prop_types.size == 1
|
|
421
|
-
# Only recursively merge if it's an object type
|
|
422
|
-
merged[:properties][key] = if prop_types.first == 'object'
|
|
423
|
-
build_merged_schema_from_variations(prop_variations)
|
|
424
|
-
else
|
|
425
|
-
prop_variations.first.dup
|
|
426
|
-
end
|
|
427
|
-
else
|
|
428
|
-
unique_props = prop_variations.map { |p| p.reject { |k, _| k == :nullable } }.uniq
|
|
429
|
-
merged[:properties][key] = { oneOf: unique_props }
|
|
430
|
-
end
|
|
431
|
-
|
|
432
|
-
merged[:properties][key][:nullable] = true if has_nullable || prop_variations.size < variations.size
|
|
433
|
-
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
|
|
434
435
|
end
|
|
436
|
+
end.uniq
|
|
437
|
+
end
|
|
435
438
|
|
|
436
|
-
|
|
437
|
-
|
|
439
|
+
def without_nullable(prop)
|
|
440
|
+
prop.reject { |k, _| k == :nullable }
|
|
441
|
+
end
|
|
438
442
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
variations.first
|
|
442
|
-
end
|
|
443
|
+
def one_of_schema(variations)
|
|
444
|
+
{ oneOf: variations.map { |p| without_nullable(p) }.uniq }
|
|
443
445
|
end
|
|
444
446
|
end
|