Skip to content

Commit

Permalink
Merge branch 'main' into depfu/update/json_schemer-1.0.3
Browse files Browse the repository at this point in the history
  • Loading branch information
mkon committed Jul 7, 2023
2 parents 23e451a + 1efae0a commit 1e2256d
Show file tree
Hide file tree
Showing 12 changed files with 507 additions and 16 deletions.
4 changes: 4 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ Layout/SpaceInsideHashLiteralBraces:
Lint/AssignmentInCondition:
Enabled: false

Style/BlockDelimiters:
EnforcedStyle: braces_for_chaining
Style/MultilineBlockChain:
Enabled: false
Style/ClassAndModuleChildren:
Enabled: false
Style/ConditionalAssignment:
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,9 @@ it 'responds with 200 and matches the doc' do

The `match_openapi_doc($doc)` method allows passing options as a 2nd argument.
This allows overriding the default request.path lookup in case this does not find
the correct response definition in your schema. This is especially important with
dynamic paths.
the correct response definition in your schema. This can be usefull when there is
dynamic parameters in the path and the matcher fails to resolve the request path to
an endpoint in the openapi specification.

Example:

Expand All @@ -67,7 +68,9 @@ raise result.errors.merge("/n") unless result.valid?

### How it works

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:
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:

* The response is documented
* Required headers are present
Expand Down
1 change: 1 addition & 0 deletions lib/openapi_contracts.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
require 'active_support'
require 'active_support/core_ext/array'
require 'active_support/core_ext/hash'
require 'active_support/core_ext/class'
require 'active_support/core_ext/module'
require 'active_support/core_ext/string'
Expand Down
10 changes: 8 additions & 2 deletions lib/openapi_contracts/doc.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ class Doc
autoload :FileParser, 'openapi_contracts/doc/file_parser'
autoload :Method, 'openapi_contracts/doc/method'
autoload :Parser, 'openapi_contracts/doc/parser'
autoload :Parameter, 'openapi_contracts/doc/parameter'
autoload :Path, 'openapi_contracts/doc/path'
autoload :Response, 'openapi_contracts/doc/response'
autoload :Schema, 'openapi_contracts/doc/schema'
Expand All @@ -17,8 +18,9 @@ def self.parse(dir, filename = 'openapi.yaml')
def initialize(schema)
@schema = Schema.new(schema)
@paths = @schema['paths'].to_h do |path, _|
[path, Path.new(@schema.at_pointer(['paths', path]))]
[path, Path.new(path, @schema.at_pointer(['paths', path]))]
end
@dynamic_paths = paths.select(&:dynamic?)
end

# Returns an Enumerator over all paths
Expand All @@ -42,7 +44,11 @@ def responses(&block)
end

def with_path(path)
@paths[path]
if @paths.key?(path)
@paths[path]
else
@dynamic_paths.find { |p| p.matches?(path) }
end
end
end
end
86 changes: 86 additions & 0 deletions lib/openapi_contracts/doc/parameter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
module OpenapiContracts
class Doc::Parameter
attr_reader :schema

def initialize(options)
@name = options[:name]
@in = options[:in]
@required = options[:required]
@schema = options[:schema]
end

def matches?(value)
case schema['type']
when 'integer'
integer_parameter_matches?(value)
when 'number'
number_parameter_matches?(value)
when 'string'
string_parameter_matches?(value)
else
# Not yet implemented
false
end
end

private

def integer_parameter_matches?(value)
return false unless /^-?\d+$/.match?(value)

parsed = value.to_i
return false unless minimum_number_matches?(parsed)
return false unless maximum_number_matches?(parsed)

true
end

def number_parameter_matches?(value)
return false unless /^-?(\d+\.)?\d+$/.match?(value)

parsed = value.to_f
return false unless minimum_number_matches?(parsed)
return false unless maximum_number_matches?(parsed)

true
end

def minimum_number_matches?(value)
if (min = schema['minimum'])
if schema['exclusiveMinimum']
return false if value <= min
elsif value < min
return false
end
end
true
end

