rspec-openapi 0.25.1 → 0.27.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.
@@ -48,44 +48,25 @@ end)
48
48
  class << RSpec::OpenAPI::Extractors::Hanami = Object.new
49
49
  # @param [ActionDispatch::Request] request
50
50
  # @param [RSpec::Core::Example] example
51
- # @return Array
51
+ # @return [Hash]
52
52
  def request_attributes(request, example)
53
53
  route = Hanami.app.router.recognize(Rack::MockRequest.env_for(request.path, method: request.method))
54
54
 
55
55
  return RSpec::OpenAPI::Extractors::Rack.request_attributes(request, example) unless route.routable?
56
56
 
57
- summary, tags, formats, operation_id, required_request_params, security, description, deprecated, example_mode,
58
- example_key, example_name, response_enum, request_enum = SharedExtractor.attributes(example)
59
-
57
+ attrs = SharedExtractor.attributes(example)
60
58
  path = request.path
59
+ result = InspectorAnalyzer.call(request.method, replace_path_params(path, route, '/:%<key>s'))
61
60
 
62
61
  raw_path_params = route.params
63
-
64
- result = InspectorAnalyzer.call(request.method, replace_path_params(path, route, '/:%{key}'))
65
-
66
- summary ||= result[:summary]
67
- tags ||= result[:tags]
68
- path = replace_path_params(path, route, '/{%{key}}')
69
-
70
62
  raw_path_params = raw_path_params.slice(*(raw_path_params.keys - RSpec::OpenAPI.ignored_path_params))
71
63
 
72
- [
73
- path,
74
- summary,
75
- tags,
76
- operation_id,
77
- required_request_params,
78
- raw_path_params,
79
- description,
80
- security,
81
- deprecated,
82
- formats,
83
- example_mode,
84
- example_key,
85
- example_name,
86
- response_enum,
87
- request_enum,
88
- ]
64
+ attrs.merge(
65
+ path: replace_path_params(path, route, '/{%<key>s}'),
66
+ path_params: raw_path_params,
67
+ summary: attrs[:summary] || result[:summary],
68
+ tags: attrs[:tags] || result[:tags],
69
+ )
89
70
  end
90
71
 
91
72
  # @param [RSpec::ExampleGroups::*] context
@@ -103,7 +84,7 @@ class << RSpec::OpenAPI::Extractors::Hanami = Object.new
103
84
  return path if route.params.empty?
104
85
 
105
86
  route.params.each_pair do |key, value|
106
- path = path.sub("/#{value}", format % { key: key })
87
+ path = path.sub("/#{value}", format(format, key: key))
107
88
  end
108
89
 
109
90
  path
@@ -4,32 +4,15 @@
4
4
  class << RSpec::OpenAPI::Extractors::Rack = Object.new
5
5
  # @param [ActionDispatch::Request] request
6
6
  # @param [RSpec::Core::Example] example
7
- # @return Array
7
+ # @return [Hash]
8
8
  def request_attributes(request, example)
9
- summary, tags, formats, operation_id, required_request_params, security, description, deprecated, example_mode,
10
- example_key, example_name, response_enum, request_enum = SharedExtractor.attributes(example)
11
-
12
- raw_path_params = request.path_parameters
13
9
  path = request.path
14
- summary ||= "#{request.method} #{path}"
15
-
16
- [
17
- path,
18
- summary,
19
- tags,
20
- operation_id,
21
- required_request_params,
22
- raw_path_params,
23
- description,
24
- security,
25
- deprecated,
26
- formats,
27
- example_mode,
28
- example_key,
29
- example_name,
30
- response_enum,
31
- request_enum,
32
- ]
10
+ attrs = SharedExtractor.attributes(example)
11
+ attrs.merge(
12
+ path: path,
13
+ path_params: request.path_parameters,
14
+ summary: attrs[:summary] || "#{request.method} #{path}",
15
+ )
33
16
  end
34
17
 
35
18
  # @param [RSpec::ExampleGroups::*] context
@@ -4,7 +4,7 @@
4
4
  class << RSpec::OpenAPI::Extractors::Rails = Object.new
5
5
  # @param [ActionDispatch::Request] request
6
6
  # @param [RSpec::Core::Example] example
7
- # @return Array
7
+ # @return [Hash]
8
8
  def request_attributes(request, example)
9
9
  # Reverse the destructive modification by Rails https://github.com/rails/rails/blob/v6.0.3.4/actionpack/lib/action_dispatch/journey/router.rb#L33-L41
10
10
  fixed_request = request.dup
@@ -16,40 +16,27 @@ class << RSpec::OpenAPI::Extractors::Rails = Object.new
16
16
 
17
17
  raise "No route matched for #{fixed_request.request_method} #{fixed_request.path_info}" if route.nil?
18
18
 
