openapi_contracts 0.6.0 → 0.7.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 +9 -0
- data/lib/openapi_contracts/doc/file_parser.rb +75 -0
- data/lib/openapi_contracts/doc/method.rb +18 -0
- data/lib/openapi_contracts/doc/parser.rb +8 -54
- data/lib/openapi_contracts/doc/path.rb +18 -0
- data/lib/openapi_contracts/doc/schema.rb +22 -15
- data/lib/openapi_contracts/doc.rb +31 -8
- data/lib/openapi_contracts/helper.rb +7 -0
- data/lib/openapi_contracts/match.rb +34 -0
- data/lib/openapi_contracts/rspec.rb +34 -0
- data/lib/openapi_contracts/validators/base.rb +33 -0
- data/lib/openapi_contracts/validators/body.rb +38 -0
- data/lib/openapi_contracts/validators/documented.rb +18 -0
- data/lib/openapi_contracts/validators/headers.rb +16 -0
- data/lib/openapi_contracts/validators/http_status.rb +17 -0
- data/lib/openapi_contracts/validators.rb +17 -0
- data/lib/openapi_contracts.rb +13 -6
- metadata +33 -9
- data/lib/openapi_contracts/matchers/match_openapi_doc.rb +0 -116
- data/lib/openapi_contracts/matchers.rb +0 -9
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 56248ce2cb1b1a67d4cf1d7390a9815bb9f20a3b9b488e0dd116aa994f8bd9a0
|
4
|
+
data.tar.gz: a6a8df4841e768c0905bad6699ef0d9295221dad637e5ef8a01c35bb9442a8f0
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 578b3ef8c71e5a19667e34e2791be7051693d198bf62dfcdaa8b9fc09403cbe54bc12af266c61103eb743a32a8ea0f88552d6eefeecba7ca14d55a3868f0deb8
|
7
|
+
data.tar.gz: 07c76b46bd842bfb5aa60e9af6cf30a2a45185b682e841183624c222580b3022c7b19239bb2b081a0ae9eedbab365ce78ec55bd4a76e5ba02b9d120fd2a6d853
|
data/README.md
CHANGED
@@ -56,6 +56,15 @@ Example:
|
|
56
56
|
it { is_expected.to match_openapi_doc($api_doc, path: '/messages/{id}').with_http_status(:ok) }
|
57
57
|
```
|
58
58
|
|
59
|
+
### Without RSpec
|
60
|
+
|
61
|
+
You can also use the Validator directly:
|
62
|
+
```ruby
|
63
|
+
# Let's raise an error if the response does not match
|
64
|
+
result = OpenapiContracts.match($doc, response, options = {})
|
65
|
+
raise result.errors.merge("/n") unless result.valid?
|
66
|
+
```
|
67
|
+
|
59
68
|
### How it works
|
60
69
|
|
61
70
|
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:
|
@@ -0,0 +1,75 @@
|
|
1
|
+
module OpenapiContracts
|
2
|
+
class Doc::FileParser
|
3
|
+
Result = Struct.new(:data, :path) do
|
4
|
+
def to_mergable_hash
|
5
|
+
d = data
|
6
|
+
path.ascend do |p|
|
7
|
+
d = {p.basename.to_s => d}
|
8
|
+
end
|
9
|
+
d
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.parse(root, pathname)
|
14
|
+
new(root, pathname).call
|
15
|
+
end
|
16
|
+
|
17
|
+
def initialize(root, pathname)
|
18
|
+
@root = root
|
19
|
+
@pathname = pathname.relative? ? root.join(pathname) : pathname
|
20
|
+
end
|
21
|
+
|
22
|
+
def call
|
23
|
+
schema = YAML.safe_load(File.read(@pathname))
|
24
|
+
schema = transform_hash(schema)
|
25
|
+
Result.new(schema, @pathname.relative_path_from(@root).sub_ext(''))
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def transform_hash(data)
|
31
|
+
data.each_with_object({}) do |(key, val), m|
|
32
|
+
if val.is_a?(Array)
|
33
|
+
m[key] = transform_array(val)
|
34
|
+
elsif val.is_a?(Hash)
|
35
|
+
m[key] = transform_hash(val)
|
36
|
+
elsif key == '$ref'
|
37
|
+
m.merge! transform_pointer(key, val)
|
38
|
+
else
|
39
|
+
m[key] = val
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def transform_array(data)
|
45
|
+
data.each_with_object([]) do |item, m|
|
46
|
+
case item
|
47
|
+
when Hash
|
48
|
+
m.push transform_hash(item)
|
49
|
+
when Array
|
50
|
+
m.push transform_array(item)
|
51
|
+
else
|
52
|
+
m.push item
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def transform_pointer(key, target)
|
58
|
+
if %r{^#/(?<pointer>.*)} =~ target
|
59
|
+
# A JSON Pointer
|
60
|
+
{key => "#/#{@pathname.relative_path_from(@root).sub_ext('').join(pointer)}"}
|
61
|
+
elsif %r{^(?<relpath>[^#]+)(?:#/(?<pointer>.*))?} =~ target
|
62
|
+
if relpath.start_with?('paths') # path description file pointer
|
63
|
+
# Inline the file contents
|
64
|
+
self.class.parse(@root, Pathname(relpath)).data
|
65
|
+
else # A file pointer with potential JSON sub-pointer
|
66
|
+
tgt = @pathname.parent.relative_path_from(@root).join(relpath).sub_ext('')
|
67
|
+
tgt = tgt.join(pointer) if pointer
|
68
|
+
{key => "#/#{tgt}"}
|
69
|
+
end
|
70
|
+
else
|
71
|
+
{key => target}
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module OpenapiContracts
|
2
|
+
class Doc::Method
|
3
|
+
def initialize(schema)
|
4
|
+
@schema = schema
|
5
|
+
@responses = schema['responses'].to_h do |status, _|
|
6
|
+
[status, Doc::Response.new(schema.navigate('responses', status))]
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
def responses
|
11
|
+
@responses.each_value
|
12
|
+
end
|
13
|
+
|
14
|
+
def with_status(status)
|
15
|
+
@responses[status]
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -10,29 +10,21 @@ module OpenapiContracts
|
|
10
10
|
|
11
11
|
def parse(path)
|
12
12
|
abs_path = @dir.join(path)
|
13
|
-
|
13
|
+
file = Doc::FileParser.parse(@dir, abs_path)
|
14
|
+
data = file.data
|
14
15
|
data.deep_merge! merge_components
|
15
|
-
data = join_partials(abs_path.dirname, data)
|
16
16
|
nullable_to_type!(data)
|
17
17
|
end
|
18
18
|
|
19
19
|
private
|
20
20
|
|
21
|
-
def
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
def join_partials(cwd, data)
|
27
|
-
data.each_with_object({}) do |(key, val), m|
|
28
|
-
if val.is_a?(Hash)
|
29
|
-
m[key] = join_partials(cwd, val)
|
30
|
-
elsif key == '$ref' && val !~ /^#/
|
31
|
-
m.merge! parse_file(cwd.join(val))
|
32
|
-
else
|
33
|
-
m[key] = val
|
34
|
-
end
|
21
|
+
def merge_components
|
22
|
+
data = {}
|
23
|
+
Dir[File.expand_path('components/**/*.yaml', @dir)].each do |file|
|
24
|
+
result = Doc::FileParser.parse(@dir, Pathname(file))
|
25
|
+
data.deep_merge!(result.to_mergable_hash)
|
35
26
|
end
|
27
|
+
data
|
36
28
|
end
|
37
29
|
|
38
30
|
def nullable_to_type!(object)
|
@@ -48,43 +40,5 @@ module OpenapiContracts
|
|
48
40
|
object.each { |o| nullable_to_type! o }
|
49
41
|
end
|
50
42
|
end
|
51
|
-
|
52
|
-
def merge_components
|
53
|
-
data = {}
|
54
|
-
Dir[File.expand_path('components/**/*.yaml', @dir)].each do |file|
|
55
|
-
pointer = json_pointer(Pathname(file)).split('/')
|
56
|
-
i = 0
|
57
|
-
pointer.reduce(data) do |h, p|
|
58
|
-
i += 1
|
59
|
-
if i == pointer.size
|
60
|
-
h[p] = parse_file(file)
|
61
|
-
else
|
62
|
-
h[p] ||= {}
|
63
|
-
end
|
64
|
-
h[p]
|
65
|
-
end
|
66
|
-
end
|
67
|
-
data
|
68
|
-
end
|
69
|
-
|
70
|
-
def translate_paths!(data, cwd)
|
71
|
-
case data
|
72
|
-
when Array
|
73
|
-
data.each { |v| translate_paths!(v, cwd) }
|
74
|
-
when Hash
|
75
|
-
data.each_pair do |k, v|
|
76
|
-
if k == '$ref' && v !~ %r{^#/}
|
77
|
-
v.replace json_pointer(cwd.join(v), '#/')
|
78
|
-
else
|
79
|
-
translate_paths!(v, cwd)
|
80
|
-
end
|
81
|
-
end
|
82
|
-
end
|
83
|
-
end
|
84
|
-
|
85
|
-
def json_pointer(pathname, prefix = '')
|
86
|
-
relative = pathname.relative_path_from(@dir)
|
87
|
-
"#{prefix}#{relative.to_s.delete_suffix(pathname.extname)}"
|
88
|
-
end
|
89
43
|
end
|
90
44
|
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module OpenapiContracts
|
2
|
+
class Doc::Path
|
3
|
+
def initialize(schema)
|
4
|
+
@schema = schema
|
5
|
+
@methods = @schema.to_h do |method, _|
|
6
|
+
[method, Doc::Method.new(@schema.navigate(method))]
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
def methods
|
11
|
+
@methods.each_value
|
12
|
+
end
|
13
|
+
|
14
|
+
def with_method(method)
|
15
|
+
@methods[method]
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -1,38 +1,45 @@
|
|
1
1
|
module OpenapiContracts
|
2
|
+
# Represents a part or the whole schema
|
3
|
+
# Generally even parts of the schema contain the whole schema, but store the pointer to
|
4
|
+
# their position in the overall schema. This allows even small sub-schemas to resolve
|
5
|
+
# links to any other part of the schema
|
2
6
|
class Doc::Schema
|
3
|
-
attr_reader :
|
7
|
+
attr_reader :pointer, :raw
|
4
8
|
|
5
|
-
def initialize(
|
6
|
-
@
|
7
|
-
@
|
9
|
+
def initialize(raw, pointer = nil)
|
10
|
+
@raw = raw
|
11
|
+
@pointer = pointer.freeze
|
8
12
|
end
|
9
13
|
|
14
|
+
# Resolves Schema ref pointers links like "$ref: #/some/path" and returns new sub-schema
|
15
|
+
# at the target if the current schema is only a ref link.
|
10
16
|
def follow_refs
|
11
|
-
if (ref =
|
12
|
-
|
17
|
+
if (ref = as_h['$ref'])
|
18
|
+
at_pointer(ref.split('/')[1..])
|
13
19
|
else
|
14
20
|
self
|
15
21
|
end
|
16
22
|
end
|
17
23
|
|
24
|
+
# Generates a fragment pointer for the current schema path
|
18
25
|
def fragment
|
19
|
-
|
26
|
+
pointer.map { |p| p.gsub('/', '~1') }.join('/').then { |s| "#/#{s}" }
|
20
27
|
end
|
21
28
|
|
22
|
-
delegate :dig, :fetch, :key?, :[], to: :
|
29
|
+
delegate :dig, :fetch, :key?, :[], :to_h, to: :as_h
|
23
30
|
|
24
|
-
def
|
25
|
-
self.class.new(
|
31
|
+
def at_pointer(pointer)
|
32
|
+
self.class.new(raw, pointer)
|
26
33
|
end
|
27
34
|
|
28
|
-
def
|
29
|
-
return @
|
35
|
+
def as_h
|
36
|
+
return @raw if pointer.nil? || pointer.empty?
|
30
37
|
|
31
|
-
@
|
38
|
+
@raw.dig(*pointer)
|
32
39
|
end
|
33
40
|
|
34
|
-
def navigate(*
|
35
|
-
self.class.new(
|
41
|
+
def navigate(*spointer)
|
42
|
+
self.class.new(@raw, (pointer + Array.wrap(spointer)))
|
36
43
|
end
|
37
44
|
end
|
38
45
|
end
|
@@ -1,25 +1,48 @@
|
|
1
1
|
module OpenapiContracts
|
2
2
|
class Doc
|
3
|
-
autoload :Header,
|
4
|
-
autoload :
|
5
|
-
autoload :
|
6
|
-
autoload :
|
3
|
+
autoload :Header, 'openapi_contracts/doc/header'
|
4
|
+
autoload :FileParser, 'openapi_contracts/doc/file_parser'
|
5
|
+
autoload :Method, 'openapi_contracts/doc/method'
|
6
|
+
autoload :Parser, 'openapi_contracts/doc/parser'
|
7
|
+
autoload :Path, 'openapi_contracts/doc/path'
|
8
|
+
autoload :Response, 'openapi_contracts/doc/response'
|
9
|
+
autoload :Schema, 'openapi_contracts/doc/schema'
|
7
10
|
|
8
11
|
def self.parse(dir, filename = 'openapi.yaml')
|
9
12
|
new Parser.call(dir, filename)
|
10
13
|
end
|
11
14
|
|
15
|
+
attr_reader :schema
|
16
|
+
|
12
17
|
def initialize(schema)
|
13
18
|
@schema = Schema.new(schema)
|
19
|
+
@paths = @schema['paths'].to_h do |path, _|
|
20
|
+
[path, Path.new(@schema.at_pointer(['paths', path]))]
|
21
|
+
end
|
14
22
|
end
|
15
23
|
|
16
|
-
|
24
|
+
# Returns an Enumerator over all paths
|
25
|
+
def paths
|
26
|
+
@paths.each_value
|
27
|
+
end
|
17
28
|
|
18
29
|
def response_for(path, method, status)
|
19
|
-
path
|
20
|
-
|
30
|
+
with_path(path)&.with_method(method)&.with_status(status)
|
31
|
+
end
|
32
|
+
|
33
|
+
# Returns an Enumerator over all Responses
|
34
|
+
def responses(&block)
|
35
|
+
return enum_for(:responses) unless block_given?
|
36
|
+
|
37
|
+
paths.each do |path|
|
38
|
+
path.methods.each do |method|
|
39
|
+
method.responses.each(&block)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
21
43
|
|
22
|
-
|
44
|
+
def with_path(path)
|
45
|
+
@paths[path]
|
23
46
|
end
|
24
47
|
end
|
25
48
|
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module OpenapiContracts
|
2
|
+
class Match
|
3
|
+
attr_reader :errors
|
4
|
+
|
5
|
+
def initialize(doc, response, options = {})
|
6
|
+
@doc = doc
|
7
|
+
@response = response
|
8
|
+
@options = options
|
9
|
+
end
|
10
|
+
|
11
|
+
def valid?
|
12
|
+
return @errors.empty? if instance_variable_defined?(:@errors)
|
13
|
+
|
14
|
+
@errors = matchers.call
|
15
|
+
@errors.empty?
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def lookup_api_spec
|
21
|
+
@doc.response_for(
|
22
|
+
@options.fetch(:path, @response.request.path),
|
23
|
+
@response.request.request_method.downcase,
|
24
|
+
@response.status.to_s
|
25
|
+
)
|
26
|
+
end
|
27
|
+
|
28
|
+
def matchers
|
29
|
+
env = Env.new(lookup_api_spec, @response, @options[:status])
|
30
|
+
Validators::ALL.reverse
|
31
|
+
.reduce(->(err) { err }) { |s, m| m.new(s, env) }
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
RSpec::Matchers.define :match_openapi_doc do |doc, options = {}| # rubocop:disable Metrics/BlockLength
|
2
|
+
include OpenapiContracts::Helper
|
3
|
+
|
4
|
+
match do |response|
|
5
|
+
match = OpenapiContracts::Match.new(
|
6
|
+
doc,
|
7
|
+
response,
|
8
|
+
options.merge({status: @status}.compact)
|
9
|
+
)
|
10
|
+
return true if match.valid?
|
11
|
+
|
12
|
+
@errors = match.errors
|
13
|
+
false
|
14
|
+
end
|
15
|
+
|
16
|
+
description do
|
17
|
+
desc = 'to match the openapi schema'
|
18
|
+
desc << " with #{http_status_desc(@status)}" if @status
|
19
|
+
desc
|
20
|
+
end
|
21
|
+
|
22
|
+
failure_message do |_reponse|
|
23
|
+
@errors.map { |e| "* #{e}" }.join("\n")
|
24
|
+
end
|
25
|
+
|
26
|
+
def with_http_status(status)
|
27
|
+
if status.is_a? Symbol
|
28
|
+
@status = Rack::Utils::SYMBOL_TO_STATUS_CODE[status]
|
29
|
+
else
|
30
|
+
@status = status
|
31
|
+
end
|
32
|
+
self
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module OpenapiContracts::Validators
|
2
|
+
class Base
|
3
|
+
include OpenapiContracts::Helper
|
4
|
+
|
5
|
+
class_attribute :final, instance_writer: false
|
6
|
+
self.final = false
|
7
|
+
|
8
|
+
def initialize(stack, env)
|
9
|
+
@stack = stack # next matcher
|
10
|
+
@env = env
|
11
|
+
@errors = []
|
12
|
+
end
|
13
|
+
|
14
|
+
def call(errors = [])
|
15
|
+
validate
|
16
|
+
errors.push(*@errors)
|
17
|
+
# Do not call the next matcher when there is errors on a final matcher
|
18
|
+
return errors if @errors.any? && final?
|
19
|
+
|
20
|
+
@stack.call(errors)
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
delegate :expected_status, :response, :spec, to: :@env
|
26
|
+
|
27
|
+
# :nocov:
|
28
|
+
def validate
|
29
|
+
raise NotImplementedError
|
30
|
+
end
|
31
|
+
# :nocov:
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module OpenapiContracts::Validators
|
2
|
+
class Body < Base
|
3
|
+
private
|
4
|
+
|
5
|
+
def validate
|
6
|
+
if spec.no_content?
|
7
|
+
@errors << 'Expected empty response body' if response.body.present?
|
8
|
+
elsif !spec.supports_content_type?(response_content_type)
|
9
|
+
@errors << "Undocumented response with content-type #{response_content_type.inspect}"
|
10
|
+
else
|
11
|
+
validate_schema
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def validate_schema
|
16
|
+
schema = spec.schema_for(response_content_type)
|
17
|
+
# Trick JSONSchemer into validating only against the response schema
|
18
|
+
schemer = JSONSchemer.schema(schema.raw.merge('$ref' => schema.fragment))
|
19
|
+
schemer.validate(JSON(response.body)).each do |err|
|
20
|
+
@errors << error_to_message(err)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def error_to_message(error)
|
25
|
+
if error.key?('details')
|
26
|
+
error['details'].to_a.map do |(key, val)|
|
27
|
+
"#{key.humanize}: #{val} at #{error['data_pointer']}"
|
28
|
+
end.to_sentence
|
29
|
+
else
|
30
|
+
"#{error['data'].inspect} at #{error['data_pointer']} does not match the schema"
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def response_content_type
|
35
|
+
response.headers['Content-Type'].split(';').first
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module OpenapiContracts::Validators
|
2
|
+
class Documented < Base
|
3
|
+
self.final = true
|
4
|
+
|
5
|
+
private
|
6
|
+
|
7
|
+
def validate
|
8
|
+
return if spec
|
9
|
+
|
10
|
+
status_desc = http_status_desc(response.status)
|
11
|
+
@errors << "Undocumented request/response for #{response_desc.inspect} with #{status_desc}"
|
12
|
+
end
|
13
|
+
|
14
|
+
def response_desc
|
15
|
+
"#{response.request.request_method} #{response.request.path}"
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module OpenapiContracts::Validators
|
2
|
+
class Headers < Base
|
3
|
+
private
|
4
|
+
|
5
|
+
def validate
|
6
|
+
spec.headers.each do |header|
|
7
|
+
value = response.headers[header.name]
|
8
|
+
if value.blank?
|
9
|
+
@errors << "Missing header #{header.name}" if header.required?
|
10
|
+
elsif !JSONSchemer.schema(header.schema).valid?(value)
|
11
|
+
@errors << "Header #{header.name} does not match"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module OpenapiContracts::Validators
|
2
|
+
class HttpStatus < Base
|
3
|
+
self.final = true
|
4
|
+
|
5
|
+
private
|
6
|
+
|
7
|
+
def validate
|
8
|
+
return if expected_status.blank? || expected_status == response.status
|
9
|
+
|
10
|
+
@errors << "Response has #{http_status_desc}"
|
11
|
+
end
|
12
|
+
|
13
|
+
def http_status_desc
|
14
|
+
"http status #{Rack::Utils::HTTP_STATUS_CODES[response.status]} (#{response.status})"
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module OpenapiContracts
|
2
|
+
module Validators
|
3
|
+
autoload :Base, 'openapi_contracts/validators/base'
|
4
|
+
autoload :Body, 'openapi_contracts/validators/body'
|
5
|
+
autoload :Documented, 'openapi_contracts/validators/documented'
|
6
|
+
autoload :Headers, 'openapi_contracts/validators/headers'
|
7
|
+
autoload :HttpStatus, 'openapi_contracts/validators/http_status'
|
8
|
+
|
9
|
+
# Defines order of matching
|
10
|
+
ALL = [
|
11
|
+
Documented,
|
12
|
+
HttpStatus,
|
13
|
+
Body,
|
14
|
+
Headers
|
15
|
+
].freeze
|
16
|
+
end
|
17
|
+
end
|
data/lib/openapi_contracts.rb
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
require 'active_support'
|
2
2
|
require 'active_support/core_ext/array'
|
3
|
+
require 'active_support/core_ext/class'
|
3
4
|
require 'active_support/core_ext/module'
|
4
5
|
require 'active_support/core_ext/string'
|
5
6
|
|
@@ -7,12 +8,18 @@ require 'json_schemer'
|
|
7
8
|
require 'yaml'
|
8
9
|
|
9
10
|
module OpenapiContracts
|
10
|
-
autoload :Doc,
|
11
|
-
autoload :
|
12
|
-
|
11
|
+
autoload :Doc, 'openapi_contracts/doc'
|
12
|
+
autoload :Helper, 'openapi_contracts/helper'
|
13
|
+
autoload :Match, 'openapi_contracts/match'
|
14
|
+
autoload :Validators, 'openapi_contracts/validators'
|
15
|
+
|
16
|
+
Env = Struct.new(:spec, :response, :expected_status)
|
13
17
|
|
14
|
-
|
15
|
-
|
16
|
-
|
18
|
+
module_function
|
19
|
+
|
20
|
+
def match(doc, response, options = {})
|
21
|
+
Match.new(doc, response, options)
|
17
22
|
end
|
18
23
|
end
|
24
|
+
|
25
|
+
require 'openapi_contracts/rspec' if defined?(RSpec)
|
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.7.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- mkon
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-
|
11
|
+
date: 2023-05-31 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activesupport
|
@@ -44,6 +44,20 @@ dependencies:
|
|
44
44
|
- - "~>"
|
45
45
|
- !ruby/object:Gem::Version
|
46
46
|
version: 0.2.20
|
47
|
+
- !ruby/object:Gem::Dependency
|
48
|
+
name: json_spec
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - "~>"
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: 1.1.5
|
54
|
+
type: :development
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
requirements:
|
58
|
+
- - "~>"
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: 1.1.5
|
47
61
|
- !ruby/object:Gem::Dependency
|
48
62
|
name: rack
|
49
63
|
requirement: !ruby/object:Gem::Requirement
|
@@ -78,28 +92,28 @@ dependencies:
|
|
78
92
|
requirements:
|
79
93
|
- - '='
|
80
94
|
- !ruby/object:Gem::Version
|
81
|
-
version: 1.
|
95
|
+
version: 1.51.0
|
82
96
|
type: :development
|
83
97
|
prerelease: false
|
84
98
|
version_requirements: !ruby/object:Gem::Requirement
|
85
99
|
requirements:
|
86
100
|
- - '='
|
87
101
|
- !ruby/object:Gem::Version
|
88
|
-
version: 1.
|
102
|
+
version: 1.51.0
|
89
103
|
- !ruby/object:Gem::Dependency
|
90
104
|
name: rubocop-rspec
|
91
105
|
requirement: !ruby/object:Gem::Requirement
|
92
106
|
requirements:
|
93
107
|
- - '='
|
94
108
|
- !ruby/object:Gem::Version
|
95
|
-
version: 2.
|
109
|
+
version: 2.22.0
|
96
110
|
type: :development
|
97
111
|
prerelease: false
|
98
112
|
version_requirements: !ruby/object:Gem::Requirement
|
99
113
|
requirements:
|
100
114
|
- - '='
|
101
115
|
- !ruby/object:Gem::Version
|
102
|
-
version: 2.
|
116
|
+
version: 2.22.0
|
103
117
|
- !ruby/object:Gem::Dependency
|
104
118
|
name: simplecov
|
105
119
|
requirement: !ruby/object:Gem::Requirement
|
@@ -124,12 +138,22 @@ files:
|
|
124
138
|
- README.md
|
125
139
|
- lib/openapi_contracts.rb
|
126
140
|
- lib/openapi_contracts/doc.rb
|
141
|
+
- lib/openapi_contracts/doc/file_parser.rb
|
127
142
|
- lib/openapi_contracts/doc/header.rb
|
143
|
+
- lib/openapi_contracts/doc/method.rb
|
128
144
|
- lib/openapi_contracts/doc/parser.rb
|
145
|
+
- lib/openapi_contracts/doc/path.rb
|
129
146
|
- lib/openapi_contracts/doc/response.rb
|
130
147
|
- lib/openapi_contracts/doc/schema.rb
|
131
|
-
- lib/openapi_contracts/
|
132
|
-
- lib/openapi_contracts/
|
148
|
+
- lib/openapi_contracts/helper.rb
|
149
|
+
- lib/openapi_contracts/match.rb
|
150
|
+
- lib/openapi_contracts/rspec.rb
|
151
|
+
- lib/openapi_contracts/validators.rb
|
152
|
+
- lib/openapi_contracts/validators/base.rb
|
153
|
+
- lib/openapi_contracts/validators/body.rb
|
154
|
+
- lib/openapi_contracts/validators/documented.rb
|
155
|
+
- lib/openapi_contracts/validators/headers.rb
|
156
|
+
- lib/openapi_contracts/validators/http_status.rb
|
133
157
|
homepage: https://github.com/mkon/openapi_contracts
|
134
158
|
licenses:
|
135
159
|
- MIT
|
@@ -153,7 +177,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
153
177
|
- !ruby/object:Gem::Version
|
154
178
|
version: '0'
|
155
179
|
requirements: []
|
156
|
-
rubygems_version: 3.
|
180
|
+
rubygems_version: 3.4.6
|
157
181
|
signing_key:
|
158
182
|
specification_version: 4
|
159
183
|
summary: Openapi schemas as API contracts
|
@@ -1,116 +0,0 @@
|
|
1
|
-
module OpenapiContracts
|
2
|
-
module Matchers
|
3
|
-
class MatchOpenapiDoc
|
4
|
-
def initialize(doc, options)
|
5
|
-
@doc = doc
|
6
|
-
@options = options
|
7
|
-
@errors = []
|
8
|
-
end
|
9
|
-
|
10
|
-
def with_http_status(status)
|
11
|
-
if status.is_a? Symbol
|
12
|
-
@status = Rack::Utils::SYMBOL_TO_STATUS_CODE[status]
|
13
|
-
else
|
14
|
-
@status = status
|
15
|
-
end
|
16
|
-
self
|
17
|
-
end
|
18
|
-
|
19
|
-
def matches?(response)
|
20
|
-
@response = response
|
21
|
-
response_documented? && http_status_matches? && [headers_match?, body_matches?].all?
|
22
|
-
@errors.empty?
|
23
|
-
end
|
24
|
-
|
25
|
-
def description
|
26
|
-
desc = 'to match the openapi schema'
|
27
|
-
desc << " with #{http_status_desc(@status)}" if @status
|
28
|
-
desc
|
29
|
-
end
|
30
|
-
|
31
|
-
def failure_message
|
32
|
-
@errors.map { |e| "* #{e}" }.join("\n")
|
33
|
-
end
|
34
|
-
|
35
|
-
def failure_message_when_negated
|
36
|
-
'request matched the schema'
|
37
|
-
end
|
38
|
-
|
39
|
-
private
|
40
|
-
|
41
|
-
def response_desc
|
42
|
-
"#{@response.request.request_method} #{@response.request.path}"
|
43
|
-
end
|
44
|
-
|
45
|
-
def response_spec
|
46
|
-
@response_spec ||= @doc.response_for(
|
47
|
-
@options.fetch(:path, @response.request.path),
|
48
|
-
@response.request.request_method.downcase,
|
49
|
-
@response.status.to_s
|
50
|
-
)
|
51
|
-
end
|
52
|
-
|
53
|
-
def response_content_type
|
54
|
-
@response.headers['Content-Type'].split(';').first
|
55
|
-
end
|
56
|
-
|
57
|
-
def headers_match?
|
58
|
-
response_spec.headers.each do |header|
|
59
|
-
value = @response.headers[header.name]
|
60
|
-
if value.blank?
|
61
|
-
@errors << "Missing header #{header.name}" if header.required?
|
62
|
-
elsif !JSONSchemer.schema(header.schema).valid?(value)
|
63
|
-
@errors << "Header #{header.name} does not match"
|
64
|
-
end
|
65
|
-
end
|
66
|
-
@errors.empty?
|
67
|
-
end
|
68
|
-
|
69
|
-
def http_status_desc(status = nil)
|
70
|
-
status ||= @response.status
|
71
|
-
"http status #{Rack::Utils::HTTP_STATUS_CODES[status]} (#{status})"
|
72
|
-
end
|
73
|
-
|
74
|
-
def http_status_matches?
|
75
|
-
if @status.present? && @status != @response.status
|
76
|
-
@errors << "Response has #{http_status_desc}"
|
77
|
-
false
|
78
|
-
else
|
79
|
-
true
|
80
|
-
end
|
81
|
-
end
|
82
|
-
|
83
|
-
def body_matches?
|
84
|
-
if response_spec.no_content?
|
85
|
-
@errors << 'Expected empty response body' if @response.body.present?
|
86
|
-
elsif !response_spec.supports_content_type?(response_content_type)
|
87
|
-
@errors << "Undocumented response with content-type #{response_content_type.inspect}"
|
88
|
-
else
|
89
|
-
@schema = response_spec.schema_for(response_content_type)
|
90
|
-
schemer = JSONSchemer.schema(@schema.schema.merge('$ref' => @schema.fragment))
|
91
|
-
schemer.validate(JSON(@response.body)).each do |err|
|
92
|
-
@errors << error_to_message(err)
|
93
|
-
end
|
94
|
-
end
|
95
|
-
@errors.empty?
|
96
|
-
end
|
97
|
-
|
98
|
-
def error_to_message(error)
|
99
|
-
if error.key?('details')
|
100
|
-
error['details'].to_a.map do |(key, val)|
|
101
|
-
"#{key.humanize}: #{val} at #{error['data_pointer']}"
|
102
|
-
end.to_sentence
|
103
|
-
else
|
104
|
-
"#{error['data'].inspect} at #{error['data_pointer']} does not match the schema"
|
105
|
-
end
|
106
|
-
end
|
107
|
-
|
108
|
-
def response_documented?
|
109
|
-
return true if response_spec
|
110
|
-
|
111
|
-
@errors << "Undocumented request/response for #{response_desc.inspect} with #{http_status_desc}"
|
112
|
-
false
|
113
|
-
end
|
114
|
-
end
|
115
|
-
end
|
116
|
-
end
|