rspec-openapi 0.25.0 → 0.26.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.
@@ -38,13 +38,11 @@ end
38
38
  InspectorAnalyzer = Inspector.new
39
39
 
40
40
  # Add default parameter to load inspector before test cases run
41
- module InspectorAnalyzerPrepender
41
+ Hanami::Slice::ClassMethods.prepend(Module.new do
42
42
  def router(inspector: InspectorAnalyzer)
43
43
  super
44
44
  end
45
- end
46
-
47
- Hanami::Slice::ClassMethods.prepend(InspectorAnalyzerPrepender)
45
+ end)
48
46
 
49
47
  # Extractor for hanami
50
48
  class << RSpec::OpenAPI::Extractors::Hanami = Object.new
@@ -56,18 +54,21 @@ class << RSpec::OpenAPI::Extractors::Hanami = Object.new
56
54
 
57
55
  return RSpec::OpenAPI::Extractors::Rack.request_attributes(request, example) unless route.routable?
58
56
 
59
- summary, tags, formats, operation_id, required_request_params, security, description, deprecated, example_mode,
60
- example_key, example_name, response_enum, request_enum = SharedExtractor.attributes(example)
57
+ summary, tags, formats, operation_id, required_request_params, security, description, deprecated,
58
+ request_example_mode, response_example_mode,
59
+ example_key, example_name, response_enum, request_enum, response_additional_properties,
60
+ request_additional_properties, response_hybrid_additional_properties,
61
+ request_hybrid_additional_properties = SharedExtractor.attributes(example)
61
62
 
62
63
  path = request.path
63
64
 
64
65
  raw_path_params = route.params
65
66
 
66
- result = InspectorAnalyzer.call(request.method, add_id(path, route))
67
+ result = InspectorAnalyzer.call(request.method, replace_path_params(path, route, '/:%{key}'))
67
68
 
68
69
  summary ||= result[:summary]
69
70
  tags ||= result[:tags]
70
- path = add_openapi_id(path, route)
71
+ path = replace_path_params(path, route, '/{%{key}}')
71
72
 
72
73
  raw_path_params = raw_path_params.slice(*(raw_path_params.keys - RSpec::OpenAPI.ignored_path_params))
73
74
 
@@ -82,11 +83,16 @@ class << RSpec::OpenAPI::Extractors::Hanami = Object.new
82
83
  security,
83
84
  deprecated,
84
85
  formats,
85
- example_mode,
86
+ request_example_mode,
87
+ response_example_mode,
86
88
  example_key,
87
89
  example_name,
88
90
  response_enum,
89
91
  request_enum,
92
+ response_additional_properties,
93
+ request_additional_properties,
94
+ response_hybrid_additional_properties,
95
+ request_hybrid_additional_properties,
90
96
  ]
91
97
  end
92
98
 
@@ -101,21 +107,11 @@ class << RSpec::OpenAPI::Extractors::Hanami = Object.new
101
107
 
102
108
  private
103
109
 
104
- def add_id(path, route)
105
- return path if route.params.empty?
106
-
107
- route.params.each_pair do |key, value|
108
- path = path.sub("/#{value}", "/:#{key}")
109
- end
110
-
111
- path
112
- end
113
-
114
- def add_openapi_id(path, route)
110
+ def replace_path_params(path, route, format)
115
111
  return path if route.params.empty?
116
112
 
117
113
  route.params.each_pair do |key, value|
118
- path = path.sub("/#{value}", "/{#{key}}")
114
+ path = path.sub("/#{value}", format % { key: key })
119
115
  end
120
116
 
121
117
  path
@@ -6,8 +6,11 @@ class << RSpec::OpenAPI::Extractors::Rack = Object.new
6
6
  # @param [RSpec::Core::Example] example
7
7
  # @return Array
8
8
  def request_attributes(request, example)
