rspec-openapi 0.21.5 → 0.22.1

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: b35d6193603f213ab605629e833a56827da2cc4071a9bde884ed7f67ae257946
4
- data.tar.gz: d3f5d1b55e242ccde842aca000aebdcff950fac29d11197cdd2002bedd9f6976
3
+ metadata.gz: 4f37b771a178f8725b58190cecd8833eb49254bff604de7fef59bcb89b64cbf0
4
+ data.tar.gz: 956a8fd4c973a3c25112bdf13110cae26a6e35d146a037bd0a3af17f8b871596
5
5
  SHA512:
6
- metadata.gz: c30cc46860494d67aed4b1eb9ea11db58b77bbfec31f3d1fef1f0723aad7a16775ed30f4b874331eec6ed26a3238522f8ee018d0ae252fe769a78de3b6b5eb46
7
- data.tar.gz: e50cf253d00c8d4c11d6abe1793c32b8a5eec594dca37d8fdeb48969a3e4c13c79b40e8a034f3ca0f4cec2909e0c48d5d8082131c9109ffe9015ddc0ec5bdf75
6
+ metadata.gz: a833eaee2f5d656eda27a40982aea8f8a8e737c82035cff713045a99fc19e2a29fcc810aa301da130d2739eb17fa1540d2df1b656489cbf21f1a889fe49397f5
7
+ data.tar.gz: a2a04437d06f870317d4c36bc4cbe9990beda5b5f2068eeca6f8b0e99b823a565862bb4572e924d4f49272c0a1e06ee7b4fb487d585a8b1bc5819755e57f9cd7
@@ -4,7 +4,7 @@ on:
4
4
  workflow_dispatch:
5
5
  inputs:
6
6
  version:
7
- description: 'Version to release (e.g. 0.21.3 or v0.21.3)'
7
+ description: 'Version to release (e.g. 0.22.2 or 0.23.0)'
8
8
  required: true
9
9
 
10
10
  jobs:
@@ -12,15 +12,11 @@ jobs:
12
12
  name: Prepare release PR
13
13
  runs-on: ubuntu-latest
14
14
 
15
- permissions:
16
- id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
17
- contents: write # required to push release branch and open PR
18
- pull-requests: write # required to create the release PR with GITHUB_TOKEN
19
-
20
15
  steps:
21
- - uses: actions/checkout@v5
16
+ - uses: actions/checkout@v6
22
17
  with:
23
18
  fetch-depth: 0
19
+ token: ${{ secrets.PREPARE_RELEASE_PAT }}
24
20
 
25
21
  - name: Bump version.rb
26
22
  run: |
@@ -47,8 +43,27 @@ jobs:
47
43
  exit 1
48
44
  end
49
45
  RUBY
50
-
51
46
  ruby -pi -e "sub(/VERSION = .*/, \"VERSION = '$version'\")" lib/rspec/openapi/version.rb
47
+
48
+ VERSION_NO_V="$version" ruby - <<'RUBY'
49
+ version = ENV.fetch('VERSION_NO_V')
50
+ segments = version.split('.').map(&:to_i)
51
+ unless segments.size >= 3
52
+ warn "Version must have at least major.minor.patch: #{version}"
53
+ exit 1
54
+ end
55
+
56
+ major, minor, patch = segments
57
+ patch_bump = [major, minor, patch + 1].join('.')
58
+ minor_bump = [major, minor + 1, 0].join('.')
59
+ example = "Version to release (e.g. #{patch_bump} or #{minor_bump})"
60
+
61
+ path = ".github/workflows/create_release.yml"
62
+ content = File.read(path)
63
+ content.sub!(/description:\s*'Version to release \(e\.g\. [^']+\)'/,
64
+ "description: '#{example}'")
65
+ File.write(path, content)
66
+ RUBY
52
67
  git status --short
53
68
 
54
69
  - name: Commit version bump
@@ -65,11 +80,12 @@ jobs:
65
80
  git push origin "HEAD:${release_branch}"
66
81
 
67
82
  - name: Open release PR
68
- uses: peter-evans/create-pull-request@v6
83
+ uses: peter-evans/create-pull-request@v8
69
84
  with:
70
- token: ${{ secrets.GITHUB_TOKEN }}
85
+ token: ${{ secrets.PREPARE_RELEASE_PAT }}
71
86
  add-paths: |
