openapi_contracts 0.9.1 → 0.10.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: 989040d95a13df9bd1c3138c908071ff04593a4de03422e13ae4262dd6414cdb
4
- data.tar.gz: 1d5202b6e194132c185ed6476e169b4678dbc9e234225c30de65cf2decbeb53b
3
+ metadata.gz: 5891083b3cb25419145aab30a963c61f28973c40b8a4a74ea080b63bc970670b
4
+ data.tar.gz: 5febe1b938ba77edef2e6955e3b8cf8a161f40e5b5f828c6cc39f5105ed734d7
5
5
  SHA512:
6
- metadata.gz: 8c4f45d3aa218538737b24630b952fe90c91d824063b2a18573d681809edb6766d28dc0d3503f126b204d48a92ee7120d83f1f73c96dfc94df678d282e275f08
7
- data.tar.gz: 2782dbc72660c32caf5dc8b23d7719df2b1176c2cb2c7feeef838f54f6e306ef3574abb836b26ee66c25f5f8e394f1462d660712f56de90ab37f0aac1e873990
6
+ metadata.gz: 87441e69b4caa9ac627e82c27e3d0b0e15162da1587c3bdb7ed4f5c2102fdda02f583df01652676d056b60af24fa9c67dedf38e38b1dfed346211f439de640c2
7
+ data.tar.gz: a725d1696dc0e7a3ee19bc3c46c1248f0ada9185bb492f44ea5c571944d98dbb6eb0f69625f68169ed931717782438524bbdc337014ea83116ad60a2d565b545
data/README.md CHANGED
@@ -74,6 +74,33 @@ result = OpenapiContracts.match($doc, response, options = {})
74
74
  raise result.errors.merge("/n") unless result.valid?
75
75
  ```
76
76
 
77
+ ## Coverage reporting
78
+
79
+ You can generate a coverage report, giving an indication how many of your OpenApi operations and
80
+ responses are verified.
81
+
82
+ To enable the report, set the configuration `OpenapiContracts.collect_coverage = true`.
83
+
84
+ After the tests completed, you can generate the JSON file, for example:
85
+
86
+ ```ruby
87
+ RSpec.configure do |c|
88
+ c.after(:suite) do
89
+ $your_api_doc.coverage.report.generate(Rails.root.join("openapi_coverage.json"))
90
+ end
91
+ end
92
+ ```
93
+
94
+ In case you run tests on multiple nodes and need to merge reports:
95
+
96
+ ```ruby
97
+ OpenapiContracts::Coverage.merge_reports(
98
+ $your_api_doc,
99
+ *Dir[Rails.root.join("openapi_coverage_*.json")]
100
+ ).generate(Rails.root.join("openapi_coverage.json"))
101
+
102
+ ```
103
+
77
104
  ## How it works
78
105
 
79
106
  It uses the `request.path`, `request.method`, `status` and `headers` on the test subject
@@ -88,16 +115,7 @@ Then it does the following checks:
88
115
 
89
116
  ## Known Issues
90
117
 
91
- ### OpenApi 3.0
92
-
93
- For openapi schemas < 3.1, data is validated using JSON Schema Draft 04, even tho OpenApi 3.0 is a super+subset of Draft 05.
94
- This is due to the fact that we validate the data using json-schemer which does not support 05 and even then would not be fully compatible.
95
- However compatibility issues should be fairly rare and there might be workarounds by describing the data slightly different.
96
-
97
- ### OpenAPi 3.1
98
-
99
- Here exists a similar problem. OpenApi 3.1 is finally fully compatible with JSON Draft 2020-12, but there is no support yet in json-schemer,
100
- so we use the closest draft which is 07.
118
+ None at the moment :)
101
119
 
102
120
  ## Future plans
103
121
 
@@ -0,0 +1,63 @@
1
+ class OpenapiContracts::Coverage
2
+ class Report
3
+ def self.merge(doc, *reports)
4
+ reports.each_with_object(Report.new(doc)) do |r, m|
5
+ m.merge!(r)
6
+ end
7
+ end
8
+
9
+ attr_reader :data
10
+
11
+ def as_json(*)
12
+ report
13
+ end
14
+
15
+ def initialize(doc, data = {})
16
+ @doc = doc
17
+ @data = data
18
+ end
19
+
20
+ def generate(pathname)
21
+ File.write(pathname, JSON.pretty_generate(report))
22
+ end
23
+
24
+ def merge!(data)
25
+ @data.deep_merge!(data) { |_key, val1, val2| val1 + val2 }
26
+ end
27
+
28
+ def meta
29
+ {
30
+ 'operations' => {
31
+ 'covered' => total_covered_operations,
32
+ 'total' => @doc.operations.count
33
+ },
34
+ 'responses' => {
35
+ 'covered' => total_covered_responses,
36
+ 'total' => @doc.responses.count
37
+ }
38
+ }.tap do |d|
39
+ d['operations']['quota'] = d['operations']['covered'].to_f / d['operations']['total']
40
+ d['responses']['quota'] = d['responses']['covered'].to_f / d['responses']['total']
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def report
47
+ {
48
+ 'meta' => meta,
49
+ 'paths' => @data
50
+ }
51
+ end
52
+
53
+ def total_covered_operations
54
+ @doc.operations.select { |o| @data.dig(o.path.to_s, o.verb).present? }.count
55
+ end
56
+
57
+ def total_covered_responses
58
+ @doc.responses.select { |r|
59
+ @data.dig(r.operation.path.to_s, r.operation.verb, r.status).present?
60
+ }.count
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,19 @@
1
+ class OpenapiContracts::Coverage
2
+ class Store
3
+ attr_accessor :data
4
+
5
+ def initialize
6
+ @data = {}
7
+ end
8
+
9
+ def clear!
10
+ @data = {}
11
+ end
12
+
13
+ def increment!(path, method, status, media_type)
14
+ keys = [path, method, status]
15
+ val = @data.dig(*keys) || Hash.new(0).tap { |h| OpenapiContracts.hash_bury!(@data, keys, h) }
16
+ val[media_type] += 1
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,24 @@
1
+ module OpenapiContracts
2
+ class Coverage
3
+ autoload :Report, 'openapi_contracts/coverage/report'
4
+ autoload :Store, 'openapi_contracts/coverage/store'
5
+
6
+ def self.merge_reports(doc, *others)
7
+ reports = others.map { |fp| JSON(File.read(fp))['paths'] }
8
+ Report.merge(doc, *reports)
9
+ end
10
+
11
+ attr_reader :store
12
+
13
+ def initialize(doc)
14
+ @store = Store.new
15
+ @doc = doc
16
+ end
17
+
18
+ delegate :clear!, :data, :increment!, to: :store
19
+
20
+ def report
21
+ Report.new(@doc, store.data)
22
+ end
23
+ end
24
+ end
@@ -2,14 +2,20 @@ module OpenapiContracts
2
2
  class Doc::Operation
3
3
  include Doc::WithParameters
4
4
 
5
+ attr_reader :path
6
+
5
7
  def initialize(path, spec)
6
8
  @path = path
7
9
  @spec = spec
8
- @responses = spec.navigate('responses').each.to_h do |status, subspec| # rubocop:disable Style/HashTransformValues
9
- [status, Doc::Response.new(subspec)]
10
+ @responses = spec.navigate('responses').each.to_h do |status, subspec|
11
+ [status, Doc::Response.new(self, status, subspec)]
10
12
  end
11
13
  end
12
14
 
15
+ def verb
16
+ @spec.pointer[2]
17
+ end
18
+
13
19
  def request_body
14
20
  return @request_body if instance_variable_defined?(:@request_body)
15
21
 
@@ -28,10 +28,7 @@ module OpenapiContracts
28
28
  private
29
29
 
30
30
  def schemer
31
- @schemer ||= begin
32
- schema = @spec.navigate('schema')
33
- JSONSchemer.schema(Validators::SchemaValidation.build_validation_schema(schema))
34
- end
31
+ @schemer ||= Validators::SchemaValidation.validation_schemer(@spec.navigate('schema'))
35
32
  end
36
33
 
37
34
  def integer_parameter_matches?(value)
@@ -10,6 +10,9 @@ module OpenapiContracts
10
10
  @path = path
11
11
  @spec = spec
12
12
  @supported_methods = HTTP_METHODS & @spec.keys
13
+ @operations = @supported_methods.to_h do |verb|
14
+ [verb, Doc::Operation.new(self, @spec.navigate(verb))]
15
+ end
13
16
  end
14
17
 
15
18
  def dynamic?
@@ -17,7 +20,7 @@ module OpenapiContracts
17
20
  end
18
21
 
19
22
  def operations
20
- @supported_methods.each.lazy.map { |m| Doc::Operation.new(self, @spec.navigate(m)) }
23
+ @operations.each_value
21
24
  end
22
25
 
23
26
  def path_regexp
@@ -34,13 +37,15 @@ module OpenapiContracts
34
37
  end
35
38
 
36
39
  def supports_method?(method)
37
- @supported_methods.include?(method)
40
+ @operations.key?(method)
38
41
  end
39
42
 
40
- def with_method(method)
41
- return unless supports_method?(method)
43
+ def to_s
44
+ @path
45
+ end
42
46
 
43
- Doc::Operation.new(self, @spec.navigate(method))
47
+ def with_method(method)
48
+ @operations[method]
44
49
  end
45
50
  end
46
51
  end
@@ -22,7 +22,7 @@ module OpenapiContracts
22
22
  "<#{self.class.name}#{to_a}>"
23
23
  end
24
24
 
25
- delegate :empty?, to: :@segments
25
+ delegate :any?, :empty?, :[], to: :@segments
26
26
 
27
27
  def navigate(*segments)
28
28
  self.class[to_a + segments]
@@ -1,6 +1,12 @@
1
1
  module OpenapiContracts
2
2
  class Doc::Response
3
- def initialize(schema)
3
+ attr_reader :coverage, :schema, :status, :operation
4
+
5
+ delegate :pointer, to: :schema
6
+
7
+ def initialize(operation, status, schema)
8
+ @operation = operation
9
+ @status = status
4
10
  @schema = schema.follow_refs
5
11
  end
6
12
 
@@ -14,7 +14,7 @@ module OpenapiContracts
14
14
  new Parser.call(dir, filename)
15
15
  end
16
16
 
17
- attr_reader :schema
17
+ attr_reader :coverage, :schema
18
18
 
19
19
  def initialize(raw)
20
20
  @schema = Schema.new(raw)
@@ -22,6 +22,7 @@ module OpenapiContracts
22
22
  [path, Path.new(path, @schema.at_pointer(Doc::Pointer['paths', path]))]
23
23
  end
24
24
  @dynamic_paths = paths.select(&:dynamic?)
25
+ @coverage = Coverage.new(self)
25
26
  end
26
27
 
27
28
  # Returns an Enumerator over all paths
@@ -33,14 +34,21 @@ module OpenapiContracts
33
34
  OperationRouter.new(self).route(path, method.downcase)
34
35
  end
35
36
 
37
+ # Returns an Enumerator over all Operations
38
+ def operations(&block)
39
+ return enum_for(:operations) unless block_given?
40
+
41
+ paths.each do |path|
42
+ path.operations.each(&block)
43
+ end
44
+ end
45
+
36
46
  # Returns an Enumerator over all Responses
37
47
  def responses(&block)
38
48
  return enum_for(:responses) unless block_given?
39
49
 
40
- paths.each do |path|
41
- path.operations.each do |operation|
42
- operation.responses.each(&block)
43
- end
50
+ operations.each do |operation|
51
+ operation.responses.each(&block)
44
52
  end
45
53
  end
46
54
 
@@ -19,11 +19,20 @@ module OpenapiContracts
19
19
  return @errors.empty? if instance_variable_defined?(:@errors)
20
20
 
21
21
  @errors = matchers.call
22
+ @doc.coverage.increment!(operation.path.to_s, request_method, status, media_type) if collect_coverage?
22
23
  @errors.empty?
23
24
  end
24
25
 
25
26
  private
26
27
 
28
+ def collect_coverage?
29
+ OpenapiContracts.collect_coverage && @request.present? && @errors.empty? && !@options[:nocov]
30
+ end
31
+
32
+ def media_type
33
+ @response.headers['Content-Type']&.split(';')&.first || 'no_content'
34
+ end
35
+
27
36
  def matchers
28
37
  env = Env.new(
29
38
  options: @options,
@@ -39,10 +48,7 @@ module OpenapiContracts
39
48
  end
40
49
 
41
50
  def operation
42
- @doc.operation_for(
43
- @options.fetch(:path, @request.path),
44
- @request.request_method.downcase
45
- )
51
+ @operation ||= @doc.operation_for(path, request_method)
46
52
  end
47
53
 
48
54
  def request_compatible?
@@ -54,5 +60,17 @@ module OpenapiContracts
54
60
  ancestors = @response.class.ancestors.map(&:to_s)
55
61
  MIN_RESPONSE_ANCESTORS.all? { |s| ancestors.include?(s) }
56
62
  end
63
+
64
+ def request_method
65
+ @request.request_method.downcase
66
+ end
67
+
68
+ def path
69
+ @options.fetch(:path, @request.path)
70
+ end
71
+
72
+ def status
73
+ @response.status.to_s
74
+ end
57
75
  end
58
76
  end
@@ -2,7 +2,7 @@ module OpenapiContracts
2
2
  class Parser
3
3
  autoload :Transformers, 'openapi_contracts/parser/transformers'
4
4
 
5
- TRANSFORMERS = [Transformers::Nullable, Transformers::Pointer].freeze
5
+ TRANSFORMERS = [Transformers::Pointer].freeze
6
6
 
7
7
  def self.call(dir, filename)
8
8
  new(dir.join(filename)).parse
@@ -20,13 +20,17 @@ module OpenapiContracts
20
20
  @filenesting = build_file_list
21
21
  @filenesting.each_with_object({}) do |(path, pointer), schema|
22
22
  target = pointer.to_a.reduce(schema) { |d, k| d[k] ||= {} }
23
- target.delete('$ref') # ref file pointers must be replaced
23
+ target.delete('$ref') # ref file pointers should be in the file list so save to delete
24
24
  target.merge! file_to_data(path, pointer)
25
25
  end
26
26
  end
27
27
 
28
28
  private
29
29
 
30
+ # file list consists of
31
+ # - root file
32
+ # - all files in components/
33
+ # - all path files referenced by the root file
30
34
  def build_file_list
31
35
  list = {@rootfile.relative_path_from(@cwd) => Doc::Pointer[]}
32
36
  Dir[File.expand_path('components/**/*.yaml', @cwd)].each do |file|
@@ -19,9 +19,11 @@ RSpec::Matchers.define :match_openapi_doc do |doc, options = {}| # rubocop:disab
19
19
  desc
20
20
  end
21
21
 
22
+ # :nocov:
22
23
  failure_message do |_response|
23
24
  @errors.map { |e| "* #{e}" }.join("\n")
24
25
  end
26
+ # :nocov:
25
27
 
26
28
  def with_http_status(status)
27
29
  if status.is_a? Symbol
@@ -2,37 +2,31 @@ module OpenapiContracts::Validators
2
2
  module SchemaValidation
3
3
  module_function
4
4
 
5
- def build_validation_schema(schema)
6
- schema.raw.merge(
7
- '$ref' => schema.fragment,
8
- '$schema' => schema_draft_version(schema)
9
- )
5
+ def error_to_message(error)
6
+ msg = error['error']
7
+ msg.sub!(/^value/, error['data'].to_json) if error['data'].to_json.length < 50
8
+ msg
10
9
  end
11
10
 
12
- def error_to_message(error)
13
- pointer = " at #{error['data_pointer']}" if error['data_pointer'].present?
14
- if error.key?('details')
15
- error['details'].to_a.map { |(key, val)|
16
- "#{key.humanize}: #{val}#{pointer}"
17
- }.to_sentence
11
+ def schema_draft_version(schema)
12
+ if schema.openapi_version.blank? || schema.openapi_version < Gem::Version.new('3.1')
13
+ JSONSchemer.openapi30
18
14
  else
19
- "#{error['data'].inspect}#{pointer} does not match the schema"
15
+ JSONSchemer.openapi31
20
16
  end
21
17
  end
22
18
 
23
- def schema_draft_version(schema)
24
- if schema.openapi_version.blank? || schema.openapi_version < Gem::Version.new('3.1')
25
- # Closest compatible version is actually draft 5 but not supported by json-schemer
26
- 'http://json-schema.org/draft-04/schema#'
19
+ def validation_schemer(schema)
20
+ schemer = JSONSchemer.schema(schema.raw, meta_schema: schema_draft_version(schema))
21
+ if schema.pointer.any?
22
+ schemer.ref(schema.fragment)
27
23
  else
28
- # >= 3.1 is actually comptable with 2020-12 but not yet supported by json-schemer
29
- 'http://json-schema.org/draft-07/schema#'
24
+ schemer
30
25
  end
31
26
  end
32
27
 
33
28
  def validate_schema(schema, data)
34
- schemer = JSONSchemer.schema(build_validation_schema(schema))
35
- schemer.validate(data).map do |err|
29
+ validation_schemer(schema).validate(data).map do |err|
36
30
  error_to_message(err)
37
31
  end
38
32
  end
@@ -11,6 +11,7 @@ require 'rack'
11
11
  require 'yaml'
12
12
 
13
13
  module OpenapiContracts
14
+ autoload :Coverage, 'openapi_contracts/coverage'
14
15
  autoload :Doc, 'openapi_contracts/doc'
15
16
  autoload :Helper, 'openapi_contracts/helper'
16
17
  autoload :Match, 'openapi_contracts/match'
@@ -19,13 +20,28 @@ module OpenapiContracts
19
20
  autoload :PayloadParser, 'openapi_contracts/payload_parser'
20
21
  autoload :Validators, 'openapi_contracts/validators'
21
22
 
23
+ include ActiveSupport::Configurable
24
+
22
25
  Env = Struct.new(:operation, :options, :request, :response, keyword_init: true)
23
26
 
27
+ config_accessor(:collect_coverage) { false }
28
+
24
29
  module_function
25
30
 
26
31
  def match(doc, response, options = {})
27
32
  Match.new(doc, response, options)
28
33
  end
34
+
35
+ def hash_bury(hash, keys, value)
36
+ other = keys.reverse.reduce(value) { |m, k| {k => m} }
37
+ hash.deep_merge other
38
+ end
39
+
40
+ def hash_bury!(hash, keys, value)
41
+ other = keys.reverse.reduce(value) { |m, k| {k => m} }
42
+ hash.deep_merge! other
43
+ other
44
+ end
29
45
  end
30
46
 
31
47
  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.9.1
4
+ version: 0.10.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-08-16 00:00:00.000000000 Z
11
+ date: 2023-09-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -36,14 +36,14 @@ dependencies:
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: 1.0.3
39
+ version: 2.0.0
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: 1.0.3
46
+ version: 2.0.0
47
47
  - !ruby/object:Gem::Dependency
48
48
  name: rack
49
49
  requirement: !ruby/object:Gem::Requirement
@@ -92,28 +92,28 @@ dependencies:
92
92
  requirements:
93
93
  - - '='
94
94
  - !ruby/object:Gem::Version
95
- version: 1.55.1
95
+ version: 1.56.1
96
96
  type: :development
97
97
  prerelease: false
98
98
  version_requirements: !ruby/object:Gem::Requirement
99
99
  requirements:
100
100
  - - '='
101
101
  - !ruby/object:Gem::Version
102
- version: 1.55.1
102
+ version: 1.56.1
103
103
  - !ruby/object:Gem::Dependency
104
104
  name: rubocop-rspec
105
105
  requirement: !ruby/object:Gem::Requirement
106
106
  requirements:
107
107
  - - '='
108
108
  - !ruby/object:Gem::Version
109
- version: 2.23.0
109
+ version: 2.23.2
110
110
  type: :development
111
111
  prerelease: false
112
112
  version_requirements: !ruby/object:Gem::Requirement
113
113
  requirements:
114
114
  - - '='
115
115
  - !ruby/object:Gem::Version
116
- version: 2.23.0
116
+ version: 2.23.2
117
117
  - !ruby/object:Gem::Dependency
118
118
  name: simplecov
119
119
  requirement: !ruby/object:Gem::Requirement
@@ -137,6 +137,9 @@ extra_rdoc_files: []
137
137
  files:
138
138
  - README.md
139
139
  - lib/openapi_contracts.rb
140
+ - lib/openapi_contracts/coverage.rb
141
+ - lib/openapi_contracts/coverage/report.rb
142
+ - lib/openapi_contracts/coverage/store.rb
140
143
  - lib/openapi_contracts/doc.rb
141
144
  - lib/openapi_contracts/doc/header.rb
142
145
  - lib/openapi_contracts/doc/operation.rb
@@ -153,7 +156,6 @@ files:
153
156
  - lib/openapi_contracts/parser.rb
154
157
  - lib/openapi_contracts/parser/transformers.rb
155
158
  - lib/openapi_contracts/parser/transformers/base.rb
156
- - lib/openapi_contracts/parser/transformers/nullable.rb
157
159
  - lib/openapi_contracts/parser/transformers/pointer.rb
158
160
  - lib/openapi_contracts/payload_parser.rb
159
161
  - lib/openapi_contracts/rspec.rb
@@ -188,7 +190,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
188
190
  - !ruby/object:Gem::Version
189
191
  version: '0'
190
192
  requirements: []
191
- rubygems_version: 3.4.6
193
+ rubygems_version: 3.4.18
192
194
  signing_key:
193
195
  specification_version: 4
194
196
  summary: Openapi schemas as API contracts
@@ -1,10 +0,0 @@
1
- module OpenapiContracts::Parser::Transformers
2
- class Nullable < Base
3
- def call(object)
4
- return unless object['type'].present? && object['nullable'] == true
5
-
6
- object.delete('nullable')
7
- object['type'] = [object['type'], 'null']
8
- end
9
- end
10
- end