Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/openapi_first/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ def router # rubocop:disable Metrics/MethodLength
request,
request_method:,
path:,
path_parameters: parameters.path,
use_patterns_for_path_matching: config.use_patterns_for_path_matching,
content_type: request.content_type,
allow_empty_content: request.allow_empty_content?
)
Expand All @@ -81,6 +83,8 @@ def router # rubocop:disable Metrics/MethodLength
response,
request_method:,
path:,
path_parameters: parameters.path,
use_patterns_for_path_matching: config.use_patterns_for_path_matching,
status: response.status,
response_content_type: response.content_type
)
Expand Down
4 changes: 3 additions & 1 deletion lib/openapi_first/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ def initialize
@request_validation_error_response = OpenapiFirst.find_error_response(:default)
@request_validation_raise_error = false
@response_validation_raise_error = true
@use_patterns_for_path_matching = false
@hooks = (HOOKS.map { [_1, Set.new] }).to_h
@path = nil
end

attr_reader :request_validation_error_response, :hooks
attr_accessor :request_validation_raise_error, :response_validation_raise_error, :path
attr_accessor :request_validation_raise_error, :response_validation_raise_error, :path,
:use_patterns_for_path_matching

def clone
copy = super
Expand Down
14 changes: 8 additions & 6 deletions lib/openapi_first/router.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,16 @@ def routes
end

# Add a request definition
def add_request(request, request_method:, path:, content_type: nil, allow_empty_content: false)
route = route_at(path, request_method)
def add_request(request, request_method:, path:, path_parameters: [], use_patterns_for_path_matching: false, content_type: nil, allow_empty_content: false)
route = route_at(path, request_method, path_parameters, use_patterns_for_path_matching)
requests = route[:requests]
requests[content_type] = request
requests[nil] = request if allow_empty_content
end

# Add a response definition
def add_response(response, request_method:, path:, status:, response_content_type: nil)
(route_at(path, request_method)[:responses][status] ||= {})[response_content_type] = response
def add_response(response, request_method:, path:, path_parameters: [], use_patterns_for_path_matching: false, status:, response_content_type: nil)
(route_at(path, request_method, path_parameters, use_patterns_for_path_matching)[:responses][status] ||= {})[response_content_type] = response
end

# Return all request objects that match the given path and request method
Expand All @@ -74,10 +74,12 @@ def match(request_method, path, content_type: nil)

private

def route_at(path, request_method)
def route_at(path, request_method, path_parameters, use_patterns_for_path_matching)
request_method = request_method.upcase
path_item = if PathTemplate.template?(path)
@dynamic[path] ||= { template: PathTemplate.new(path) }
@dynamic[path] ||= {
template: PathTemplate.new(path, path_parameters, use_patterns_for_path_matching)
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think of not calling PathTemplate.new if use_patterns_for_path_matching is true, but a new class MyPatternMatchingTemplate.new?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am totally fine with that but I guess we should first finish the discussion in the issue as these changes are not necessary if you do not agree with the underlying idea of the problem which means the solution will probably stay on a fork forever

}
else
@static[path] ||= {}
end
Expand Down
41 changes: 39 additions & 2 deletions lib/openapi_first/router/path_template.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ def self.template?(string)
string.include?('{')
end

def initialize(template)
def initialize(template, path_parameters, use_patterns_for_path_matching)
@template = template
@path_parameters = path_parameters
@use_patterns_for_path_matching = use_patterns_for_path_matching
@names = template.scan(TEMPLATE_EXPRESSION_NAME).flatten
@pattern = build_pattern(template)
end
Expand All @@ -38,11 +40,46 @@ def match(path)

def build_pattern(template)
parts = template.split(TEMPLATE_EXPRESSION).map! do |part|
part.start_with?('{') ? ALLOWED_PARAMETER_CHARACTERS : Regexp.escape(part)
if part.start_with?('{')
name = part.match(TEMPLATE_EXPRESSION_NAME)[1]
parameter = @path_parameters.find { |p| p['name'] == name }
if @use_patterns_for_path_matching && parameter&.[]('schema')&.[]('pattern')
transform_pattern(parameter['schema']['pattern'])
else
ALLOWED_PARAMETER_CHARACTERS
end
else
Regexp.escape(part)
end
end