72
87
  lib/rspec/openapi/version.rb
88
+ .github/workflows/create_release.yml
73
89
  branch: ${{ env.RELEASE_BRANCH }}
74
90
  title: Release v${{ env.VERSION_NO_V }}
75
91
  commit-message: Bump version to ${{ env.VERSION_NO_V }}
@@ -15,7 +15,7 @@ jobs:
15
15
  contents: write # to create GitHub release
16
16
 
17
17
  steps:
18
- - uses: actions/checkout@v5
18
+ - uses: actions/checkout@v6
19
19
  with:
20
20
  fetch-depth: 0
21
21
 
@@ -23,7 +23,7 @@ jobs:
23
23
  uses: ruby/setup-ruby@v1
24
24
  with:
25
25
  bundler-cache: true
26
- ruby-version: ruby
26
+ ruby-version: '4.0'
27
27
 
28
28
  - name: Verify tag matches version.rb
29
29
  run: |
@@ -19,7 +19,7 @@ jobs:
19
19
  - name: Set up Ruby
20
20
  uses: ruby/setup-ruby@v1
21
21
  with:
22
- ruby-version: 3.3
22
+ ruby-version: '4.0'
23
23
  bundler-cache: true
24
24
 
25
25
  - name: Rubocop run
@@ -27,6 +27,8 @@ jobs:
27
27
  rails: 7.1.3.2
28
28
  - ruby: ruby:3.4
29
29
  rails: 8.0.2
30
+ - ruby: ruby:4.0
31
+ rails: 8.1.2
30
32
  coverage: coverage
31
33
  env:
32
34
  RAILS_VERSION: ${{ matrix.rails == '' && '6.1.6' || matrix.rails }}
data/Gemfile CHANGED
@@ -8,11 +8,15 @@ gemspec
8
8
  gem 'rails', ENV['RAILS_VERSION'] || '6.0.6.1'
9
9
 
10
10
  if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.0.0')
11
- gem 'hanami', ENV['HANAMI_VERSION'] || '2.1.0'
12
- gem 'hanami-controller', ENV['HANAMI_VERSION'] || '2.1.0'
13
- gem 'hanami-router', ENV['HANAMI_VERSION'] || '2.1.0'
14
-
15
- gem 'dry-logger', '1.0.3'
11
+ if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('4.0.0')
12
+ gem 'dry-logger', '1.2.1'
13
+ gem 'hanami', ENV['HANAMI_VERSION'] || '2.3.2'
14
+ else
15
+ gem 'dry-logger', '1.0.3'
16
+ gem 'hanami', ENV['HANAMI_VERSION'] || '2.1.0'
17
+ end
18
+ gem 'hanami-controller'
19
+ gem 'hanami-router'
16
20
  end
17
21
 
18
22
  gem 'concurrent-ruby', '1.3.4'
data/README.md CHANGED
@@ -135,9 +135,15 @@ RSpec::OpenAPI.title = -> (example) {
135
135
  end
136
136
  }
137
137
 
138
- # Disable generating `example`
138
+ # Disable generating `example` globally
139
139
  RSpec::OpenAPI.enable_example = false
140
140
 
141
+ # Customize example name generation (used for multiple examples)
142
+ RSpec::OpenAPI.example_name_builder = -> (example) { example.description }
143
+
144
+ # Disable generating example summaries for `examples`
145
+ RSpec::OpenAPI.enable_example_summary = false
146
+
141
147
  # Change `info.version`
142
148
  RSpec::OpenAPI.application_version = '1.0.0'
143
149
 
@@ -360,6 +366,93 @@ Some examples' attributes can be overwritten via RSpec metadata options. Example
360
366
 
361
367
  **NOTE**: `description` key will override also the one provided by `RSpec::OpenAPI.description_builder` method.
362
368
 
