rspec-openapi 0.23.0 → 0.25.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9de2ab7b530ea8a400d157e5296b042e7a1988b84537a2def73007a169d530fe
4
- data.tar.gz: 4550f2cee402ccbd45c2722a8bac19550494e2822ea5344a09294ba06845901b
3
+ metadata.gz: 65e97b91c81a4798c7907d907174d8c7efe874ad26dd3d630db2c86db97f913d
4
+ data.tar.gz: a593eaf58214a7bc5578f3cee065d263d7b494507727ac05c60111d01b0a2a7d
5
5
  SHA512:
6
- metadata.gz: 9ad939c9bfc561a429838f5e6149c1cefbe7c6e1346c8d0cf95fd83fa7cbd55654c7dcb9d889d9fdb8f6eead6c35c6eb316d35445679ba6cabc3b8e7cc2a5022
7
- data.tar.gz: bac87ded3a5f9cbcbedf675b8a29e18ea53ec87f3abc396d56872279a6c88c879afcb09589db87796736b947cef34ef9b59af49e903e297c6aa0c4389f5b2465
6
+ metadata.gz: 3e88fae82f5452b748d2ade83adc141fff0e888946c48ec3219999012bd75a7162e678d89af1e1ccb584e14250739715e0ffd53ec1b1ff101080ca2abd44ba82
7
+ data.tar.gz: e49fdd879b02d43fe0f8ac4ef2d443b7a0366c5f133d5012f771be380c706b9fa28597f6bd6e6e111bee599bee26e5ae71f8dec262e4bf6070c679790e42d654
@@ -4,7 +4,7 @@ on:
4
4
  workflow_dispatch:
5
5
  inputs:
6
6
  version:
7
- description: 'Version to release (e.g. 0.23.1 or 0.24.0)'
7
+ description: 'Version to release (e.g. 0.25.1 or 0.26.0)'
8
8
  required: true
9
9
 
10
10
  jobs:
data/Gemfile CHANGED
@@ -24,7 +24,7 @@ gem 'concurrent-ruby', '1.3.4'
24
24
  gem 'roda'
25
25
 
26
26
  gem 'rails-dom-testing', '~> 2.2'
27
- gem 'rspec-rails'
27
+ gem 'rspec-rails', '>= 5.0'
28
28
 
29
29
  group :test do
30
30
  gem 'simplecov', git: 'https://github.com/exoego/simplecov.git', branch: 'branch-fix'
data/README.md CHANGED
@@ -366,6 +366,87 @@ Some examples' attributes can be overwritten via RSpec metadata options. Example
366
366
 
367
367
  **NOTE**: `description` key will override also the one provided by `RSpec::OpenAPI.description_builder` method.
368
368
 
369
+ ### Enum Support
370
+
371
+ You can specify enum values for string properties that should have a fixed set of allowed values. Since enums cannot be reliably inferred from test data, you can define them via the `enum` metadata option:
372
+
373
+ ```rb
374
+ it 'returns user status', openapi: {
375
+ enum: {
376
+ 'status' => %w[active inactive suspended],
377
+ },
378
+ } do
379
+ get '/users/1'
380
+ expect(response.status).to eq(200)
381
+ end
382
+ ```
383
+
384
+ This generates:
385
+
386
+ ```yaml
387
+ schema:
388
+ type: object
389
+ properties:
390
+ status:
391
+ type: string
392
+ enum:
393
+ - active
394
+ - inactive
395
+ - suspended
396
+ ```
397
+
398
+ #### Nested Paths
399
+
400
+ For nested objects, use dot notation to specify the path:
401
+
402
+ ```rb
403
+ it 'returns user with role', openapi: {
404
+ enum: {
405
+ 'status' => %w[active inactive],
406
+ 'user.role' => %w[admin user guest],
407
+ },
408
+ } do
409
+ get '/teams/1'
410
+ # Response: { "status": "active", "user": { "name": "John", "role": "admin" } }
411
+ expect(response.status).to eq(200)
412
+ end
413
+ ```
414
+
415
+ #### Array Items
416
+
417
+ For properties inside array items, use the array property name followed by the item property:
418
+
419
+ ```rb
420
+ it 'returns items with status', openapi: {
421
+ enum: {
422
+ 'items.status' => %w[pending completed failed],
423
+ 'items.priority' => %w[high medium low],
424
+ },
425
+ } do
426
+ get '/tasks'
427
+ # Response: { "items": [{ "id": 1, "status": "pending", "priority": "high" }] }
428
+ expect(response.status).to eq(200)
429
+ end
430
+ ```
431
+
432
+ #### Request vs Response Enums
433
+
434
+ By default, `enum` applies to both request and response bodies. If you need different enum values for request and response, use `request_enum` and `response_enum`:
435
+
436
+ ```rb
437
+ it 'creates a task', openapi: {
438
+ request_enum: {
439
+ 'action' => %w[create update delete],
440
+ },
441
+ response_enum: {
442
+ 'status' => %w[pending processing completed],
443
+ },
444
+ } do
445
+ post '/tasks', params: { action: 'create', name: 'New Task' }
446
+ expect(response.status).to eq(201)
447
+ end
448
+ ```
449
+
369
450
  ### Multiple Examples Mode
