rspec-openapi 0.6.1 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d9529f01aae2b2d1c77050b4a01f299c77a285057aa6fc7088a69ee7d2638e61
4
- data.tar.gz: ca00d5561aaa0faeaadc1d42e7d2a25f729567b21af001c9677675e3738ae195
3
+ metadata.gz: 0ce13b09ac314ce43bb224b5c8ff1260f283f2a415032418c3908ac51a4a6b8b
4
+ data.tar.gz: dc9aaa1fe4d3f1228bed06e37cf1b26e87e045b3666b244514587204b31a8b78
5
5
  SHA512:
6
- metadata.gz: 5de0ee281ad93e679c8f5456769fc1569f51ebfe5408c884d7061f9c802e3f8578ad48799a71306a6ce7cf0aa76959b2e30e2c97956e98237d08745c13d7aa54
7
- data.tar.gz: 0a0076cedb0633762235833ead555db74720fad22c9f03fa84a7cdeaba617a823117a508c5acdd2e518ac1660582849c94ab68ccaed629e8f109ecfefe64227c
6
+ metadata.gz: 37d15147e73665c3dd236e971623a4a7a2feafeb08042191d42952e2b46a0526617bdbce87b2294d62856fc96730a92cee538bd408ea8218190ec8d90c7a151f
7
+ data.tar.gz: 209e5b659ccaa36c63673314172f1d05e67d1a3d879afb2cb5907d12500dc2fa18c52c42bfc9cb0193d0e4c13a85649f9930b91e52339a5ffa900183aa400be3
data/README.md CHANGED
@@ -137,9 +137,12 @@ RSpec::OpenAPI.info = {
137
137
  }
138
138
  }
139
139
 
140
- # Set `headers` - generate parameters with headers for a request
140
+ # Set request `headers` - generate parameters with headers for a request
141
141
  RSpec::OpenAPI.request_headers = %w[X-Authorization-Token]
142
142
 
143
+ # Set response `headers` - generate parameters with headers for a response
144
+ RSpec::OpenAPI.response_headers = %w[X-Cursor]
145
+
143
146
  # Set `servers` - generate servers of a schema file
144
147
  RSpec::OpenAPI.servers = [{ url: 'http://localhost:3000' }]
145
148
 
@@ -158,6 +161,111 @@ RSpec::OpenAPI.description_builder = -> (example) { example.description }
158
161
  RSpec::OpenAPI.example_types = %i[request]
159
162
  ```
160
163
 
164
+ ### Can I use rspec-openapi with `$ref` to minimize duplication of schema?
165
+
166
+ Yes, rspec-openapi v0.7.0+ supports [`$ref` mechanism](https://swagger.io/docs/specification/using-ref/) and generates
167
+ schemas under `#/components/schemas` with some manual steps.
168
+
169
+ 1. First, generate plain OpenAPI file.
170
+ 2. Then, manually replace the duplications with `$ref`.
171
+
172
+ ```yaml
173
+ paths:
174
+ "/users":
175
+ get:
176
+ responses:
177
+ '200':
178
+ content:
179
+ application/json:
180
+ schema:
181
+ type: array
182
+ items:
183
+ $ref: "#/components/schemas/User"
184
+ "/users/{id}":
185
+ get:
186
+ responses:
187
+ '200':
188
+ content:
189
+ application/json:
190
+ schema:
191
+ $ref: "#/components/schemas/User"
192
+ # Note) #/components/schamas is not needed to be defined.
193
+ ```
194
+
195
+ 3. Then, re-run rspec-openapi. It will generate `#/components/schemas` with the referenced schema (`User` for example) newly-generated or updated.
196
+
197
+ ```yaml
198
+ paths:
199
+ "/users":
200
+ get:
201
+ responses:
202
+ '200':
203
+ content:
204
+ application/json:
205
+ schema:
206
+ type: array
207
+ items:
208
+ $ref: "#/components/schemas/User"
209
+ "/users/{id}":
210
+ get:
211
+ responses:
212
+ '200':
213
+ content:
214
+ application/json:
215
+ schema:
216
+ $ref: "#/components/schemas/User"
217
+ components:
218
+ schemas:
219
+ User:
220
+ type: object
221
+ properties:
222
+ id:
223
+ type: string
224
+ name:
225
+ type: string
226
+ role:
227
+ type: array
228
+ items:
229
+ type: string
230
+ ```
231
+
232
+ rspec-openapi also supports `$ref` in `properties` of schemas. Example)
233
+
234
+ ```yaml
235
+ paths:
236
+ "/locations":
237
+ get:
238
+ responses:
239
+ '200':
240
+ content:
241
+ application/json:
242
+ schema:
243
+ type: array
244
+ items:
245
+ $ref: "#/components/schemas/Location"
246
+ components:
247
+ schemas:
248
+ Location:
249
+ type: object
250
+ properties:
251
+ id:
252
+ type: string
253
+ name:
254
+ type: string
255
+ Coordinate:
256
+ "$ref": "#/components/schemas/Coordinate"
257
+ Coordinate:
258
+ type: object
259
+ properties:
260
+ lat:
261
+ type: string
262
+ lon:
263
+ type: string
264
+ ```
265
+
266
+ Note that automatic `schemas` update feature is still new and may not work in complex scenario.
267
+ If you find a room for improvement, open an issue.
268
+
161
269
  ### How can I add information which can't be generated from RSpec?
162
270
 
163
271
  rspec-openapi tries to keep manual modifications as much as possible when generating specs.
@@ -0,0 +1,65 @@
1
+ require_relative 'hash_helper'
2
+
3
+ class << RSpec::OpenAPI::ComponentsUpdater = Object.new
4
+ # @param [Hash] base
5
+ # @param [Hash] fresh
6
+ def update!(base, fresh)
7
+ # Top-level schema: Used as the body of request or response
8
+ top_level_refs = paths_to_top_level_refs(base)
9
+ return if top_level_refs.empty?
10
+ fresh_schemas = build_fresh_schemas(top_level_refs, base, fresh)
11
+
12
+ # Nested schema: References in Top-level schemas. May contain some top-level schema.
13
+ generated_schema_names = fresh_schemas.keys
14
+ nested_refs = find_non_top_level_nested_refs(base, generated_schema_names)
15
+ nested_refs.each do |paths|
16
+ parent_name = paths[-4]
17
+ property_name = paths[-2]
18
+ nested_schema = fresh_schemas.dig(parent_name, 'properties', property_name)
19
+
20
+ # Skip if the property using $ref is not found in the parent schema. The property may be removed.
21
+ next if nested_schema.nil?
22
+
23
+ schema_name = base.dig(*paths)&.gsub('#/components/schemas/', '')
24
+ fresh_schemas[schema_name] ||= {}
25
+ RSpec::OpenAPI::SchemaMerger.merge!(fresh_schemas[schema_name], nested_schema)
26
+ end
27
+
28
+ RSpec::OpenAPI::SchemaMerger.merge!(base, { 'components' => { 'schemas' => fresh_schemas }})
29
+ RSpec::OpenAPI::SchemaCleaner.cleanup_components_schemas!(base, { 'components' => { 'schemas' => fresh_schemas } })
30
+ end
31
+
32
+ private
33
+
34
+ def build_fresh_schemas(references, base, fresh)
35
+ references.inject({}) do |acc, paths|
36
+ ref_link = dig_schema(base, paths).dig('$ref')
37
+ schema_name = ref_link.gsub('#/components/schemas/', '')
38
+ schema_body = dig_schema(fresh, paths)
39
+ RSpec::OpenAPI::SchemaMerger.merge!(acc, { schema_name => schema_body })
40
+ end
41
+ end
42
+
43
+ def dig_schema(obj, paths)
44
+ obj.dig(*paths, 'schema', 'items') || obj.dig(*paths, 'schema')
45
+ end
46
+
47
+ def paths_to_top_level_refs(base)
48
+ request_bodies = RSpec::OpenAPI::HashHelper::matched_paths(base, 'paths.*.*.requestBody.content.application/json')
49
+ responses = RSpec::OpenAPI::HashHelper::matched_paths(base, 'paths.*.*.responses.*.content.application/json')
50
+ (request_bodies + responses).select do |paths|
51
+ dig_schema(base, paths)&.dig('$ref')&.start_with?('#/components/schemas/')
52
+ end
53
+ end
54
+
55
+ def find_non_top_level_nested_refs(base, generated_names)
56
+ nested_refs = RSpec::OpenAPI::HashHelper::matched_paths(base, 'components.schemas.*.properties.*.$ref')
57
+
58
+ # Reject already-generated schemas to reduce unnecessary loop
59
+ nested_refs.reject do |paths|
60
+ ref_link = base.dig(*paths)
61
+ schema_name = ref_link.gsub('#/components/schemas/', '')
62
+ generated_names.include?(schema_name)
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,23 @@
1
+ class << RSpec::OpenAPI::HashHelper = Object.new
2
+ def paths_to_all_fields(obj)
3
+ case obj
4
+ when Hash
5
+ obj.each.flat_map do |k, v|
6
+ k = k.to_s
7
+ [[k]] + paths_to_all_fields(v).map { |x| [k, *x] }
8
+ end
9
+ else
10
+ []
11
+ end
12
+ end
13
+
14
+ def matched_paths(obj, selector)
15
+ selector_parts = selector.split('.').map(&:to_s)
16
+ selectors = paths_to_all_fields(obj).select do |key_parts|
17
+ key_parts.size == selector_parts.size && key_parts.zip(selector_parts).all? do |kp, sp|
18
+ kp == sp || (sp == '*' && kp != nil)
19
+ end
20
+ end
21
+ selectors
22
+ end
23
+ end
@@ -1,9 +1,11 @@
1
1
  require 'rspec'