369
+ ### Multiple Examples Mode
370
+
371
+ You can generate multiple named examples for the same endpoint using `example_mode`:
372
+
373
+ ```rb
374
+ describe '#index', openapi: { example_mode: :multiple } do
375
+ it 'with pagination' do
376
+ get '/tables', params: { page: 1, per: 10 }
377
+ expect(response.status).to eq(200)
378
+ end
379
+
380
+ it 'with filter' do
381
+ get '/tables', params: { filter: { name: 'test' } }
382
+ expect(response.status).to eq(200)
383
+ end
384
+ end
385
+ ```
386
+
387
+ This generates OpenAPI with multiple named examples:
388
+
389
+ ```yaml
390
+ responses:
391
+ '200':
392
+ content:
393
+ application/json:
394
+ schema: { ... }
395
+ examples:
396
+ with_pagination:
397
+ value: { ... }
398
+ with_filter:
399
+ value: { ... }
400
+ ```
401
+
402
+ Available `example_mode` values:
403
+ - `:single` (default) - generates single `example` field
404
+ - `:multiple` - generates named `examples` with test descriptions as keys
405
+ - `:none` - generates only schema, no examples
406
+
407
+ The mode is inherited by nested contexts and can be overridden at any level.
408
+
409
+ **Note:** If multiple examples resolve to the same example key for a single endpoint, the last one wins (overwrites).
410
+
411
+ #### Merge Behavior with Mixed Modes
412
+
413
+ When multiple tests target the same endpoint with different `example_mode` settings (even from different spec files), the merger automatically converts to `examples` format:
414
+
415
+ ```rb
416
+ # spec/requests/api_spec.rb
417
+ describe 'GET /users' do
418
+ it 'returns users' do # default :single mode
419
+ get '/users'
420
+ expect(response.status).to eq(200)
421
+ end
422
+ end
423
+
424
+ # spec/requests/admin_spec.rb
425
+ describe 'GET /users', openapi: { example_mode: :multiple } do
426
+ it 'with admin privileges' do
427
+ get '/users', headers: { 'X-Admin': 'true' }
428
+ expect(response.status).to eq(200)
429
+ end
430
+ end
431
+ ```
432
+
433
+ Result - both examples merged into `examples`:
434
+ ```yaml
435
+ responses:
436
+ '200':
437
+ content:
438
+ application/json:
439
+ examples:
440
+ returns_users:
441
+ value: { ... }
442
+ with_admin_privileges:
443
+ value: { ... }
444
+ ```
445
+
446
+ To exclude specific tests from example generation, use `example_mode: :none`:
447
+
448
+ ```rb
449
+ describe 'GET /users', openapi: { example_mode: :none } do
450
+ it 'edge case test' do
451
+ # This won't add examples to OpenAPI spec
452
+ end
453
+ end
454
+ ```
455
+
363
456
  ## Experimental minitest support
364
457
 
365
458
  Even if you are not using `rspec` this gem might help you with its experimental support for `minitest`.
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Normalizes example keys for OpenAPI examples field
4
+ module RSpec::OpenAPI::ExampleKey
5
+ def self.normalize(value)
6
+ return nil if value.nil?
7
+
8
+ value.to_s.downcase.tr(' ', '_')
9
+ end
10
+ end
@@ -56,15 +56,9 @@ class << RSpec::OpenAPI::Extractors::Hanami = Object.new
56
56
 
57
57
  return RSpec::OpenAPI::Extractors::Rack.request_attributes(request, example) unless route.routable?
58
58
 
59
- metadata = merge_openapi_metadata(example.metadata)
60
- summary = metadata[:summary] || RSpec::OpenAPI.summary_builder.call(example)
61
- tags = metadata[:tags] || RSpec::OpenAPI.tags_builder.call(example)
62
- formats = metadata[:formats] || RSpec::OpenAPI.formats_builder.curry.call(example)
63
- operation_id = metadata[:operation_id]
64
- required_request_params = metadata[:required_request_params] || []
65
- security = metadata[:security]
66
- description = metadata[:description] || RSpec::OpenAPI.description_builder.call(example)
67
- deprecated = metadata[:deprecated]
59
+ summary, tags, formats, operation_id, required_request_params, security, description, deprecated, example_mode,
60
+ example_key, example_name = SharedExtractor.attributes(example)
61
+
68
62
  path = request.path
69
63
 
70
64
  raw_path_params = route.params
@@ -88,6 +82,9 @@ class << RSpec::OpenAPI::Extractors::Hanami = Object.new
88
82
  security,
89
83
  deprecated,
90
84
  formats,
85
+ example_mode,
86
+ example_key,
87
+ example_name,
91
88
  ]
92
89
  end
93
90
 
@@ -102,24 +99,6 @@ class << RSpec::OpenAPI::Extractors::Hanami = Object.new
102
99
 
103
100
  private
104
101
 