9
- summary, tags, formats, operation_id, required_request_params, security, description, deprecated, example_mode,
10
- example_key, example_name, response_enum, request_enum = SharedExtractor.attributes(example)
9
+ summary, tags, formats, operation_id, required_request_params, security, description, deprecated,
10
+ request_example_mode, response_example_mode,
11
+ example_key, example_name, response_enum, request_enum, response_additional_properties,
12
+ request_additional_properties, response_hybrid_additional_properties,
13
+ request_hybrid_additional_properties = SharedExtractor.attributes(example)
11
14
 
12
15
  raw_path_params = request.path_parameters
13
16
  path = request.path
@@ -24,11 +27,16 @@ class << RSpec::OpenAPI::Extractors::Rack = Object.new
24
27
  security,
25
28
  deprecated,
26
29
  formats,
27
- example_mode,
30
+ request_example_mode,
31
+ response_example_mode,
28
32
  example_key,
29
33
  example_name,
30
34
  response_enum,
31
35
  request_enum,
36
+ response_additional_properties,
37
+ request_additional_properties,
38
+ response_hybrid_additional_properties,
39
+ request_hybrid_additional_properties,
32
40
  ]
33
41
  end
34
42
 
@@ -16,8 +16,11 @@ class << RSpec::OpenAPI::Extractors::Rails = Object.new
16
16
 
17
17
  raise "No route matched for #{fixed_request.request_method} #{fixed_request.path_info}" if route.nil?
18
18
 
19
- summary, tags, formats, operation_id, required_request_params, security, description, deprecated, example_mode,
20
- example_key, example_name, response_enum, request_enum = SharedExtractor.attributes(example)
19
+ summary, tags, formats, operation_id, required_request_params, security, description, deprecated,
20
+ request_example_mode, response_example_mode,
21
+ example_key, example_name, response_enum, request_enum, response_additional_properties,
22
+ request_additional_properties, response_hybrid_additional_properties,
23
+ request_hybrid_additional_properties = SharedExtractor.attributes(example)
21
24
 
22
25
  raw_path_params = request.path_parameters
23
26
 
@@ -40,16 +43,26 @@ class << RSpec::OpenAPI::Extractors::Rails = Object.new
40
43
  security,
41
44
  deprecated,
42
45
  formats,
43
- example_mode,
46
+ request_example_mode,
47
+ response_example_mode,
44
48
  example_key,
45
49
  example_name,
46
50
  response_enum,
47
51
  request_enum,
52
+ response_additional_properties,
53
+ request_additional_properties,
54
+ response_hybrid_additional_properties,
55
+ request_hybrid_additional_properties,
48
56
  ]
49
57
  end
50
58
 
51
59
  # @param [RSpec::ExampleGroups::*] context
52
60
  def request_response(context)
61
+ # Read @integration_session directly so user-defined let(:request)/let(:response)
62
+ # don't shadow the real ActionDispatch objects we need for OpenAPI extraction.
63
+ session = context.instance_variable_get(:@integration_session)
64
+ return [session.request, session.response] if session
65
+
53
66
  [context.request, context.response]
54
67
  end
55
68
 
@@ -4,6 +4,14 @@
4
4
  class SharedExtractor
5
5
  VALID_EXAMPLE_MODES = %i[none single multiple].freeze
6
6
 
7
+ EXAMPLE_MODE_MULTIPLE_SHORTHAND_WARNING = <<~MSG.tr("\n", ' ').strip.freeze
8
+ [rspec-openapi] DEPRECATION: example_mode: :multiple currently means
9
+ { request: :single, response: :multiple }. A future major version will
10
+ change it to { request: :multiple, response: :multiple } (both sides
11
+ multi). Specify the hash form explicitly to lock in current behavior or
12
+ opt in early.
13
+ MSG
14
+
7
15
  def self.attributes(example)
8
16
  metadata = merge_openapi_metadata(example.metadata)
9
17
  summary = metadata[:summary] || RSpec::OpenAPI.summary_builder.call(example)
@@ -14,7 +22,7 @@ class SharedExtractor
14
22
  security = metadata[:security]
15
23
  description = metadata[:description] || RSpec::OpenAPI.description_builder.call(example)