370
451
 
371
452
  You can generate multiple named examples for the same endpoint using `example_mode`:
@@ -57,7 +57,7 @@ class << RSpec::OpenAPI::Extractors::Hanami = Object.new
57
57
  return RSpec::OpenAPI::Extractors::Rack.request_attributes(request, example) unless route.routable?
58
58
 
59
59
  summary, tags, formats, operation_id, required_request_params, security, description, deprecated, example_mode,
60
- example_key, example_name = SharedExtractor.attributes(example)
60
+ example_key, example_name, response_enum, request_enum = SharedExtractor.attributes(example)
61
61
 
62
62
  path = request.path
63
63
 
@@ -85,6 +85,8 @@ class << RSpec::OpenAPI::Extractors::Hanami = Object.new
85
85
  example_mode,
86
86
  example_key,
87
87
  example_name,
88
+ response_enum,
89
+ request_enum,
88
90
  ]
89
91
  end
90
92
 
@@ -7,7 +7,7 @@ class << RSpec::OpenAPI::Extractors::Rack = Object.new
7
7
  # @return Array
8
8
  def request_attributes(request, example)
9
9
  summary, tags, formats, operation_id, required_request_params, security, description, deprecated, example_mode,
10
- example_key, example_name = SharedExtractor.attributes(example)
10
+ example_key, example_name, response_enum, request_enum = SharedExtractor.attributes(example)
11
11
 
12
12
  raw_path_params = request.path_parameters
13
13
  path = request.path
@@ -27,6 +27,8 @@ class << RSpec::OpenAPI::Extractors::Rack = Object.new
27
27
  example_mode,
28
28
  example_key,
29
29
  example_name,
30
+ response_enum,
31
+ request_enum,
30
32
  ]
31
33
  end
32
34
 
@@ -17,7 +17,7 @@ class << RSpec::OpenAPI::Extractors::Rails = Object.new
17
17
  raise "No route matched for #{fixed_request.request_method} #{fixed_request.path_info}" if route.nil?
18
18
 
19
19
  summary, tags, formats, operation_id, required_request_params, security, description, deprecated, example_mode,
20
- example_key, example_name = SharedExtractor.attributes(example)
20
+ example_key, example_name, response_enum, request_enum = SharedExtractor.attributes(example)
21
21
 
22
22
  raw_path_params = request.path_parameters
23
23
 
@@ -43,6 +43,8 @@ class << RSpec::OpenAPI::Extractors::Rails = Object.new
43
43
  example_mode,
44
44
  example_key,
45
45
  example_name,
46
+ response_enum,
47
+ request_enum,
46
48
  ]
47
49
  end
48
50
 
@@ -20,8 +20,20 @@ class SharedExtractor
20
20
  example_key = RSpec::OpenAPI::ExampleKey.normalize(raw_example_key)