%r{^#{parts.join}/?$}
end

def transform_pattern(pattern)
pattern = pattern_with_correct_start(pattern)
pattern = pattern_with_correct_end(pattern)
single_capturing_group(pattern)
end

def pattern_with_correct_start(pattern)
return pattern[1..] if pattern.start_with?('^')
return pattern[2..] if pattern.start_with?('\A')

"[^/?#]*#{pattern}"
end

def pattern_with_correct_end(pattern)
return pattern[..-2] if pattern.end_with?('$')
return pattern[..-3] if pattern.end_with?('\Z')
return pattern[..-3] if pattern.end_with?('\z')

"#{pattern}[^/?#]*$"
end

def single_capturing_group(pattern)
%r{(#{pattern.gsub(/(?<!\\)\((?!\?[:<!=])/) { "(?:" }})}
end
end
end
end
123 changes: 109 additions & 14 deletions spec/router/path_template_spec.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# frozen_string_literal: true

RSpec.describe OpenapiFirst::Router::PathTemplate do
let(:path_parameters) { [] }
let(:use_patterns_for_path_matching) { false }

describe '.template?' do
specify do
expect(described_class.template?('/totally/static')).to be(false)
Expand All @@ -10,52 +13,144 @@

describe '#to_s' do
specify do
expect(described_class.new('/stations/{id}').to_s).to eq('/stations/{id}')
expect(
described_class.new('/stations/{id}', path_parameters, use_patterns_for_path_matching).to_s
).to eq('/stations/{id}')
end
end

describe '#match' do
it 'returns empty params with exact string match' do
expect(described_class.new('/a/b').match('/a/b')).to eq({})
expect(
described_class.new('/a/b', path_parameters, use_patterns_for_path_matching)
.match('/a/b')
).to eq({})
end

it 'returns params with multiple matches' do
expect(described_class.new('/{a}/{b}').match('/1/2')).to eq({ 'a' => '1', 'b' => '2' })
expect(
described_class.new('/{a}/{b}', path_parameters, use_patterns_for_path_matching )
.match('/1/2')
).to eq({ 'a' => '1', 'b' => '2' })
end

it 'ignores trailing slashes in paths' do
expect(described_class.new('/{a}/{b}').match('/1/2/')).to eq({ 'a' => '1', 'b' => '2' })
expect(
described_class.new('/{a}/{b}', path_parameters, use_patterns_for_path_matching)
.match('/1/2/')
).to eq({ 'a' => '1', 'b' => '2' })
end

it 'returns params with kebab-case names' do
expect(described_class.new('/kebab-path/{ke-bab}/{under_score}').match('/kebab-path/1/2'))
.to eq({ 'ke-bab' => '1', 'under_score' => '2' })
expect(
described_class.new('/kebab-path/{ke-bab}/{under_score}', path_parameters, use_patterns_for_path_matching)
.match('/kebab-path/1/2')
).to eq({ 'ke-bab' => '1', 'under_score' => '2' })
end

it 'returns params where variable is in the middle' do
expect(described_class.new('/stuff/{id}/things').match('/stuff/42/things')).to eq({ 'id' => '42' })
expect(
described_class.new('/stuff/{id}/things', path_parameters, use_patterns_for_path_matching)
.match('/stuff/42/things')
).to eq({ 'id' => '42' })
end

it 'works with /stuff/{a}..{b}' do
expect(described_class.new('/stuff/{a}..{b}').match('/stuff/some..other')).to eq({ 'a' => 'some',
'b' => 'other' })
expect(
described_class.new('/stuff/{a}..{b}', path_parameters, use_patterns_for_path_matching)
.match('/stuff/some..other')
).to eq({ 'a' => 'some', 'b' => 'other' })
end

it 'works with special characters in path' do
expect(described_class.new('/stuff/{range}').match('/stuff/some..other')).to eq({ 'range' => 'some..other' })
expect(described_class.new('/stuff/{bang}').match('/stuff/bang!boom!')).to eq({ 'bang' => 'bang!boom!' })
expect(
described_class.new('/stuff/{range}', path_parameters, use_patterns_for_path_matching)
.match('/stuff/some..other')
).to eq({ 'range' => 'some..other' })
expect(
described_class.new('/stuff/{bang}', path_parameters, use_patterns_for_path_matching)
.match('/stuff/bang!boom!')
).to eq({ 'bang' => 'bang!boom!' })
end

it 'returns nil without match' do
expect(described_class.new('/{a}/middle/{b}').match('/1/2/3')).to be_nil
expect(
described_class.new('/{a}/middle/{b}', path_parameters, use_patterns_for_path_matching)
.match('/1/2/3')
).to be_nil
end

it 'returns nil when path has more parts' do
expect(described_class.new('/foo/{id}').match('/foo/middle/bar')).to be_nil
expect(
described_class.new('/foo/{id}', path_parameters, use_patterns_for_path_matching)
.match('/foo/middle/bar')
).to be_nil
end

it 'returns nil when path without variables does not match' do
expect(described_class.new('/a/b').match('/1/2')).to be_nil
expect(
described_class.new('/a/b', path_parameters, use_patterns_for_path_matching)
.match('/1/2')
).to be_nil
end

context 'when using path parameters with patterns' do
let(:path_parameters) do
[
{ 'name' => 'foo', 'schema' => { 'pattern' => '^foo$' } },
{ 'name' => 'bar', 'schema' => { 'pattern' => 'bar' } }
]
end

context 'when use_patterns_for_path_matching is false' do
let(:use_patterns_for_path_matching) { false }

it 'matches when the pattern matches' do
expect(
described_class.new('/{foo}', path_parameters, use_patterns_for_path_matching)
.match('/foo')
).to eq({ 'foo' => 'foo' })
end

it 'matches even though the pattern does not match' do
expect(
described_class.new('/{foo}', path_parameters, use_patterns_for_path_matching)
.match('/bar')
).to eq({ 'foo' => 'bar' })
end
end

context 'when use_patterns_for_path_matching is true' do
let(:use_patterns_for_path_matching) { true }

it 'matches when the pattern matches' do
expect(
described_class.new('/{foo}', path_parameters, use_patterns_for_path_matching)
.match('/foo')
).to eq({ 'foo' => 'foo' })
end

it 'does not match when the pattern does not match' do
expect(
described_class.new('/{foo}', path_parameters, use_patterns_for_path_matching)
.match('/bar')
).to be_nil
end

it 'uses start and end anchors correctly' do
expect(
described_class.new('/{foo}', path_parameters, use_patterns_for_path_matching)
.match('/123foo456')
).to be_nil
end

it 'uses the lack of start and end anchors correctly' do
expect(
described_class.new('/{bar}', path_parameters, use_patterns_for_path_matching)
.match('/123bar456')
).to eq({ 'bar' => '123bar456' })
end
end
end
end
end
Loading