105
- def merge_openapi_metadata(metadata)
106
- collect_openapi_metadata(metadata).reduce({}, &:merge)
107
- end
108
-
109
- def collect_openapi_metadata(metadata)
110
- [].tap do |result|
111
- current = metadata
112
-
113
- while current
114
- [current[:example_group], current].each do |meta|
115
- result.unshift(meta[:openapi]) if meta&.dig(:openapi)
116
- end
117
-
118
- current = current[:parent_example_group]
119
- end
120
- end
121
- end
122
-
123
102
  def add_id(path, route)
124
103
  return path if route.params.empty?
125
104
 
@@ -6,18 +6,13 @@ 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
- metadata = merge_openapi_metadata(example.metadata)
10
- summary = metadata[:summary] || RSpec::OpenAPI.summary_builder.call(example)
11
- tags = metadata[:tags] || RSpec::OpenAPI.tags_builder.call(example)
12
- formats = metadata[:formats] || RSpec::OpenAPI.formats_builder.curry.call(example)
13
- operation_id = metadata[:operation_id]
14
- required_request_params = metadata[:required_request_params] || []
15
- security = metadata[:security]
16
- description = metadata[:description] || RSpec::OpenAPI.description_builder.call(example)
17
- deprecated = metadata[:deprecated]
9
+ summary, tags, formats, operation_id, required_request_params, security, description, deprecated, example_mode,
10
+ example_key, example_name = SharedExtractor.attributes(example)
11
+
18
12
  raw_path_params = request.path_parameters
19
13
  path = request.path
20
14
  summary ||= "#{request.method} #{path}"
15
+
21
16
  [
22
17
  path,
23
18
  summary,
@@ -29,6 +24,9 @@ class << RSpec::OpenAPI::Extractors::Rack = Object.new
29
24
  security,
30
25
  deprecated,
31
26
  formats,
27
+ example_mode,
28
+ example_key,
29
+ example_name,
32
30
  ]
33
31
  end
34
32
 
@@ -40,24 +38,4 @@ class << RSpec::OpenAPI::Extractors::Rack = Object.new
40
38
 
41
39
  [request, response]
42
40
  end
43
-
44
- private
45
-
46
- def merge_openapi_metadata(metadata)
47
- collect_openapi_metadata(metadata).reduce({}, &:merge)
48
- end
49
-
50
- def collect_openapi_metadata(metadata)
51
- [].tap do |result|
52
- current = metadata
53
-
54
- while current
55
- [current[:example_group], current].each do |meta|
56
- result.unshift(meta[:openapi]) if meta&.dig(:openapi)
57
- end
58
-
59
- current = current[:parent_example_group]
60
- end
61
- end
62
- end
63
41
  end
@@ -16,16 +16,9 @@ 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
- metadata = merge_openapi_metadata(example.metadata)
20
- summary = metadata[:summary] || RSpec::OpenAPI.summary_builder.call(example)
21
- tags = metadata[:tags] || RSpec::OpenAPI.tags_builder.call(example)
22
- formats = metadata[:formats] || RSpec::OpenAPI.formats_builder.curry.call(example)
23
-
24
- operation_id = metadata[:operation_id]
25
- required_request_params = metadata[:required_request_params] || []
26
- security = metadata[:security]
27
- description = metadata[:description] || RSpec::OpenAPI.description_builder.call(example)
28
- deprecated = metadata[:deprecated]
19
+ summary, tags, formats, operation_id, required_request_params, security, description, deprecated, example_mode,
20
+ example_key, example_name = SharedExtractor.attributes(example)
21
+
29
22
  raw_path_params = request.path_parameters
30
23
 
31
24
  summary ||= route.requirements[:action]
@@ -47,6 +40,9 @@ class << RSpec::OpenAPI::Extractors::Rails = Object.new
47
40
  security,
48
41
  deprecated,
49
42
  formats,
43
+ example_mode,
44
+ example_key,
45
+ example_name,
50
46
  ]
51
47
  end
52
48
 
@@ -57,24 +53,6 @@ class << RSpec::OpenAPI::Extractors::Rails = Object.new
57
53
 
58
54
  private
59
55
 
