rspec-openapi 0.29.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: 97857dc0658df5323bf3997e3acc31cf8f8b2f713a2d2ec6e3fdb0be7661aaf5
4
- data.tar.gz: 9ecdb6c88c4c58042f805d899cda3d50fe2b670286de4d7f64b3799fd1ee5d48
3
+ metadata.gz: 2ce922f425644ad7ab8645bf2dcd7df5f93085e5e15cae1f826feff526fc96f6
4
+ data.tar.gz: 19b7d372d3a1324903ec229f96292afdf738f99e492d76c16d281b9323e369f1
5
5
  SHA512:
6
- metadata.gz: 14577c70d8caca9754f4893be1720ed67dbb4762f1479a4454e188cc954ba8e9651f887cc0c69a95c91c1c98f994739bf12667f7470a491b60f9f2ce21ac81fc
7
- data.tar.gz: feb18e9c32bed1f101dca61683e30975857257999fac322ff9ed51519a7ac13d583ad1cf68b3f20c56782919c9bb2d6d83d94396204c3287c468c692b1b121e1
6
+ metadata.gz: fe915320f2594675a5930eb28dd580f99fe4377e3c8dea443656d0de171187dd8cd30f2bbac720ea5c2a19271001e734740d09e472ee0cf7987caf89e11ebad6
7
+ data.tar.gz: 2f41de3cafeeeb044748cf71bf6cd8163f4d083d145ea8c9adbf6dfeb57336d2582c1280086c5e008a8f679e7a03f780eca4e4a76b9ef741c3d2e40633d21d48
data/README.md CHANGED
@@ -159,6 +159,10 @@ RSpec::OpenAPI.enable_example_summary = false
159
159
  # Change `info.version`
160
160
  RSpec::OpenAPI.application_version = '1.0.0'
161
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
+
162
166
  # Set the info header details
163
167
  RSpec::OpenAPI.info = {
164
168
  description: 'My beautiful API',
@@ -187,6 +191,14 @@ RSpec::OpenAPI.security_schemes = {
187
191
  },
188
192
  }
189
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
+
190
202
  # Generate a comment on top of a schema file
191
203
  RSpec::OpenAPI.comment = <<~EOS
192
204
  This file is auto-generated by rspec-openapi https://github.com/k0kubun/rspec-openapi
@@ -238,6 +250,46 @@ RSpec::OpenAPI.post_process_hook = -> (path, records, spec) do
238
250
  end
239
251
  ```
240
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
+
241
293
  ### Can I use rspec-openapi with `$ref` to minimize duplication of schema?
242
294
 
243
295
  Yes, rspec-openapi v0.7.0+ supports [`$ref` mechanism](https://swagger.io/docs/specification/using-ref/) and generates
@@ -405,6 +457,39 @@ The merge happens at the top level of the `openapi:` hash. Scalar keys (`summary
405
457
  …) follow last-wins, and a nested level that re-declares a structured key (`tags`, `security`,
406
458
  `enum`, …) replaces the inherited value for that key rather than deep-merging it.
407
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
+
408
493
  ### Enum Support
409
494
 
410
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)
@@ -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
@@ -9,7 +9,7 @@ class << RSpec::OpenAPI::RecordBuilder = Object.new
9
9
  # @param [RSpec::Core::Example] example
10
10
  # @return [RSpec::OpenAPI::Record,nil]
11
11
  def build(context, example:, extractor:)
12
- request, response = extractor.request_response(context)
12
+ request, response = select_request_response(context, example, extractor)
13
13
  return if request.nil?
14
14
 
15
15
  attributes = extractor.request_attributes(request, example)
@@ -21,6 +21,13 @@ class << RSpec::OpenAPI::RecordBuilder = Object.new
21
21
 
22
22
  private
23
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
+
24
31
  def build_record(title, request, response, attributes)
25
32
  request_headers, response_headers = extract_headers(request, response)
26
33
  RSpec::OpenAPI::Record.new(
@@ -43,6 +50,8 @@ class << RSpec::OpenAPI::RecordBuilder = Object.new
43
50
  def safe_parse_body(response, media_type)
44
51
  # Use raw body, because Nokogiri-parsed HTML are modified (new lines injection, meta injection, and so on) :(
45
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)
46
55
 
47
56
  response.parsed_body
48
57
  rescue JSON::ParserError
@@ -45,6 +45,10 @@ class RSpec::OpenAPI::ResultRecorder
45
45
  def build_spec(primary, records)
46
46
  title = records.first.title
47
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)
48
52
  schema = RSpec::OpenAPI::DefaultSchema.build(title)
49
53
  schema[:info].merge!(RSpec::OpenAPI.info)
50
54
  RSpec::OpenAPI::SchemaMerger.merge!(spec, schema)
@@ -70,6 +74,8 @@ class RSpec::OpenAPI::ResultRecorder
70
74
  RSpec::OpenAPI::SchemaCleaner.cleanup!(spec, new_from_zero)
71
75
  RSpec::OpenAPI::ComponentsUpdater.update!(spec, new_from_zero)
72
76
  RSpec::OpenAPI::SchemaCleaner.cleanup_empty_required_array!(spec)
77
+ RSpec::OpenAPI::OperationConverter.to_additional_operations!(spec) if RSpec::OpenAPI.supports_additional_operations?
73
78
  RSpec::OpenAPI::SchemaSorter.deep_sort!(spec)
79
+ RSpec::OpenAPI::NullableConverter.to_json_schema!(spec) if RSpec::OpenAPI.json_schema_based?
74
80
  end
75
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:)
@@ -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.29.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.29.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.29.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: