rspec-openapi 0.6.1 → 0.7.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: 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: []