19
- summary, tags, formats, operation_id, required_request_params, security, description, deprecated, example_mode,
20
- example_key, example_name, response_enum, request_enum = SharedExtractor.attributes(example)
21
-
22
- raw_path_params = request.path_parameters
23
-
24
- summary ||= route.requirements[:action]
25
- tags ||= [route.requirements[:controller]&.classify].compact
19
+ attrs = SharedExtractor.attributes(example)
26
20
  # :controller and :action always exist. :format is added when routes is configured as such.
27
21
  # TODO: Use .except(:controller, :action, :format) when we drop support for Ruby 2.x
22
+ raw_path_params = request.path_parameters
28
23
  raw_path_params = raw_path_params.slice(*(raw_path_params.keys - RSpec::OpenAPI.ignored_path_params))
29
24
 
30
- summary ||= "#{request.method} #{path}"
31
-
32
- [
33
- path,
34
- summary,
35
- tags,
36
- operation_id,
37
- required_request_params,
38
- raw_path_params,
39
- description,
40
- security,
41
- deprecated,
42
- formats,
43
- example_mode,
44
- example_key,
45
- example_name,
46
- response_enum,
47
- request_enum,
48
- ]
25
+ attrs.merge(
26
+ path: path,
27
+ path_params: raw_path_params,
28
+ summary: attrs[:summary] || route.requirements[:action] || "#{request.method} #{path}",
29
+ tags: attrs[:tags] || [route.requirements[:controller]&.classify].compact,
30
+ )
49
31
  end
50
32
 
51
33
  # @param [RSpec::ExampleGroups::*] context
52
34
  def request_response(context)
35
+ # Read @integration_session directly so user-defined let(:request)/let(:response)
36
+ # don't shadow the real ActionDispatch objects we need for OpenAPI extraction.
37
+ session = context.instance_variable_get(:@integration_session)
38
+ return [session.request, session.response] if session
39
+
53
40
  [context.request, context.response]
54
41
  end
55
42
 
@@ -2,31 +2,67 @@
2
2
 
3
3
  # Shared extractor for extracting OpenAPI metadata from RSpec examples
4
4
  class SharedExtractor
5
- VALID_EXAMPLE_MODES = %i[none single multiple].freeze
5
+ VALID_EXAMPLE_MODES = [:none, :single, :multiple].freeze
6
+
7
+ EXAMPLE_MODE_MULTIPLE_SHORTHAND_WARNING = <<~MSG.tr("\n", ' ').strip.freeze
8
+ [rspec-openapi] DEPRECATION: example_mode: :multiple currently means
9
+ { request: :single, response: :multiple }. A future major version will
10
+ change it to { request: :multiple, response: :multiple } (both sides
11
+ multi). Specify the hash form explicitly to lock in current behavior or
12
+ opt in early.
13
+ MSG
6
14
 
7
15
  def self.attributes(example)
8
16
  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)
17
+ request_example_mode, response_example_mode = normalize_example_mode(metadata[:example_mode], example)
18
+ response_additional_properties, request_additional_properties = resolve_additional_properties(metadata)
19
+ response_hybrid_additional_properties, request_hybrid_additional_properties =
20
+ resolve_hybrid_additional_properties(metadata)
21
+ # Enum support: response_enum and request_enum can override the general enum
22
+ base_enum = normalize_enum(metadata[:enum])
23
+
24
+ {
25
+ summary: metadata[:summary] || RSpec::OpenAPI.summary_builder.call(example),
26
+ tags: metadata[:tags] || RSpec::OpenAPI.tags_builder.call(example),
27
+ formats: metadata[:formats] || RSpec::OpenAPI.formats_builder.curry.call(example),
28
+ operation_id: metadata[:operation_id],
29
+ required_request_params: metadata[:required_request_params] || [],
30
+ security: metadata[:security],
31
+ description: metadata[:description] || RSpec::OpenAPI.description_builder.call(example),
32
+ deprecated: metadata[:deprecated],
33
+ request_example_mode: request_example_mode,
34
+ response_example_mode: response_example_mode,
35
+ example_key: resolve_example_key(metadata, example),
36
+ example_name: metadata[:example_name] || RSpec::OpenAPI.example_name_builder.call(example),
37
+ response_enum: normalize_enum(metadata[:response_enum]) || base_enum,
38
+ request_enum: normalize_enum(metadata[:request_enum]) || base_enum,
39
+ response_additional_properties: response_additional_properties,
40
+ request_additional_properties: request_additional_properties,
41
+ response_hybrid_additional_properties: response_hybrid_additional_properties,
42
+ request_hybrid_additional_properties: request_hybrid_additional_properties,
43
+ }
44
+ end
45
+
46
+ def self.resolve_example_key(metadata, example)
18
47
  example_name = metadata[:example_name] || RSpec::OpenAPI.example_name_builder.call(example)