60
- def merge_openapi_metadata(metadata)
61
- collect_openapi_metadata(metadata).reduce({}, &:merge)
62
- end
63
-
64
- def collect_openapi_metadata(metadata)
65
- [].tap do |result|
66
- current = metadata
67
-
68
- while current
69
- [current[:example_group], current].each do |meta|
70
- result.unshift(meta[:openapi]) if meta&.dig(:openapi)
71
- end
72
-
73
- current = current[:parent_example_group]
74
- end
75
- end
76
- end
77
-
78
56
  # @param [ActionDispatch::Request] request
79
57
  def find_rails_route(request, app: Rails.application, path_prefix: '')
80
58
  app.routes.router.recognize(request) do |route, _parameters|
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared extractor for extracting OpenAPI metadata from RSpec examples
4
+ class SharedExtractor
5
+ VALID_EXAMPLE_MODES = %i[none single multiple].freeze
6
+
7
+ def self.attributes(example)
8
+ metadata = merge_openapi_metadata(example.metadata)
9
+ summary = metadata[:summary] || RSpec::OpenAPI.summary_builder.call(example)
10
+ tags = metadata[:tags] || RSpec::OpenAPI.tags_builder.call(example)
11
+ formats = metadata[:formats] || RSpec::OpenAPI.formats_builder.curry.call(example)
12
+ operation_id = metadata[:operation_id]
13
+ required_request_params = metadata[:required_request_params] || []
14
+ security = metadata[:security]
15
+ description = metadata[:description] || RSpec::OpenAPI.description_builder.call(example)
16
+ deprecated = metadata[:deprecated]
17
+ example_mode = normalize_example_mode(metadata[:example_mode], example)
18
+ example_name = metadata[:example_name] || RSpec::OpenAPI.example_name_builder.call(example)
19
+ raw_example_key = metadata[:example_key] || example_name
20
+ example_key = RSpec::OpenAPI::ExampleKey.normalize(raw_example_key)
21
+ example_key = 'default' if example_key.nil? || example_key.empty?
22
+
23
+ [summary, tags, formats, operation_id, required_request_params, security, description, deprecated, example_mode,
24
+ example_key, example_name,]
25
+ end
26
+
27
+ def self.merge_openapi_metadata(metadata)
28
+ collect_openapi_metadata(metadata).reduce({}, &:merge)
29
+ end
30
+
31
+ def self.collect_openapi_metadata(metadata)
32
+ [].tap do |result|
33
+ current = metadata
34
+
35
+ while current
36
+ [current[:example_group], current].each do |meta|
37
+ result.unshift(meta[:openapi]) if meta&.dig(:openapi)
38
+ end
39
+
40
+ current = current[:parent_example_group]
41
+ end
42
+ end
43
+ end
44
+
45
+ def self.normalize_example_mode(value, example = nil)
46
+ return :single if value.nil?
47
+
48
+ raise ArgumentError, example_mode_error(value, example) unless value.is_a?(String) || value.is_a?(Symbol)
49
+
50
+ mode = value.to_s.strip.downcase.to_sym
51
+ return mode if VALID_EXAMPLE_MODES.include?(mode)
52
+
53
+ raise ArgumentError, example_mode_error(value, example)
54
+ end
55
+
56
+ def self.example_mode_error(value, example)
57
+ context = example&.full_description
58
+ context = " (example: #{context})" if context
59
+ "example_mode must be one of #{VALID_EXAMPLE_MODES.inspect}, got #{value.inspect}#{context}"
60
+ end
61
+ end
@@ -4,7 +4,28 @@ class << RSpec::OpenAPI::KeyTransformer = Object.new
4
4
  def symbolize(value)
5
5
  case value
6
6
  when Hash
7
- value.to_h { |k, v| [k.to_sym, symbolize(v)] }
7
+ value.to_h do |k, v|
8
+ if k.to_sym == :examples
9
+ [k.to_sym, symbolize_examples(v)]
10
+ else
11
+ [k.to_sym, symbolize(v)]
12
+ end
13
+ end
14
+ when Array
15
+ value.map { |v| symbolize(v) }
16
+ else
17
+ value
18
+ end
19
+ end
20
+
21
+ def symbolize_examples(value)
22
+ case value
23
+ when Hash
24
+ value.to_h do |k, v|
25
+ k = k.downcase.tr(' ', '_') unless k.is_a?(Symbol)
26
+
27
+ [k.to_sym, symbolize(v)]
28
+ end
8
29
  when Array
9
30
  value.map { |v| symbolize(v) }
