openapi_contracts 0.6.0 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
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