16
24
  deprecated = metadata[:deprecated]
17
- example_mode = normalize_example_mode(metadata[:example_mode], example)
25
+ request_example_mode, response_example_mode = normalize_example_mode(metadata[:example_mode], example)
18
26
  example_name = metadata[:example_name] || RSpec::OpenAPI.example_name_builder.call(example)
19
27
  raw_example_key = metadata[:example_key] || example_name
20
28
  example_key = RSpec::OpenAPI::ExampleKey.normalize(raw_example_key)
@@ -25,8 +33,29 @@ class SharedExtractor
25
33
  response_enum = normalize_enum(metadata[:response_enum]) || base_enum
26
34
  request_enum = normalize_enum(metadata[:request_enum]) || base_enum
27
35
 
28
- [summary, tags, formats, operation_id, required_request_params, security, description, deprecated, example_mode,
29
- example_key, example_name, response_enum, request_enum,]
36
+ response_additional_properties, request_additional_properties = resolve_additional_properties(metadata)
37
+ response_hybrid_additional_properties, request_hybrid_additional_properties =
38
+ resolve_hybrid_additional_properties(metadata)
39
+
40
+ [summary, tags, formats, operation_id, required_request_params, security, description, deprecated,
41
+ request_example_mode, response_example_mode,
42
+ example_key, example_name, response_enum, request_enum, response_additional_properties,
43
+ request_additional_properties, response_hybrid_additional_properties,
44
+ request_hybrid_additional_properties,]
45
+ end
46
+
47
+ def self.resolve_additional_properties(metadata)
48
+ base = normalize_additional_properties(metadata[:additional_properties])
49
+ response = normalize_additional_properties(metadata[:response_additional_properties]) || base
50
+ request = normalize_additional_properties(metadata[:request_additional_properties]) || base
51
+ [response, request]
52
+ end
53
+
54
+ def self.resolve_hybrid_additional_properties(metadata)
55
+ base = normalize_additional_properties(metadata[:hybrid_additional_properties])
56
+ response = normalize_additional_properties(metadata[:response_hybrid_additional_properties]) || base
57
+ request = normalize_additional_properties(metadata[:request_hybrid_additional_properties]) || base
58
+ [response, request]
30
59
  end
31
60
 
32
61
  def self.normalize_enum(enum_hash)
@@ -36,6 +65,14 @@ class SharedExtractor
36
65
  enum_hash.transform_keys(&:to_s)
37
66
  end
38
67
 
68
+ def self.normalize_additional_properties(hash)
69
+ return nil if hash.nil? || hash.empty?
70
+
71
+ hash.each_with_object({}) do |(path, schema), result|
72
+ result[path.to_s] = RSpec::OpenAPI::KeyTransformer.symbolize(schema)
73
+ end
74
+ end
75
+
39
76
  def self.merge_openapi_metadata(metadata)
40
77
  collect_openapi_metadata(metadata).reduce({}, &:merge)
41
78
  end
@@ -54,9 +91,33 @@ class SharedExtractor
54
91
  end
55
92
  end
56
93
 
94
+ # Returns [request_mode, response_mode]. Accepts either a bare Symbol/String
95
+ # (applied to both sides, except :multiple which is treated as a backward-compat
96
+ # shorthand for { request: :single, response: :multiple } and emits a one-time
97
+ # deprecation warning) or a Hash with :request / :response keys.
57
98
  def self.normalize_example_mode(value, example = nil)
58
- return :single if value.nil?
99
+ return %i[single single] if value.nil?
59
100
 
101
+ case value
102
+ when Hash
103
+ [
104
+ normalize_example_mode_hash_value(value, :request, example),
105
+ normalize_example_mode_hash_value(value, :response, example),
106
+ ]
107
+ when Symbol, String
108
+ mode = coerce_example_mode_value(value, example)
109
+ if mode == :multiple
110
+ warn_example_mode_multiple_shorthand
111
+ %i[single multiple]
112
+ else
113
+ [mode, mode]
114
+ end
115
+ else
116
+ raise ArgumentError, example_mode_error(value, example)
117
+ end
118
+ end
119
+
120
+ def self.coerce_example_mode_value(value, example)
60
121
  raise ArgumentError, example_mode_error(value, example) unless value.is_a?(String) || value.is_a?(Symbol)
