openapi-ruby 4.0.3 → 4.1.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/README.md +59 -0
- data/lib/openapi_ruby/adapters/context_resolution.rb +113 -0
- data/lib/openapi_ruby/adapters/minitest.rb +22 -14
- data/lib/openapi_ruby/adapters/rspec.rb +15 -15
- data/lib/openapi_ruby/configuration.rb +7 -0
- data/lib/openapi_ruby/errors.rb +7 -0
- data/lib/openapi_ruby/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7990b0f6b4af407b5df408029221d095428b91e0f0cc71f798f77e6d37901eb5
|
|
4
|
+
data.tar.gz: 4514fb71405d81e25a2e079e9e0ebcb03110f1430c327be386fd4d63c336eff1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6f2f27ba8f415683fb454b707e90eec7b9bb3f6adf5c5c1835b1f4002d83afb898414d4a735cc372d9b6a5a47f636c7aa2d0bf24dee092c5fe2b40180828a218
|
|
7
|
+
data.tar.gz: 666bb48fd0aaa60c3b25ab242e180735513416db9b34aaa22ec9dcabd54cc0dbcb2aeb5a50d36c7ae860f68fda83cb5261e0706561decce219f657fde4003853
|
data/README.md
CHANGED
|
@@ -507,6 +507,65 @@ PATTERN="test/integration/api/**/*_test.rb" rake openapi_ruby:generate
|
|
|
507
507
|
|
|
508
508
|
Suites using FactoryBot rather than fixtures don't hit this.
|
|
509
509
|
|
|
510
|
+
### How a request finds its api_path (Style 2)
|
|
511
|
+
|
|
512
|
+
Style 2 separates the `api_path` declaration from the request that exercises
|
|
513
|
+
it, so `assert_api_response` has to match the request back to a declaration. It
|
|
514
|
+
narrows the declared paths by, in order:
|
|
515
|
+
|
|
516
|
+
1. the verb — only paths declaring it stay in
|
|
517
|
+
2. the path params — a path needing `{project_id}` is out if none was supplied,
|
|
518
|
+
and a path is out if it doesn't use every key given in `path_params:`
|
|
519
|
+
3. the expected status — `assert_api_response :put, 422` skips paths that don't
|
|
520
|
+
declare a 422 for that verb
|
|
521
|
+
4. how many supplied keys the path can explain, as either one of its own path
|
|
522
|
+
params or a parameter declared on the operation
|
|
523
|
+
|
|
524
|
+
That resolves a collection path against a member path, nested resources, and
|
|
525
|
+
sibling paths distinguished by status. It cannot resolve paths that agree on all
|
|
526
|
+
four:
|
|
527
|
+
|
|
528
|
+
```ruby
|
|
529
|
+
api_path "/timers/{id}" { put("Update") { response(200, "ok") } }
|
|
530
|
+
api_path "/timers/{id}/start" { put("Start") { response(200, "ok") } }
|
|
531
|
+
api_path "/timers/{id}/stop" { put("Stop") { response(200, "ok") } }
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
Nothing at the call site tells those apart, so that raises
|
|
535
|
+
`OpenapiRuby::AmbiguousApiPath` naming the candidates rather than silently
|
|
536
|
+
picking the first and validating against the wrong response schema. Two ways to
|
|
537
|
+
resolve it. Name the path on the request:
|
|
538
|
+
|
|
539
|
+
```ruby
|
|
540
|
+
assert_api_response :put, 200, path_params: {id: timer.id}, api_path: "/timers/{id}/start"
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
Or, in RSpec, declare each path in its own example group — a nested `describe`
|
|
544
|
+
only sees paths declared at or above it:
|
|
545
|
+
|
|
546
|
+
```ruby
|
|
547
|
+
describe "start" do
|
|
548
|
+
api_path("/timers/{id}/start") { put("Start") { response(200, "ok") } }
|
|
549
|
+
|
|
550
|
+
it { assert_api_response :put, 200, path_params: {id: timer.id} }
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
describe "stop" do
|
|
554
|
+
api_path("/timers/{id}/stop") { put("Stop") { response(200, "ok") } }
|
|
555
|
+
|
|
556
|
+
it { assert_api_response :put, 200, path_params: {id: timer.id} }
|
|
557
|
+
end
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
To require one path per test class regardless, switch on:
|
|
561
|
+
|
|
562
|
+
```ruby
|
|
563
|
+
config.single_api_path_per_class = true
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
`api_path` then raises `OpenapiRuby::MultipleApiPaths` as soon as a class
|
|
567
|
+
declares a second path. Off by default.
|
|
568
|
+
|
|
510
569
|
### Migrating from RSpec to Minitest (or vice versa)
|
|
511
570
|
|
|
512
571
|
When both `spec/spec_helper.rb` and `test/test_helper.rb` are present, the rake task auto-selects `FRAMEWORK=hybrid` — it requires both adapters and loads both glob patterns (`spec/**/*_spec.rb,test/**/*_test.rb`) into one process. Style 1 `path(...)` and Style 2 `api_path(...)` definitions register into the same `MetadataStore`, so a single schema file holds paths contributed by either DSL.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenapiRuby
|
|
4
|
+
module Adapters
|
|
5
|
+
# Shared by the Minitest and RSpec Style 2 adapters.
|
|
6
|
+
#
|
|
7
|
+
# Style 2 separates the `api_path` declaration from the request that
|
|
8
|
+
# exercises it, so the request has to be matched back to a declaration.
|
|
9
|
+
# Everything used to do that here is a hard fact about the request, never a
|
|
10
|
+
# guess: the verb, which path params the template needs against which the
|
|
11
|
+
# caller supplied, whether the candidate declares the status the assertion
|
|
12
|
+
# demands, and whether the remaining params are declared on the operation.
|
|
13
|
+
#
|
|
14
|
+
# That leaves one case it cannot decide. `/timers/{id}` and
|
|
15
|
+
# `/timers/{id}/start` under the same verb, the same status and the same
|
|
16
|
+
# `{id}` are indistinguishable from the call site — the information simply
|
|
17
|
+
# isn't there. Picking one silently sends the request to the wrong endpoint
|
|
18
|
+
# and validates it against the wrong response schema, so a test passes while
|
|
19
|
+
# exercising something else. Raise instead, and name the candidates; the
|
|
20
|
+
# author resolves it by passing `api_path:` or by scoping the declarations.
|
|
21
|
+
module ContextResolution
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def resolve(contexts, method, path_params, owner:, params: {}, expected_status: nil, api_path: nil)
|
|
25
|
+
if api_path
|
|
26
|
+
selected = find_declared(contexts, api_path)
|
|
27
|
+
raise OpenapiRuby::Error, unknown_path_message(contexts, api_path, owner) unless selected
|
|
28
|
+
return selected
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
candidates = contexts.select { |ctx| ctx.operations.key?(method.to_s) }
|
|
32
|
+
return candidates.first if candidates.size <= 1
|
|
33
|
+
|
|
34
|
+
supplied = keys_of(params) | keys_of(path_params)
|
|
35
|
+
required = keys_of(path_params)
|
|
36
|
+
|
|
37
|
+
candidates = narrow(candidates) { |ctx| path_params_fit?(ctx, required, supplied) }
|
|
38
|
+
if expected_status
|
|
39
|
+
candidates = narrow(candidates) { |ctx| declares_status?(ctx, method, expected_status) }
|
|
40
|
+
end
|
|
41
|
+
candidates = fewest_unaccounted(candidates, method, supplied)
|
|
42
|
+
|
|
43
|
+
return candidates.first if candidates.size == 1
|
|
44
|
+
|
|
45
|
+
raise OpenapiRuby::AmbiguousApiPath, ambiguity_message(candidates, method, owner)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A template only fits if it needs no path param the caller did not supply,
|
|
49
|
+
# and uses every param the caller explicitly declared as one.
|
|
50
|
+
def path_params_fit?(context, required, supplied)
|
|
51
|
+
template = template_params(context)
|
|
52
|
+
|
|
53
|
+
(required - template).empty? && (template - supplied).empty?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def declares_status?(context, method, expected_status)
|
|
57
|
+
context.operations[method.to_s].responses.key?(expected_status.to_s)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Prefer the candidate that can explain the most supplied keys as either a
|
|
61
|
+
# path param of its own template or a parameter declared on it. A key that
|
|
62
|
+
# fits nowhere means the request was probably meant for a sibling path.
|
|
63
|
+
def fewest_unaccounted(candidates, method, supplied)
|
|
64
|
+
ranked = candidates.group_by { |ctx| (supplied - accounted_keys(ctx, method)).size }
|
|
65
|
+
|
|
66
|
+
ranked[ranked.keys.min]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def accounted_keys(context, method)
|
|
70
|
+
declared = context.path_parameters + (context.operations[method.to_s]&.parameters || [])
|
|
71
|
+
|
|
72
|
+
template_params(context) | declared.filter_map { |param| param["name"]&.to_s }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def template_params(context)
|
|
76
|
+
context.path_template.scan(/\{(\w+)\}/).flatten
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def find_declared(contexts, api_path)
|
|
80
|
+
template = api_path.respond_to?(:path_template) ? api_path.path_template : api_path.to_s
|
|
81
|
+
|
|
82
|
+
contexts.find { |ctx| ctx.path_template == template }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def narrow(candidates)
|
|
86
|
+
narrowed = candidates.select { |ctx| yield(ctx) }
|
|
87
|
+
|
|
88
|
+
narrowed.empty? ? candidates : narrowed
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def keys_of(params)
|
|
92
|
+
params.keys.map(&:to_s)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def ambiguity_message(matches, method, owner)
|
|
96
|
+
paths = matches.map { |ctx| ctx.path_template.inspect }.join(", ")
|
|
97
|
+
|
|
98
|
+
"#{method.to_s.upcase} matches more than one api_path in #{owner}: #{paths}. " \
|
|
99
|
+
"Requests are matched on the verb, the path params supplied and the declared " \
|
|
100
|
+
"response status, none of which tell these apart. Pass api_path: to pick one, " \
|
|
101
|
+
"or declare each api_path in its own class or nested describe block."
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def unknown_path_message(contexts, api_path, owner)
|
|
105
|
+
template = api_path.respond_to?(:path_template) ? api_path.path_template : api_path.to_s
|
|
106
|
+
declared = contexts.map { |ctx| ctx.path_template.inspect }.join(", ")
|
|
107
|
+
|
|
108
|
+
"No api_path #{template.inspect} declared in #{owner}. " \
|
|
109
|
+
"Declared: #{declared.empty? ? "none" : declared}."
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "openapi_ruby"
|
|
4
|
+
require_relative "context_resolution"
|
|
4
5
|
require "cgi"
|
|
5
6
|
require "uri"
|
|
6
7
|
|
|
@@ -20,16 +21,30 @@ module OpenapiRuby
|
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
def api_path(template, &block)
|
|
24
|
+
guard_single_api_path!(template)
|
|
25
|
+
|
|
23
26
|
context = OpenapiRuby::DSL::Context.new(template, schema_name: _openapi_schema_name)
|
|
24
27
|
context.instance_eval(&block) if block
|
|
25
28
|
self._openapi_contexts = _openapi_contexts + [context]
|
|
26
29
|
OpenapiRuby::DSL::MetadataStore.register(context)
|
|
27
30
|
context
|
|
28
31
|
end
|
|
32
|
+
|
|
33
|
+
private def guard_single_api_path!(template)
|
|
34
|
+
return unless OpenapiRuby.configuration.single_api_path_per_class
|
|
35
|
+
|
|
36
|
+
existing = _openapi_contexts.first
|
|
37
|
+
return if existing.nil?
|
|
38
|
+
|
|
39
|
+
raise OpenapiRuby::MultipleApiPaths,
|
|
40
|
+
"#{name || self} already declares api_path #{existing.path_template.inspect}; " \
|
|
41
|
+
"declare #{template.inspect} in its own class."
|
|
42
|
+
end
|
|
29
43
|
end
|
|
30
44
|
|
|
31
|
-
def assert_api_response(method, expected_status, params: {}, headers: {}, body: nil, path_params: {},
|
|
32
|
-
|
|
45
|
+
def assert_api_response(method, expected_status, params: {}, headers: {}, body: nil, path_params: {},
|
|
46
|
+
api_path: nil, &block)
|
|
47
|
+
context = find_context_for(method, path_params, params, expected_status, api_path)
|
|
33
48
|
raise OpenapiRuby::Error, "No api_path defined for #{method.upcase} in #{self.class}" unless context
|
|
34
49
|
|
|
35
50
|
operation = context.operations[method.to_s]
|
|
@@ -123,18 +138,11 @@ module OpenapiRuby
|
|
|
123
138
|
|
|
124
139
|
private
|
|
125
140
|
|
|
126
|
-
def find_context_for(method, path_params)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if has_path_params
|
|
133
|
-
ctx.path_template.include?("{")
|
|
134
|
-
else
|
|
135
|
-
!ctx.path_template.include?("{")
|
|
136
|
-
end
|
|
137
|
-
end
|
|
141
|
+
def find_context_for(method, path_params, params, expected_status, api_path)
|
|
142
|
+
OpenapiRuby::Adapters::ContextResolution.resolve(
|
|
143
|
+
self.class._openapi_contexts, method, path_params,
|
|
144
|
+
params: params, expected_status: expected_status, api_path: api_path, owner: self.class
|
|
145
|
+
)
|
|
138
146
|
end
|
|
139
147
|
|
|
140
148
|
def expand_path(template, params)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "openapi_ruby"
|
|
4
|
+
require_relative "context_resolution"
|
|
4
5
|
require "cgi"
|
|
5
6
|
require "uri"
|
|
6
7
|
|
|
@@ -21,8 +22,11 @@ module OpenapiRuby
|
|
|
21
22
|
schema_name = metadata[:openapi_schema_name]
|
|
22
23
|
context = DSL::Context.new(template, schema_name: schema_name)
|
|
23
24
|
context.instance_eval(&block) if block
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
# Replace rather than push: RSpec copies parent metadata into child
|
|
26
|
+
# groups by reference, so mutating the array in place would leak this
|
|
27
|
+
# declaration back up to the parent and sideways to its siblings.
|
|
28
|
+
# Building a new one is what makes a nested describe an actual scope.
|
|
29
|
+
metadata[:openapi_api_contexts] = (metadata[:openapi_api_contexts] || []) + [context]
|
|
26
30
|
DSL::MetadataStore.register(context)
|
|
27
31
|
context
|
|
28
32
|
end
|
|
@@ -123,9 +127,10 @@ module OpenapiRuby
|
|
|
123
127
|
# Minitest-style assertion: looks up the api_path context, makes the
|
|
124
128
|
# request, validates the response status + body, then yields to the
|
|
125
129
|
# block for additional expectations.
|
|
126
|
-
def assert_api_response(method, expected_status, params: {}, headers: {}, body: nil, path_params: {},
|
|
130
|
+
def assert_api_response(method, expected_status, params: {}, headers: {}, body: nil, path_params: {},
|
|
131
|
+
api_path: nil, &block)
|
|
127
132
|
meta = ::RSpec.current_example.metadata
|
|
128
|
-
context = find_api_context_for(meta, method, path_params)
|
|
133
|
+
context = find_api_context_for(meta, method, path_params, params, expected_status, api_path)
|
|
129
134
|
raise OpenapiRuby::Error, "No api_path defined for #{method.upcase} in this example group" unless context
|
|
130
135
|
|
|
131
136
|
operation = context.operations[method.to_s]
|
|
@@ -334,19 +339,14 @@ module OpenapiRuby
|
|
|
334
339
|
|
|
335
340
|
private
|
|
336
341
|
|
|
337
|
-
def find_api_context_for(metadata, method, path_params)
|
|
342
|
+
def find_api_context_for(metadata, method, path_params, params, expected_status, api_path)
|
|
338
343
|
contexts = find_in_metadata(metadata, :openapi_api_contexts) || []
|
|
339
|
-
has_path_params = path_params.any?
|
|
340
344
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
else
|
|
347
|
-
!ctx.path_template.include?("{")
|
|
348
|
-
end
|
|
349
|
-
end
|
|
345
|
+
OpenapiRuby::Adapters::ContextResolution.resolve(
|
|
346
|
+
contexts, method, path_params,
|
|
347
|
+
params: params, expected_status: expected_status, api_path: api_path,
|
|
348
|
+
owner: metadata[:full_description] || "this example group"
|
|
349
|
+
)
|
|
350
350
|
end
|
|
351
351
|
|
|
352
352
|
def expand_path(template, params)
|
|
@@ -18,6 +18,12 @@ module OpenapiRuby
|
|
|
18
18
|
# Middleware (runtime validation)
|
|
19
19
|
attr_accessor :request_validation, :response_validation, :coerce_params
|
|
20
20
|
|
|
21
|
+
# Style 2 only: require each test class to declare a single api_path.
|
|
22
|
+
# Off by default — resolution handles more than one path per class, so this
|
|
23
|
+
# is for suites that want the convention enforced anyway.
|
|
24
|
+
# See Adapters::ContextResolution for how a request is matched.
|
|
25
|
+
attr_accessor :single_api_path_per_class
|
|
26
|
+
|
|
21
27
|
# Test DSL: validate that requests match the declared operation before sending.
|
|
22
28
|
# Enabled by default; set to false to disable.
|
|
23
29
|
attr_accessor :test_request_validation
|
|
@@ -51,6 +57,7 @@ module OpenapiRuby
|
|
|
51
57
|
@response_validation = :disabled
|
|
52
58
|
@coerce_params = true
|
|
53
59
|
@test_request_validation = true
|
|
60
|
+
@single_api_path_per_class = false
|
|
54
61
|
@schema_output_dir = "openapi"
|
|
55
62
|
@schema_output_format = :yaml
|
|
56
63
|
@ui_enabled = false
|
data/lib/openapi_ruby/errors.rb
CHANGED
|
@@ -14,6 +14,13 @@ module OpenapiRuby
|
|
|
14
14
|
end
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
# Raised when a Style 2 request matches more than one declared api_path.
|
|
18
|
+
class AmbiguousApiPath < Error; end
|
|
19
|
+
|
|
20
|
+
# Raised when a class declares a second api_path and
|
|
21
|
+
# `config.single_api_path_per_class` is on.
|
|
22
|
+
class MultipleApiPaths < Error; end
|
|
23
|
+
|
|
17
24
|
class SchemaValidationError < Error
|
|
18
25
|
attr_reader :validation_errors
|
|
19
26
|
|
data/lib/openapi_ruby/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: openapi-ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 4.0
|
|
4
|
+
version: 4.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Morten Hartvig
|
|
@@ -88,6 +88,7 @@ files:
|
|
|
88
88
|
- lib/generators/openapi_ruby/install/templates/openapi_helper.rb.tt
|
|
89
89
|
- lib/openapi-ruby.rb
|
|
90
90
|
- lib/openapi_ruby.rb
|
|
91
|
+
- lib/openapi_ruby/adapters/context_resolution.rb
|
|
91
92
|
- lib/openapi_ruby/adapters/minitest.rb
|
|
92
93
|
- lib/openapi_ruby/adapters/rspec.rb
|
|
93
94
|
- lib/openapi_ruby/components/base.rb
|