openapi_contracts 0.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 +7 -0
- data/README.md +55 -0
- data/lib/openapi_contracts/doc/header.rb +18 -0
- data/lib/openapi_contracts/doc/parser.rb +32 -0
- data/lib/openapi_contracts/doc/response.rb +27 -0
- data/lib/openapi_contracts/doc.rb +21 -0
- data/lib/openapi_contracts/matchers/match_openapi_doc.rb +98 -0
- data/lib/openapi_contracts/matchers.rb +13 -0
- data/lib/openapi_contracts.rb +17 -0
- metadata +130 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 827fa48e143922260919e9a3231316f1505e34444a7b6a15051ccdb0303c1440
|
4
|
+
data.tar.gz: c02b963ccc67dd5c71c50843a4a517a4c90a9913b182f18c588a1b044a34992b
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: cbd07c48ee48324e0cdae9c7ea8e69defe61cdfc9c17eb4aaea396debef6d5109bbaae153cda430924d533ea415c7fc02b4a2e8c440c3ad121ca64b53aa49988
|
7
|
+
data.tar.gz: a5c26d7a6391f38443688a7a7d43ba785af7ed0840e651e6a60bb0dcbbc83e09e9ec0fa860a9a9f18c2b407c11b866b9e46eaad2de51b933c60a0980e78a5f37
|
data/README.md
ADDED
@@ -0,0 +1,55 @@
|
|
1
|
+
# OpenapiContracts
|
2
|
+
|
3
|
+
Use openapi documentation as an api contract.
|
4
|
+
|
5
|
+
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.
|
6
|
+
|
7
|
+
Adds RSpec matchers to easily verify that your responses match the OpenAPI documentation.
|
8
|
+
|
9
|
+
## Usage
|
10
|
+
|
11
|
+
First parse your api documentation:
|
12
|
+
|
13
|
+
```ruby
|
14
|
+
# This must point to the folder where the "openapi.yaml" file is
|
15
|
+
$doc = OpenapiContracts::Doc.parse(Rails.root.join('spec', 'fixtures', 'openapi', 'api-docs', 'openapi'))
|
16
|
+
```
|
17
|
+
|
18
|
+
Ideally you do this once in a RSpec `before(:suite)` hook.
|
19
|
+
|
20
|
+
Then you can use these matchers in your request specs:
|
21
|
+
|
22
|
+
```ruby
|
23
|
+
subject { make_request and response }
|
24
|
+
|
25
|
+
let(:make_request) { get '/some/path' }
|
26
|
+
|
27
|
+
it { is_expected.to match_openapi_doc($doc) }
|
28
|
+
```
|
29
|
+
|
30
|
+
You can assert a specific http status to make sure the response is of the right status:
|
31
|
+
|
32
|
+
```ruby
|
33
|
+
it { is_expected.to match_openapi_doc($api_doc).with_http_status(:ok) }
|
34
|
+
|
35
|
+
# this is equal to
|
36
|
+
it 'responds with 200 and matches the doc' do
|
37
|
+
expect(subject).to have_http_status(:ok)
|
38
|
+
expect(subject).to match_openapi_doc($api_doc)
|
39
|
+
}
|
40
|
+
```
|
41
|
+
|
42
|
+
### How it works
|
43
|
+
|
44
|
+
It uses the `request.path`, `request.method`, `status` and `headers` on the test subject (which must be the response) to find the response schema in the OpenAPI document. Then it does the following checks:
|
45
|
+
|
46
|
+
* The response is documented
|
47
|
+
* Required headers are present
|
48
|
+
* Documented headers match the schema (via json-schema)
|
49
|
+
* The response body matches the schema (via json-schema)
|
50
|
+
|
51
|
+
## Future plans
|
52
|
+
|
53
|
+
* Validate sent requests against the request schema
|
54
|
+
* Validate Webmock stubs against the OpenAPI doc
|
55
|
+
* Generate example payloads from the OpenAPI doc
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module OpenapiContracts
|
2
|
+
class Doc::Parser
|
3
|
+
def self.call(dir)
|
4
|
+
new(dir).parse('openapi.yaml')
|
5
|
+
end
|
6
|
+
|
7
|
+
def initialize(dir)
|
8
|
+
@dir = dir
|
9
|
+
end
|
10
|
+
|
11
|
+
def parse(path = 'openapi.yaml')
|
12
|
+
abs_path = @dir.join(path)
|
13
|
+
yaml = File.read(abs_path)
|
14
|
+
data = YAML.safe_load(yaml)
|
15
|
+
join_components(abs_path.dirname, data)
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def join_components(current_path, data)
|
21
|
+
data.each_with_object({}) do |(key, val), m|
|
22
|
+
if val.is_a?(Hash)
|
23
|
+
m[key] = join_components(current_path, val)
|
24
|
+
elsif key == '$ref'
|
25
|
+
m.merge! parse(current_path.join(val))
|
26
|
+
else
|
27
|
+
m[key] = val
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module OpenapiContracts
|
2
|
+
class Doc::Response
|
3
|
+
def initialize(data)
|
4
|
+
@data = data
|
5
|
+
end
|
6
|
+
|
7
|
+
def headers
|
8
|
+
return @headers if instance_variable_defined? :@headers
|
9
|
+
|
10
|
+
@headers = @data.fetch('headers', {}).map do |(k, v)|
|
11
|
+
Doc::Header.new(k, v)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def schema_for(content_type)
|
16
|
+
@data.dig('content', content_type, 'schema')
|
17
|
+
end
|
18
|
+
|
19
|
+
def no_content?
|
20
|
+
!@data.key? 'content'
|
21
|
+
end
|
22
|
+
|
23
|
+
def supports_content_type?(content_type)
|
24
|
+
@data.dig('content', content_type).present?
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module OpenapiContracts
|
2
|
+
class Doc
|
3
|
+
autoload :Header, 'openapi_contracts/doc/header'
|
4
|
+
autoload :Parser, 'openapi_contracts/doc/parser'
|
5
|
+
autoload :Response, 'openapi_contracts/doc/response'
|
6
|
+
|
7
|
+
def self.parse(dir)
|
8
|
+
new Parser.call(dir)
|
9
|
+
end
|
10
|
+
|
11
|
+
def initialize(spec, pointer = [])
|
12
|
+
@spec = spec
|
13
|
+
end
|
14
|
+
|
15
|
+
delegate :dig, :fetch, :[], to: :@spec
|
16
|
+
|
17
|
+
def response_for(path, method, status)
|
18
|
+
dig('paths', path, method, 'responses', status)&.then { |d| Response.new(d) }
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
module OpenapiContracts
|
2
|
+
module Matchers
|
3
|
+
class MatchOpenapiDoc
|
4
|
+
def initialize(doc)
|
5
|
+
@doc = doc
|
6
|
+
@errors = []
|
7
|
+
end
|
8
|
+
|
9
|
+
def with_http_status(status)
|
10
|
+
if status.is_a? Symbol
|
11
|
+
@status = Rack::Utils::SYMBOL_TO_STATUS_CODE[status]
|
12
|
+
else
|
13
|
+
@status = status
|
14
|
+
end
|
15
|
+
self
|
16
|
+
end
|
17
|
+
|
18
|
+
def matches?(response)
|
19
|
+
@response = response
|
20
|
+
response_documented? && http_status_matches? && [headers_match?, body_matches?].all?
|
21
|
+
@errors.empty?
|
22
|
+
end
|
23
|
+
|
24
|
+
def description
|
25
|
+
desc = 'to match the openapi schema'
|
26
|
+
desc << " with #{http_status_desc(@status)}" if @status
|
27
|
+
desc
|
28
|
+
end
|
29
|
+
|
30
|
+
def failure_message
|
31
|
+
@errors.map { |e| "* #{e}" }.join("\n")
|
32
|
+
end
|
33
|
+
|
34
|
+
def failure_message_when_negated
|
35
|
+
'request matched the schema'
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def response_desc
|
41
|
+
"#{@response.request.request_method} #{@response.request.path}"
|
42
|
+
end
|
43
|
+
|
44
|
+
def response_spec
|
45
|
+
@response_spec ||= @doc.response_for(@response.request.path, @response.request.request_method.downcase, @response.status.to_s)
|
46
|
+
end
|
47
|
+
|
48
|
+
def response_content_type
|
49
|
+
@response.headers['Content-Type'].split(';').first
|
50
|
+
end
|
51
|
+
|
52
|
+
def headers_match?
|
53
|
+
response_spec.headers.each do |header|
|
54
|
+
value = @response.headers[header.name]
|
55
|
+
if value.blank?
|
56
|
+
@errors << "Missing header #{header.name}" if header.required?
|
57
|
+
elsif (errors = JSON::Validator.fully_validate(header.schema, value)).any?
|
58
|
+
@errors << "Header #{header.name} does not match: #{errors.to_sentence}"
|
59
|
+
end
|
60
|
+
end
|
61
|
+
@errors.empty?
|
62
|
+
end
|
63
|
+
|
64
|
+
def http_status_desc(status = nil)
|
65
|
+
status ||= @response.status
|
66
|
+
"http status #{Rack::Utils::HTTP_STATUS_CODES[status]} (#{status})"
|
67
|
+
end
|
68
|
+
|
69
|
+
def http_status_matches?
|
70
|
+
if @status.present? && @status != @response.status
|
71
|
+
@errors << "Response has #{http_status_desc}"
|
72
|
+
false
|
73
|
+
else
|
74
|
+
true
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def body_matches?
|
79
|
+
if response_spec.no_content?
|
80
|
+
@errors << "Expected empty response body" if @response.body.present?
|
81
|
+
elsif !response_spec.supports_content_type?(response_content_type)
|
82
|
+
@errors << "Undocumented response with content-type #{response_content_type.inspect}"
|
83
|
+
else
|
84
|
+
@schema = response_spec.schema_for(response_content_type)
|
85
|
+
@errors += JSON::Validator.fully_validate(@schema, JSON(@response.body))
|
86
|
+
end
|
87
|
+
@errors.empty?
|
88
|
+
end
|
89
|
+
|
90
|
+
def response_documented?
|
91
|
+
return true if response_spec
|
92
|
+
|
93
|
+
@errors << "Undocumented request/response for #{response_desc.inspect} with #{http_status_desc}"
|
94
|
+
false
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module OpenapiContracts
|
2
|
+
module Matchers
|
3
|
+
autoload :MatchOpenapiDoc, 'openapi_contracts/matchers/match_openapi_doc'
|
4
|
+
|
5
|
+
def match_openapi_doc(doc)
|
6
|
+
MatchOpenapiDoc.new(doc)
|
7
|
+
end
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
RSpec.configure do |config|
|
12
|
+
config.include OpenapiContracts::Matchers
|
13
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
require 'active_support'
|
2
|
+
if ActiveSupport.version >= Gem::Version.new('7')
|
3
|
+
require 'active_support/isolated_execution_state' # required by array somehow
|
4
|
+
end
|
5
|
+
require 'active_support/core_ext/array'
|
6
|
+
require 'active_support/core_ext/module'
|
7
|
+
|
8
|
+
require 'json-schema'
|
9
|
+
|
10
|
+
module OpenapiContracts
|
11
|
+
autoload :Doc, 'openapi_contracts/doc'
|
12
|
+
autoload :Matchers, 'openapi_contracts/matchers'
|
13
|
+
end
|
14
|
+
|
15
|
+
RSpec.configure do |config|
|
16
|
+
config.include OpenapiContracts::Matchers
|
17
|
+
end
|
metadata
ADDED
@@ -0,0 +1,130 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: openapi_contracts
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- mkon
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2022-05-03 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activesupport
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '6.1'
|
20
|
+
- - "<"
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: '8'
|
23
|
+
type: :runtime
|
24
|
+
prerelease: false
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
26
|
+
requirements:
|
27
|
+
- - ">="
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '6.1'
|
30
|
+
- - "<"
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '8'
|
33
|
+
- !ruby/object:Gem::Dependency
|
34
|
+
name: json-schema
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - "~>"
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: 2.8.1
|
40
|
+
type: :runtime
|
41
|
+
prerelease: false
|
42
|
+
version_requirements: !ruby/object:Gem::Requirement
|
43
|
+
requirements:
|
44
|
+
- - "~>"
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: 2.8.1
|
47
|
+
- !ruby/object:Gem::Dependency
|
48
|
+
name: rack
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - "~>"
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: 2.2.3
|
54
|
+
type: :development
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
requirements:
|
58
|
+
- - "~>"
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: 2.2.3
|
61
|
+
- !ruby/object:Gem::Dependency
|
62
|
+
name: rspec
|
63
|
+
requirement: !ruby/object:Gem::Requirement
|
64
|
+
requirements:
|
65
|
+
- - "~>"
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: 3.11.0
|
68
|
+
type: :development
|
69
|
+
prerelease: false
|
70
|
+
version_requirements: !ruby/object:Gem::Requirement
|
71
|
+
requirements:
|
72
|
+
- - "~>"
|
73
|
+
- !ruby/object:Gem::Version
|
74
|
+
version: 3.11.0
|
75
|
+
- !ruby/object:Gem::Dependency
|
76
|
+
name: simplecov
|
77
|
+
requirement: !ruby/object:Gem::Requirement
|
78
|
+
requirements:
|
79
|
+
- - "~>"
|
80
|
+
- !ruby/object:Gem::Version
|
81
|
+
version: 0.21.2
|
82
|
+
type: :development
|
83
|
+
prerelease: false
|
84
|
+
version_requirements: !ruby/object:Gem::Requirement
|
85
|
+
requirements:
|
86
|
+
- - "~>"
|
87
|
+
- !ruby/object:Gem::Version
|
88
|
+
version: 0.21.2
|
89
|
+
description:
|
90
|
+
email:
|
91
|
+
- konstantin@munteanu.de
|
92
|
+
executables: []
|
93
|
+
extensions: []
|
94
|
+
extra_rdoc_files: []
|
95
|
+
files:
|
96
|
+
- README.md
|
97
|
+
- lib/openapi_contracts.rb
|
98
|
+
- lib/openapi_contracts/doc.rb
|
99
|
+
- lib/openapi_contracts/doc/header.rb
|
100
|
+
- lib/openapi_contracts/doc/parser.rb
|
101
|
+
- lib/openapi_contracts/doc/response.rb
|
102
|
+
- lib/openapi_contracts/matchers.rb
|
103
|
+
- lib/openapi_contracts/matchers/match_openapi_doc.rb
|
104
|
+
homepage: https://github.com/mkon/openapi_contracts
|
105
|
+
licenses:
|
106
|
+
- MIT
|
107
|
+
metadata: {}
|
108
|
+
post_install_message:
|
109
|
+
rdoc_options: []
|
110
|
+
require_paths:
|
111
|
+
- lib
|
112
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
113
|
+
requirements:
|
114
|
+
- - ">="
|
115
|
+
- !ruby/object:Gem::Version
|
116
|
+
version: '2.6'
|
117
|
+
- - "<"
|
118
|
+
- !ruby/object:Gem::Version
|
119
|
+
version: '4'
|
120
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - ">="
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
125
|
+
requirements: []
|
126
|
+
rubygems_version: 3.1.6
|
127
|
+
signing_key:
|
128
|
+
specification_version: 4
|
129
|
+
summary: Openapi schemas as API contracts
|
130
|
+
test_files: []
|