openapi_contracts 0.1.0 → 0.2.0

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