10
31
  else
@@ -15,8 +15,12 @@ RSpec::OpenAPI::Record = Struct.new(
15
15
  :formats, # @param [Proc] - ->(key) { key.end_with?('_at') ? 'date-time' : nil }
16
16
  :operation_id, # @param [String] - "request-1234"
17
17
  :description, # @param [String] - "returns a status"
18
+ :example_key, # @param [String] - "with_flat_query_parameters"
19
+ :example_name, # @param [String] - "with flat query parameters"
18
20
  :security, # @param [Array] - [{securityScheme1: []}]
19
21
  :deprecated, # @param [Boolean] - true
22
+ :example_enabled, # @param [Boolean] - true
23
+ :example_mode, # @param [Symbol] - :none | :single | :multiple
20
24
  :status, # @param [Integer] - 200
21
25
  :response_body, # @param [Object] - {"status" => "ok"}
22
26
  :response_headers, # @param [Array] - [["header_key1", "header_value1"], ["header_key2", "header_value2"]]
@@ -12,8 +12,8 @@ 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, formats =
16
- extractor.request_attributes(request, example)
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)
17
17
 
18
18
  return if RSpec::OpenAPI.ignored_paths.any? { |ignored_path| path.match?(ignored_path) }
19
19
 
@@ -41,6 +41,10 @@ class << RSpec::OpenAPI::RecordBuilder = Object.new
41
41
  response_headers: response_headers,
42
42
  response_content_type: response.media_type,
43
43
  response_content_disposition: response.header['Content-Disposition'],
44
+ example_enabled: RSpec::OpenAPI.enable_example,
45
+ example_mode: example_mode,
46
+ example_key: example_key,
47
+ example_name: example_name,
44
48
  ).freeze
45
49
  end
46
50
 
@@ -15,14 +15,7 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
15
15
  disposition = normalize_content_disposition(record.response_content_disposition)
16
16
 
17
17
  has_content = !normalize_content_type(record.response_content_type).nil?
18
- if has_content
19
- response[:content] = {
20
- normalize_content_type(record.response_content_type) => {
21
- schema: build_property(record.response_body, disposition: disposition, record: record),
22
- example: response_example(record, disposition: disposition),
23
- }.compact,
24
- }
25
- end
18
+ response[:content] = build_content(disposition, record) if has_content
26
19
  end
27
20
 
28
21
  http_method = record.http_method.downcase
@@ -52,19 +45,74 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
52
45
  %w[delete get].include?(http_method)
53
46
  end
54
47
 
48
+ def build_content(disposition, record)
49
+ content_type = normalize_content_type(record.response_content_type)
50
+ schema = build_property(record.response_body, disposition: disposition, record: record)
51
+
52
+ # If examples are globally disabled, always return schema-only content.
53
+ return { content_type => { schema: schema }.compact } unless example_enabled?(record)
54
+
55
+ case record.example_mode
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
82
+ end
83
+
55
84
  def enrich_with_required_keys(obj)
56
85
  obj[:required] = obj[:properties]&.keys || []
57
86
  obj
58
87
  end
59
88
 
60
89
  def response_example(record, disposition:)
61
- return nil if !example_enabled? || disposition
90
+ return nil if !example_enabled?(record) || disposition
62
91
 
63
92
  record.response_body
64
93
  end
65
94
 
66
- def example_enabled?
67
- RSpec::OpenAPI.enable_example
95
+ def build_example_object(record, disposition:)
96
+ summary = example_summary(record)
97
+ example = {}
98
+ example[:summary] = summary if summary
99
+ example[:value] = response_example(record, disposition: disposition)
100
+ example
101
+ end
102
+
103
+ def example_summary(record)
104
+ return nil unless example_summary_enabled?
105
+ return nil if record.example_name.nil? || record.example_name.empty?
106
+
107
+ record.example_name
108
+ end
109
+
110
+ def example_enabled?(record)
111
+ record.example_enabled
112
+ end
113
+
114
+ def example_summary_enabled?
115
+ RSpec::OpenAPI.enable_example_summary
68
116
  end
69
117
 
70
118
  def build_parameters(record)
@@ -74,7 +122,7 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
74
122
  in: 'path',
75
123
  required: true,
76
124
  schema: build_property(try_cast(value), key: key, record: record),
