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 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: []