19
48
  raw_example_key = metadata[:example_key] || example_name
20
49
  example_key = RSpec::OpenAPI::ExampleKey.normalize(raw_example_key)
21
50
  example_key = 'default' if example_key.nil? || example_key.empty?
51
+ example_key
52
+ end
22
53
 
23
- # Enum support: response_enum and request_enum can override the general enum
24
- base_enum = normalize_enum(metadata[:enum])
25
- response_enum = normalize_enum(metadata[:response_enum]) || base_enum
26
- request_enum = normalize_enum(metadata[:request_enum]) || base_enum
54
+ def self.resolve_additional_properties(metadata)
55
+ base = normalize_additional_properties(metadata[:additional_properties])
56
+ response = normalize_additional_properties(metadata[:response_additional_properties]) || base
57
+ request = normalize_additional_properties(metadata[:request_additional_properties]) || base
58
+ [response, request]
59
+ end
27
60
 
28
- [summary, tags, formats, operation_id, required_request_params, security, description, deprecated, example_mode,
29
- example_key, example_name, response_enum, request_enum,]
61
+ def self.resolve_hybrid_additional_properties(metadata)
62
+ base = normalize_additional_properties(metadata[:hybrid_additional_properties])
63
+ response = normalize_additional_properties(metadata[:response_hybrid_additional_properties]) || base
64
+ request = normalize_additional_properties(metadata[:request_hybrid_additional_properties]) || base
65
+ [response, request]
30
66
  end
31
67
 
32
68
  def self.normalize_enum(enum_hash)
@@ -36,6 +72,14 @@ class SharedExtractor
36
72
  enum_hash.transform_keys(&:to_s)
37
73
  end
38
74
 
75
+ def self.normalize_additional_properties(hash)
76
+ return nil if hash.nil? || hash.empty?
77
+
78
+ hash.each_with_object({}) do |(path, schema), result|
79
+ result[path.to_s] = RSpec::OpenAPI::KeyTransformer.symbolize(schema)
80
+ end
81
+ end
82
+
39
83
  def self.merge_openapi_metadata(metadata)
40
84
  collect_openapi_metadata(metadata).reduce({}, &:merge)
41
85
  end
@@ -54,9 +98,33 @@ class SharedExtractor
54
98
  end
55
99
  end
56
100
 
101
+ # Returns [request_mode, response_mode]. Accepts either a bare Symbol/String
102
+ # (applied to both sides, except :multiple which is treated as a backward-compat
103
+ # shorthand for { request: :single, response: :multiple } and emits a one-time
104
+ # deprecation warning) or a Hash with :request / :response keys.
57
105
  def self.normalize_example_mode(value, example = nil)
58
- return :single if value.nil?
106
+ return [:single, :single] if value.nil?
107
+
108
+ case value
109
+ when Hash
110
+ [
111
+ normalize_example_mode_hash_value(value, :request, example),
112
+ normalize_example_mode_hash_value(value, :response, example),
113
+ ]
114
+ when Symbol, String
115
+ mode = coerce_example_mode_value(value, example)
116
+ if mode == :multiple
117
+ warn_example_mode_multiple_shorthand
118
+ [:single, :multiple]
119
+ else
120
+ [mode, mode]
121
+ end
122
+ else
123
+ raise ArgumentError, example_mode_error(value, example)
124
+ end
125
+ end
59
126
 
127
+ def self.coerce_example_mode_value(value, example)
60
128
  raise ArgumentError, example_mode_error(value, example) unless value.is_a?(String) || value.is_a?(Symbol)
61
129
 
62
130
  mode = value.to_s.strip.downcase.to_sym
@@ -65,9 +133,25 @@ class SharedExtractor
65
133
  raise ArgumentError, example_mode_error(value, example)
66
134
  end
67
135
 
136
+ def self.normalize_example_mode_hash_value(hash, key, example)
137
+ raw = hash[key]
138
+ raw = hash[key.to_s] if raw.nil?
139
+ return :single if raw.nil?
140
+
141
+ coerce_example_mode_value(raw, example)
142
+ end
143
+
144
+ def self.warn_example_mode_multiple_shorthand
145
+ return if @warned_example_mode_multiple_shorthand
146
+
147
+ @warned_example_mode_multiple_shorthand = true
148
+ Kernel.warn(EXAMPLE_MODE_MULTIPLE_SHORTHAND_WARNING)
149
+ end
150
+
68
151
  def self.example_mode_error(value, example)
69
152
  context = example&.full_description
70
153
  context = " (example: #{context})" if context
71
- "example_mode must be one of #{VALID_EXAMPLE_MODES.inspect}, got #{value.inspect}#{context}"
154
+ "example_mode must be a Symbol/String in #{VALID_EXAMPLE_MODES.inspect} " \
155
+ "or a Hash with :request/:response keys, got #{value.inspect}#{context}"
72
156
  end