77
- example: (try_cast(value) if example_enabled?),
125
+ example: (try_cast(value) if example_enabled?(record)),
78
126
  }.compact
79
127
  end
80
128
 
@@ -84,7 +132,7 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
84
132
  in: 'query',
85
133
  required: record.required_request_params.include?(key),
86
134
  schema: build_property(try_cast(value), key: key, record: record),
87
- example: (try_cast(value) if example_enabled?),
135
+ example: (try_cast(value) if example_enabled?(record)),
88
136
  }.compact
89
137
  end
90
138
 
@@ -94,7 +142,7 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
94
142
  in: 'header',
95
143
  required: true,
96
144
  schema: build_property(try_cast(value), key: key, record: record),
97
- example: (try_cast(value) if example_enabled?),
145
+ example: (try_cast(value) if example_enabled?(record)),
98
146
  }.compact
99
147
  end
100
148
 
@@ -135,7 +183,7 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
135
183
  content: {
136
184
  normalize_content_type(record.request_content_type) => {
137
185
  schema: build_property(record.request_params, record: record),
138
- example: (build_example(record.request_params) if example_enabled?),
186
+ example: (build_example(record.request_params) if example_enabled?(record)),
139
187
  }.compact,
140
188
  },
141
189
  }
@@ -32,10 +32,16 @@ class << RSpec::OpenAPI::SchemaCleaner = Object.new
32
32
  # cleanup requestBody
33
33
  cleanup_hash!(base, spec, 'paths.*.*.requestBody.content.application/json.schema.properties.*')
34
34
  cleanup_hash!(base, spec, 'paths.*.*.requestBody.content.application/json.example.*')
35
+ cleanup_hash!(base, spec, 'paths.*.*.requestBody.content.application/json.examples.*')
35
36
 
36
37
  # cleanup responses
37
38
  cleanup_hash!(base, spec, 'paths.*.*.responses.*.content.application/json.schema.properties.*')
38
39
  cleanup_hash!(base, spec, 'paths.*.*.responses.*.content.application/json.example.*')
40
+ cleanup_hash!(base, spec, 'paths.*.*.responses.*.content.application/json.examples.*')
41
+
42
+ # cleanup temporary fields used for internal processing
43
+ cleanup_temporary_fields!(base)
44
+
39
45
  base
40
46
  end
41
47
 
@@ -71,6 +77,24 @@ class << RSpec::OpenAPI::SchemaCleaner = Object.new
71
77
 
72
78
  private
73
79
 
80
+ # Recursively remove temporary fields like :_example_key and :_example_name from the schema
81
+ def cleanup_temporary_fields!(hash)
82
+ return unless hash.is_a?(Hash)
83
+
84
+ hash.delete(:_example_key)
85
+ hash.delete(:_example_summary)
86
+ hash.delete(:_example_name)
87
+
88
+ hash.each_value do |value|
89
+ case value
90
+ when Hash
91
+ cleanup_temporary_fields!(value)
92
+ when Array
93
+ value.each { |item| cleanup_temporary_fields!(item) if item.is_a?(Hash) }
94
+ end
95
+ end
96
+ end
97
+
74
98
  def remove_parameters_conflicting_with_security_scheme!(path_definition, security_scheme, security_scheme_name)
75
99
  return unless path_definition[:security]
76
100
  return unless path_definition[:parameters]
@@ -25,6 +25,9 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
25
25
 
26
26
  spec.each do |key, value|
27
27
  if base[key].is_a?(Hash) && value.is_a?(Hash)
28
+ # Handle example/examples conflict - convert to examples when mixed
29
+ normalize_example_fields!(base[key], value)
30
+
28
31
  # If the new value has oneOf, replace the entire value instead of merging
29
32
  if value.key?(:oneOf)
30
33
  base[key] = value
@@ -77,6 +80,26 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
77
80
 
78
81
  SIMILARITY_THRESHOLD = 0.5
79
82
 
83
+ # Normalize example/examples fields when there's a conflict
84
+ # OpenAPI spec doesn't allow both example and examples in the same object
85
+ def normalize_example_fields!(base, spec)
86
+ if base.key?(:example) && spec.key?(:examples)
87
+ convert_example_to_examples!(base)
88
+ elsif base.key?(:examples) && spec.key?(:example)
89
+ convert_example_to_examples!(spec)
90
+ end
91
+ end
92
+
93
+ def convert_example_to_examples!(hash)
94
+ name = RSpec::OpenAPI::ExampleKey.normalize(hash.delete(:_example_key)) || 'default'
95
+ summary = hash.delete(:_example_summary)
96
+ value = hash.delete(:example)
97
+ example = {}
98
+ example[:summary] = summary if summary
99
+ example[:value] = value
100
+ hash[:examples] = { name => example }
101
+ end
102
+
80
103
  def merge_closest_match!(options, spec)
81
104
  score, option = options.map { |option| [similarity(option, spec), option] }.max_by(&:first)
82
105
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RSpec
4
4
  module OpenAPI
5
- VERSION = '0.21.5'
5
+ VERSION = '0.22.1'
6
6
  end
7
7
  end
data/lib/rspec/openapi.rb CHANGED
@@ -7,12 +7,14 @@ require 'rspec/openapi/record_builder'
7
7
  require 'rspec/openapi/result_recorder'
8
8
  require 'rspec/openapi/schema_builder'
9
9
  require 'rspec/openapi/schema_file'
10
+ require 'rspec/openapi/example_key'
10
11
  require 'rspec/openapi/schema_merger'
11
12
  require 'rspec/openapi/schema_cleaner'
12
13
  require 'rspec/openapi/schema_sorter'
13
14
  require 'rspec/openapi/key_transformer'
14
15
  require 'rspec/openapi/shared_hooks'
15
16
  require 'rspec/openapi/extractors'
17
+ require 'rspec/openapi/extractors/shared_extractor'
16
18
  require 'rspec/openapi/extractors/rack'
17
19
 
18
20
  module RSpec::OpenAPI
@@ -30,7 +32,9 @@ module RSpec::OpenAPI
30
32
  @title = File.basename(Dir.pwd)
31
33
  @comment = nil
32
34
  @enable_example = true
35
+ @enable_example_summary = true
33
36
  @description_builder = ->(example) { example.description }
37
+ @example_name_builder = :description.to_proc
34
38
  @summary_builder = ->(example) { example.metadata[:summary] }
35
39
  @tags_builder = ->(example) { example.metadata[:tags] }
36
40
  @formats_builder = ->(example) { example.metadata[:formats] }
@@ -54,7 +58,9 @@ module RSpec::OpenAPI
54
58
  :title,
55
59
  :comment,
56
60
  :enable_example,
61
+ :enable_example_summary,
57
62
  :description_builder,
63
+ :example_name_builder,
58
64
  :summary_builder,
59
65
  :tags_builder,
60
66
  :formats_builder,
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.21.5
4
+ version: 0.22.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Takashi Kokubun
@@ -82,10 +82,12 @@ files:
82
82
  - lib/rspec/openapi.rb
83
83
  - lib/rspec/openapi/components_updater.rb
84
84
  - lib/rspec/openapi/default_schema.rb
85
+ - lib/rspec/openapi/example_key.rb
85
86
  - lib/rspec/openapi/extractors.rb
86
87
  - lib/rspec/openapi/extractors/hanami.rb
87
88
  - lib/rspec/openapi/extractors/rack.rb
88
89
  - lib/rspec/openapi/extractors/rails.rb
90
+ - lib/rspec/openapi/extractors/shared_extractor.rb
89
91
  - lib/rspec/openapi/hash_helper.rb
90
92
  - lib/rspec/openapi/key_transformer.rb
91
93
  - lib/rspec/openapi/minitest_hooks.rb
@@ -110,7 +112,7 @@ licenses:
110
112
  metadata:
111
113
  homepage_uri: https://github.com/exoego/rspec-openapi
112
114
  source_code_uri: https://github.com/exoego/rspec-openapi
113
- changelog_uri: https://github.com/exoego/rspec-openapi/releases/tag/v0.21.5
115
+ changelog_uri: https://github.com/exoego/rspec-openapi/releases/tag/v0.22.1
114
116
  rubygems_mfa_required: 'true'
115
117
  rdoc_options: []
116
118
  require_paths:
@@ -126,7 +128,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
126
128
  - !ruby/object:Gem::Version
127
129
  version: '0'
128
130
  requirements: []
129
- rubygems_version: 3.6.9
131
+ rubygems_version: 4.0.3
130
132
  specification_version: 4
131
133
  summary: Generate OpenAPI schema from RSpec request specs
132
134
  test_files: []