21
21
  example_key = 'default' if example_key.nil? || example_key.empty?
22
22
 
23
+ # Enum support: response_enum and request_enum can override the general enum
24
+ base_enum = normalize_enum(metadata[:enum])
25
+ response_enum = normalize_enum(metadata[:response_enum]) || base_enum
26
+ request_enum = normalize_enum(metadata[:request_enum]) || base_enum
27
+
23
28
  [summary, tags, formats, operation_id, required_request_params, security, description, deprecated, example_mode,
24
- example_key, example_name,]
29
+ example_key, example_name, response_enum, request_enum,]
30
+ end
31
+
32
+ def self.normalize_enum(enum_hash)
33
+ return nil if enum_hash.nil? || enum_hash.empty?
34
+
35
+ # Convert all keys to strings for consistent lookup
36
+ enum_hash.transform_keys(&:to_s)
25
37
  end
26
38
 
27
39
  def self.merge_openapi_metadata(metadata)
@@ -26,5 +26,7 @@ RSpec::OpenAPI::Record = Struct.new(
26
26
  :response_headers, # @param [Array] - [["header_key1", "header_value1"], ["header_key2", "header_value2"]]
27
27
  :response_content_type, # @param [String] - "application/json"
28
28
  :response_content_disposition, # @param [String] - "inline"
29
+ :response_enum, # @param [Hash] - {"status" => ["active", "inactive"], "user.role" => ["admin", "user"]}
30
+ :request_enum, # @param [Hash] - {"type" => ["create", "update"]}
29
31
  keyword_init: true,
30
32
  )
@@ -13,7 +13,7 @@ class << RSpec::OpenAPI::RecordBuilder = Object.new
13
13
 
14
14
  title = RSpec::OpenAPI.title.then { |t| t.is_a?(Proc) ? t.call(example) : t }
15
15
  path, summary, tags, operation_id, required_request_params, raw_path_params, description, security, deprecated,
16
- formats, example_mode, example_key, example_name = extractor.request_attributes(request, example)
16
+ formats, example_mode, example_key, example_name, response_enum, request_enum = extractor.request_attributes(request, example)
17
17
 
18
18
  return if RSpec::OpenAPI.ignored_paths.any? { |ignored_path| path.match?(ignored_path) }
19
19
 
@@ -45,6 +45,8 @@ class << RSpec::OpenAPI::RecordBuilder = Object.new
45
45
  example_mode: example_mode,
46
46
  example_key: example_key,
47
47
  example_name: example_name,
48
+ response_enum: response_enum,
49
+ request_enum: request_enum,
48
50
  ).freeze
49
51
  end
50
52
 
@@ -47,7 +47,7 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
47
47
 
48
48
  def build_content(disposition, record)
49
49
  content_type = normalize_content_type(record.response_content_type)
50
- schema = build_property(record.response_body, disposition: disposition, record: record)
50
+ schema = build_property(record.response_body, disposition: disposition, record: record, context: :response)
51
51
 
52
52
  # If examples are globally disabled, always return schema-only content.
53
53
  return { content_type => { schema: schema }.compact } unless example_enabled?(record)
@@ -121,7 +121,7 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
121
121
  name: build_parameter_name(key, value),
122
122
  in: 'path',
123
123
  required: true,
124
- schema: build_property(try_cast(value), key: key, record: record),
124
+ schema: build_property(try_cast(value), key: key, record: record, path: key.to_s, context: :request),
125
125
  example: (try_cast(value) if example_enabled?(record)),
126
126
  }.compact
127
127
  end
@@ -131,7 +131,7 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
131
131
  name: key,
132
132
  in: 'query',
133
133
  required: record.required_request_params.include?(key),
134
- schema: build_property(try_cast(value), key: key, record: record),
134
+ schema: build_property(try_cast(value), key: key, record: record, path: key.to_s, context: :request),
135
135
  example: (try_cast(value) if example_enabled?(record)),
136
136
  }.compact
137
137
  end
@@ -141,7 +141,7 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
141
141
  name: build_parameter_name(key, value),
142
142
  in: 'header',
