openapi_contracts 0.7.0 → 0.8.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: 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