openapi_contracts 0.7.0 → 0.8.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: 56248ce2cb1b1a67d4cf1d7390a9815bb9f20a3b9b488e0dd116aa994f8bd9a0
4
- data.tar.gz: a6a8df4841e768c0905bad6699ef0d9295221dad637e5ef8a01c35bb9442a8f0
3
+ metadata.gz: 25a37c81bfa9cb9d1d26f26a25b7c81f0675a153c199223639bdd48c76537cb6
4
+ data.tar.gz: 48529ed541f4ad72568d222d2a57e30ef7f1a7b5e59fbb447961a58d57eafe26
5
5
  SHA512:
6
- metadata.gz: 578b3ef8c71e5a19667e34e2791be7051693d198bf62dfcdaa8b9fc09403cbe54bc12af266c61103eb743a32a8ea0f88552d6eefeecba7ca14d55a3868f0deb8
7
- data.tar.gz: 07c76b46bd842bfb5aa60e9af6cf30a2a45185b682e841183624c222580b3022c7b19239bb2b081a0ae9eedbab365ce78ec55bd4a76e5ba02b9d120fd2a6d853
6
+ metadata.gz: 1cc4ee62cea4e015834ae6a493bc771f13670b553cfdc1085b7dac79ea5a347583786ec4cd1db31c9054d88a010dd5d779c64f13df4388a3f55a3eae07ef577a
7
+ data.tar.gz: 9991124beb074d92107b1026e6ffd529cca988d8154eac6c6cc92aa5103df5de11cf917d1809545e26810de07959202f295a5d23a1a15077e639d2212bbc6286
data/README.md CHANGED
@@ -47,8 +47,9 @@ it 'responds with 200 and matches the doc' do
47
47
 
48
48
  The `match_openapi_doc($doc)` method allows passing options as a 2nd argument.
49
49
  This allows overriding the default request.path lookup in case this does not find
50
- the correct response definition in your schema. This is especially important with
51
- dynamic paths.
50
+ the correct response definition in your schema. This can be usefull when there is
51
+ dynamic parameters in the path and the matcher fails to resolve the request path to
52
+ an endpoint in the openapi specification.
52
53
 
53
54
  Example:
54
55
 
@@ -67,7 +68,9 @@ raise result.errors.merge("/n") unless result.valid?
67
68
 
68
69
  ### How it works
69
70
 
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:
71
+ It uses the `request.path`, `request.method`, `status` and `headers` on the test subject
72
+ (which must be the response) to find the response schema in the OpenAPI document.
73
+ Then it does the following checks:
71
74
 
72
75
  * The response is documented
73
76
  * Required headers are present
@@ -10,13 +10,14 @@ module OpenapiContracts
10
10
  end
11
11
  end
12
12
 
13
- def self.parse(root, pathname)
14
- new(root, pathname).call
13
+ def self.parse(rootfile, pathname)
14
+ new(rootfile, pathname).call
15
15
  end
16
16
 
17
- def initialize(root, pathname)
18
- @root = root
19
- @pathname = pathname.relative? ? root.join(pathname) : pathname
17
+ def initialize(rootfile, pathname)
18
+ @root = rootfile.parent
19
+ @rootfile = rootfile
20
+ @pathname = pathname.relative? ? @root.join(pathname) : pathname
20
21
  end
21
22
 
22
23
  def call
@@ -57,11 +58,11 @@ module OpenapiContracts
57
58
  def transform_pointer(key, target)