61
122
 
62
123
  mode = value.to_s.strip.downcase.to_sym
@@ -65,9 +126,25 @@ class SharedExtractor
65
126
  raise ArgumentError, example_mode_error(value, example)
66
127
  end
67
128
 
129
+ def self.normalize_example_mode_hash_value(hash, key, example)
130
+ raw = hash[key]
131
+ raw = hash[key.to_s] if raw.nil?
132
+ return :single if raw.nil?
133
+
134
+ coerce_example_mode_value(raw, example)
135
+ end
136
+
137
+ def self.warn_example_mode_multiple_shorthand
138
+ return if @warned_example_mode_multiple_shorthand
139
+
140
+ @warned_example_mode_multiple_shorthand = true
141
+ Kernel.warn(EXAMPLE_MODE_MULTIPLE_SHORTHAND_WARNING)
142
+ end
143
+
68
144
  def self.example_mode_error(value, example)
69
145
  context = example&.full_description
70
146
  context = " (example: #{context})" if context
71
- "example_mode must be one of #{VALID_EXAMPLE_MODES.inspect}, got #{value.inspect}#{context}"
147
+ "example_mode must be a Symbol/String in #{VALID_EXAMPLE_MODES.inspect} " \
148
+ "or a Hash with :request/:response keys, got #{value.inspect}#{context}"
72
149
  end
73
150
  end
@@ -20,7 +20,8 @@ RSpec::OpenAPI::Record = Struct.new(
20
20
  :security, # @param [Array] - [{securityScheme1: []}]
21
21
  :deprecated, # @param [Boolean] - true
22
22
  :example_enabled, # @param [Boolean] - true
23
- :example_mode, # @param [Symbol] - :none | :single | :multiple
23
+ :request_example_mode, # @param [Symbol] - :none | :single | :multiple
24
+ :response_example_mode, # @param [Symbol] - :none | :single | :multiple
24
25
  :status, # @param [Integer] - 200
25
26
  :response_body, # @param [Object] - {"status" => "ok"}
26
27
  :response_headers, # @param [Array] - [["header_key1", "header_value1"], ["header_key2", "header_value2"]]
@@ -28,5 +29,9 @@ RSpec::OpenAPI::Record = Struct.new(
28
29
  :response_content_disposition, # @param [String] - "inline"
29
30
  :response_enum, # @param [Hash] - {"status" => ["active", "inactive"], "user.role" => ["admin", "user"]}
30
31
  :request_enum, # @param [Hash] - {"type" => ["create", "update"]}
32
+ :response_additional_properties, # @param [Hash] - {"data" => { type: "boolean" }}
33
+ :request_additional_properties, # @param [Hash] - {"meta" => { type: "string" }}
34
+ :response_hybrid_additional_properties, # @param [Hash] - {"data" => { type: "string" }}
35
+ :request_hybrid_additional_properties, # @param [Hash] - {"meta" => { type: "string" }}
31
36
  keyword_init: true,
32
37
  )
@@ -12,8 +12,11 @@ class << RSpec::OpenAPI::RecordBuilder = Object.new
12
12
  return if request.nil?
13
13
 
14
14
  title = RSpec::OpenAPI.title.then { |t| t.is_a?(Proc) ? t.call(example) : t }
15
- path, summary, tags, operation_id, required_request_params, raw_path_params, description, security, deprecated,
16
- formats, example_mode, example_key, example_name, response_enum, request_enum = extractor.request_attributes(request, example)
15
+ path, summary, tags, operation_id, required_request_params, raw_path_params,
16
+ description, security, deprecated, formats, request_example_mode, response_example_mode,
17
+ example_key, example_name, response_enum, request_enum, response_additional_properties,
18
+ request_additional_properties, response_hybrid_additional_properties,
19
+ request_hybrid_additional_properties = extractor.request_attributes(request, example)
17
20
 
18
21
  return if RSpec::OpenAPI.ignored_paths.any? { |ignored_path| path.match?(ignored_path) }
19
22
 
@@ -42,11 +45,16 @@ class << RSpec::OpenAPI::RecordBuilder = Object.new
42
45
  response_content_type: response.media_type,
43
46
  response_content_disposition: response.header['Content-Disposition'],
44
47
  example_enabled: RSpec::OpenAPI.enable_example,
45
- example_mode: example_mode,
48
+ request_example_mode: request_example_mode,
49
+ response_example_mode: response_example_mode,
46
50
  example_key: example_key,
47
51
  example_name: example_name,
48
52
  response_enum: response_enum,
49
53
  request_enum: request_enum,
54
+ response_additional_properties: response_additional_properties,
55
+ request_additional_properties: request_additional_properties,
56
+ response_hybrid_additional_properties: response_hybrid_additional_properties,
57
+ request_hybrid_additional_properties: request_hybrid_additional_properties,
50
58
  ).freeze