2
+ require 'rspec/openapi/components_updater'
2
3
  require 'rspec/openapi/default_schema'
3
4
  require 'rspec/openapi/record_builder'
4
5
  require 'rspec/openapi/schema_builder'
5
6
  require 'rspec/openapi/schema_file'
6
7
  require 'rspec/openapi/schema_merger'
8
+ require 'rspec/openapi/schema_cleaner'
7
9
 
8
10
  path_records = Hash.new { |h, k| h[k] = [] }
9
11
  error_records = {}
@@ -23,13 +25,18 @@ RSpec.configuration.after(:suite) do
23
25
  schema = RSpec::OpenAPI::DefaultSchema.build(title)
24
26
  schema[:info].merge!(RSpec::OpenAPI.info)
25
27
  RSpec::OpenAPI::SchemaMerger.merge!(spec, schema)
28
+ new_from_zero = {}
26
29
  records.each do |record|
27
30
  begin
28
- RSpec::OpenAPI::SchemaMerger.merge!(spec, RSpec::OpenAPI::SchemaBuilder.build(record))
31
+ record_schema = RSpec::OpenAPI::SchemaBuilder.build(record)
32
+ RSpec::OpenAPI::SchemaMerger.merge!(spec, record_schema)
33
+ RSpec::OpenAPI::SchemaMerger.merge!(new_from_zero, record_schema)
29
34
  rescue StandardError, NotImplementedError => e # e.g. SchemaBuilder raises a NotImplementedError
30
35
  error_records[e] = record # Avoid failing the build
31
36
  end
32
37
  end
38
+ RSpec::OpenAPI::SchemaCleaner.cleanup!(spec, new_from_zero)
39
+ RSpec::OpenAPI::ComponentsUpdater.update!(spec, new_from_zero)
33
40
  end
34
41
  end
35
42
  if error_records.any?
@@ -11,6 +11,7 @@ RSpec::OpenAPI::Record = Struct.new(
11
11
  :description, # @param [String] - "returns a status"
12
12
  :status, # @param [Integer] - 200
13
13
  :response_body, # @param [Object] - {"status" => "ok"}
14
+ :response_headers, # @param [Array] - [["header_key1", "header_value1"], ["header_key2", "header_value2"]]
14
15
  :response_content_type, # @param [String] - "application/json"
15
16
  :response_content_disposition, # @param [String] - "inline"
16
17
  keyword_init: true,
@@ -42,6 +42,12 @@ class << RSpec::OpenAPI::RecordBuilder = Object.new
42
42
 
43
43
  metadata_options = example.metadata[:openapi] || {}
44
44
 
45
+ response_headers = RSpec::OpenAPI.response_headers.each_with_object([]) do |header, headers_arr|
46
+ header_key = header
47
+ header_value = response.headers[header_key]
48
+ headers_arr << [header_key, header_value] if header_value
49
+ end
50
+
45
51
  RSpec::OpenAPI::Record.new(
46
52
  method: request.request_method,
47
53
  path: path,
@@ -55,6 +61,7 @@ class << RSpec::OpenAPI::RecordBuilder = Object.new
55
61
  description: metadata_options[:description] || RSpec::OpenAPI.description_builder.call(example),
56
62
  status: response.status,
57
63
  response_body: response_body,
64
+ response_headers: response_headers,
58
65
  response_content_type: response.media_type,
59
66
  response_content_disposition: response.header["Content-Disposition"],
60
67
  ).freeze
@@ -6,6 +6,9 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
6
6
  description: record.description,
7
7
  }