58
59
  if %r{^#/(?<pointer>.*)} =~ target
59
60
  # A JSON Pointer
60
- {key => "#/#{@pathname.relative_path_from(@root).sub_ext('').join(pointer)}"}
61
+ {key => generate_absolute_pointer(pointer)}
61
62
  elsif %r{^(?<relpath>[^#]+)(?:#/(?<pointer>.*))?} =~ target
62
63
  if relpath.start_with?('paths') # path description file pointer
63
64
  # Inline the file contents
64
- self.class.parse(@root, Pathname(relpath)).data
65
+ self.class.parse(@rootfile, Pathname(relpath)).data
65
66
  else # A file pointer with potential JSON sub-pointer
66
67
  tgt = @pathname.parent.relative_path_from(@root).join(relpath).sub_ext('')
67
68
  tgt = tgt.join(pointer) if pointer
@@ -71,5 +72,14 @@ module OpenapiContracts
71
72
  {key => target}
72
73
  end
73
74
  end
75
+
76
+ # A JSON pointer to the currently parsed file as seen from the root openapi file
77
+ def generate_absolute_pointer(json_pointer)
78
+ if @rootfile == @pathname
79
+ "#/#{json_pointer}"
80
+ else
81
+ "#/#{@pathname.relative_path_from(@root).sub_ext('').join(json_pointer)}"
82
+ end
83
+ end
74
84
  end
75
85
  end
@@ -0,0 +1,86 @@
1
+ module OpenapiContracts
2
+ class Doc::Parameter
3
+ attr_reader :schema
4
+
5
+ def initialize(options)
6
+ @name = options[:name]
7
+ @in = options[:in]
8
+ @required = options[:required]
9
+ @schema = options[:schema]
10
+ end
11
+
12
+ def matches?(value)
13
+ case schema['type']
14
+ when 'integer'
15
+ integer_parameter_matches?(value)
16
+ when 'number'
17
+ number_parameter_matches?(value)
18
+ when 'string'
19
+ string_parameter_matches?(value)
20
+ else
21
+ # Not yet implemented
22
+ false
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def integer_parameter_matches?(value)
29
+ return false unless /^-?\d+$/.match?(value)
30
+
31
+ parsed = value.to_i
32
+ return false unless minimum_number_matches?(parsed)
33
+ return false unless maximum_number_matches?(parsed)
34
+
35
+ true
36
+ end
37
+
38
+ def number_parameter_matches?(value)
39
+ return false unless /^-?(\d+\.)?\d+$/.match?(value)
40
+
41
+ parsed = value.to_f
42
+ return false unless minimum_number_matches?(parsed)
43
+ return false unless maximum_number_matches?(parsed)
44
+
45
+ true
46
+ end
47
+
48
+ def minimum_number_matches?(value)
49
+ if (min = schema['minimum'])
50
+ if schema['exclusiveMinimum']
51
+ return false if value <= min
52
+ elsif value < min
53
+ return false
54
+ end
55
+ end
56
+ true
57
+ end
58
+
59
+ def maximum_number_matches?(value)
60
+ if (max = schema['maximum'])
61
+ if schema['exclusiveMaximum']
62
+ return false if value >= max
63
+ elsif value > max
64
+ return false
65
+ end
66
+ end
67
+ true
68
+ end
69
+
70
+ def string_parameter_matches?(value)
71
+ if (pat = schema['pattern'])
72
+ Regexp.new(pat).match?(value)
73
+ else
74
+ if (min = schema['minLength']) && (value.length < min)
75
+ return false
76
+ end
77
+
78
+ if (max = schema['maxLength']) && (value.length > max)
79
+ return false
80
+ end
81
+
82
+ true
83
+ end
84
+ end
85
+ end
86
+ end
@@ -1,27 +1,27 @@
1
1
  module OpenapiContracts
2
2
  class Doc::Parser
3
3
  def self.call(dir, filename)
4
- new(dir).parse(filename)
4
+ new(dir.join(filename)).parse
5
5
  end
6
6
 
7
- def initialize(dir)
8
- @dir = dir
7
+ def initialize(rootfile)
8
+ @rootfile = rootfile
9
9
  end
10
10
 
11
- def parse(path)
12
- abs_path = @dir.join(path)
13
- file = Doc::FileParser.parse(@dir, abs_path)
11
+ def parse
12
+ file = Doc::FileParser.parse(@rootfile, @rootfile)
14
13
  data = file.data
15
14
  data.deep_merge! merge_components
16
15
  nullable_to_type!(data)
16
+ # debugger
17
17
  end
18
18
 
19
19
  private
20
20
 
21
21
  def merge_components
22
22
  data = {}
23
- Dir[File.expand_path('components/**/*.yaml', @dir)].each do |file|
24
- result = Doc::FileParser.parse(@dir, Pathname(file))
23
+ Dir[File.expand_path('components/**/*.yaml', @rootfile.parent)].each do |file|
24
+ result = Doc::FileParser.parse(@rootfile, Pathname(file))
25
25
  data.deep_merge!(result.to_mergable_hash)
26
26
  end
27
27
  data
@@ -1,18 +1,61 @@
1
1
  module OpenapiContracts
2
2
  class Doc::Path
3
- def initialize(schema)
3
+ def initialize(path, schema)
4
+ @path = path
4
5
  @schema = schema
5
- @methods = @schema.to_h do |method, _|
6
+
7
+ @methods = (known_http_methods & @schema.keys).to_h do |method|
6
8
  [method, Doc::Method.new(@schema.navigate(method))]
7
9
  end
8
10
  end
9
11
 
12
+ def dynamic?
13
+ @path.include?('{')
14
+ end
15
+
16
+ def matches?(path)
17
+ @path == path || regexp_path.match(path) do |m|
18
+ m.named_captures.each do |k, v|
19
+ return false unless parameter_matches?(k, v)
20
+ end
21
+ true
22
+ end
23
+ end
24
+
10
25
  def methods
11
26
  @methods.each_value
12
27
  end
13
28
 
29
+ def static?
30
+ !dynamic?
31
+ end
32
+
14
33
  def with_method(method)
15
34
  @methods[method]
16
35
  end
36
+
37
+ private
38
+
39
+ def parameter_matches?(name, value)
40
+ parameter = @schema['parameters']
41
+ &.find { |p| p['name'] == name && p['in'] == 'path' }
42
+ &.then { |s| Doc::Parameter.new(s.with_indifferent_access) }
43
+
44
+ return false unless parameter
45
+
46
+ parameter.matches?(value)
47
+ end
48
+
49
+ def regexp_path
50
+ re = /\{(\S+)\}/
51
+ @path.gsub(re) { |placeholder|
52
+ placeholder.match(re) { |m| "(?<#{m[1]}>[^/]*)" }
53
+ }.then { |str| Regexp.new(str) }
54
+ end
55
+
56
+ def known_http_methods
57
+ # https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods
58
+ %w(get head post put delete connect options trace patch).freeze
59
+ end
17
60
  end
18
61
  end
@@ -26,7 +26,7 @@ module OpenapiContracts
26
26
  pointer.map { |p| p.gsub('/', '~1') }.join('/').then { |s| "#/#{s}" }
27
27
  end
28
28
 
29
- delegate :dig, :fetch, :key?, :[], :to_h, to: :as_h
29
+ delegate :dig, :fetch, :keys, :key?, :[], :to_h, to: :as_h
30
30
 
31
31
  def at_pointer(pointer)
32
32
  self.class.new(raw, pointer)
@@ -4,6 +4,7 @@ module OpenapiContracts
4
4
  autoload :FileParser, 'openapi_contracts/doc/file_parser'
5
5
  autoload :Method, 'openapi_contracts/doc/method'
6
6
  autoload :Parser, 'openapi_contracts/doc/parser'
7
+ autoload :Parameter, 'openapi_contracts/doc/parameter'
7
8
  autoload :Path, 'openapi_contracts/doc/path'
8
9
  autoload :Response, 'openapi_contracts/doc/response'
9
10
  autoload :Schema, 'openapi_contracts/doc/schema'
@@ -17,8 +18,9 @@ module OpenapiContracts
17
18
  def initialize(schema)
18
19
  @schema = Schema.new(schema)
19
20
  @paths = @schema['paths'].to_h do |path, _|
20
- [path, Path.new(@schema.at_pointer(['paths', path]))]
21
+ [path, Path.new(path, @schema.at_pointer(['paths', path]))]
21
22
  end
23
+ @dynamic_paths = paths.select(&:dynamic?)
22
24
  end
23
25
 
24
26
  # Returns an Enumerator over all paths
@@ -42,7 +44,11 @@ module OpenapiContracts
42
44
  end
43
45
 
44
46
  def with_path(path)
45
- @paths[path]
47
+ if @paths.key?(path)
48
+ @paths[path]
49
+ else
50
+ @dynamic_paths.find { |p| p.matches?(path) }
51
+ end
46
52
  end
47
53
  end
48
54
  end
@@ -23,9 +23,9 @@ module OpenapiContracts::Validators
23
23
 
24
24
  def error_to_message(error)
25
25
  if error.key?('details')
26
- error['details'].to_a.map do |(key, val)|
26
+ error['details'].to_a.map { |(key, val)|
27
27
  "#{key.humanize}: #{val} at #{error['data_pointer']}"
28
- end.to_sentence
28
+ }.to_sentence
29
29
  else
30
30
  "#{error['data'].inspect} at #{error['data_pointer']} does not match the schema"
31
31
  end
@@ -1,5 +1,6 @@
1
1
  require 'active_support'
2
2
  require 'active_support/core_ext/array'
3
+ require 'active_support/core_ext/hash'
3
4
  require 'active_support/core_ext/class'
4
5
  require 'active_support/core_ext/module'
5
6
  require 'active_support/core_ext/string'
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.7.0
4
+ version: 0.8.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-05-31 00:00:00.000000000 Z
11
+ date: 2023-07-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -92,14 +92,14 @@ dependencies:
92
92
  requirements:
93
93
  - - '='
94
94
  - !ruby/object:Gem::Version
95
- version: 1.51.0
95
+ version: 1.52.0
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.51.0
102
+ version: 1.52.0
103
103
  - !ruby/object:Gem::Dependency
104
104
  name: rubocop-rspec
105
105
  requirement: !ruby/object:Gem::Requirement
@@ -141,6 +141,7 @@ files:
141
141
  - lib/openapi_contracts/doc/file_parser.rb
142
142
  - lib/openapi_contracts/doc/header.rb
143
143
  - lib/openapi_contracts/doc/method.rb
144
+ - lib/openapi_contracts/doc/parameter.rb
144
145
  - lib/openapi_contracts/doc/parser.rb
145
146
  - lib/openapi_contracts/doc/path.rb
146
147
  - lib/openapi_contracts/doc/response.rb