51
59
  end
52
60
 
@@ -4,9 +4,15 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
4
4
  # @param [RSpec::OpenAPI::Record] record
5
5
  # @return [Hash]
6
6
  def build(record)
7
- response = {
8
- description: record.description,
9
- }
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
10
16
 
11
17
  response_headers = build_response_headers(record)
12
18
  response[:headers] = response_headers unless response_headers.empty?
@@ -52,7 +58,7 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
52
58
  # If examples are globally disabled, always return schema-only content.
53
59
  return { content_type => { schema: schema }.compact } unless example_enabled?(record)
54
60
 
55
- case record.example_mode
61
+ case record.response_example_mode
56
62
  when :none
57
63
  # Only schema, no examples
58
64
  {
@@ -74,8 +80,7 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
74
80
  content_type => {
75
81
  schema: schema,
76
82
  example: response_example(record, disposition: disposition),
77
- _example_key: record.example_key,
78
- _example_summary: example_summary(record),
83
+ **example_metadata(record),
79
84
  }.compact,
80
85
  }
81
86
  end
@@ -93,13 +98,21 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
93
98
  end
94
99
 
95
100
  def build_example_object(record, disposition:)
101
+ build_named_example(record, response_example(record, disposition: disposition))
102
+ end
103
+
104
+ def build_named_example(record, value)
96
105
  summary = example_summary(record)
97
106
  example = {}
98
107
  example[:summary] = summary if summary
99
- example[:value] = response_example(record, disposition: disposition)
108
+ example[:value] = value
100
109
  example
101
110
  end
102
111
 
112
+ def example_metadata(record)
113
+ { _example_key: record.example_key, _example_summary: example_summary(record) }
114
+ end
115
+
103
116
  def example_summary(record)
104
117
  return nil unless example_summary_enabled?
105
118
  return nil if record.example_name.nil? || record.example_name.empty?
@@ -191,16 +204,33 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
191
204
 
192
205
  def build_request_body(record)
193
206
  return nil if record.request_content_type.nil?
194
- return nil if record.status >= 400
207
+ return nil if record.status >= 400 && record.request_example_mode != :multiple
195
208
 
196
- {
197
- content: {
198
- normalize_content_type(record.request_content_type) => {
199
- schema: build_property(record.request_params, record: record, context: :request),
200
- example: (build_example(record.request_params) if example_enabled?(record)),
201
- }.compact,
202
- },
203
- }
209
+ content_type = normalize_content_type(record.request_content_type)
210
+ schema = build_property(record.request_params, record: record, context: :request)
211
+
212
+ return { content: { content_type => { schema: schema }.compact } } unless example_enabled?(record)
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
232
+
233
+ { content: { content_type => body.compact } }
204
234
  end
205
235
 