143
143
  required: true,
144
- schema: build_property(try_cast(value), key: key, record: record),
144
+ schema: build_property(try_cast(value), key: key, record: record, path: key.to_s, context: :request),
145
145
  example: (try_cast(value) if example_enabled?(record)),
146
146
  }.compact
147
147
  end
@@ -158,7 +158,7 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
158
158
 
159
159
  record.response_headers.each do |key, value|
160
160
  headers[key] = {
161
- schema: build_property(try_cast(value), key: key, record: record),
161
+ schema: build_property(try_cast(value), key: key, record: record, path: key.to_s, context: :response),
162
162
  }.compact
163
163
  end
164
164
 
@@ -196,29 +196,31 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
196
196
  {
197
197
  content: {
198
198
  normalize_content_type(record.request_content_type) => {
199
- schema: build_property(record.request_params, record: record),
199
+ schema: build_property(record.request_params, record: record, context: :request),
200
200
  example: (build_example(record.request_params) if example_enabled?(record)),
201
201
  }.compact,
202
202
  },
203
203
  }
204
204
  end
205
205
 
206
- def build_property(value, disposition: nil, key: nil, record: nil)
206
+ def build_property(value, disposition: nil, key: nil, record: nil, path: nil, context: nil)
207
207
  format = disposition ? 'binary' : infer_format(key, record)
208
+ enum = infer_enum(path, record, context)
208
209
 
209
- property = build_type(value, format: format)
210
+ property = build_type(value, format: format, enum: enum)
210
211
 
211
212
  case value
212
213
  when Array
213
214
  property[:items] = if value.empty?
214
215
  {} # unknown
215
216
  else
216
- build_array_items_schema(value, record: record)
217
+ build_array_items_schema(value, record: record, path: path, context: context)
217
218
  end
218
219
  when Hash
219
220
  property[:properties] = {}.tap do |properties|
220
- value.each do |key, v|
221
- properties[key] = build_property(v, record: record, key: key)
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)
222
224
  end
223
225
  end
224
226
  property = enrich_with_required_keys(property)
@@ -226,29 +228,34 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
226
228
  property
227
229
  end
228
230
 
229
- def build_type(value, format: nil)
230
- return { type: 'string', format: format } if format
231
-
232
- case value
233
- when String
234
- { type: 'string' }
235
- when Integer
236
- { type: 'integer' }
237
- when Float
238
- { type: 'number', format: 'float' }
239
- when TrueClass, FalseClass
240
- { type: 'boolean' }
241
- when Array
242
- { type: 'array' }
243
- when Hash
244
- { type: 'object' }
245
- when ActionDispatch::Http::UploadedFile
246
- { type: 'string', format: 'binary' }
247
- when NilClass
248
- { nullable: true }
249
- else
250
- raise NotImplementedError, "type detection is not implemented for: #{value.inspect}"
251
- end
231
+ def build_type(value, format: nil, enum: nil)
232
+ result = if format
233
+ { type: 'string', format: format }
234
+ else
235
+ case value
236
+ when String
237
+ { type: 'string' }
238
+ when Integer
239
+ { type: 'integer' }
240
+ when Float
241
+ { type: 'number', format: 'float' }
242
+ when TrueClass, FalseClass
243
+ { type: 'boolean' }
244
+ when Array
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}"
254
+ end
255
+ end
256
+
257
+ result[:enum] = enum if enum
258
+ result
252
259
  end
253
260
 
254
261
  def infer_format(key, record)
@@ -257,6 +264,16 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
257
264
  record.formats[key]
258
265
  end
259
266
 
267
+ def infer_enum(path, record, context)
268
+ return nil if !path || !record
269
+
270
+ enum_hash = context == :request ? record.request_enum : record.response_enum
271
+ return nil unless enum_hash
272
+
273
+ # Try both string and symbol keys
274
+ enum_hash[path.to_s] || enum_hash[path.to_sym]
275
+ end
276
+
260
277
  # Convert an always-String param to an appropriate type
261
278
  def try_cast(value)
262
279
  Integer(value)
