openapi_contracts 0.1.0 → 0.2.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 +6 -2
- data/lib/openapi_contracts/doc/parser.rb +48 -7
- data/lib/openapi_contracts/doc/response.rb +9 -7
- data/lib/openapi_contracts/doc/schema.rb +30 -0
- data/lib/openapi_contracts/doc.rb +8 -4
- data/lib/openapi_contracts/matchers/match_openapi_doc.rb +22 -5
- data/lib/openapi_contracts/matchers.rb +0 -4
- data/lib/openapi_contracts.rb +7 -6
- metadata +38 -8
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: abf01b7d7bc32b8559fd622dd8b4f5e8853b34f6c0b022bc62d6bbc0763ac3ef
|
4
|
+
data.tar.gz: 5f50c088ce2a94e09461b425ed5aeffb939e43de36e6eb151a85b613e61a2643
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e79b4e22603f9af923cae9bbea448b371b6927e9bc4db1a35935dc3967e33f99df3f76463803a5ec609f0de660790f8496768d40a166755293b6f3d9d4b9808b
|
7
|
+
data.tar.gz: afdbc680208de55cc64e298a72bc44b792e1a63be8c0247a6271ec1734fb13b15a43168308cb441fa835f5ebc8ef041912a4a54b5cce002f65b32c1509f43fa0
|
data/README.md
CHANGED
@@ -1,5 +1,9 @@
|
|
1
1
|
# OpenapiContracts
|
2
2
|
|
3
|
+
[](https://github.com/mkon/openapi_contracts/actions/workflows/main.yml)
|
4
|
+
[](https://badge.fury.io/rb/openapi_contracts)
|
5
|
+
[](https://depfu.com/github/mkon/openapi_contracts?project_id=35354)
|
6
|
+
|
3
7
|
Use openapi documentation as an api contract.
|
4
8
|
|
5
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.
|
@@ -45,8 +49,8 @@ It uses the `request.path`, `request.method`, `status` and `headers` on the test
|
|
45
49
|
|
46
50
|
* The response is documented
|
47
51
|
* Required headers are present
|
48
|
-
* Documented headers match the schema (via
|
49
|
-
* The response body matches the schema (via
|
52
|
+
* Documented headers match the schema (via json_schemer)
|
53
|
+
* The response body matches the schema (via json_schemer)
|
50
54
|
|
51
55
|
## Future plans
|
52
56
|
|
@@ -10,23 +10,64 @@ module OpenapiContracts
|
|
10
10
|
|
11
11
|
def parse(path = 'openapi.yaml')
|
12
12
|
abs_path = @dir.join(path)
|
13
|
-
|
14
|
-
data
|
15
|
-
|
13
|
+
data = parse_file(abs_path, translate: false)
|
14
|
+
data.deep_merge! merge_components
|
15
|
+
join_partials(abs_path.dirname, data)
|
16
16
|
end
|
17
17
|
|
18
18
|
private
|
19
19
|
|
20
|
-
def
|
20
|
+
def parse_file(path, translate: true)
|
21
|
+
schema = YAML.safe_load(File.read(path))
|
22
|
+
translate ? translate_paths(schema, Pathname(path).parent) : schema
|
23
|
+
end
|
24
|
+
|
25
|
+
def join_partials(cwd, data)
|
26
|
+
data.each_with_object({}) do |(key, val), m|
|
27
|
+
if val.is_a?(Hash)
|
28
|
+
m[key] = join_partials(cwd, val)
|
29
|
+
elsif key == '$ref' && val !~ /^#/
|
30
|
+
m.merge! parse_file(cwd.join(val))
|
31
|
+
else
|
32
|
+
m[key] = val
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def merge_components
|
38
|
+
data = {}
|
39
|
+
Dir[File.expand_path('components/**/*.yaml', @dir)].each do |file|
|
40
|
+
# pn = Pathname(file).relative_path_from(@dir)
|
41
|
+
pointer = json_pointer(Pathname(file)).split('/')
|
42
|
+
i = 0
|
43
|
+
pointer.reduce(data) do |h, p|
|
44
|
+
i += 1
|
45
|
+
if i == pointer.size
|
46
|
+
h[p] = parse_file(file)
|
47
|
+
else
|
48
|
+
h[p] ||= {}
|
49
|
+
end
|
50
|
+
h[p]
|
51
|
+
end
|
52
|
+
end
|
53
|
+
data
|
54
|
+
end
|
55
|
+
|
56
|
+
def translate_paths(data, cwd)
|
21
57
|
data.each_with_object({}) do |(key, val), m|
|
22
58
|
if val.is_a?(Hash)
|
23
|
-
m[key] =
|
24
|
-
elsif key == '$ref'
|
25
|
-
m
|
59
|
+
m[key] = translate_paths(val, cwd)
|
60
|
+
elsif key == '$ref' && val !~ %r{^#/}
|
61
|
+
m[key] = json_pointer(cwd.join(val), '#/')
|
26
62
|
else
|
27
63
|
m[key] = val
|
28
64
|
end
|
29
65
|
end
|
30
66
|
end
|
67
|
+
|
68
|
+
def json_pointer(pathname, prefix = '')
|
69
|
+
relative = pathname.relative_path_from(@dir)
|
70
|
+
"#{prefix}#{relative.to_s.delete_suffix(pathname.extname)}"
|
71
|
+
end
|
31
72
|
end
|
32
73
|
end
|
@@ -1,27 +1,29 @@
|
|
1
1
|
module OpenapiContracts
|
2
2
|
class Doc::Response
|
3
|
-
def initialize(
|
4
|
-
@
|
3
|
+
def initialize(schema)
|
4
|
+
@schema = schema
|
5
5
|
end
|
6
6
|
|
7
7
|
def headers
|
8
8
|
return @headers if instance_variable_defined? :@headers
|
9
9
|
|
10
|
-
@headers = @
|
11
|
-
Doc::Header.new(
|
10
|
+
@headers = @schema.fetch('headers', {}).map do |(key, val)|
|
11
|
+
Doc::Header.new(key, val)
|
12
12
|
end
|
13
13
|
end
|
14
14
|
|
15
15
|
def schema_for(content_type)
|
16
|
-
|
16
|
+
return unless supports_content_type?(content_type)
|
17
|
+
|
18
|
+
@schema.navigate('content', content_type, 'schema')
|
17
19
|
end
|
18
20
|
|
19
21
|
def no_content?
|
20
|
-
!@
|
22
|
+
!@schema.key? 'content'
|
21
23
|
end
|
22
24
|
|
23
25
|
def supports_content_type?(content_type)
|
24
|
-
@
|
26
|
+
@schema.dig('content', content_type).present?
|
25
27
|
end
|
26
28
|
end
|
27
29
|
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module OpenapiContracts
|
2
|
+
class Doc::Schema
|
3
|
+
attr_reader :path, :schema
|
4
|
+
|
5
|
+
def initialize(schema, path = nil)
|
6
|
+
@schema = schema
|
7
|
+
@path = path.freeze
|
8
|
+
end
|
9
|
+
|
10
|
+
def fragment
|
11
|
+
path.map { |p| p.gsub('/', '~1') }.join('/').then { |s| "#/#{s}" }
|
12
|
+
end
|
13
|
+
|
14
|
+
delegate :dig, :fetch, :key?, :[], to: :to_h
|
15
|
+
|
16
|
+
def at_path(path)
|
17
|
+
self.class.new(schema, path)
|
18
|
+
end
|
19
|
+
|
20
|
+
def to_h
|
21
|
+
return @schema if path.nil? || path.empty?
|
22
|
+
|
23
|
+
@schema.dig(*path)
|
24
|
+
end
|
25
|
+
|
26
|
+
def navigate(*sub_path)
|
27
|
+
self.class.new(schema, (path + Array.wrap(sub_path)))
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -3,19 +3,23 @@ module OpenapiContracts
|
|
3
3
|
autoload :Header, 'openapi_contracts/doc/header'
|
4
4
|
autoload :Parser, 'openapi_contracts/doc/parser'
|
5
5
|
autoload :Response, 'openapi_contracts/doc/response'
|
6
|
+
autoload :Schema, 'openapi_contracts/doc/schema'
|
6
7
|
|
7
8
|
def self.parse(dir)
|
8
9
|
new Parser.call(dir)
|
9
10
|
end
|
10
11
|
|
11
|
-
def initialize(
|
12
|
-
@
|
12
|
+
def initialize(schema)
|
13
|
+
@schema = Schema.new(schema)
|
13
14
|
end
|
14
15
|
|
15
|
-
delegate :dig, :fetch, :[], to: :@
|
16
|
+
delegate :dig, :fetch, :[], :at_path, to: :@schema
|
16
17
|
|
17
18
|
def response_for(path, method, status)
|
18
|
-
|
19
|
+
path = ['paths', path, method, 'responses', status]
|
20
|
+
return unless dig(*path).present?
|
21
|
+
|
22
|
+
Response.new(@schema.at_path(path))
|
19
23
|
end
|
20
24
|
end
|
21
25
|
end
|
@@ -42,7 +42,11 @@ module OpenapiContracts
|
|
42
42
|
end
|
43
43
|
|
44
44
|
def response_spec
|
45
|
-
@response_spec ||= @doc.response_for(
|
45
|
+
@response_spec ||= @doc.response_for(
|
46
|
+
@response.request.path,
|
47
|
+
@response.request.request_method.downcase,
|
48
|
+
@response.status.to_s
|
49
|
+
)
|
46
50
|
end
|
47
51
|
|
48
52
|
def response_content_type
|
@@ -54,8 +58,8 @@ module OpenapiContracts
|
|
54
58
|
value = @response.headers[header.name]
|
55
59
|
if value.blank?
|
56
60
|
@errors << "Missing header #{header.name}" if header.required?
|
57
|
-
elsif
|
58
|
-
@errors << "Header #{header.name} does not match
|
61
|
+
elsif !JSONSchemer.schema(header.schema).valid?(value)
|
62
|
+
@errors << "Header #{header.name} does not match"
|
59
63
|
end
|
60
64
|
end
|
61
65
|
@errors.empty?
|
@@ -77,16 +81,29 @@ module OpenapiContracts
|
|
77
81
|
|
78
82
|
def body_matches?
|
79
83
|
if response_spec.no_content?
|
80
|
-
@errors <<
|
84
|
+
@errors << 'Expected empty response body' if @response.body.present?
|
81
85
|
elsif !response_spec.supports_content_type?(response_content_type)
|
82
86
|
@errors << "Undocumented response with content-type #{response_content_type.inspect}"
|
83
87
|
else
|
84
88
|
@schema = response_spec.schema_for(response_content_type)
|
85
|
-
|
89
|
+
schemer = JSONSchemer.schema(@schema.schema.merge('$ref' => @schema.fragment))
|
90
|
+
schemer.validate(JSON(@response.body)).each do |err|
|
91
|
+
@errors << error_to_message(err)
|
92
|
+
end
|
86
93
|
end
|
87
94
|
@errors.empty?
|
88
95
|
end
|
89
96
|
|
97
|
+
def error_to_message(error)
|
98
|
+
if error.key?('details')
|
99
|
+
error['details'].to_a.map do |(key, val)|
|
100
|
+
"#{key.humanize}: #{val} at #{error['data_pointer']}"
|
101
|
+
end.to_sentence
|
102
|
+
else
|
103
|
+
"#{error['data'].inspect} at #{error['data_pointer']} does not match the schema"
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
90
107
|
def response_documented?
|
91
108
|
return true if response_spec
|
92
109
|
|
data/lib/openapi_contracts.rb
CHANGED
@@ -1,17 +1,18 @@
|
|
1
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
2
|
require 'active_support/core_ext/array'
|
6
3
|
require 'active_support/core_ext/module'
|
4
|
+
require 'active_support/core_ext/string'
|
7
5
|
|
8
|
-
require '
|
6
|
+
require 'json_schemer'
|
7
|
+
require 'yaml'
|
9
8
|
|
10
9
|
module OpenapiContracts
|
11
10
|
autoload :Doc, 'openapi_contracts/doc'
|
12
11
|
autoload :Matchers, 'openapi_contracts/matchers'
|
13
12
|
end
|
14
13
|
|
15
|
-
RSpec
|
16
|
-
|
14
|
+
if defined?(RSpec)
|
15
|
+
RSpec.configure do |config|
|
16
|
+
config.include OpenapiContracts::Matchers
|
17
|
+
end
|
17
18
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: openapi_contracts
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- mkon
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2022-05-
|
11
|
+
date: 2022-05-10 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activesupport
|
@@ -31,19 +31,19 @@ dependencies:
|
|
31
31
|
- !ruby/object:Gem::Version
|
32
32
|
version: '8'
|
33
33
|
- !ruby/object:Gem::Dependency
|
34
|
-
name:
|
34
|
+
name: json_schemer
|
35
35
|
requirement: !ruby/object:Gem::Requirement
|
36
36
|
requirements:
|
37
37
|
- - "~>"
|
38
38
|
- !ruby/object:Gem::Version
|
39
|
-
version: 2.
|
39
|
+
version: 0.2.20
|
40
40
|
type: :runtime
|
41
41
|
prerelease: false
|
42
42
|
version_requirements: !ruby/object:Gem::Requirement
|
43
43
|
requirements:
|
44
44
|
- - "~>"
|
45
45
|
- !ruby/object:Gem::Version
|
46
|
-
version: 2.
|
46
|
+
version: 0.2.20
|
47
47
|
- !ruby/object:Gem::Dependency
|
48
48
|
name: rack
|
49
49
|
requirement: !ruby/object:Gem::Requirement
|
@@ -72,6 +72,34 @@ dependencies:
|
|
72
72
|
- - "~>"
|
73
73
|
- !ruby/object:Gem::Version
|
74
74
|
version: 3.11.0
|
75
|
+
- !ruby/object:Gem::Dependency
|
76
|
+
name: rubocop
|
77
|
+
requirement: !ruby/object:Gem::Requirement
|
78
|
+
requirements:
|
79
|
+
- - '='
|
80
|
+
- !ruby/object:Gem::Version
|
81
|
+
version: 1.29.0
|
82
|
+
type: :development
|
83
|
+
prerelease: false
|
84
|
+
version_requirements: !ruby/object:Gem::Requirement
|
85
|
+
requirements:
|
86
|
+
- - '='
|
87
|
+
- !ruby/object:Gem::Version
|
88
|
+
version: 1.29.0
|
89
|
+
- !ruby/object:Gem::Dependency
|
90
|
+
name: rubocop-rspec
|
91
|
+
requirement: !ruby/object:Gem::Requirement
|
92
|
+
requirements:
|
93
|
+
- - '='
|
94
|
+
- !ruby/object:Gem::Version
|
95
|
+
version: 2.10.0
|
96
|
+
type: :development
|
97
|
+
prerelease: false
|
98
|
+
version_requirements: !ruby/object:Gem::Requirement
|
99
|
+
requirements:
|
100
|
+
- - '='
|
101
|
+
- !ruby/object:Gem::Version
|
102
|
+
version: 2.10.0
|
75
103
|
- !ruby/object:Gem::Dependency
|
76
104
|
name: simplecov
|
77
105
|
requirement: !ruby/object:Gem::Requirement
|
@@ -99,12 +127,14 @@ files:
|
|
99
127
|
- lib/openapi_contracts/doc/header.rb
|
100
128
|
- lib/openapi_contracts/doc/parser.rb
|
101
129
|
- lib/openapi_contracts/doc/response.rb
|
130
|
+
- lib/openapi_contracts/doc/schema.rb
|
102
131
|
- lib/openapi_contracts/matchers.rb
|
103
132
|
- lib/openapi_contracts/matchers/match_openapi_doc.rb
|
104
133
|
homepage: https://github.com/mkon/openapi_contracts
|
105
134
|
licenses:
|
106
135
|
- MIT
|
107
|
-
metadata:
|
136
|
+
metadata:
|
137
|
+
rubygems_mfa_required: 'true'
|
108
138
|
post_install_message:
|
109
139
|
rdoc_options: []
|
110
140
|
require_paths:
|
@@ -113,10 +143,10 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
113
143
|
requirements:
|
114
144
|
- - ">="
|
115
145
|
- !ruby/object:Gem::Version
|
116
|
-
version: '2.
|
146
|
+
version: '2.7'
|
117
147
|
- - "<"
|
118
148
|
- !ruby/object:Gem::Version
|
119
|
-
version: '
|
149
|
+
version: '3.1'
|
120
150
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
121
151
|
requirements:
|
122
152
|
- - ">="
|