rspec-openapi 0.25.0 → 0.26.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 +4 -4
- data/.github/workflows/create_release.yml +1 -1
- data/.github/workflows/publish.yml +1 -1
- data/.github/workflows/test.yml +1 -1
- data/.github/workflows/validate-openapi.yml +21 -0
- data/.gitignore +2 -0
- data/.rubocop_todo.yml +1 -1
- data/README.md +337 -36
- data/lib/rspec/openapi/components_updater.rb +21 -8
- data/lib/rspec/openapi/extractors/hanami.rb +17 -21
- data/lib/rspec/openapi/extractors/rack.rb +11 -3
- data/lib/rspec/openapi/extractors/rails.rb +16 -3
- data/lib/rspec/openapi/extractors/shared_extractor.rb +82 -5
- data/lib/rspec/openapi/record.rb +6 -1
- data/lib/rspec/openapi/record_builder.rb +11 -3
- data/lib/rspec/openapi/schema_builder.rb +105 -27
- data/lib/rspec/openapi/schema_cleaner.rb +5 -0
- data/lib/rspec/openapi/schema_merger.rb +15 -4
- data/lib/rspec/openapi/version.rb +1 -1
- data/lib/rspec/openapi.rb +1 -1
- data/redocly.yaml +31 -0
- data/rspec-openapi.gemspec +0 -1
- metadata +5 -3
|
@@ -38,13 +38,11 @@ end
|
|
|
38
38
|
InspectorAnalyzer = Inspector.new
|
|
39
39
|
|
|
40
40
|
# Add default parameter to load inspector before test cases run
|
|
41
|
-
|
|
41
|
+
Hanami::Slice::ClassMethods.prepend(Module.new do
|
|
42
42
|
def router(inspector: InspectorAnalyzer)
|
|
43
43
|
super
|
|
44
44
|
end
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
Hanami::Slice::ClassMethods.prepend(InspectorAnalyzerPrepender)
|
|
45
|
+
end)
|
|
48
46
|
|
|
49
47
|
# Extractor for hanami
|
|
50
48
|
class << RSpec::OpenAPI::Extractors::Hanami = Object.new
|
|
@@ -56,18 +54,21 @@ class << RSpec::OpenAPI::Extractors::Hanami = Object.new
|
|
|
56
54
|
|
|
57
55
|
return RSpec::OpenAPI::Extractors::Rack.request_attributes(request, example) unless route.routable?
|
|
58
56
|
|
|
59
|
-
summary, tags, formats, operation_id, required_request_params, security, description, deprecated,
|
|
60
|
-
|
|
57
|
+
summary, tags, formats, operation_id, required_request_params, security, description, deprecated,
|
|
58
|
+
request_example_mode, response_example_mode,
|
|
59
|
+
example_key, example_name, response_enum, request_enum, response_additional_properties,
|
|
60
|
+
request_additional_properties, response_hybrid_additional_properties,
|
|
61
|
+
request_hybrid_additional_properties = SharedExtractor.attributes(example)
|
|
61
62
|
|
|
62
63
|
path = request.path
|
|
63
64
|
|
|
64
65
|
raw_path_params = route.params
|
|
65
66
|
|
|
66
|
-
result = InspectorAnalyzer.call(request.method,
|
|
67
|
+
result = InspectorAnalyzer.call(request.method, replace_path_params(path, route, '/:%{key}'))
|
|
67
68
|
|
|
68
69
|
summary ||= result[:summary]
|
|
69
70
|
tags ||= result[:tags]
|
|
70
|
-
path =
|
|
71
|
+
path = replace_path_params(path, route, '/{%{key}}')
|
|
71
72
|
|
|
72
73
|
raw_path_params = raw_path_params.slice(*(raw_path_params.keys - RSpec::OpenAPI.ignored_path_params))
|
|
73
74
|
|
|
@@ -82,11 +83,16 @@ class << RSpec::OpenAPI::Extractors::Hanami = Object.new
|
|
|
82
83
|
security,
|
|
83
84
|
deprecated,
|
|
84
85
|
formats,
|
|
85
|
-
|
|
86
|
+
request_example_mode,
|
|
87
|
+
response_example_mode,
|
|
86
88
|
example_key,
|
|
87
89
|
example_name,
|
|
88
90
|
response_enum,
|
|
89
91
|
request_enum,
|
|
92
|
+
response_additional_properties,
|
|
93
|
+
request_additional_properties,
|
|
94
|
+
response_hybrid_additional_properties,
|
|
95
|
+
request_hybrid_additional_properties,
|
|
90
96
|
]
|
|
91
97
|
end
|
|
92
98
|
|
|
@@ -101,21 +107,11 @@ class << RSpec::OpenAPI::Extractors::Hanami = Object.new
|
|
|
101
107
|
|
|
102
108
|
private
|
|
103
109
|
|
|
104
|
-
def
|
|
105
|
-
return path if route.params.empty?
|
|
106
|
-
|
|
107
|
-
route.params.each_pair do |key, value|
|
|
108
|
-
path = path.sub("/#{value}", "/:#{key}")
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
path
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def add_openapi_id(path, route)
|
|
110
|
+
def replace_path_params(path, route, format)
|
|
115
111
|
return path if route.params.empty?
|
|
116
112
|
|
|
117
113
|
route.params.each_pair do |key, value|
|
|
118
|
-
path = path.sub("/#{value}",
|
|
114
|
+
path = path.sub("/#{value}", format % { key: key })
|
|
119
115
|
end
|
|
120
116
|
|
|
121
117
|
path
|
|
@@ -6,8 +6,11 @@ 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
|
-
summary, tags, formats, operation_id, required_request_params, security, description, deprecated,
|
|
10
|
-
|
|
9
|
+
summary, tags, formats, operation_id, required_request_params, security, description, deprecated,
|
|
10
|
+
request_example_mode, response_example_mode,
|
|
11
|
+
example_key, example_name, response_enum, request_enum, response_additional_properties,
|
|
12
|
+
request_additional_properties, response_hybrid_additional_properties,
|
|
13
|
+
request_hybrid_additional_properties = SharedExtractor.attributes(example)
|
|
11
14
|
|
|
12
15
|
raw_path_params = request.path_parameters
|
|
13
16
|
path = request.path
|
|
@@ -24,11 +27,16 @@ class << RSpec::OpenAPI::Extractors::Rack = Object.new
|
|
|
24
27
|
security,
|
|
25
28
|
deprecated,
|
|
26
29
|
formats,
|
|
27
|
-
|
|
30
|
+
request_example_mode,
|
|
31
|
+
response_example_mode,
|
|
28
32
|
example_key,
|
|
29
33
|
example_name,
|
|
30
34
|
response_enum,
|
|
31
35
|
request_enum,
|
|
36
|
+
response_additional_properties,
|
|
37
|
+
request_additional_properties,
|
|
38
|
+
response_hybrid_additional_properties,
|
|
39
|
+
request_hybrid_additional_properties,
|
|
32
40
|
]
|
|
33
41
|
end
|
|
34
42
|
|
|
@@ -16,8 +16,11 @@ 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,
|
|
20
|
-
|
|
19
|
+
summary, tags, formats, operation_id, required_request_params, security, description, deprecated,
|
|
20
|
+
request_example_mode, response_example_mode,
|
|
21
|
+
example_key, example_name, response_enum, request_enum, response_additional_properties,
|
|
22
|
+
request_additional_properties, response_hybrid_additional_properties,
|
|
23
|
+
request_hybrid_additional_properties = SharedExtractor.attributes(example)
|
|
21
24
|
|
|
22
25
|
raw_path_params = request.path_parameters
|
|
23
26
|
|
|
@@ -40,16 +43,26 @@ class << RSpec::OpenAPI::Extractors::Rails = Object.new
|
|
|
40
43
|
security,
|
|
41
44
|
deprecated,
|
|
42
45
|
formats,
|
|
43
|
-
|
|
46
|
+
request_example_mode,
|
|
47
|
+
response_example_mode,
|
|
44
48
|
example_key,
|
|
45
49
|
example_name,
|
|
46
50
|
response_enum,
|
|
47
51
|
request_enum,
|
|
52
|
+
response_additional_properties,
|
|
53
|
+
request_additional_properties,
|
|
54
|
+
response_hybrid_additional_properties,
|
|
55
|
+
request_hybrid_additional_properties,
|
|
48
56
|
]
|
|
49
57
|
end
|
|
50
58
|
|
|
51
59
|
# @param [RSpec::ExampleGroups::*] context
|
|
52
60
|
def request_response(context)
|
|
61
|
+
# Read @integration_session directly so user-defined let(:request)/let(:response)
|
|
62
|
+
# don't shadow the real ActionDispatch objects we need for OpenAPI extraction.
|
|
63
|
+
session = context.instance_variable_get(:@integration_session)
|
|
64
|
+
return [session.request, session.response] if session
|
|
65
|
+
|
|
53
66
|
[context.request, context.response]
|
|
54
67
|
end
|
|
55
68
|
|
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
class SharedExtractor
|
|
5
5
|
VALID_EXAMPLE_MODES = %i[none single multiple].freeze
|
|
6
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
|
|
14
|
+
|
|
7
15
|
def self.attributes(example)
|
|
8
16
|
metadata = merge_openapi_metadata(example.metadata)
|
|
9
17
|
summary = metadata[:summary] || RSpec::OpenAPI.summary_builder.call(example)
|
|
@@ -14,7 +22,7 @@ class SharedExtractor
|
|
|
14
22
|
security = metadata[:security]
|
|
15
23
|
description = metadata[:description] || RSpec::OpenAPI.description_builder.call(example)
|
|
16
24
|
deprecated = metadata[:deprecated]
|
|
17
|
-
|
|
25
|
+
request_example_mode, response_example_mode = normalize_example_mode(metadata[:example_mode], example)
|
|
18
26
|
example_name = metadata[:example_name] || RSpec::OpenAPI.example_name_builder.call(example)
|
|
19
27
|
raw_example_key = metadata[:example_key] || example_name
|
|
20
28
|
example_key = RSpec::OpenAPI::ExampleKey.normalize(raw_example_key)
|
|
@@ -25,8 +33,29 @@ class SharedExtractor
|
|
|
25
33
|
response_enum = normalize_enum(metadata[:response_enum]) || base_enum
|
|
26
34
|
request_enum = normalize_enum(metadata[:request_enum]) || base_enum
|
|
27
35
|
|
|
28
|
-
|
|
29
|
-
|
|
36
|
+
response_additional_properties, request_additional_properties = resolve_additional_properties(metadata)
|
|
37
|
+
response_hybrid_additional_properties, request_hybrid_additional_properties =
|
|
38
|
+
resolve_hybrid_additional_properties(metadata)
|
|
39
|
+
|
|
40
|
+
[summary, tags, formats, operation_id, required_request_params, security, description, deprecated,
|
|
41
|
+
request_example_mode, response_example_mode,
|
|
42
|
+
example_key, example_name, response_enum, request_enum, response_additional_properties,
|
|
43
|
+
request_additional_properties, response_hybrid_additional_properties,
|
|
44
|
+
request_hybrid_additional_properties,]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.resolve_additional_properties(metadata)
|
|
48
|
+
base = normalize_additional_properties(metadata[:additional_properties])
|
|
49
|
+
response = normalize_additional_properties(metadata[:response_additional_properties]) || base
|
|
50
|
+
request = normalize_additional_properties(metadata[:request_additional_properties]) || base
|
|
51
|
+
[response, request]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.resolve_hybrid_additional_properties(metadata)
|
|
55
|
+
base = normalize_additional_properties(metadata[:hybrid_additional_properties])
|
|
56
|
+
response = normalize_additional_properties(metadata[:response_hybrid_additional_properties]) || base
|
|
57
|
+
request = normalize_additional_properties(metadata[:request_hybrid_additional_properties]) || base
|
|
58
|
+
[response, request]
|
|
30
59
|
end
|
|
31
60
|
|
|
32
61
|
def self.normalize_enum(enum_hash)
|
|
@@ -36,6 +65,14 @@ class SharedExtractor
|
|
|
36
65
|
enum_hash.transform_keys(&:to_s)
|
|
37
66
|
end
|
|
38
67
|
|
|
68
|
+
def self.normalize_additional_properties(hash)
|
|
69
|
+
return nil if hash.nil? || hash.empty?
|
|
70
|
+
|
|
71
|
+
hash.each_with_object({}) do |(path, schema), result|
|
|
72
|
+
result[path.to_s] = RSpec::OpenAPI::KeyTransformer.symbolize(schema)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
39
76
|
def self.merge_openapi_metadata(metadata)
|
|
40
77
|
collect_openapi_metadata(metadata).reduce({}, &:merge)
|
|
41
78
|
end
|
|
@@ -54,9 +91,33 @@ class SharedExtractor
|
|
|
54
91
|
end
|
|
55
92
|
end
|
|
56
93
|
|
|
94
|
+
# Returns [request_mode, response_mode]. Accepts either a bare Symbol/String
|
|
95
|
+
# (applied to both sides, except :multiple which is treated as a backward-compat
|
|
96
|
+
# shorthand for { request: :single, response: :multiple } and emits a one-time
|
|
97
|
+
# deprecation warning) or a Hash with :request / :response keys.
|
|
57
98
|
def self.normalize_example_mode(value, example = nil)
|
|
58
|
-
return
|
|
99
|
+
return %i[single single] if value.nil?
|
|
59
100
|
|
|
101
|
+
case value
|
|
102
|
+
when Hash
|
|
103
|
+
[
|
|
104
|
+
normalize_example_mode_hash_value(value, :request, example),
|
|
105
|
+
normalize_example_mode_hash_value(value, :response, example),
|
|
106
|
+
]
|
|
107
|
+
when Symbol, String
|
|
108
|
+
mode = coerce_example_mode_value(value, example)
|
|
109
|
+
if mode == :multiple
|
|
110
|
+
warn_example_mode_multiple_shorthand
|
|
111
|
+
%i[single multiple]
|
|
112
|
+
else
|
|
113
|
+
[mode, mode]
|
|
114
|
+
end
|
|
115
|
+
else
|
|
116
|
+
raise ArgumentError, example_mode_error(value, example)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def self.coerce_example_mode_value(value, example)
|
|
60
121
|
raise ArgumentError, example_mode_error(value, example) unless value.is_a?(String) || value.is_a?(Symbol)
|
|
61
122
|
|
|
62
123
|
mode = value.to_s.strip.downcase.to_sym
|
|
@@ -65,9 +126,25 @@ class SharedExtractor
|
|
|
65
126
|
raise ArgumentError, example_mode_error(value, example)
|
|
66
127
|
end
|
|
67
128
|
|
|
129
|
+
def self.normalize_example_mode_hash_value(hash, key, example)
|
|
130
|
+
raw = hash[key]
|
|
131
|
+
raw = hash[key.to_s] if raw.nil?
|
|
132
|
+
return :single if raw.nil?
|
|
133
|
+
|
|
134
|
+
coerce_example_mode_value(raw, example)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def self.warn_example_mode_multiple_shorthand
|
|
138
|
+
return if @warned_example_mode_multiple_shorthand
|
|
139
|
+
|
|
140
|
+
@warned_example_mode_multiple_shorthand = true
|
|
141
|
+
Kernel.warn(EXAMPLE_MODE_MULTIPLE_SHORTHAND_WARNING)
|
|
142
|
+
end
|
|
143
|
+
|
|
68
144
|
def self.example_mode_error(value, example)
|
|
69
145
|
context = example&.full_description
|
|
70
146
|
context = " (example: #{context})" if context
|
|
71
|
-
"example_mode must be
|
|
147
|
+
"example_mode must be a Symbol/String in #{VALID_EXAMPLE_MODES.inspect} " \
|
|
148
|
+
"or a Hash with :request/:response keys, got #{value.inspect}#{context}"
|
|
72
149
|
end
|
|
73
150
|
end
|
data/lib/rspec/openapi/record.rb
CHANGED
|
@@ -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
|
-
:
|
|
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
|
)
|
|
@@ -12,8 +12,11 @@ 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,
|
|
16
|
-
|
|
15
|
+
path, summary, tags, operation_id, required_request_params, raw_path_params,
|
|
16
|
+
description, security, deprecated, formats, request_example_mode, response_example_mode,
|
|
17
|
+
example_key, example_name, response_enum, request_enum, response_additional_properties,
|
|
18
|
+
request_additional_properties, response_hybrid_additional_properties,
|
|
19
|
+
request_hybrid_additional_properties = extractor.request_attributes(request, example)
|
|
17
20
|
|
|
18
21
|
return if RSpec::OpenAPI.ignored_paths.any? { |ignored_path| path.match?(ignored_path) }
|
|
19
22
|
|
|
@@ -42,11 +45,16 @@ class << RSpec::OpenAPI::RecordBuilder = Object.new
|
|
|
42
45
|
response_content_type: response.media_type,
|
|
43
46
|
response_content_disposition: response.header['Content-Disposition'],
|
|
44
47
|
example_enabled: RSpec::OpenAPI.enable_example,
|
|
45
|
-
|
|
48
|
+
request_example_mode: request_example_mode,
|
|
49
|
+
response_example_mode: response_example_mode,
|
|
46
50
|
example_key: example_key,
|
|
47
51
|
example_name: example_name,
|
|
48
52
|
response_enum: response_enum,
|
|
49
53
|
request_enum: request_enum,
|
|
54
|
+
response_additional_properties: response_additional_properties,
|
|
55
|
+
request_additional_properties: request_additional_properties,
|
|
56
|
+
response_hybrid_additional_properties: response_hybrid_additional_properties,
|
|
57
|
+
request_hybrid_additional_properties: request_hybrid_additional_properties,
|
|
50
58
|
).freeze
|
|
51
59
|
end
|
|
52
60
|
|
|
@@ -4,9 +4,15 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
4
4
|
# @param [RSpec::OpenAPI::Record] record
|
|
5
5
|
# @return [Hash]
|
|
6
6
|
def build(record)
|
|
7
|
-
response =
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
response = if record.response_example_mode == :none
|
|
8
|
+
# `:none` opts out of recording, so the description is provisional.
|
|
9
|
+
# Stash it under a fallback key; SchemaCleaner promotes it to
|
|
10
|
+
# `description` only if no documented test has set one. This makes
|
|
11
|
+
# the merge result independent of RSpec's random execution order.
|
|
12
|
+
{ _fallback_description: record.description }
|
|
13
|
+
else
|
|
14
|
+
{ description: record.description }
|
|
15
|
+
end
|
|
10
16
|
|
|
11
17
|
response_headers = build_response_headers(record)
|
|
12
18
|
response[:headers] = response_headers unless response_headers.empty?
|
|
@@ -52,7 +58,7 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
52
58
|
# If examples are globally disabled, always return schema-only content.
|
|
53
59
|
return { content_type => { schema: schema }.compact } unless example_enabled?(record)
|
|
54
60
|
|
|
55
|
-
case record.
|
|
61
|
+
case record.response_example_mode
|
|
56
62
|
when :none
|
|
57
63
|
# Only schema, no examples
|
|
58
64
|
{
|
|
@@ -74,8 +80,7 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
74
80
|
content_type => {
|
|
75
81
|
schema: schema,
|
|
76
82
|
example: response_example(record, disposition: disposition),
|
|
77
|
-
|
|
78
|
-
_example_summary: example_summary(record),
|
|
83
|
+
**example_metadata(record),
|
|
79
84
|
}.compact,
|
|
80
85
|
}
|
|
81
86
|
end
|
|
@@ -93,13 +98,21 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
93
98
|
end
|
|
94
99
|
|
|
95
100
|
def build_example_object(record, disposition:)
|
|
101
|
+
build_named_example(record, response_example(record, disposition: disposition))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def build_named_example(record, value)
|
|
96
105
|
summary = example_summary(record)
|
|
97
106
|
example = {}
|
|
98
107
|
example[:summary] = summary if summary
|
|
99
|
-
example[:value] =
|
|
108
|
+
example[:value] = value
|
|
100
109
|
example
|
|
101
110
|
end
|
|
102
111
|
|
|
112
|
+
def example_metadata(record)
|
|
113
|
+
{ _example_key: record.example_key, _example_summary: example_summary(record) }
|
|
114
|
+
end
|
|
115
|
+
|
|
103
116
|
def example_summary(record)
|
|
104
117
|
return nil unless example_summary_enabled?
|
|
105
118
|
return nil if record.example_name.nil? || record.example_name.empty?
|
|
@@ -191,16 +204,33 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
191
204
|
|
|
192
205
|
def build_request_body(record)
|
|
193
206
|
return nil if record.request_content_type.nil?
|
|
194
|
-
return nil if record.status >= 400
|
|
207
|
+
return nil if record.status >= 400 && record.request_example_mode != :multiple
|
|
195
208
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
209
|
+
content_type = normalize_content_type(record.request_content_type)
|
|
210
|
+
schema = build_property(record.request_params, record: record, context: :request)
|
|
211
|
+
|
|
212
|
+
return { content: { content_type => { schema: schema }.compact } } unless example_enabled?(record)
|
|
213
|
+
|
|
214
|
+
example = build_example(record.request_params)
|
|
215
|
+
|
|
216
|
+
body =
|
|
217
|
+
case record.request_example_mode
|
|
218
|
+
when :none
|
|
219
|
+
{ schema: schema }
|
|
220
|
+
when :multiple
|
|
221
|
+
{
|
|
222
|
+
schema: schema,
|
|
223
|
+
examples: { record.example_key => build_named_example(record, example) },
|
|
224
|
+
}
|
|
225
|
+
else # :single (default)
|
|
226
|
+
{
|
|
227
|
+
schema: schema,
|
|
228
|
+
example: example,
|
|
229
|
+
**example_metadata(record),
|
|
230
|
+
}
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
{ content: { content_type => body.compact } }
|
|
204
234
|
end
|
|
205
235
|
|
|
206
236
|
def build_property(value, disposition: nil, key: nil, record: nil, path: nil, context: nil)
|
|
@@ -217,13 +247,32 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
217
247
|
build_array_items_schema(value, record: record, path: path, context: context)
|
|
218
248
|
end
|
|
219
249
|
when Hash
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
250
|
+
override = infer_additional_properties(path, record, context)
|
|
251
|
+
hybrid_override = infer_hybrid_additional_properties(path, record, context)
|
|
252
|
+
if override.is_a?(Hash) && !override.empty?
|
|
253
|
+
# Schema override: the object's keys are dynamic — replace captured
|
|
254
|
+
# `properties` / `required` with the supplied dictionary value schema.
|
|
255
|
+
property[:additionalProperties] = override
|
|
256
|
+
else
|
|
257
|
+
property[:properties] = {}.tap do |properties|
|
|
258
|
+
value.each do |k, v|
|
|
259
|
+
child_path = path ? "#{path}.#{k}" : k.to_s
|
|
260
|
+
properties[k] = build_property(v, record: record, key: k, path: child_path, context: context)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
property = enrich_with_required_keys(property)
|
|
264
|
+
# Hybrid: keep the observed `properties` / `required` and attach
|
|
265
|
+
# `additionalProperties` alongside.
|
|
266
|
+
# - Boolean values are constraints (`false` forbids extras, `true`
|
|
267
|
+
# explicitly allows them).
|
|
268
|
+
# - Hash schema values come from the dedicated `hybrid_additional_properties`
|
|
269
|
+
# metadata, expressing "known keys + extras of this type".
|
|
270
|
+
if override == true || override == false
|
|
271
|
+
property[:additionalProperties] = override
|
|
272
|
+
elsif hybrid_override.is_a?(Hash) && !hybrid_override.empty?
|
|
273
|
+
property[:additionalProperties] = hybrid_override
|
|
224
274
|
end
|
|
225
275
|
end
|
|
226
|
-
property = enrich_with_required_keys(property)
|
|
227
276
|
end
|
|
228
277
|
property
|
|
229
278
|
end
|
|
@@ -270,8 +319,38 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
270
319
|
enum_hash = context == :request ? record.request_enum : record.response_enum
|
|
271
320
|
return nil unless enum_hash
|
|
272
321
|
|
|
273
|
-
#
|
|
274
|
-
enum_hash[path.to_s]
|
|
322
|
+
# Keys are already normalized to strings by SharedExtractor.normalize_enum
|
|
323
|
+
enum_hash[path.to_s]
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def infer_additional_properties(path, record, context)
|
|
327
|
+
return nil unless record
|
|
328
|
+
|
|
329
|
+
overrides = if context == :request
|
|
330
|
+
record.request_additional_properties
|
|
331
|
+
else
|
|
332
|
+
record.response_additional_properties
|
|
333
|
+
end
|
|
334
|
+
return nil unless overrides
|
|
335
|
+
|
|
336
|
+
# path is nil at the body root; nil.to_s == '' lets users target it via { '' => ... }.
|
|
337
|
+
# Use `key?` so a literal `false` override is distinguishable from "no override".
|
|
338
|
+
return nil unless overrides.key?(path.to_s)
|
|
339
|
+
|
|
340
|
+
overrides[path.to_s]
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def infer_hybrid_additional_properties(path, record, context)
|
|
344
|
+
return nil unless record
|
|
345
|
+
|
|
346
|
+
overrides = if context == :request
|
|
347
|
+
record.request_hybrid_additional_properties
|
|
348
|
+
else
|
|
349
|
+
record.response_hybrid_additional_properties
|
|
350
|
+
end
|
|
351
|
+
return nil unless overrides
|
|
352
|
+
|
|
353
|
+
overrides[path.to_s]
|
|
275
354
|
end
|
|
276
355
|
|
|
277
356
|
# Convert an always-String param to an appropriate type
|
|
@@ -319,14 +398,13 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
|
|
|
319
398
|
content_type&.sub(/;.+\z/, '')
|
|
320
399
|
end
|
|
321
400
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
end
|
|
401
|
+
# Same logic as normalize_content_type – strips header parameters after ';'
|
|
402
|
+
alias normalize_content_disposition normalize_content_type
|
|
325
403
|
|
|
326
404
|
def build_array_items_schema(array, record: nil, path: nil, context: nil)
|
|
327
405
|
return {} if array.empty?
|
|
328
406
|
return build_property(array.first, record: record, path: path, context: context) if array.size == 1
|
|
329
|
-
return build_property(array.first, record: record, path: path, context: context) unless array.all?
|
|
407
|
+
return build_property(array.first, record: record, path: path, context: context) unless array.all?(Hash)
|
|
330
408
|
|
|
331
409
|
all_schemas = array.map { |item| build_property(item, record: record, path: path, context: context) }
|
|
332
410
|
merged_schema = all_schemas.first.dup
|
|
@@ -70,6 +70,8 @@ class << RSpec::OpenAPI::SchemaCleaner = Object.new
|
|
|
70
70
|
]
|
|
71
71
|
paths_to_objects.each do |path|
|
|
72
72
|
parent = base.dig(*path.take(path.length - 1))
|
|
73
|
+
next unless parent
|
|
74
|
+
|
|
73
75
|
# "required" array must not be present if empty
|
|
74
76
|
parent.delete(:required) if parent[:required] && parent[:required].empty?
|
|
75
77
|
end
|
|
@@ -84,6 +86,9 @@ class << RSpec::OpenAPI::SchemaCleaner = Object.new
|
|
|
84
86
|
hash.delete(:_example_key)
|
|
85
87
|
hash.delete(:_example_summary)
|
|
86
88
|
hash.delete(:_example_name)
|
|
89
|
+
if (fallback = hash.delete(:_fallback_description))
|
|
90
|
+
hash[:description] ||= fallback
|
|
91
|
+
end
|
|
87
92
|
|
|
88
93
|
hash.each_value do |value|
|
|
89
94
|
case value
|
|
@@ -9,6 +9,8 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
|
|
|
9
9
|
merge_schema!(base, spec)
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
+
SIMILARITY_THRESHOLD = 0.5
|
|
13
|
+
|
|
12
14
|
private
|
|
13
15
|
|
|
14
16
|
# Not doing `base.replace(deep_merge(base, spec))` to preserve key orders.
|
|
@@ -23,6 +25,17 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
|
|
|
23
25
|
return base
|
|
24
26
|
end
|
|
25
27
|
|
|
28
|
+
# When the new spec converts an object to a dictionary (introduces
|
|
29
|
+
# `additionalProperties` on a node that previously had `properties` /
|
|
30
|
+
# `required`), drop the stale fields so the merged result reflects the
|
|
31
|
+
# new intent. We only prune when base does not already declare
|
|
32
|
+
# `additionalProperties`, to preserve manual edits that intentionally
|
|
33
|
+
# combine fixed and dynamic keys.
|
|
34
|
+
if spec.is_a?(Hash) && spec.key?(:additionalProperties) && !base.key?(:additionalProperties)
|
|
35
|
+
base.delete(:properties)
|
|
36
|
+
base.delete(:required)
|
|
37
|
+
end
|
|
38
|
+
|
|
26
39
|
spec.each do |key, value|
|
|
27
40
|
if base[key].is_a?(Hash) && value.is_a?(Hash)
|
|
28
41
|
# Handle example/examples conflict - convert to examples when mixed
|
|
@@ -121,13 +134,11 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
|
|
|
121
134
|
end
|
|
122
135
|
|
|
123
136
|
def build_unique_params(base, key)
|
|
124
|
-
base[key].
|
|
125
|
-
|
|
137
|
+
base[key].to_h do |parameter|
|
|
138
|
+
[[parameter[:name], parameter[:in]], parameter]
|
|
126
139
|
end
|
|
127
140
|
end
|
|
128
141
|
|
|
129
|
-
SIMILARITY_THRESHOLD = 0.5
|
|
130
|
-
|
|
131
142
|
# Normalize example/examples fields when there's a conflict
|
|
132
143
|
# OpenAPI spec doesn't allow both example and examples in the same object
|
|
133
144
|
def normalize_example_fields!(base, spec)
|
data/lib/rspec/openapi.rb
CHANGED
|
@@ -33,7 +33,7 @@ module RSpec::OpenAPI
|
|
|
33
33
|
@comment = nil
|
|
34
34
|
@enable_example = true
|
|
35
35
|
@enable_example_summary = true
|
|
36
|
-
@description_builder =
|
|
36
|
+
@description_builder = :description.to_proc
|
|
37
37
|
@example_name_builder = :description.to_proc
|
|
38
38
|
@summary_builder = ->(example) { example.metadata[:summary] }
|
|
39
39
|
@tags_builder = ->(example) { example.metadata[:tags] }
|
data/redocly.yaml
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Config for `redocly lint` used by .github/workflows/validate-openapi.yml
|
|
2
|
+
# to validate test-fixture OpenAPI documents shipped under spec/apps/.
|
|
3
|
+
#
|
|
4
|
+
# spec/apps/rails/doc/smart/openapi.yaml is intentionally excluded:
|
|
5
|
+
# it is the input fixture for the smart-merge feature test (it contains
|
|
6
|
+
# unresolved $refs by design), not a complete API description.
|
|
7
|
+
extends:
|
|
8
|
+
- minimal
|
|
9
|
+
apis:
|
|
10
|
+
rails-rspec-yaml:
|
|
11
|
+
root: spec/apps/rails/doc/rspec_openapi.yaml
|
|
12
|
+
rails-rspec-json:
|
|
13
|
+
root: spec/apps/rails/doc/rspec_openapi.json
|
|
14
|
+
rails-minitest-yaml:
|
|
15
|
+
root: spec/apps/rails/doc/minitest_openapi.yaml
|
|
16
|
+
rails-minitest-json:
|
|
17
|
+
root: spec/apps/rails/doc/minitest_openapi.json
|
|
18
|
+
rails-smart-expected:
|
|
19
|
+
root: spec/apps/rails/doc/smart/expected.yaml
|
|
20
|
+
roda-rspec-yaml:
|
|
21
|
+
root: spec/apps/roda/doc/rspec_openapi.yaml
|
|
22
|
+
roda-rspec-json:
|
|
23
|
+
root: spec/apps/roda/doc/rspec_openapi.json
|
|
24
|
+
roda-minitest-yaml:
|
|
25
|
+
root: spec/apps/roda/doc/minitest_openapi.yaml
|
|
26
|
+
roda-minitest-json:
|
|
27
|
+
root: spec/apps/roda/doc/minitest_openapi.json
|
|
28
|
+
hanami-yaml:
|
|
29
|
+
root: spec/apps/hanami/doc/openapi.yaml
|
|
30
|
+
hanami-json:
|
|
31
|
+
root: spec/apps/hanami/doc/openapi.json
|