def maximum_number_matches?(value)
if (max = schema['maximum'])
if schema['exclusiveMaximum']
return false if value >= max
elsif value > max
return false
end
end
true
end

def string_parameter_matches?(value)
if (pat = schema['pattern'])
Regexp.new(pat).match?(value)
else
if (min = schema['minLength']) && (value.length < min)
return false
end

if (max = schema['maxLength']) && (value.length > max)
return false
end

true
end
end
end
end
37 changes: 36 additions & 1 deletion lib/openapi_contracts/doc/path.rb
Original file line number Diff line number Diff line change
@@ -1,23 +1,58 @@
module OpenapiContracts
class Doc::Path
def initialize(schema)
def initialize(path, schema)
@path = path
@schema = schema

@methods = (known_http_methods & @schema.keys).to_h do |method|
[method, Doc::Method.new(@schema.navigate(method))]
end
end

def dynamic?
@path.include?('{')
end

def matches?(path)
@path == path || regexp_path.match(path) do |m|
m.named_captures.each do |k, v|
return false unless parameter_matches?(k, v)
end
true
end
end

def methods
@methods.each_value
end

def static?
!dynamic?
end

def with_method(method)
@methods[method]
end

private

def parameter_matches?(name, value)
parameter = @schema['parameters']
&.find { |p| p['name'] == name && p['in'] == 'path' }
&.then { |s| Doc::Parameter.new(s.with_indifferent_access) }

return false unless parameter

parameter.matches?(value)
end

def regexp_path
re = /\{(\S+)\}/
@path.gsub(re) { |placeholder|
placeholder.match(re) { |m| "(?<#{m[1]}>[^/]*)" }
}.then { |str| Regexp.new(str) }
end

def known_http_methods
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods
%w(get head post put delete connect options trace patch).freeze
Expand Down
4 changes: 2 additions & 2 deletions lib/openapi_contracts/validators/body.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ def validate_schema

def error_to_message(error)
if error.key?('details')
error['details'].to_a.map do |(key, val)|
error['details'].to_a.map { |(key, val)|
"#{key.humanize}: #{val} at #{error['data_pointer']}"
end.to_sentence
}.to_sentence
else
"#{error['data'].inspect} at #{error['data_pointer']} does not match the schema"
end
Expand Down
3 changes: 3 additions & 0 deletions spec/.rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ require: rubocop-rspec
Metrics/BlockLength:
Enabled: false

RSpec/DescribeClass:
Exclude:
- integration/**/*
RSpec/MultipleExpectations:
Enabled: false
RSpec/MultipleMemoizedHelpers:
Expand Down
16 changes: 9 additions & 7 deletions spec/fixtures/openapi/paths/message.yaml
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
parameters:
- name: id
in: path
description: Id of the message.
required: true
schema:
type: string
pattern: '^[a-f,0-9]{8}$'
minLength: 8
get:
tags:
- Message
summary: Get Message
description: Get Message
operationId: get_message
parameters:
- name: id
in: path
description: Id of the message.
required: true
schema:
type: string
responses:
'200':
description: OK
Expand Down
19 changes: 18 additions & 1 deletion spec/integration/rspec_spec.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
RSpec.describe 'RSpec integration' do # rubocop:disable RSpec/DescribeClass
RSpec.describe 'RSpec integration' do
subject { response }

include_context 'when using GET /user'
Expand All @@ -21,6 +21,23 @@
end

context 'when using dynamic paths' do
let(:path) { '/messages/ef278ab2' }
let(:response_json) do
{
data: {
id: '1ef',
type: 'messages',
attributes: {
body: 'foo'
}
}
}
end

it { is_expected.to match_openapi_doc(doc).with_http_status(:ok) }
end

context 'when using dynamic paths with path option' do
let(:path) { '/messages/ef278' }
let(:response_json) do
{
Expand Down
Loading

0 comments on commit 1e2256d

Please sign in to comment.