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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dc0a8f6f92b0615788c15cd3d715fe4308e1058616c6f892140addbcae3b9232
4
- data.tar.gz: f7328963227bc196797674e1bd5f04af471a05507d0ec848a8386da5a568c1aa
3
+ metadata.gz: 56248ce2cb1b1a67d4cf1d7390a9815bb9f20a3b9b488e0dd116aa994f8bd9a0
4
+ data.tar.gz: a6a8df4841e768c0905bad6699ef0d9295221dad637e5ef8a01c35bb9442a8f0
5
5
  SHA512:
6
- metadata.gz: 550501aa45c7e5b66dfa666ecc3354b41a5f8c49d067490c91a964b0bf338ca55b5e785ae578c0e82da15f5d5fdbe2bcd94c99e716fee81d7506ccd7cc05aa24
7
- data.tar.gz: fa614dbb218e74bf497ac25d46d284bf32e6c9de9bda65a819f12f26a8172e405c9cd8ad1958fd760b69501e8e98214bdfb63c97e404bdfcb2590c47f1c21a8e
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
- data = parse_file(abs_path, translate: false)
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 parse_file(path, translate: true)
22
- schema = YAML.safe_load(File.read(path))
23
- translate ? translate_paths!(schema, Pathname(path).parent) : schema
24
- end
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 :path, :schema
7
+ attr_reader :pointer, :raw
4
8
 
5
- def initialize(schema, path = nil)
6
- @schema = schema
7
- @path = path.freeze
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 = dig('$ref'))
12
- at_path(ref.split('/')[1..])
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
- path.map { |p| p.gsub('/', '~1') }.join('/').then { |s| "#/#{s}" }
26
+ pointer.map { |p| p.gsub('/', '~1') }.join('/').then { |s| "#/#{s}" }
20
27
  end
21
28
 
22
- delegate :dig, :fetch, :key?, :[], to: :to_h
29
+ delegate :dig, :fetch, :key?, :[], :to_h, to: :as_h
23
30
 
24
- def at_path(path)
25
- self.class.new(schema, path)
31
+ def at_pointer(pointer)
32
+ self.class.new(raw, pointer)
26
33
  end
27
34
 
28
- def to_h
29
- return @schema if path.nil? || path.empty?
35
+ def as_h
36
+ return @raw if pointer.nil? || pointer.empty?
30
37
 
31
- @schema.dig(*path)
38
+ @raw.dig(*pointer)
32
39
  end
33
40
 
34
- def navigate(*sub_path)
35
- self.class.new(schema, (path + Array.wrap(sub_path)))
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, 'openapi_contracts/doc/header'
4
- autoload :Parser, 'openapi_contracts/doc/parser'
5
- autoload :Response, 'openapi_contracts/doc/response'
6
- autoload :Schema, 'openapi_contracts/doc/schema'
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
- delegate :dig, :fetch, :[], :at_path, to: :@schema
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 = ['paths', path, method, 'responses', status]
20
- return unless dig(*path).present?
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
- Response.new(@schema.at_path(path))
44
+ def with_path(path)
45
+ @paths[path]
23
46
  end
24
47
  end
25
48
  end
@@ -0,0 +1,7 @@
1
+ module OpenapiContracts
2
+ module Helper
3
+ def http_status_desc(status)
4
+ "http status #{Rack::Utils::HTTP_STATUS_CODES[status]} (#{status})"
5
+ end
6
+ end
7
+ 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
@@ -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, 'openapi_contracts/doc'
11
- autoload :Matchers, 'openapi_contracts/matchers'
12
- end
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
- if defined?(RSpec)
15
- RSpec.configure do |config|
16
- config.include OpenapiContracts::Matchers
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.6.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-02-11 00:00:00.000000000 Z
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.44.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.44.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.18.1
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.18.1
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/matchers.rb
132
- - lib/openapi_contracts/matchers/match_openapi_doc.rb
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.1.6
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
@@ -1,9 +0,0 @@
1
- module OpenapiContracts
2
- module Matchers
3
- autoload :MatchOpenapiDoc, 'openapi_contracts/matchers/match_openapi_doc'
4
-
5
- def match_openapi_doc(doc, options = {})
6
- MatchOpenapiDoc.new(doc, options)
7
- end
8
- end
9
- end