openapi_contracts 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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,18 @@
1
+ module OpenapiContracts
2
+ class Doc::Header
3
+ attr_reader :name
4
+
5
+ def initialize(name, data)
6
+ @name = name
7
+ @data = data
8
+ end
9
+
10
+ def required?
11
+ @data['required']
12
+ end
13
+
14
+ def schema
15
+ @data['schema']
16
+ end
17
+ end
18
+ end
@@ -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: []