@@ -306,12 +323,12 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
306
323
  content_disposition&.sub(/;.+\z/, '')
307
324
  end
308
325
 
309
- def build_array_items_schema(array, record: nil)
326
+ def build_array_items_schema(array, record: nil, path: nil, context: nil)
310
327
  return {} if array.empty?
311
- return build_property(array.first, record: record) if array.size == 1
312
- return build_property(array.first, record: record) unless array.all? { |item| item.is_a?(Hash) }
328
+ 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) }
313
330
 
314
- all_schemas = array.map { |item| build_property(item, record: record) }
331
+ all_schemas = array.map { |item| build_property(item, record: record, path: path, context: context) }
315
332
  merged_schema = all_schemas.first.dup
316
333
  merged_schema[:properties] = {}
317
334
 
@@ -65,13 +65,61 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
65
65
 
66
66
  all_parameters = all_parameters.map do |parameter|
67
67
  base_parameter = unique_base_parameters[[parameter[:name], parameter[:in]]] || {}
68
- base_parameter ? base_parameter.merge(parameter) : parameter
68
+ if base_parameter.empty?
69
+ parameter
70
+ else
71
+ merge_parameter_with_schema(base_parameter, parameter)
72
+ end
69
73
  end
70
74
 
71
75
  all_parameters.uniq! { |param| param.slice(:name, :in) }
72
76
  base[key] = all_parameters
73
77
  end
74
78
 
79
+ def merge_parameter_with_schema(base_param, new_param)
80
+ base_schema = base_param[:schema]
81
+ new_schema = new_param[:schema]
82
+
83
+ # If schemas have different types, create a oneOf
84
+ if base_schema && new_schema && schemas_have_different_types?(base_schema, new_schema)
85
+ merged_schema = merge_schemas_into_one_of(base_schema, new_schema)
86
+ base_param.merge(new_param).merge(schema: merged_schema)
87
+ else
88
+ base_param.merge(new_param)
89
+ end
90
+ end
91
+
92
+ def schemas_have_different_types?(schema1, schema2)
93
+ # If either already has oneOf, we need to merge into it
94
+ return true if schema1[:oneOf] || schema2[:oneOf]
95
+
96
+ type1 = schema1[:type]
97
+ type2 = schema2[:type]
98
+
99
+ type1 && type2 && type1 != type2
100
+ end
101
+
102
+ def merge_schemas_into_one_of(base_schema, new_schema)
103
+ existing_types = extract_schema_types(base_schema)
104
+ new_types = extract_schema_types(new_schema)
105
+
106
+ all_types = existing_types + new_types
107
+ all_types.uniq!
108
+
109
+ # If only one type remains, return it directly
110
+ return all_types.first if all_types.size == 1
111
+
112
+ { oneOf: all_types }
113
+ end
114
+
115
+ def extract_schema_types(schema)
116
+ if schema[:oneOf]
117
+ schema[:oneOf].map { |s| s.reject { |k, _| k == :example } }
118
+ else
119
+ [schema.reject { |k, _| k == :example }]
120
+ end
121
+ end
122
+
75
123
  def build_unique_params(base, key)
76
124
  base[key].each_with_object({}) do |parameter, hash|
77
125
  hash[[parameter[:name], parameter[:in]]] = parameter
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RSpec
4
4
  module OpenAPI
5
- VERSION = '0.23.0'
5
+ VERSION = '0.25.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rspec-openapi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.23.0
4
+ version: 0.25.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Takashi Kokubun
@@ -112,7 +112,7 @@ licenses:
112
112
  metadata:
113
113
  homepage_uri: https://github.com/exoego/rspec-openapi
114
114
  source_code_uri: https://github.com/exoego/rspec-openapi
115
- changelog_uri: https://github.com/exoego/rspec-openapi/releases/tag/v0.23.0
115
+ changelog_uri: https://github.com/exoego/rspec-openapi/releases/tag/v0.25.0
116
116
  rubygems_mfa_required: 'true'
117
117
  rdoc_options: []
118
118
  require_paths: