rspec-openapi 0.28.0 → 0.30.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: 43c486a798aeae6de0f9ecbec34ab2b69a11bb2b1ff053011110c611e5151fc0
4
- data.tar.gz: f483a6aca96935df40e9b34386b34548f3322982935c3801bab33ee72d20746e
3
+ metadata.gz: 2ce922f425644ad7ab8645bf2dcd7df5f93085e5e15cae1f826feff526fc96f6
4
+ data.tar.gz: 19b7d372d3a1324903ec229f96292afdf738f99e492d76c16d281b9323e369f1
5
5
  SHA512:
6
- metadata.gz: 7860f7a5030487e9671ee926bcf27967c827a4d80f6119eca70c8e9b330c296a5813bc8412d3b210e679700baef2d8710bcd9c9ca24c8b5890a14d90823f8c36
7
- data.tar.gz: c2d9e3aaab37b25acc4d1ce563076a2fcb151f33a699268d9035d1559b428f75a75dab52394d0810a0443f031e9f3f338e9996320e5c258b598d0262a71bf1a0
6
+ metadata.gz: fe915320f2594675a5930eb28dd580f99fe4377e3c8dea443656d0de171187dd8cd30f2bbac720ea5c2a19271001e734740d09e472ee0cf7987caf89e11ebad6
7
+ data.tar.gz: 2f41de3cafeeeb044748cf71bf6cd8163f4d083d145ea8c9adbf6dfeb57336d2582c1280086c5e008a8f679e7a03f780eca4e4a76b9ef741c3d2e40633d21d48
data/README.md CHANGED
@@ -116,6 +116,10 @@ RSpec::OpenAPI.path = 'doc/schema.yaml'
116
116
  # Change the output type to JSON
117
117
  RSpec::OpenAPI.path = 'doc/schema.json'
118
118
 
119
+ # Or emit the same schema in multiple formats from a single run by passing an array.
120
+ # The output format of each file is still chosen by its extension (`.json` -> JSON, otherwise YAML).
121
+ RSpec::OpenAPI.path = ['doc/openapi.yaml', 'doc/openapi.json']
122
+
119
123
  # Or generate multiple partial schema files, given an RSpec example
120
124
  RSpec::OpenAPI.path = -> (example) {
121
125
  case example.file_path
@@ -125,6 +129,12 @@ RSpec::OpenAPI.path = -> (example) {
125
129
  end
126
130
  }
127
131
 
132
+ # The two can be combined: a proc may also return an array to fan a partial schema out to multiple formats.
133
+ RSpec::OpenAPI.path = -> (example) {
134
+ base = example.file_path.match?(%r[spec/requests/api/v2/]) ? 'doc/openapi/v2' : 'doc/openapi'
135
+ ["#{base}.yaml", "#{base}.json"]
136
+ }
137
+
128
138
  # Change the default title of the generated schema
129
139
  RSpec::OpenAPI.title = 'OpenAPI Documentation'
130
140
 
@@ -149,6 +159,10 @@ RSpec::OpenAPI.enable_example_summary = false
149
159
  # Change `info.version`
150
160
  RSpec::OpenAPI.application_version = '1.0.0'
151
161
 
162
+ # Change the target OpenAPI version (defaults to '3.2.0').
163
+ # Use a 3.0.x string to keep the previous output, or a 3.1.x string.
164
+ RSpec::OpenAPI.openapi_version = '3.0.3'
165
+
152
166
  # Set the info header details
153
167
  RSpec::OpenAPI.info = {
154
168
  description: 'My beautiful API',
@@ -177,6 +191,14 @@ RSpec::OpenAPI.security_schemes = {
177
191
  },
178
192
  }
179
193
 
194
+ # Set root-level `tags` definitions, emitted verbatim.
195
+ #
196
+ # OpenAPI 3.2+: Structured fields such as `summary`, `parent` and `kind` are allowed.
197
+ RSpec::OpenAPI.root_tags = [
198
+ { name: 'Users', summary: 'User management', kind: 'nav' },
199
+ { name: 'Admin', parent: 'Users' },
200
+ ]
201
+
180
202
  # Generate a comment on top of a schema file
181
203
  RSpec::OpenAPI.comment = <<~EOS
182
204
  This file is auto-generated by rspec-openapi https://github.com/k0kubun/rspec-openapi
@@ -190,10 +212,16 @@ RSpec::OpenAPI.description_builder = -> (example) { example.description }
190
212
 
191
213
  # Generate a custom summary, given an RSpec example
192
214
  # This example uses the summary from the example_group.
215
+ # NOTE: a fixed `dig` only reaches one specific depth. `summary` declared on an
216
+ # ancestor group is already inherited automatically (see "Inheritance of
217
+ # `openapi:` metadata"), so a builder is only needed for fully custom logic.
193
218
  RSpec::OpenAPI.summary_builder = ->(example) { example.metadata.dig(:example_group, :openapi, :summary) }
194
219
 
195
220
  # Generate a custom tags, given an RSpec example
196
- # This example uses the tags from the parent_example_group
221
+ # This example uses the tags from the parent_example_group.
222
+ # NOTE: `dig(:example_group, :parent_example_group, ...)` only matches tags
223
+ # declared exactly two levels up; it won't find tags on deeper ancestors. Rely
224
+ # on the built-in inheritance unless you need custom resolution.
197
225
  RSpec::OpenAPI.tags_builder = -> (example) { example.metadata.dig(:example_group, :parent_example_group, :openapi, :tags) }
198
226
 
199
227
  # Configure custom format for specific properties
@@ -222,6 +250,46 @@ RSpec::OpenAPI.post_process_hook = -> (path, records, spec) do
222
250
  end
223
251
  ```
224
252
 
253
+ ### OpenAPI version
254
+
255
+ The generated `openapi` version defaults to `3.2.0` (the latest). OpenAPI 3.1+ is aligned with
256
+ JSON Schema 2020-12, so nullable values are expressed via the type array instead of the `nullable` keyword.
257
+
258
+ ```yaml
259
+ # 3.0 (set RSpec::OpenAPI.openapi_version = '3.0.x')
260
+ name:
261
+ type: string
262
+ nullable: true
263
+ ```
264
+
265
+ ```yaml
266
+ # 3.1 (set RSpec::OpenAPI.openapi_version = '3.1.x')
267
+ # 3.2 (set RSpec::OpenAPI.openapi_version = '3.2.x')
268
+ name:
269
+ type:
270
+ - string
271
+ - null
272
+ ```
273
+
274
+ - Set `RSpec::OpenAPI.openapi_version` to a `3.0.x` string to keep the previous `nullable: true` output.
275
+ - A `3.1.x` string produces the same schema shapes as the default `3.2.0`; they differ only in the reported
276
+ version and the 3.2-only constructs described below (`query` / `additionalOperations` / `itemSchema`).
277
+
278
+ Upgrade note: an existing file is read regardless of its version, so the first `OPENAPI=1` run after upgrading
279
+ rewrites it to the new default (e.g. `3.0.3` -> `3.2.0`, `nullable: true` -> a `null` type). Pin
280
+ `RSpec::OpenAPI.openapi_version = '3.0.3'` to opt out.
281
+
282
+ ### Supported OpenAPI features
283
+
284
+ | Feature | OpenAPI 3.0.x | 3.1.x | 3.2.x (default) |
285
+ |---|:----------------:|:---:|:---:|
286
+ | Core generation (paths / schemas / examples) | ✓ | ✓ | ✓ |
287
+ | Null representation | `nullable: true` | type array `[..., 'null']` | type array `[..., 'null']` |
288
+ | Structured root tags (`summary` / `parent` / `kind`) | name only | name only | ✓ |
289
+ | `query` field (QUERY method) | — | — | ✓ |
290
+ | `additionalOperations` (non-standard verbs: COPY, MOVE, …) | — | — | ✓ |
291
+ | Streaming `itemSchema` (jsonl / ndjson / json-seq / SSE) | — | — | ✓ |
292
+
225
293
  ### Can I use rspec-openapi with `$ref` to minimize duplication of schema?
226
294
 
227
295
  Yes, rspec-openapi v0.7.0+ supports [`$ref` mechanism](https://swagger.io/docs/specification/using-ref/) and generates
@@ -369,6 +437,59 @@ end
369
437
 
370
438
  **NOTE**: `description` key will override also the one provided by `RSpec::OpenAPI.description_builder` method.
371
439
 
440
+ ### Inheritance of `openapi:` metadata
441
+
442
+ `openapi:` metadata is inherited from the surrounding `describe`/`context` groups down to each
443
+ example, at any nesting depth. Inner levels override outer ones key by key, so a nested group only
444
+ needs to declare the keys it wants to change:
445
+
446
+ ```rb
447
+ describe 'GET /tables', openapi: { summary: 'Get a list of tables', tags: %w[Table] } do
448
+ context 'with pagination', openapi: { example_mode: :multiple } do
449
+ # This example inherits summary: 'Get a list of tables' and tags: ['Table']
450
+ # from the outer describe, and adds example_mode: :multiple.
451
+ it { get '/tables', params: { page: 1 } }
452
+ end
453
+ end
454
+ ```
455
+
456
+ The merge happens at the top level of the `openapi:` hash. Scalar keys (`summary`, `operation_id`,
457
+ …) follow last-wins, and a nested level that re-declares a structured key (`tags`, `security`,
458
+ `enum`, …) replaces the inherited value for that key rather than deep-merging it.
459
+
460
+ ### Selecting which request to document (`request_pattern`)
461
+
462
+ By default rspec-openapi documents the *last* request/response exchange issued in an example.
463
+ When a single example performs several requests on the same path (for example a `DELETE`
464
+ followed by a `GET` that asserts the resource is gone), that trailing request would otherwise
465
+ overwrite the operation you actually want to document.
466
+
467
+ Use the `request_pattern` metadata to pick the exchange explicitly. Its value is a
468
+ `"<HTTP method> <path>"` string, where the path may use `{param}` placeholders:
469
+
470
+ ```rb
471
+ describe 'Deleting a widget',
472
+ openapi: { summary: 'Delete a widget', request_pattern: 'DELETE /widgets/{id}' } do
473
+ it 'deletes the widget and verifies it is gone' do
474
+ delete '/widgets/1'
475
+ expect(response.status).to eq(200)
476
+
477
+ # Without request_pattern this trailing GET would become the documented
478
+ # exchange; the selector keeps the DELETE above as the operation instead.
479
+ get '/widgets/1?missing=1'
480
+ expect(response.status).to eq(404)
481
+ end
482
+ end
483
+ ```
484
+
485
+ - Each `{param}` placeholder matches a single path segment independently, so multi-segment
486
+ templates such as `'DELETE /orgs/{org_id}/members/{user_id}'` work as expected.
487
+ - When several requests match the pattern, the last one wins.
488
+ - It works for both Rails (`ActionDispatch::IntegrationTest`) and `Rack::Test`-based specs
489
+ (e.g. Roda, Hanami).
490
+ - If the pattern can't be parsed, or no request issued in the example matches it, generation
491
+ fails fast with a message listing the requests that were actually recorded.
492
+
372
493
  ### Enum Support
373
494
 
374
495
  You can specify enum values for string properties that should have a fixed set of allowed values. Since enums cannot be
@@ -3,7 +3,7 @@
3
3
  class << RSpec::OpenAPI::DefaultSchema = Object.new
4
4
  def build(title)
5
5
  spec = {
6
- openapi: '3.0.3',
6
+ openapi: RSpec::OpenAPI.openapi_version,
7
7
  info: {
8
8
  title: title,
9
9
  version: RSpec::OpenAPI.application_version,
@@ -18,6 +18,8 @@ class << RSpec::OpenAPI::DefaultSchema = Object.new
18
18
  }
19
19
  end
20
20
 
21
+ spec[:tags] = RSpec::OpenAPI.root_tags if RSpec::OpenAPI.root_tags.present?
22
+
21
23
  spec.freeze
22
24
  end
23
25
  end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec::OpenAPI::ExchangeRecorder
4
+ THREAD_KEY = :rspec_openapi_exchanges
5
+ VERBS = [:get, :post, :put, :patch, :delete, :head, :options].freeze
6
+
7
+ module VerbTracking
8
+ VERBS.each do |verb|
9
+ define_method(verb) do |*args, &block|
10
+ result = super(*args, &block)
11
+ RSpec::OpenAPI::ExchangeRecorder.capture_from_context(self)
12
+ result
13
+ end
14
+ ruby2_keywords(verb)
15
+ end
16
+ end
17
+
18
+ class << self
19
+ def reset!(example)
20
+ if pattern_for(example)
21
+ example.example_group.prepend(VerbTracking)
22
+ Thread.current[THREAD_KEY] = []
23
+ else
24
+ Thread.current[THREAD_KEY] = nil
25
+ end
26
+ end
27
+
28
+ def pattern_for(example)
29
+ SharedExtractor.merge_openapi_metadata(example.metadata)[:request_pattern]
30
+ end
31
+
32
+ def capture_from_context(context)
33
+ exchanges = Thread.current[THREAD_KEY]
34
+ return unless exchanges
35
+
36
+ exchanges << build_exchange(context)
37
+ end
38
+
39
+ # Returns the exchange matching a `"METHOD /path/template"` pattern.
40
+ #
41
+ # @param [String] pattern e.g. "DELETE /resources/{id}"
42
+ # @return [Array(ActionDispatch::Request, ActionDispatch::TestResponse)]
43
+ def fetch(pattern)
44
+ method, path_template = parse_pattern(pattern)
45
+ path_matcher = path_template_to_regexp(path_template)
46
+
47
+ found = recorded.reverse_each.find do |request, _response|
48
+ request.request_method.to_s.upcase == method && request.path.match?(path_matcher)
49
+ end
50
+ return found if found
51
+
52
+ raise ArgumentError, no_match_message(pattern)
53
+ end
54
+
55
+ private
56
+
57
+ def recorded
58
+ Thread.current[THREAD_KEY]
59
+ end
60
+
61
+ def parse_pattern(pattern)
62
+ match = %r{\A(\S+)\s+(/\S*)\z}.match(pattern.to_s.strip)
63
+ unless match
64
+ raise ArgumentError,
65
+ "[rspec-openapi] Invalid request_pattern #{pattern.inspect}. " \
66
+ 'Expected "<HTTP method> <path>", e.g. "DELETE /widgets/{id}".'
67
+ end
68
+
69
+ [match[1].upcase, match[2]]
70
+ end
71
+
72
+ def no_match_message(pattern)
73
+ issued = recorded.map { |request, _response| "#{request.request_method} #{request.path}" }
74
+ listing = issued.empty? ? '(no requests were recorded)' : issued.join(', ')
75
+ "[rspec-openapi] request_pattern #{pattern.inspect} did not match any request " \
76
+ "issued in this example. Recorded requests: #{listing}."
77
+ end
78
+
79
+ def path_template_to_regexp(template)
80
+ segments = template.split(/\{[^}]+\}/, -1)
81
+ pattern = segments.map { |segment| Regexp.escape(segment) }.join('[^/]+')
82
+ /\A#{pattern}\z/
83
+ end
84
+
85
+ def build_exchange(context)
86
+ if context.respond_to?(:last_request)
87
+ SharedExtractor.build_request_response(context.last_request.env, context.last_response.to_a)
88
+ else
89
+ session = context.instance_variable_get(:@integration_session)
90
+ SharedExtractor.build_request_response(session.request.env, session.response.to_a)
91
+ end
92
+ end
93
+ end
94
+ end
@@ -71,11 +71,7 @@ class << RSpec::OpenAPI::Extractors::Hanami = Object.new
71
71
 
72
72
  # @param [RSpec::ExampleGroups::*] context
73
73
  def request_response(context)
74
- request = ActionDispatch::Request.new(context.last_request.env)
75
- request.body.rewind if request.body.respond_to?(:rewind)
76
- response = ActionDispatch::TestResponse.new(*context.last_response.to_a)
77
-
78
- [request, response]
74
+ SharedExtractor.build_request_response(context.last_request.env, context.last_response.to_a)
79
75
  end
80
76
 
81
77
  private
@@ -17,10 +17,6 @@ class << RSpec::OpenAPI::Extractors::Rack = Object.new
17
17
 
18
18
  # @param [RSpec::ExampleGroups::*] context
19
19
  def request_response(context)
20
- request = ActionDispatch::Request.new(context.last_request.env)
21
- request.body.rewind if request.body.respond_to?(:rewind)
22
- response = ActionDispatch::TestResponse.new(*context.last_response.to_a)
23
-
24
- [request, response]
20
+ SharedExtractor.build_request_response(context.last_request.env, context.last_response.to_a)
25
21
  end
26
22
  end
@@ -12,6 +12,12 @@ class SharedExtractor
12
12
  opt in early.
13
13
  MSG
14
14
 
15
+ def self.build_request_response(env, response_array)
16
+ request = ActionDispatch::Request.new(env)
17
+ request.body.rewind if request.body.respond_to?(:rewind)
18
+ [request, ActionDispatch::TestResponse.new(*response_array)]
19
+ end
20
+
15
21
  def self.attributes(example)
16
22
  metadata = merge_openapi_metadata(example.metadata)
17
23
  request_example_mode, response_example_mode = normalize_example_mode(metadata[:example_mode], example)
@@ -86,14 +92,12 @@ class SharedExtractor
86
92
 
87
93
  def self.collect_openapi_metadata(metadata)
88
94
  [].tap do |result|
89
- current = metadata
90
-
91
- while current
92
- [current[:example_group], current].each do |meta|
93
- result.unshift(meta[:openapi]) if meta&.dig(:openapi)
94
- end
95
+ result.unshift(metadata[:openapi]) if metadata[:openapi]
95
96
 
96
- current = current[:parent_example_group]
97
+ group = metadata.fetch(:example_group) { metadata[:parent_example_group] }
98
+ while group
99
+ result.unshift(group[:openapi]) if group[:openapi]
100
+ group = group[:parent_example_group]
97
101
  end
98
102
  end
99
103
  end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Converts between the internal `nullable: true` form and the 3.1+ JSON Schema
4
+ # null form (`type: [..., 'null']`). The builder and merger only use `nullable`;
5
+ # normalize! runs on read, to_json_schema! on write.
6
+ class << RSpec::OpenAPI::NullableConverter = Object.new
7
+ # Keys whose values hold user data, not schemas. Their contents must never be
8
+ # rewritten, or a recorded example/default that happens to contain a field
9
+ # named `type` or `nullable` would be silently mangled.
10
+ DATA_KEYS = [:example, :examples, :default, :enum].freeze
11
+
12
+ def to_json_schema!(node)
13
+ each_schema(node) { |schema| nullable_to_type_null!(schema) }
14
+ node
15
+ end
16
+
17
+ def normalize!(node)
18
+ each_schema(node) { |schema| type_null_to_nullable!(schema) }
19
+ node
20
+ end
21
+
22
+ private
23
+
24
+ def nullable_to_type_null!(schema)
25
+ return unless schema.delete(:nullable)
26
+
27
+ schema[:type] =
28
+ case (type = schema[:type])
29
+ when nil then 'null'
30
+ else [type, 'null']
31
+ end
32
+ end
33
+
34
+ def type_null_to_nullable!(schema)
35
+ case (type = schema[:type])
36
+ when 'null'
37
+ schema.delete(:type)
38
+ schema[:nullable] = true
39
+ when Array
40
+ return unless type.include?('null')
41
+
42
+ rest = type - ['null']
43
+ if rest.empty?
44
+ schema.delete(:type)
45
+ else
46
+ schema[:type] = rest.one? ? rest.first : rest
47
+ end
48
+ schema[:nullable] = true
49
+ end
50
+ end
51
+
52
+ # Yield every schema Hash in the tree, skipping the data subtrees in DATA_KEYS. Each
53
+ # transform mutates only its own node's keys, so the following each still
54
+ # iterates a stable Hash.
55
+ def each_schema(node, &block)
56
+ case node
57
+ when Hash
58
+ yield node
59
+ node.each { |key, value| each_schema(value, &block) unless DATA_KEYS.include?(key) }
60
+ when Array
61
+ node.each { |value| each_schema(value, &block) }
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Moves non-standard HTTP methods between the internal flat form
4
+ # (`paths.<path>.<method>`) and the 3.2 `additionalOperations` map. 3.2 keeps the
5
+ # 8 standard methods plus `query` as fixed fields; other verbs (COPY, MOVE, ...)
6
+ # go under `additionalOperations`. normalize! on read, to_additional_operations! on write.
7
+ class << RSpec::OpenAPI::OperationConverter = Object.new
8
+ FIXED_METHODS = [:get, :put, :post, :delete, :options, :head, :patch, :trace, :query].freeze
9
+ # Path Item fields that are not operations.
10
+ PATH_ITEM_METADATA = [:summary, :description, :servers, :parameters, :additionalOperations].push(:$ref).freeze
11
+
12
+ def to_additional_operations!(spec)
13
+ each_path_item(spec) { |item| relocate_non_standard!(item) }
14
+ spec
15
+ end
16
+
17
+ def normalize!(spec)
18
+ each_path_item(spec) { |item| inline_additional_operations!(item) }
19
+ spec
20
+ end
21
+
22
+ private
23
+
24
+ def relocate_non_standard!(item)
25
+ non_standard = item.keys - FIXED_METHODS - PATH_ITEM_METADATA
26
+ non_standard.each do |method|
27
+ additional = (item[:additionalOperations] ||= {})
28
+ additional[method.to_s.upcase.to_sym] = item.delete(method)
29
+ end
30
+ end
31
+
32
+ def inline_additional_operations!(item)
33
+ additional = item.delete(:additionalOperations)
34
+ return unless additional.is_a?(Hash)
35
+
36
+ additional.each { |verb, operation| item[verb.to_s.downcase.to_sym] = operation }
37
+ end
38
+
39
+ def each_path_item(spec)
40
+ paths = spec[:paths]
41
+ return spec unless paths.is_a?(Hash)
42
+
43
+ paths.each_value { |item| yield item if item.is_a?(Hash) }
44
+ spec
45
+ end
46
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'logger'
3
4
  require 'action_dispatch'
4
5
  require 'rspec/openapi/record'
5
6
 
@@ -8,7 +9,7 @@ class << RSpec::OpenAPI::RecordBuilder = Object.new
8
9
  # @param [RSpec::Core::Example] example
9
10
  # @return [RSpec::OpenAPI::Record,nil]
10
11
  def build(context, example:, extractor:)
11
- request, response = extractor.request_response(context)
12
+ request, response = select_request_response(context, example, extractor)
12
13
  return if request.nil?
13
14
 
14
15
  attributes = extractor.request_attributes(request, example)
@@ -20,6 +21,13 @@ class << RSpec::OpenAPI::RecordBuilder = Object.new
20
21
 
21
22
  private
22
23
 
24
+ def select_request_response(context, example, extractor)
25
+ pattern = RSpec::OpenAPI::ExchangeRecorder.pattern_for(example)
26
+ return RSpec::OpenAPI::ExchangeRecorder.fetch(pattern) if pattern
27
+
28
+ extractor.request_response(context)
29
+ end
30
+
23
31
  def build_record(title, request, response, attributes)
24
32
  request_headers, response_headers = extract_headers(request, response)
25
33
  RSpec::OpenAPI::Record.new(
@@ -42,6 +50,8 @@ class << RSpec::OpenAPI::RecordBuilder = Object.new
42
50
  def safe_parse_body(response, media_type)
43
51
  # Use raw body, because Nokogiri-parsed HTML are modified (new lines injection, meta injection, and so on) :(
44
52
  return response.body if media_type == 'text/html'
53
+ # Keep streaming bodies raw so they can be split per item (see StreamParser).
54
+ return response.body if RSpec::OpenAPI.sequential_media_type?(media_type)
45
55
 
46
56
  response.parsed_body
47
57
  rescue JSON::ParserError
@@ -7,31 +7,16 @@ class RSpec::OpenAPI::ResultRecorder
7
7
  end
8
8
 
9
9
  def record_results!
10
- @path_records.each do |path, records|
11
- # Look for a path-specific config file and run it.
12
- config_file = File.join(File.dirname(path), RSpec::OpenAPI.config_filename)
13
- begin
14
- require config_file if File.exist?(config_file)
15
- rescue StandardError => e
16
- puts "WARNING: Unable to load #{config_file}: #{e}"
17
- end
10
+ @path_records.each do |paths, records|
11
+ # A single record set may target multiple output files (e.g. both YAML and
12
+ # JSON). The first path is the canonical source we read/merge into; the rest
13
+ # mirror the same built spec, each formatted by its own extension.
14
+ primary, *mirrors = Array(paths)
15
+ next if primary.nil?
18
16
 
19
- title = records.first.title
20
- RSpec::OpenAPI::SchemaFile.new(path).edit do |spec|
21
- schema = RSpec::OpenAPI::DefaultSchema.build(title)
22
- schema[:info].merge!(RSpec::OpenAPI.info)
23
- RSpec::OpenAPI::SchemaMerger.merge!(spec, schema)
24
- new_from_zero = {}
25
- records.each do |record|
26
- record_schema = RSpec::OpenAPI::SchemaBuilder.build(record)
27
- RSpec::OpenAPI::SchemaMerger.merge!(spec, record_schema)
28
- RSpec::OpenAPI::SchemaMerger.merge!(new_from_zero, record_schema)
29
- rescue StandardError, NotImplementedError => e # e.g. SchemaBuilder raises a NotImplementedError
30
- @error_records[e] = record # Avoid failing the build
31
- end
32
- cleanup_schema!(new_from_zero, spec)
33
- execute_post_process_hook(path, records, spec)
34
- end
17
+ load_path_config(primary)
18
+ built_spec = build_spec(primary, records)
19
+ mirrors.each { |mirror_path| RSpec::OpenAPI::SchemaFile.new(mirror_path).write(built_spec) }
35
20
  end
36
21
  end
37
22
 
@@ -49,6 +34,37 @@ class RSpec::OpenAPI::ResultRecorder
49
34
 
50
35
  private
51
36
 
37
+ # Look for a path-specific config file and run it.
38
+ def load_path_config(path)
39
+ config_file = File.join(File.dirname(path), RSpec::OpenAPI.config_filename)
40
+ require config_file if File.exist?(config_file)
41
+ rescue StandardError => e
42
+ puts "WARNING: Unable to load #{config_file}: #{e}"
43
+ end
44
+
45
+ def build_spec(primary, records)
46
+ title = records.first.title
47
+ RSpec::OpenAPI::SchemaFile.new(primary).edit do |spec|
48
+ # Normalize an existing file back to the internal form so merge/cleanup is
49
+ # version-agnostic; cleanup_schema! re-applies the target form.
50
+ RSpec::OpenAPI::NullableConverter.normalize!(spec)
51
+ RSpec::OpenAPI::OperationConverter.normalize!(spec)
52
+ schema = RSpec::OpenAPI::DefaultSchema.build(title)
53
+ schema[:info].merge!(RSpec::OpenAPI.info)
54
+ RSpec::OpenAPI::SchemaMerger.merge!(spec, schema)
55
+ new_from_zero = {}
56
+ records.each do |record|
57
+ record_schema = RSpec::OpenAPI::SchemaBuilder.build(record)
58
+ RSpec::OpenAPI::SchemaMerger.merge!(spec, record_schema)
59
+ RSpec::OpenAPI::SchemaMerger.merge!(new_from_zero, record_schema)
60
+ rescue StandardError, NotImplementedError => e # e.g. SchemaBuilder raises a NotImplementedError
61
+ @error_records[e] = record # Avoid failing the build
62
+ end
63
+ cleanup_schema!(new_from_zero, spec)
64
+ execute_post_process_hook(primary, records, spec)
65
+ end
66
+ end
67
+
52
68
  def execute_post_process_hook(path, records, spec)
53
69
  RSpec::OpenAPI.post_process_hook.call(path, records, spec) if RSpec::OpenAPI.post_process_hook.is_a?(Proc)
54
70
  end
@@ -58,6 +74,8 @@ class RSpec::OpenAPI::ResultRecorder
58
74
  RSpec::OpenAPI::SchemaCleaner.cleanup!(spec, new_from_zero)
59
75
  RSpec::OpenAPI::ComponentsUpdater.update!(spec, new_from_zero)
60
76
  RSpec::OpenAPI::SchemaCleaner.cleanup_empty_required_array!(spec)
77
+ RSpec::OpenAPI::OperationConverter.to_additional_operations!(spec) if RSpec::OpenAPI.supports_additional_operations?
61
78
  RSpec::OpenAPI::SchemaSorter.deep_sort!(spec)
79
+ RSpec::OpenAPI::NullableConverter.to_json_schema!(spec) if RSpec::OpenAPI.json_schema_based?
62
80
  end
63
81
  end
@@ -2,6 +2,10 @@
2
2
 
3
3
  require 'rspec/core'
4
4
 
5
+ RSpec.configuration.before(:each) do |example|
6
+ RSpec::OpenAPI::ExchangeRecorder.reset!(example)
7
+ end
8
+
5
9
  RSpec.configuration.after(:each) do |example|
6
10
  if RSpec::OpenAPI.example_types.include?(example.metadata[:type]) && example.metadata[:openapi] != false
7
11
  path = RSpec::OpenAPI.path.then { |p| p.is_a?(Proc) ? p.call(example) : p }
@@ -56,6 +56,12 @@ class << RSpec::OpenAPI::SchemaBuilder
56
56
  disposition = normalize_content_disposition(record.response_content_disposition)
57
57
  content_type = normalize_content_type(record.response_content_type)
58
58
  ctx = BuildContext.new(record: record, context: :response)
59
+
60
+ if RSpec::OpenAPI.supports_item_schema? && RSpec::OpenAPI.sequential_media_type?(content_type)
61
+ item_schema = build_stream_item_schema(record.response_body, content_type, ctx)
62
+ return { content_type => { itemSchema: item_schema } } if item_schema
63
+ end
64
+
59
65
  schema = build_property(record.response_body, ctx, disposition: disposition)
60
66
  example = response_example(record, disposition: disposition)
61
67
 
@@ -63,6 +69,14 @@ class << RSpec::OpenAPI::SchemaBuilder
63
69
  { content_type => body }
64
70
  end
65
71
 
72
+ def build_stream_item_schema(raw, content_type, ctx)
73
+ items = RSpec::OpenAPI::StreamParser.items(raw, content_type)
74
+ return nil if items.empty?
75
+
76
+ variations = items.map { |item| build_property(item, ctx) }
77
+ build_merged_schema_from_variations(variations)
78
+ end
79
+
66
80
  # Returns the per-content-type body (schema + optional example/examples).
67
81
  # Shared by response content and request body to keep example_mode handling in one place.
68
82
  def build_example_body(schema, record, mode:, example:)
@@ -14,11 +14,30 @@ class RSpec::OpenAPI::SchemaFile
14
14
  @path = path
15
15
  end
16
16
 
17
+ # Reads the existing spec, lets the block mutate it, writes the result back
18
+ # and returns the (symbolized) spec so it can be mirrored to other files.
19
+ # @return [Hash]
17
20
  def edit(&block)
18
21
  spec = read
19
22
  block.call(spec)
23
+ spec
20
24
  ensure
21
- write(RSpec::OpenAPI::KeyTransformer.stringify(spec))
25
+ write(spec)
26
+ end
27
+
28
+ # Writes an already-built spec to this file, choosing the format from the
29
+ # file extension.
30
+ # @param [Hash] spec
31
+ def write(spec)
32
+ stringified = RSpec::OpenAPI::KeyTransformer.stringify(spec)
33
+ FileUtils.mkdir_p(File.dirname(@path))
34
+ output =
35
+ if json?
36
+ JSON.pretty_generate(stringified)
37
+ else
38
+ prepend_comment(YAML.dump(stringified))
39
+ end
40
+ File.write(@path, output)
22
41
  end
23
42
 
24
43
  private
@@ -33,18 +52,6 @@ class RSpec::OpenAPI::SchemaFile
33
52
  RSpec::OpenAPI::KeyTransformer.symbolize(content)
34
53
  end
35
54
 
36
- # @param [Hash] spec
37
- def write(spec)
38
- FileUtils.mkdir_p(File.dirname(@path))
39
- output =
40
- if json?
41
- JSON.pretty_generate(spec)
42
- else
43
- prepend_comment(YAML.dump(spec))
44
- end
45
- File.write(@path, output)
46
- end
47
-
48
55
  def prepend_comment(content)
49
56
  return content if RSpec::OpenAPI.comment.nil?
50
57
 
@@ -4,18 +4,27 @@ class << RSpec::OpenAPI::SchemaSorter = Object.new
4
4
  # Sort some unpredictably ordered properties in a lexicographical manner to make the order more predictable.
5
5
  #
6
6
  # @param [Hash|Array]
7
+ # Operation containers: fixed methods sit directly under the path item, while
8
+ # 3.2 puts non-standard verbs (COPY, MOVE, ...) one level deeper, under
9
+ # `additionalOperations`. Both need the same response/content sorting, or their
10
+ # order follows RSpec's random execution order and produces churny diffs.
11
+ OPERATION_SELECTORS = ['paths.*.*', 'paths.*.additionalOperations.*'].freeze
12
+
7
13
  def deep_sort!(spec)
8
14
  # paths
9
15
  deep_sort_by_selector!(spec, 'paths')
10
16
 
11
- # methods
17
+ # methods (and the additionalOperations verb map)
12
18
  deep_sort_by_selector!(spec, 'paths.*')
19
+ deep_sort_by_selector!(spec, 'paths.*.additionalOperations')
13
20
 
14
- # response status code
15
- deep_sort_by_selector!(spec, 'paths.*.*.responses')
21
+ OPERATION_SELECTORS.each do |operation|
22
+ # response status code
23
+ deep_sort_by_selector!(spec, "#{operation}.responses")
16
24
 
17
- # content-type
18
- deep_sort_by_selector!(spec, 'paths.*.*.responses.*.content')
25
+ # content-type
26
+ deep_sort_by_selector!(spec, "#{operation}.responses.*.content")
27
+ end
19
28
  end
20
29
 
21
30
  private
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ # Splits a streaming body into its items (for `itemSchema`), skipping unparseable
6
+ # chunks. Supported content types: RSpec::OpenAPI::SEQUENTIAL_MEDIA_TYPES.
7
+ class << RSpec::OpenAPI::StreamParser = Object.new
8
+ def items(raw, content_type)
9
+ chunks =
10
+ case content_type
11
+ when 'application/json-seq' then raw.split("\x1e") # RFC 7464 record separator
12
+ when 'text/event-stream' then sse_data(raw)
13
+ else raw.split("\n") # NDJSON / JSON Lines
14
+ end
15
+
16
+ chunks.filter_map { |chunk| parse_json(chunk) }
17
+ end
18
+
19
+ private
20
+
21
+ def parse_json(chunk)
22
+ text = chunk.to_s.strip
23
+ return nil if text.empty?
24
+
25
+ JSON.parse(text)
26
+ rescue JSON::ParserError
27
+ nil
28
+ end
29
+
30
+ # SSE: events are blank-line separated; join the `data:` lines of each.
31
+ def sse_data(raw)
32
+ events = []
33
+ current = []
34
+ raw.each_line do |line|
35
+ line = line.chomp
36
+ if line.empty?
37
+ events << current.join("\n") unless current.empty?
38
+ current = []
39
+ elsif line.start_with?('data:')
40
+ current << line.sub(/\Adata: ?/, '')
41
+ end
42
+ end
43
+ events << current.join("\n") unless current.empty?
44
+ events
45
+ end
46
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RSpec
4
4
  module OpenAPI
5
- VERSION = '0.28.0'
5
+ VERSION = '0.30.0'
6
6
  end
7
7
  end
data/lib/rspec/openapi.rb CHANGED
@@ -3,7 +3,11 @@
3
3
  require 'rspec/openapi/version'
4
4
  require 'rspec/openapi/components_updater'
5
5
  require 'rspec/openapi/default_schema'
6
+ require 'rspec/openapi/nullable_converter'
7
+ require 'rspec/openapi/operation_converter'
8
+ require 'rspec/openapi/stream_parser'
6
9
  require 'rspec/openapi/record_builder'
10
+ require 'rspec/openapi/exchange_recorder'
7
11
  require 'rspec/openapi/result_recorder'
8
12
  require 'rspec/openapi/schema_builder'
9
13
  require 'rspec/openapi/schema_file'
@@ -18,6 +22,15 @@ require 'rspec/openapi/extractors/shared_extractor'
18
22
  require 'rspec/openapi/extractors/rack'
19
23
 
20
24
  module RSpec::OpenAPI
25
+ # Streaming media types whose body is a sequence of items, not one document.
26
+ # Their raw body is kept unparsed and split per item (see StreamParser).
27
+ SEQUENTIAL_MEDIA_TYPES = [
28
+ 'application/jsonl',
29
+ 'application/x-ndjson',
30
+ 'application/json-seq',
31
+ 'text/event-stream',
32
+ ].freeze
33
+
21
34
  class Config
22
35
  class << self
23
36
  attr_accessor :debug_enabled
@@ -40,9 +53,11 @@ module RSpec::OpenAPI
40
53
  @formats_builder = ->(example) { example.metadata[:formats] }
41
54
  @info = {}
42
55
  @application_version = '1.0.0'
56
+ @openapi_version = '3.2.0'
43
57
  @request_headers = []
44
58
  @servers = []
45
59
  @security_schemes = []
60
+ @root_tags = []
46
61
  @example_types = [:request]
47
62
  @response_headers = []
48
63
  @path_records = Hash.new { |h, k| h[k] = [] }
@@ -69,6 +84,7 @@ module RSpec::OpenAPI
69
84
  :request_headers,
70
85
  :servers,
71
86
  :security_schemes,
87
+ :root_tags,
72
88
  :example_types,
73
89
  :response_headers,
74
90
  :path_records,
@@ -76,7 +92,57 @@ module RSpec::OpenAPI
76
92
  :ignored_path_params,
77
93
  :post_process_hook
78
94
 
79
- attr_reader :config_filename
95
+ attr_reader :config_filename, :openapi_version
96
+
97
+ SUPPORTED_OPENAPI_MAJOR_MINORS = ['3.0', '3.1', '3.2'].freeze
98
+
99
+ def openapi_version=(version)
100
+ major_minor = Gem::Version.new(version).segments.first(2).join('.')
101
+ unless SUPPORTED_OPENAPI_MAJOR_MINORS.include?(major_minor)
102
+ raise ArgumentError, "Unsupported OpenAPI version: #{version.inspect}. " \
103
+ "Supported: #{SUPPORTED_OPENAPI_MAJOR_MINORS.map { |v| "#{v}.x" }.join(', ')}"
104
+ end
105
+ @openapi_version = version
106
+ end
107
+
108
+ def openapi_version_at_least?(version)
109
+ Gem::Version.new(openapi_version) >= Gem::Version.new(version)
110
+ end
111
+
112
+ # 3.1+ drops `nullable` in favour of JSON Schema null types.
113
+ def json_schema_based?
114
+ openapi_version_at_least?('3.1')
115
+ end
116
+
117
+ # 3.2 adds the `query` field and the `additionalOperations` map.
118
+ def supports_additional_operations?
119
+ openapi_version_at_least?('3.2')
120
+ end
121
+
122
+ # 3.2 adds `itemSchema` for sequential (streaming) media types.
123
+ def supports_item_schema?
124
+ openapi_version_at_least?('3.2')
125
+ end
126
+
127
+ def sequential_media_type?(media_type)
128
+ SEQUENTIAL_MEDIA_TYPES.include?(media_type)
129
+ end
130
+
131
+ # Allow Rails request specs to issue extra verbs (e.g. QUERY); ActionDispatch
132
+ # otherwise rejects unknown verbs. No-op outside Rails.
133
+ def register_http_methods(methods)
134
+ # simplecov:disable branch non-Rails guard for roda/hanami; the suite always loads Rails
135
+ return unless defined?(ActionDispatch::Request::HTTP_METHODS)
136
+
137
+ # simplecov:enable
138
+ Array(methods).each do |method|
139
+ verb = method.to_s.upcase
140
+ next if ActionDispatch::Request::HTTP_METHODS.include?(verb)
141
+
142
+ ActionDispatch::Request::HTTP_METHODS << verb
143
+ ActionDispatch::Request::HTTP_METHOD_LOOKUP[verb] = verb.downcase.to_sym
144
+ end
145
+ end
80
146
  end
81
147
  end
82
148
 
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.28.0
4
+ version: 0.30.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Takashi Kokubun
@@ -66,6 +66,7 @@ files:
66
66
  - lib/rspec/openapi/components_updater.rb
67
67
  - lib/rspec/openapi/default_schema.rb
68
68
  - lib/rspec/openapi/example_key.rb
69
+ - lib/rspec/openapi/exchange_recorder.rb
69
70
  - lib/rspec/openapi/extractors.rb
70
71
  - lib/rspec/openapi/extractors/hanami.rb
71
72
  - lib/rspec/openapi/extractors/rack.rb
@@ -74,6 +75,8 @@ files:
74
75
  - lib/rspec/openapi/hash_helper.rb
75
76
  - lib/rspec/openapi/key_transformer.rb
76
77
  - lib/rspec/openapi/minitest_hooks.rb
78
+ - lib/rspec/openapi/nullable_converter.rb
79
+ - lib/rspec/openapi/operation_converter.rb
77
80
  - lib/rspec/openapi/record.rb
78
81
  - lib/rspec/openapi/record_builder.rb
79
82
  - lib/rspec/openapi/result_recorder.rb
@@ -85,6 +88,7 @@ files:
85
88
  - lib/rspec/openapi/schema_merger.rb
86
89
  - lib/rspec/openapi/schema_sorter.rb
87
90
  - lib/rspec/openapi/shared_hooks.rb
91
+ - lib/rspec/openapi/stream_parser.rb
88
92
  - lib/rspec/openapi/version.rb
89
93
  - rspec-openapi.gemspec
90
94
  homepage: https://github.com/exoego/rspec-openapi
@@ -93,7 +97,7 @@ licenses:
93
97
  metadata:
94
98
  homepage_uri: https://github.com/exoego/rspec-openapi
95
99
  source_code_uri: https://github.com/exoego/rspec-openapi
96
- changelog_uri: https://github.com/exoego/rspec-openapi/releases/tag/v0.28.0
100
+ changelog_uri: https://github.com/exoego/rspec-openapi/releases/tag/v0.30.0
97
101
  rubygems_mfa_required: 'true'
98
102
  rdoc_options: []
99
103
  require_paths: