openapi_contracts 0.1.0 → 0.2.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: 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
  - - ">="