206
236
  def build_property(value, disposition: nil, key: nil, record: nil, path: nil, context: nil)
@@ -217,13 +247,32 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
217
247
  build_array_items_schema(value, record: record, path: path, context: context)
218
248
  end
219
249
  when Hash
220
- property[:properties] = {}.tap do |properties|
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)
250
+ override = infer_additional_properties(path, record, context)
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
224
274
  end
225
275
  end
226
- property = enrich_with_required_keys(property)
227
276
  end
228
277
  property
229
278
  end
@@ -270,8 +319,38 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
270
319
  enum_hash = context == :request ? record.request_enum : record.response_enum
271
320
  return nil unless enum_hash
272
321
 
273
- # Try both string and symbol keys
274
- enum_hash[path.to_s] || enum_hash[path.to_sym]
322
+ # Keys are already normalized to strings by SharedExtractor.normalize_enum
323
+ enum_hash[path.to_s]
324
+ end
325
+
326
+ def infer_additional_properties(path, record, context)
327
+ return nil unless record
328
+
329
+ overrides = if context == :request
330
+ record.request_additional_properties
331
+ else
332
+ record.response_additional_properties
333
+ end
334
+ return nil unless overrides
335
+
336
+ # path is nil at the body root; nil.to_s == '' lets users target it via { '' => ... }.
337
+ # Use `key?` so a literal `false` override is distinguishable from "no override".
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
352
+
353
+ overrides[path.to_s]
275
354
  end
276
355
 
277
356
  # Convert an always-String param to an appropriate type
@@ -319,14 +398,13 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
319
398
  content_type&.sub(/;.+\z/, '')
320
399
  end
321
400
 
322
- def normalize_content_disposition(content_disposition)
323
- content_disposition&.sub(/;.+\z/, '')
324
- end
401
+ # Same logic as normalize_content_type – strips header parameters after ';'
402
+ alias normalize_content_disposition normalize_content_type
325
403
 
326
404
  def build_array_items_schema(array, record: nil, path: nil, context: nil)
327
405
  return {} if array.empty?
328
406
  return build_property(array.first, record: record, path: path, context: context) if array.size == 1
329
- return build_property(array.first, record: record, path: path, context: context) unless array.all? { |item| item.is_a?(Hash) }
407
+ return build_property(array.first, record: record, path: path, context: context) unless array.all?(Hash)
330
408
 
331
409
  all_schemas = array.map { |item| build_property(item, record: record, path: path, context: context) }
332
410
  merged_schema = all_schemas.first.dup
@@ -70,6 +70,8 @@ class << RSpec::OpenAPI::SchemaCleaner = Object.new
70
70
  ]
71
71
  paths_to_objects.each do |path|
72
72
  parent = base.dig(*path.take(path.length - 1))
73
+ next unless parent
74
+
73
75
  # "required" array must not be present if empty
74
76
  parent.delete(:required) if parent[:required] && parent[:required].empty?
75
77
  end
@@ -84,6 +86,9 @@ class << RSpec::OpenAPI::SchemaCleaner = Object.new
84
86
  hash.delete(:_example_key)
85
87
  hash.delete(:_example_summary)
86
88
  hash.delete(:_example_name)
89
+ if (fallback = hash.delete(:_fallback_description))
90
+ hash[:description] ||= fallback
91
+ end
87
92
 
88
93
  hash.each_value do |value|
89
94
  case value
@@ -9,6 +9,8 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
9
9
  merge_schema!(base, spec)
10
10
  end
11
11
 
12
+ SIMILARITY_THRESHOLD = 0.5
13
+
12
14
  private
13
15
 
14
16
  # Not doing `base.replace(deep_merge(base, spec))` to preserve key orders.
@@ -23,6 +25,17 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
23
25
  return base
24
26
  end
25
27
 
