openapi_contracts 0.7.1 → 0.9.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +43 -19
- data/lib/openapi_contracts/doc/operation.rb +27 -0
- data/lib/openapi_contracts/doc/parameter.rb +49 -0
- data/lib/openapi_contracts/doc/path.rb +32 -12
- data/lib/openapi_contracts/doc/pointer.rb +81 -0
- data/lib/openapi_contracts/doc/request.rb +17 -0
- data/lib/openapi_contracts/doc/response.rb +5 -5
- data/lib/openapi_contracts/doc/schema.rb +44 -10
- data/lib/openapi_contracts/doc/with_parameters.rb +9 -0
- data/lib/openapi_contracts/doc.rb +17 -14
- data/lib/openapi_contracts/match.rb +34 -10
- data/lib/openapi_contracts/operation_router.rb +33 -0
- data/lib/openapi_contracts/parser/transformers/base.rb +15 -0
- data/lib/openapi_contracts/parser/transformers/nullable.rb +10 -0
- data/lib/openapi_contracts/parser/transformers/pointer.rb +34 -0
- data/lib/openapi_contracts/parser/transformers.rb +5 -0
- data/lib/openapi_contracts/parser.rb +61 -0
- data/lib/openapi_contracts/payload_parser.rb +39 -0
- data/lib/openapi_contracts/rspec.rb +2 -2
- data/lib/openapi_contracts/validators/base.rb +5 -1
- data/lib/openapi_contracts/validators/documented.rb +12 -5
- data/lib/openapi_contracts/validators/headers.rb +4 -0
- data/lib/openapi_contracts/validators/http_status.rb +2 -6
- data/lib/openapi_contracts/validators/request_body.rb +26 -0
- data/lib/openapi_contracts/validators/response_body.rb +28 -0
- data/lib/openapi_contracts/validators/schema_validation.rb +40 -0
- data/lib/openapi_contracts/validators.rb +9 -6
- data/lib/openapi_contracts.rb +11 -5
- metadata +31 -20
- data/lib/openapi_contracts/doc/file_parser.rb +0 -85
- data/lib/openapi_contracts/doc/method.rb +0 -18
- data/lib/openapi_contracts/doc/parser.rb +0 -44
- data/lib/openapi_contracts/validators/body.rb +0 -38
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6a6d176876364825de3b5e11746a49add3125a4511003e24002f8dda270347d6
|
4
|
+
data.tar.gz: 468ebb8a26dadc72b3f0d9fdb0e9a2f8a39b2e94418c555926fe0b12d0ea0b8a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c310ffa9c64a85d3cef12f2e2fa685ae52f0bfeff975ab63a1532f88be964dc2acd577b24f4a70017efb0bc4b19c0a522a56b9660686ba33c89d970959ca1690
|
7
|
+
data.tar.gz: 1a2293a48671a21d6f6915162c1240fa56b719e2d40947b8e46782635c6cc6cd7d4e26ee6012f7360b098d1ab04bf82f282e090fb373d9e1e9ddb982a6672dce
|
data/README.md
CHANGED
@@ -4,24 +4,24 @@
|
|
4
4
|
[![Gem Version](https://badge.fury.io/rb/openapi_contracts.svg)](https://badge.fury.io/rb/openapi_contracts)
|
5
5
|
[![Depfu](https://badges.depfu.com/badges/8ac57411497df02584bbf59685634e45/overview.svg)](https://depfu.com/github/mkon/openapi_contracts?project_id=35354)
|
6
6
|
|
7
|
-
Use
|
7
|
+
Use OpenAPI documentation as an API contract.
|
8
8
|
|
9
9
|
Currently supports OpenAPI documentation in the structure as used by [Redocly](https://github.com/Redocly/create-openapi-repo), but should also work for single file schemas.
|
10
10
|
|
11
|
-
Adds RSpec matchers to easily verify that your responses match the OpenAPI documentation.
|
11
|
+
Adds RSpec matchers to easily verify that your requests and responses match the OpenAPI documentation.
|
12
12
|
|
13
13
|
## Usage
|
14
14
|
|
15
|
-
First parse your
|
15
|
+
First, parse your API documentation:
|
16
16
|
|
17
17
|
```ruby
|
18
|
-
# This must point to the folder where the
|
19
|
-
$doc = OpenapiContracts::Doc.parse(Rails.root.join('spec/fixtures/openapi/api-docs
|
18
|
+
# This must point to the folder where the OAS file is stored
|
19
|
+
$doc = OpenapiContracts::Doc.parse(Rails.root.join('spec/fixtures/openapi/api-docs'), '<filename>')
|
20
20
|
```
|
21
21
|
|
22
|
-
|
22
|
+
In case the `filename` argument is not set, parser will by default search for the file named `openapi.yaml`.
|
23
23
|
|
24
|
-
Then you can use these matchers in your request specs:
|
24
|
+
Ideally you do this once in an RSpec `before(:suite)` hook. Then you can use these matchers in your request specs:
|
25
25
|
|
26
26
|
```ruby
|
27
27
|
subject { make_request and response }
|
@@ -34,48 +34,72 @@ it { is_expected.to match_openapi_doc($doc) }
|
|
34
34
|
You can assert a specific http status to make sure the response is of the right status:
|
35
35
|
|
36
36
|
```ruby
|
37
|
-
it { is_expected.to match_openapi_doc($
|
37
|
+
it { is_expected.to match_openapi_doc($doc).with_http_status(:ok) }
|
38
38
|
|
39
|
-
#
|
39
|
+
# This is equal to
|
40
40
|
it 'responds with 200 and matches the doc' do
|
41
41
|
expect(subject).to have_http_status(:ok)
|
42
|
-
expect(subject).to match_openapi_doc($
|
43
|
-
|
42
|
+
expect(subject).to match_openapi_doc($doc)
|
43
|
+
end
|
44
44
|
```
|
45
45
|
|
46
46
|
### Options
|
47
47
|
|
48
48
|
The `match_openapi_doc($doc)` method allows passing options as a 2nd argument.
|
49
|
-
This allows overriding the default request.path lookup in case this does not find
|
50
|
-
the correct response definition in your schema. This is especially important with
|
51
|
-
dynamic paths.
|
52
49
|
|
53
|
-
|
50
|
+
* `path` allows overriding the default `request.path` lookup in case it does not find the
|
51
|
+
correct response definition in your schema. This is especially important when there are
|
52
|
+
dynamic parameters in the path and the matcher fails to resolve the request path to
|
53
|
+
an endpoint in the OAS file.
|
54
54
|
|
55
55
|
```ruby
|
56
|
-
it { is_expected.to match_openapi_doc($
|
56
|
+
it { is_expected.to match_openapi_doc($doc, path: '/messages/{id}').with_http_status(:ok) }
|
57
57
|
```
|
58
58
|
|
59
|
+
* `request_body` can be set to `true` in case the validation of the request body against the OpenAPI _requestBody_ schema is required.
|
60
|
+
|
61
|
+
```ruby
|
62
|
+
it { is_expected.to match_openapi_doc($doc, request_body: true).with_http_status(:created) }
|
63
|
+
```
|
64
|
+
|
65
|
+
Both options can as well be used simultaneously.
|
66
|
+
|
59
67
|
### Without RSpec
|
60
68
|
|
61
69
|
You can also use the Validator directly:
|
70
|
+
|
62
71
|
```ruby
|
63
72
|
# Let's raise an error if the response does not match
|
64
73
|
result = OpenapiContracts.match($doc, response, options = {})
|
65
74
|
raise result.errors.merge("/n") unless result.valid?
|
66
75
|
```
|
67
76
|
|
68
|
-
|
77
|
+
## How it works
|
69
78
|
|
70
|
-
It uses the `request.path`, `request.method`, `status` and `headers` on the test subject
|
79
|
+
It uses the `request.path`, `request.method`, `status` and `headers` on the test subject
|
80
|
+
(which must be the response) to find the request and response schemas in the OpenAPI document.
|
81
|
+
Then it does the following checks:
|
71
82
|
|
72
83
|
* The response is documented
|
73
84
|
* Required headers are present
|
74
85
|
* Documented headers match the schema (via json_schemer)
|
75
86
|
* The response body matches the schema (via json_schemer)
|
87
|
+
* The request body matches the schema (via json_schemer) - if `request_body: true`
|
88
|
+
|
89
|
+
## Known Issues
|
90
|
+
|
91
|
+
### OpenApi 3.0
|
92
|
+
|
93
|
+
For openapi schemas < 3.1, data is validated using JSON Schema Draft 04, even tho OpenApi 3.0 is a super+subset of Draft 05.
|
94
|
+
This is due to the fact that we validate the data using json-schemer which does not support 05 and even then would not be fully compatible.
|
95
|
+
However compatibility issues should be fairly rare and there might be workarounds by describing the data slightly different.
|
96
|
+
|
97
|
+
### OpenAPi 3.1
|
98
|
+
|
99
|
+
Here exists a similar problem. OpenApi 3.1 is finally fully compatible with JSON Draft 2020-12, but there is no support yet in json-schemer,
|
100
|
+
so we use the closest draft which is 07.
|
76
101
|
|
77
102
|
## Future plans
|
78
103
|
|
79
|
-
* Validate sent requests against the request schema
|
80
104
|
* Validate Webmock stubs against the OpenAPI doc
|
81
105
|
* Generate example payloads from the OpenAPI doc
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module OpenapiContracts
|
2
|
+
class Doc::Operation
|
3
|
+
include Doc::WithParameters
|
4
|
+
|
5
|
+
def initialize(path, spec)
|
6
|
+
@path = path
|
7
|
+
@spec = spec
|
8
|
+
@responses = spec.navigate('responses').each.to_h do |status, subspec| # rubocop:disable Style/HashTransformValues
|
9
|
+
[status, Doc::Response.new(subspec)]
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
def request_body
|
14
|
+
return @request_body if instance_variable_defined?(:@request_body)
|
15
|
+
|
16
|
+
@request_body = @spec.navigate('requestBody')&.then { |s| Doc::Request.new(s) }
|
17
|
+
end
|
18
|
+
|
19
|
+
def responses
|
20
|
+
@responses.each_value
|
21
|
+
end
|
22
|
+
|
23
|
+
def response_for_status(status)
|
24
|
+
@responses[status.to_s]
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
module OpenapiContracts
|
2
|
+
class Doc::Parameter
|
3
|
+
attr_reader :name, :in, :schema
|
4
|
+
|
5
|
+
def initialize(spec)
|
6
|
+
@spec = spec
|
7
|
+
options = spec.to_h
|
8
|
+
@name = options['name']
|
9
|
+
@in = options['in']
|
10
|
+
@required = options['required']
|
11
|
+
end
|
12
|
+
|
13
|
+
def in_path?
|
14
|
+
@in == 'path'
|
15
|
+
end
|
16
|
+
|
17
|
+
def matches?(value)
|
18
|
+
case @spec.dig('schema', 'type')
|
19
|
+
when 'integer'
|
20
|
+
integer_parameter_matches?(value)
|
21
|
+
when 'number'
|
22
|
+
number_parameter_matches?(value)
|
23
|
+
else
|
24
|
+
schemer.valid?(value)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def schemer
|
31
|
+
@schemer ||= begin
|
32
|
+
schema = @spec.navigate('schema')
|
33
|
+
JSONSchemer.schema(Validators::SchemaValidation.build_validation_schema(schema))
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def integer_parameter_matches?(value)
|
38
|
+
return false unless /^-?\d+$/.match?(value)
|
39
|
+
|
40
|
+
schemer.valid?(value.to_i)
|
41
|
+
end
|
42
|
+
|
43
|
+
def number_parameter_matches?(value)
|
44
|
+
return false unless /^-?(\d+\.)?\d+$/.match?(value)
|
45
|
+
|
46
|
+
schemer.valid?(value.to_f)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -1,26 +1,46 @@
|
|
1
1
|
module OpenapiContracts
|
2
2
|
class Doc::Path
|
3
|
-
|
4
|
-
@schema = schema
|
3
|
+
include Doc::WithParameters
|
5
4
|
|
6
|
-
|
7
|
-
|
5
|
+
HTTP_METHODS = %w(get head post put delete connect options trace patch).freeze
|
6
|
+
|
7
|
+
attr_reader :path
|
8
|
+
|
9
|
+
def initialize(path, spec)
|
10
|
+
@path = path
|
11
|
+
@spec = spec
|
12
|
+
@supported_methods = HTTP_METHODS & @spec.keys
|
13
|
+
end
|
14
|
+
|
15
|
+
def dynamic?
|
16
|
+
@path.include?('{')
|
17
|
+
end
|
18
|
+
|
19
|
+
def operations
|
20
|
+
@supported_methods.each.lazy.map { |m| Doc::Operation.new(self, @spec.navigate(m)) }
|
21
|
+
end
|
22
|
+
|
23
|
+
def path_regexp
|
24
|
+
@path_regexp ||= begin
|
25
|
+
re = /\{(\S+)\}/
|
26
|
+
@path.gsub(re) { |placeholder|
|
27
|
+
placeholder.match(re) { |m| "(?<#{m[1]}>[^/]*)" }
|
28
|
+
}.then { |str| Regexp.new(str) }
|
8
29
|
end
|
9
30
|
end
|
10
31
|
|
11
|
-
def
|
12
|
-
|
32
|
+
def static?
|
33
|
+
!dynamic?
|
13
34
|
end
|
14
35
|
|
15
|
-
def
|
16
|
-
@
|
36
|
+
def supports_method?(method)
|
37
|
+
@supported_methods.include?(method)
|
17
38
|
end
|
18
39
|
|
19
|
-
|
40
|
+
def with_method(method)
|
41
|
+
return unless supports_method?(method)
|
20
42
|
|
21
|
-
|
22
|
-
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods
|
23
|
-
%w(get head post put delete connect options trace patch).freeze
|
43
|
+
Doc::Operation.new(self, @spec.navigate(method))
|
24
44
|
end
|
25
45
|
end
|
26
46
|
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
module OpenapiContracts
|
2
|
+
class Doc::Pointer
|
3
|
+
def self.[](*segments)
|
4
|
+
new Array.wrap(segments).flatten
|
5
|
+
end
|
6
|
+
|
7
|
+
def self.from_json_pointer(str)
|
8
|
+
raise ArguementError unless %r{^#/(?<pointer>.*)} =~ str
|
9
|
+
|
10
|
+
new(pointer.split('/').map { |s| s.gsub('~1', '/') })
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.from_path(pathname)
|
14
|
+
new pathname.to_s.split('/')
|
15
|
+
end
|
16
|
+
|
17
|
+
def initialize(segments)
|
18
|
+
@segments = segments
|
19
|
+
end
|
20
|
+
|
21
|
+
def inspect
|
22
|
+
"<#{self.class.name}#{to_a}>"
|
23
|
+
end
|
24
|
+
|
25
|
+
delegate :empty?, to: :@segments
|
26
|
+
|
27
|
+
def navigate(*segments)
|
28
|
+
self.class[to_a + segments]
|
29
|
+
end
|
30
|
+
|
31
|
+
def parent
|
32
|
+
self.class[to_a[0..-2]]
|
33
|
+
end
|
34
|
+
|
35
|
+
def to_a
|
36
|
+
@segments
|
37
|
+
end
|
38
|
+
|
39
|
+
def to_json_pointer
|
40
|
+
escaped_segments.join('/').then { |s| "#/#{s}" }
|
41
|
+
end
|
42
|
+
|
43
|
+
def to_json_schemer_pointer
|
44
|
+
www_escaped_segments.join('/').then { |s| "#/#{s}" }
|
45
|
+
end
|
46
|
+
|
47
|
+
def walk(object)
|
48
|
+
return object if empty?
|
49
|
+
|
50
|
+
@segments.inject(object) do |obj, key|
|
51
|
+
return nil unless obj
|
52
|
+
|
53
|
+
if obj.is_a?(Array)
|
54
|
+
raise ArgumentError unless /^\d+$/ =~ key
|
55
|
+
|
56
|
+
key = key.to_i
|
57
|
+
end
|
58
|
+
|
59
|
+
obj[key]
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def ==(other)
|
64
|
+
to_a == other.to_a
|
65
|
+
end
|
66
|
+
|
67
|
+
private
|
68
|
+
|
69
|
+
def escaped_segments
|
70
|
+
@segments.map do |s|
|
71
|
+
s.gsub(%r{/}, '~1')
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def www_escaped_segments
|
76
|
+
escaped_segments.map do |s|
|
77
|
+
URI.encode_www_form_component(s)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module OpenapiContracts
|
2
|
+
class Doc::Request
|
3
|
+
def initialize(schema)
|
4
|
+
@schema = schema.follow_refs
|
5
|
+
end
|
6
|
+
|
7
|
+
def schema_for(media_type)
|
8
|
+
return unless supports_media_type?(media_type)
|
9
|
+
|
10
|
+
@schema.navigate('content', media_type, 'schema')
|
11
|
+
end
|
12
|
+
|
13
|
+
def supports_media_type?(media_type)
|
14
|
+
@schema.dig('content', media_type).present?
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -12,18 +12,18 @@ module OpenapiContracts
|
|
12
12
|
end
|
13
13
|
end
|
14
14
|
|
15
|
-
def schema_for(
|
16
|
-
return unless
|
15
|
+
def schema_for(media_type)
|
16
|
+
return unless supports_media_type?(media_type)
|
17
17
|
|
18
|
-
@schema.navigate('content',
|
18
|
+
@schema.navigate('content', media_type, 'schema')
|
19
19
|
end
|
20
20
|
|
21
21
|
def no_content?
|
22
22
|
!@schema.key? 'content'
|
23
23
|
end
|
24
24
|
|
25
|
-
def
|
26
|
-
@schema.dig('content',
|
25
|
+
def supports_media_type?(media_type)
|
26
|
+
@schema.dig('content', media_type).present?
|
27
27
|
end
|
28
28
|
end
|
29
29
|
end
|
@@ -6,16 +6,47 @@ module OpenapiContracts
|
|
6
6
|
class Doc::Schema
|
7
7
|
attr_reader :pointer, :raw
|
8
8
|
|
9
|
-
def initialize(raw, pointer =
|
9
|
+
def initialize(raw, pointer = Doc::Pointer[])
|
10
|
+
raise ArgumentError unless pointer.is_a?(Doc::Pointer)
|
11
|
+
|
10
12
|
@raw = raw
|
11
13
|
@pointer = pointer.freeze
|
12
14
|
end
|
13
15
|
|
16
|
+
def each # rubocop:disable Metrics/MethodLength
|
17
|
+
data = resolve
|
18
|
+
case data
|
19
|
+
when Array
|
20
|
+
enum = data.each_with_index
|
21
|
+
Enumerator.new(enum.size) do |yielder|
|
22
|
+
loop do
|
23
|
+
_item, index = enum.next
|
24
|
+
yielder << navigate(index.to_s)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
when Hash
|
28
|
+
enum = data.each_key
|
29
|
+
Enumerator.new(enum.size) do |yielder|
|
30
|
+
loop do
|
31
|
+
key = enum.next
|
32
|
+
yielder << [key, navigate(key)]
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
# :nocov:
|
39
|
+
def inspect
|
40
|
+
"<#{self.class.name} @pointer=#{@pointer.inspect}>"
|
41
|
+
end
|
42
|
+
# :nocov:
|
43
|
+
|
14
44
|
# Resolves Schema ref pointers links like "$ref: #/some/path" and returns new sub-schema
|
15
45
|
# at the target if the current schema is only a ref link.
|
16
46
|
def follow_refs
|
17
|
-
|
18
|
-
|
47
|
+
data = resolve
|
48
|
+
if data.is_a?(Hash) && data.key?('$ref')
|
49
|
+
at_pointer Doc::Pointer.from_json_pointer(data['$ref'])
|
19
50
|
else
|
20
51
|
self
|
21
52
|
end
|
@@ -23,23 +54,26 @@ module OpenapiContracts
|
|
23
54
|
|
24
55
|
# Generates a fragment pointer for the current schema path
|
25
56
|
def fragment
|
26
|
-
pointer.
|
57
|
+
pointer.to_json_schemer_pointer
|
27
58
|
end
|
28
59
|
|
29
|
-
delegate :dig, :fetch, :keys, :key?, :[], :to_h, to: :
|
60
|
+
delegate :dig, :fetch, :keys, :key?, :[], :to_h, to: :resolve
|
30
61
|
|
31
62
|
def at_pointer(pointer)
|
32
63
|
self.class.new(raw, pointer)
|
33
64
|
end
|
34
65
|
|
35
|
-
def
|
36
|
-
|
66
|
+
def openapi_version
|
67
|
+
@raw['openapi']&.then { |v| Gem::Version.new(v) }
|
68
|
+
end
|
37
69
|
|
38
|
-
|
70
|
+
# Returns the actual sub-specification contents at the pointer of this Specification
|
71
|
+
def resolve
|
72
|
+
@pointer.walk(@raw)
|
39
73
|
end
|
40
74
|
|
41
|
-
def navigate(*
|
42
|
-
self.class.new(@raw,
|
75
|
+
def navigate(*segments)
|
76
|
+
self.class.new(@raw, pointer.navigate(segments)).follow_refs
|
43
77
|
end
|
44
78
|
end
|
45
79
|
end
|
@@ -1,12 +1,14 @@
|
|
1
1
|
module OpenapiContracts
|
2
2
|
class Doc
|
3
|
-
autoload :Header,
|
4
|
-
autoload :
|
5
|
-
autoload :
|
6
|
-
autoload :
|
7
|
-
autoload :
|
8
|
-
autoload :
|
9
|
-
autoload :
|
3
|
+
autoload :Header, 'openapi_contracts/doc/header'
|
4
|
+
autoload :Operation, 'openapi_contracts/doc/operation'
|
5
|
+
autoload :Parameter, 'openapi_contracts/doc/parameter'
|
6
|
+
autoload :Path, 'openapi_contracts/doc/path'
|
7
|
+
autoload :Pointer, 'openapi_contracts/doc/pointer'
|
8
|
+
autoload :Request, 'openapi_contracts/doc/request'
|
9
|
+
autoload :Response, 'openapi_contracts/doc/response'
|
10
|
+
autoload :Schema, 'openapi_contracts/doc/schema'
|
11
|
+
autoload :WithParameters, 'openapi_contracts/doc/with_parameters'
|
10
12
|
|
11
13
|
def self.parse(dir, filename = 'openapi.yaml')
|
12
14
|
new Parser.call(dir, filename)
|
@@ -14,11 +16,12 @@ module OpenapiContracts
|
|
14
16
|
|
15
17
|
attr_reader :schema
|
16
18
|
|
17
|
-
def initialize(
|
18
|
-
@schema = Schema.new(
|
19
|
+
def initialize(raw)
|
20
|
+
@schema = Schema.new(raw)
|
19
21
|
@paths = @schema['paths'].to_h do |path, _|
|
20
|
-
[path, Path.new(@schema.at_pointer(['paths', path]))]
|
22
|
+
[path, Path.new(path, @schema.at_pointer(Doc::Pointer['paths', path]))]
|
21
23
|
end
|
24
|
+
@dynamic_paths = paths.select(&:dynamic?)
|
22
25
|
end
|
23
26
|
|
24
27
|
# Returns an Enumerator over all paths
|
@@ -26,8 +29,8 @@ module OpenapiContracts
|
|
26
29
|
@paths.each_value
|
27
30
|
end
|
28
31
|
|
29
|
-
def
|
30
|
-
|
32
|
+
def operation_for(path, method)
|
33
|
+
OperationRouter.new(self).route(path, method.downcase)
|
31
34
|
end
|
32
35
|
|
33
36
|
# Returns an Enumerator over all Responses
|
@@ -35,8 +38,8 @@ module OpenapiContracts
|
|
35
38
|
return enum_for(:responses) unless block_given?
|
36
39
|
|
37
40
|
paths.each do |path|
|
38
|
-
path.
|
39
|
-
|
41
|
+
path.operations.each do |operation|
|
42
|
+
operation.responses.each(&block)
|
40
43
|
end
|
41
44
|
end
|
42
45
|
end
|
@@ -1,11 +1,18 @@
|
|
1
1
|
module OpenapiContracts
|
2
2
|
class Match
|
3
|
+
DEFAULT_OPTIONS = {request_body: false}.freeze
|
4
|
+
MIN_REQUEST_ANCESTORS = %w(Rack::Request::Env Rack::Request::Helpers).freeze
|
5
|
+
MIN_RESPONSE_ANCESTORS = %w(Rack::Response::Helpers).freeze
|
6
|
+
|
3
7
|
attr_reader :errors
|
4
8
|
|
5
9
|
def initialize(doc, response, options = {})
|
6
10
|
@doc = doc
|
7
11
|
@response = response
|
8
|
-
@
|
12
|
+
@request = options.delete(:request) { response.request }
|
13
|
+
@options = DEFAULT_OPTIONS.merge(options)
|
14
|
+
raise ArgumentError, "#{@response} must be compatible with Rack::Response::Helpers" unless response_compatible?
|
15
|
+
raise ArgumentError, "#{@request} must be compatible with Rack::Request::{Env,Helpers}" unless request_compatible?
|
9
16
|
end
|
10
17
|
|
11
18
|
def valid?
|
@@ -17,18 +24,35 @@ module OpenapiContracts
|
|
17
24
|
|
18
25
|
private
|
19
26
|
|
20
|
-
def
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
@
|
27
|
+
def matchers
|
28
|
+
env = Env.new(
|
29
|
+
options: @options,
|
30
|
+
operation: operation,
|
31
|
+
request: @request,
|
32
|
+
response: @response
|
25
33
|
)
|
34
|
+
validators = Validators::ALL.dup
|
35
|
+
validators.delete(Validators::HttpStatus) unless @options[:status]
|
36
|
+
validators.delete(Validators::RequestBody) unless @options[:request_body]
|
37
|
+
validators.reverse
|
38
|
+
.reduce(->(err) { err }) { |s, m| m.new(s, env) }
|
26
39
|
end
|
27
40
|
|
28
|
-
def
|
29
|
-
|
30
|
-
|
31
|
-
|
41
|
+
def operation
|
42
|
+
@doc.operation_for(
|
43
|
+
@options.fetch(:path, @request.path),
|
44
|
+
@request.request_method.downcase
|
45
|
+
)
|
46
|
+
end
|
47
|
+
|
48
|
+
def request_compatible?
|
49
|
+
ancestors = @request.class.ancestors.map(&:to_s)
|
50
|
+
MIN_REQUEST_ANCESTORS.all? { |s| ancestors.include?(s) }
|
51
|
+
end
|
52
|
+
|
53
|
+
def response_compatible?
|
54
|
+
ancestors = @response.class.ancestors.map(&:to_s)
|
55
|
+
MIN_RESPONSE_ANCESTORS.all? { |s| ancestors.include?(s) }
|
32
56
|
end
|
33
57
|
end
|
34
58
|
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module OpenapiContracts
|
2
|
+
class OperationRouter
|
3
|
+
def initialize(doc)
|
4
|
+
@doc = doc
|
5
|
+
@dynamic_paths = doc.paths.select(&:dynamic?)
|
6
|
+
end
|
7
|
+
|
8
|
+
def route(actual_path, method)
|
9
|
+
@doc.with_path(actual_path)&.then { |p| return p.with_method(method) }
|
10
|
+
|
11
|
+
@dynamic_paths.each do |path|
|
12
|
+
next unless path.supports_method?(method)
|
13
|
+
next unless m = path.path_regexp.match(actual_path)
|
14
|
+
|
15
|
+
operation = path.with_method(method)
|
16
|
+
parameters = (path.parameters + operation.parameters).select(&:in_path?)
|
17
|
+
|
18
|
+
return operation if parameter_match?(m.named_captures, parameters)
|
19
|
+
end
|
20
|
+
|
21
|
+
nil
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def parameter_match?(actual_params, parameters)
|
27
|
+
actual_params.each do |k, v|
|
28
|
+
return false unless parameters&.find { |s| s.name == k }&.matches?(v)
|
29
|
+
end
|
30
|
+
true
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|