openapi_contracts 0.1.0 → 0.4.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: '099e0e1cdfaf7938e46ba4bdcdbad4d6627d9065a5af46eb757290b27d3faed8'
4
+ data.tar.gz: a49babd20945272751b9609fa09ddcd7b0da35824aff3de586b059c9d465cba2
5
5
  SHA512:
6
- metadata.gz: cbd07c48ee48324e0cdae9c7ea8e69defe61cdfc9c17eb4aaea396debef6d5109bbaae153cda430924d533ea415c7fc02b4a2e8c440c3ad121ca64b53aa49988
7
- data.tar.gz: a5c26d7a6391f38443688a7a7d43ba785af7ed0840e651e6a60bb0dcbbc83e09e9ec0fa860a9a9f18c2b407c11b866b9e46eaad2de51b933c60a0980e78a5f37
6
+ metadata.gz: 192fc72178785d45bb029bd9fdd2f1a8af8f33409957375e582701f539d6e8a38715856eabda776904e9acb6d4a36b330f4872a7db1b3f335daeb456299b41f8
7
+ data.tar.gz: 978184d22801606c97d24926a941b5b6d61bdf08d786417df0246cd7ff5ed8321572d60554060ec0c8575ca73f56aa41784d441e154bb9b0d1064f125f9df7d6
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.
@@ -12,7 +16,7 @@ First parse your api documentation:
12
16
 
13
17
  ```ruby
14
18
  # This must point to the folder where the "openapi.yaml" file is
15
- $doc = OpenapiContracts::Doc.parse(Rails.root.join('spec', 'fixtures', 'openapi', 'api-docs', 'openapi'))
19
+ $doc = OpenapiContracts::Doc.parse(Rails.root.join('spec/fixtures/openapi/api-docs/openapi'))
16
20
  ```
17
21
 
18
22
  Ideally you do this once in a RSpec `before(:suite)` hook.
@@ -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,78 @@ 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
+ data = join_partials(abs_path.dirname, data)
16
+ nullable_to_type!(data)
16
17
  end
17
18
 
18
19
  private
19
20
 
20
- def join_components(current_path, data)
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)
21
27
  data.each_with_object({}) do |(key, val), m|
22
28
  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))
29
+ m[key] = join_partials(cwd, val)
30
+ elsif key == '$ref' && val !~ /^#/
31
+ m.merge! parse_file(cwd.join(val))
26
32
  else
27
33
  m[key] = val
28
34
  end
29
35
  end
30
36
  end
37
+
38
+ def nullable_to_type!(object)
39
+ case object
40
+ when Hash
41
+ if object['type'] && object['nullable']
42
+ object['type'] = [object['type'], 'null']
43
+ object.delete 'nullable'
44
+ else
45
+ object.each_value { |o| nullable_to_type! o }
46
+ end
47
+ when Array
48
+ object.each { |o| nullable_to_type! o }
49
+ end
50
+ 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
+ data.each_with_object({}) do |(key, val), m|
72
+ if val.is_a?(Hash)
73
+ m[key] = translate_paths(val, cwd)
74
+ elsif key == '$ref' && val !~ %r{^#/}
75
+ m[key] = json_pointer(cwd.join(val), '#/')
76
+ else
77
+ m[key] = val
78
+ end
79
+ end
80
+ end
81
+
82
+ def json_pointer(pathname, prefix = '')
83
+ relative = pathname.relative_path_from(@dir)
84
+ "#{prefix}#{relative.to_s.delete_suffix(pathname.extname)}"
85
+ end
31
86
  end
32
87
  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.4.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-06-02 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.1
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.1
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.11.1
96
+ type: :development
97
+ prerelease: false
98
+ version_requirements: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - '='
101
+ - !ruby/object:Gem::Version
102
+ version: 2.11.1
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.3'
120
150
  required_rubygems_version: !ruby/object:Gem::Requirement
121
151
  requirements:
122
152
  - - ">="