28
+ # When the new spec converts an object to a dictionary (introduces
29
+ # `additionalProperties` on a node that previously had `properties` /
30
+ # `required`), drop the stale fields so the merged result reflects the
31
+ # new intent. We only prune when base does not already declare
32
+ # `additionalProperties`, to preserve manual edits that intentionally
33
+ # combine fixed and dynamic keys.
34
+ if spec.is_a?(Hash) && spec.key?(:additionalProperties) && !base.key?(:additionalProperties)
35
+ base.delete(:properties)
36
+ base.delete(:required)
37
+ end
38
+
26
39
  spec.each do |key, value|
27
40
  if base[key].is_a?(Hash) && value.is_a?(Hash)
28
41
  # Handle example/examples conflict - convert to examples when mixed
@@ -121,13 +134,11 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
121
134
  end
122
135
 
123
136
  def build_unique_params(base, key)
124
- base[key].each_with_object({}) do |parameter, hash|
125
- hash[[parameter[:name], parameter[:in]]] = parameter
137
+ base[key].to_h do |parameter|
138
+ [[parameter[:name], parameter[:in]], parameter]
126
139
  end
127
140
  end
128
141
 
129
- SIMILARITY_THRESHOLD = 0.5
130
-
131
142
  # Normalize example/examples fields when there's a conflict
132
143
  # OpenAPI spec doesn't allow both example and examples in the same object
133
144
  def normalize_example_fields!(base, spec)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RSpec
4
4
  module OpenAPI
5
- VERSION = '0.25.0'
5
+ VERSION = '0.26.0'
6
6
  end
7
7
  end
data/lib/rspec/openapi.rb CHANGED
@@ -33,7 +33,7 @@ module RSpec::OpenAPI
33
33
  @comment = nil
34
34
  @enable_example = true
35
35
  @enable_example_summary = true
36
- @description_builder = ->(example) { example.description }
36
+ @description_builder = :description.to_proc
37
37
  @example_name_builder = :description.to_proc
38
38
  @summary_builder = ->(example) { example.metadata[:summary] }
39
39
  @tags_builder = ->(example) { example.metadata[:tags] }
data/redocly.yaml ADDED
@@ -0,0 +1,31 @@
1
+ # Config for `redocly lint` used by .github/workflows/validate-openapi.yml
2
+ # to validate test-fixture OpenAPI documents shipped under spec/apps/.
3
+ #
4
+ # spec/apps/rails/doc/smart/openapi.yaml is intentionally excluded:
5
+ # it is the input fixture for the smart-merge feature test (it contains
6
+ # unresolved $refs by design), not a complete API description.
7
+ extends:
8
+ - minimal
9
+ apis:
10
+ rails-rspec-yaml:
11
+ root: spec/apps/rails/doc/rspec_openapi.yaml
12
+ rails-rspec-json:
13
+ root: spec/apps/rails/doc/rspec_openapi.json
14
+ rails-minitest-yaml:
15
+ root: spec/apps/rails/doc/minitest_openapi.yaml
16
+ rails-minitest-json:
17
+ root: spec/apps/rails/doc/minitest_openapi.json
18
+ rails-smart-expected:
19
+ root: spec/apps/rails/doc/smart/expected.yaml
20
+ roda-rspec-yaml:
21
+ root: spec/apps/roda/doc/rspec_openapi.yaml
22
+ roda-rspec-json:
23
+ root: spec/apps/roda/doc/rspec_openapi.json
24
+ roda-minitest-yaml:
25
+ root: spec/apps/roda/doc/minitest_openapi.yaml
26
+ roda-minitest-json:
27
+ root: spec/apps/roda/doc/minitest_openapi.json
28
+ hanami-yaml:
29
+ root: spec/apps/hanami/doc/openapi.yaml
30
+ hanami-json:
31
+ root: spec/apps/hanami/doc/openapi.json
@@ -31,5 +31,4 @@ Gem::Specification.new do |spec|
31
31
  spec.add_dependency 'actionpack', '>= 5.2.0'
32
32
  spec.add_dependency 'rails-dom-testing'
33
33
  spec.add_dependency 'rspec-core'
34
- spec.metadata['rubygems_mfa_required'] = 'true'
35
34
  end