8
8
 
9
+ response_headers = build_response_headers(record)
10
+ response[:headers] = response_headers unless response_headers.empty?
11
+
9
12
  if record.response_body
10
13
  disposition = normalize_content_disposition(record.response_content_disposition)
11
14
  response[:content] = {
@@ -81,6 +84,18 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
81
84
  parameters
82
85
  end
83
86
 
87
+ def build_response_headers(record)
88
+ headers = {}
89
+
90
+ record.response_headers.each do |key, value|
91
+ headers[key] = {
92
+ schema: build_property(try_cast(value)),
93
+ }.compact
94
+ end
95
+
96
+ headers
97
+ end
98
+
84
99
  def build_parameter_name(key, value)
85
100
  key = key.to_s
86
101
  if value.is_a?(Hash) && (value_keys = value.keys).size == 1
@@ -110,7 +125,11 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
110
125
 
111
126
  case value
112
127
  when Array
113
- property[:items] = build_property(value.first)
128
+ if value.empty?
129
+ property[:items] = {} # unknown
130
+ else
131
+ property[:items] = build_property(value.first)
132
+ end
114
133
  when Hash
115
134
  property[:properties] = {}.tap do |properties|
116
135
  value.each do |key, v|
@@ -0,0 +1,86 @@
1
+ # For Ruby 3.0+
2
+ require 'set'
3
+
4
+ require_relative 'hash_helper'
5
+
6
+ class << RSpec::OpenAPI::SchemaCleaner = Object.new
7
+ # Cleanup the properties, of component schemas, that exists in the base but not in the spec.
8
+ #
9
+ # @param [Hash] base
10
+ # @param [Hash] spec
11
+ def cleanup_components_schemas!(base, spec)
12
+ cleanup_hash!(base, spec, 'components.schemas.*')
13
+ cleanup_hash!(base, spec, 'components.schemas.*.properties.*')
14
+ end
15
+
16
+ # Cleanup specific elements that exists in the base but not in the spec
17
+ #
18
+ # @param [Hash] base
19
+ # @param [Hash] spec
20
+ def cleanup!(base, spec)
21
+ # cleanup URLs
22
+ cleanup_hash!(base, spec, 'paths.*')
23
+
24
+ # cleanup HTTP methods
25
+ cleanup_hash!(base, spec, 'paths.*.*')
26
+
27
+ # cleanup parameters
28
+ cleanup_array!(base, spec, 'paths.*.*.parameters', %w[name in])
29
+
30
+ # cleanup requestBody
31
+ cleanup_hash!(base, spec, 'paths.*.*.requestBody.content.application/json.schema.properties.*')
32
+ cleanup_hash!(base, spec, 'paths.*.*.requestBody.content.application/json.example.*')
33
+
34
+ # cleanup responses
35
+ cleanup_hash!(base, spec, 'paths.*.*.responses.*.content.application/json.schema.properties.*')
36
+ cleanup_hash!(base, spec, 'paths.*.*.responses.*.content.application/json.example.*')
37
+ base
38
+ end
39
+
40
+ private
41
+
42
+ def cleanup_array!(base, spec, selector, fields_for_identity = [])
43
+ marshal = lambda do |obj|
44
+ Marshal.dump(slice(obj, fields_for_identity))
45
+ end
46
+
47
+ RSpec::OpenAPI::HashHelper::matched_paths(base, selector).each do |paths|
48
+ target_array = base.dig(*paths)
49
+ spec_array = spec.dig(*paths)
50
+ unless target_array.is_a?(Array) && spec_array.is_a?(Array)
51
+ next
52
+ end
53
+ spec_identities = Set.new(spec_array.map(&marshal))
54
+ target_array.select! { |e| spec_identities.include?(marshal.call(e)) }
55
+ target_array.sort_by! { |param| fields_for_identity.map {|f| param[f] }.join('-') }
56
+ # Keep the last duplicate to produce the result stably
57
+ deduplicated = target_array.reverse.uniq { |param| slice(param, fields_for_identity) }.reverse
58
+ target_array.replace(deduplicated)
59
+ end
60
+ base
61
+ end
62
+
63
+ def cleanup_hash!(base, spec, selector)
64
+ RSpec::OpenAPI::HashHelper::matched_paths(base, selector).each do |paths|
65
+ exist_in_base = !base.dig(*paths).nil?
66
+ not_in_spec = spec.dig(*paths).nil?
67
+ if exist_in_base && not_in_spec
68
+ if paths.size == 1
69
+ base.delete(paths.last)
70
+ else
71
+ parent_node = base.dig(*paths[0..-2])
72
+ parent_node.delete(paths.last)
73
+ end
74
+ end
75
+ end
76
+ base
77
+ end
78
+
79
+ def slice(obj, fields_for_identity)
80
+ if fields_for_identity.any?
81
+ obj.slice(*fields_for_identity)
82
+ else
83
+ obj
84
+ end
85
+ end
86
+ end
@@ -1,5 +1,5 @@
1
1
  module RSpec
2
2
  module OpenAPI
3
- VERSION = '0.6.1'
3
+ VERSION = '0.7.0'
4
4
  end
5
5
  end
data/lib/rspec/openapi.rb CHANGED
@@ -11,8 +11,18 @@ module RSpec::OpenAPI
11
11
  @request_headers = []
12
12
  @servers = []
13
13
  @example_types = %i[request]
14
+ @response_headers = []
14
15
 
15
16
  class << self
16
- attr_accessor :path, :comment, :enable_example, :description_builder, :info, :application_version, :request_headers, :servers, :example_types
17
+ attr_accessor :path,
18
+ :comment,
19
+ :enable_example,
20
+ :description_builder,
21
+ :info,
22
+ :application_version,
23
+ :request_headers,
24
+ :servers,
25
+ :example_types,
26
+ :response_headers
17
27
  end
18
28
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rspec-openapi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Takashi Kokubun
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2022-07-08 00:00:00.000000000 Z
11
+ date: 2022-08-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionpack
@@ -57,11 +57,14 @@ files:
57
57
  - bin/console
58
58
  - bin/setup
59
59
  - lib/rspec/openapi.rb
60
+ - lib/rspec/openapi/components_updater.rb
60
61
  - lib/rspec/openapi/default_schema.rb
62
+ - lib/rspec/openapi/hash_helper.rb
61
63
  - lib/rspec/openapi/hooks.rb
62
64
  - lib/rspec/openapi/record.rb
63
65
  - lib/rspec/openapi/record_builder.rb
64
66
  - lib/rspec/openapi/schema_builder.rb
67
+ - lib/rspec/openapi/schema_cleaner.rb
65
68
  - lib/rspec/openapi/schema_file.rb
66
69
  - lib/rspec/openapi/schema_merger.rb
67
70
  - lib/rspec/openapi/version.rb
@@ -74,7 +77,7 @@ metadata:
74
77
  homepage_uri: https://github.com/k0kubun/rspec-openapi
75
78
  source_code_uri: https://github.com/k0kubun/rspec-openapi
76
79
  changelog_uri: https://github.com/k0kubun/rspec-openapi/blob/master/CHANGELOG.md
77
- post_install_message:
80
+ post_install_message:
78
81
  rdoc_options: []
79
82
  require_paths:
80
83
  - lib
@@ -89,8 +92,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
89
92
  - !ruby/object:Gem::Version
90
93
  version: '0'
91
94
  requirements: []
92
- rubygems_version: 3.3.7
93
- signing_key:
95
+ rubygems_version: 3.1.6
96
+ signing_key:
94
97
  specification_version: 4
95
98
  summary: Generate OpenAPI schema from RSpec request specs
96
99
  test_files: []