73
157
  end
@@ -20,7 +20,8 @@ RSpec::OpenAPI::Record = Struct.new(
20
20
  :security, # @param [Array] - [{securityScheme1: []}]
21
21
  :deprecated, # @param [Boolean] - true
22
22
  :example_enabled, # @param [Boolean] - true
23
- :example_mode, # @param [Symbol] - :none | :single | :multiple
23
+ :request_example_mode, # @param [Symbol] - :none | :single | :multiple
24
+ :response_example_mode, # @param [Symbol] - :none | :single | :multiple
24
25
  :status, # @param [Integer] - 200
25
26
  :response_body, # @param [Object] - {"status" => "ok"}
26
27
  :response_headers, # @param [Array] - [["header_key1", "header_value1"], ["header_key2", "header_value2"]]
@@ -28,5 +29,9 @@ RSpec::OpenAPI::Record = Struct.new(
28
29
  :response_content_disposition, # @param [String] - "inline"
29
30
  :response_enum, # @param [Hash] - {"status" => ["active", "inactive"], "user.role" => ["admin", "user"]}
30
31
  :request_enum, # @param [Hash] - {"type" => ["create", "update"]}
32
+ :response_additional_properties, # @param [Hash] - {"data" => { type: "boolean" }}
33
+ :request_additional_properties, # @param [Hash] - {"meta" => { type: "string" }}
34
+ :response_hybrid_additional_properties, # @param [Hash] - {"data" => { type: "string" }}
35
+ :request_hybrid_additional_properties, # @param [Hash] - {"meta" => { type: "string" }}
31
36
  keyword_init: true,
32
37
  )
@@ -11,48 +11,34 @@ class << RSpec::OpenAPI::RecordBuilder = Object.new
11
11
  request, response = extractor.request_response(context)
12
12
  return if request.nil?
13
13
 
14
+ attributes = extractor.request_attributes(request, example)
15
+ return if RSpec::OpenAPI.ignored_paths.any? { |ignored_path| attributes[:path].match?(ignored_path) }
16
+
14
17
  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,
16
- description, security, deprecated, formats, example_mode, example_key,
17
- example_name, response_enum, request_enum = extractor.request_attributes(request, example)
18
+ build_record(title, request, response, attributes)
19
+ end
18
20
 
19
- return if RSpec::OpenAPI.ignored_paths.any? { |ignored_path| path.match?(ignored_path) }
21
+ private
20
22
 
23
+ def build_record(title, request, response, attributes)
21
24
  request_headers, response_headers = extract_headers(request, response)
22
-
23
25
  RSpec::OpenAPI::Record.new(
24
26
  title: title,
25
27
  http_method: request.method,
26
- path: path,
27
- path_params: raw_path_params,
28
28
  query_params: request.query_parameters,
29
29
  request_params: raw_request_params(request),
30
- required_request_params: required_request_params,
31
30
  request_content_type: request.media_type,
32
31
  request_headers: request_headers,
33
- summary: summary,
34
- tags: tags,
35
- formats: formats,
36
- operation_id: operation_id,
37
- description: description,
38
- security: security,
39
- deprecated: deprecated,
40
32
  status: response.status,
41
33
  response_body: safe_parse_body(response, response.media_type),
42
34
  response_headers: response_headers,
43
35
  response_content_type: response.media_type,
44
36
  response_content_disposition: response.header['Content-Disposition'],
45
37
  example_enabled: RSpec::OpenAPI.enable_example,
46
- example_mode: example_mode,
47
- example_key: example_key,
48
- example_name: example_name,
49
- response_enum: response_enum,
50
- request_enum: request_enum,
38
+ **attributes,
51
39
  ).freeze
52
40
  end
53
41
 
54
- private
55
-
56
42
  def safe_parse_body(response, media_type)
57
43
  # Use raw body, because Nokogiri-parsed HTML are modified (new lines injection, meta injection, and so on) :(
58
44
  return response.body if media_type == 'text/html'
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ class << RSpec::OpenAPI::SchemaBuilder
4
+ # Lookup context threaded through schema building. Bundles the metadata
5
+ # needed to resolve formats, enums, and additionalProperties overrides for
6
+ # a value at a given path under a record's request/response side.
7
+ BuildContext = Struct.new(:record, :context, :path, :key, keyword_init: true) do
8
+ def descend(child_key)
9
+ self.class.new(
10
+ record: record, context: context, key: child_key,
11
+ path: path ? "#{path}.#{child_key}" : child_key.to_s,
12
+ )
13
+ end
14
+
15
+ def for_array_items
16
+ self.class.new(record: record, context: context, path: path, key: nil)
17
+ end
18
+ end
19
+ private_constant :BuildContext
20
+ end