diff --git a/lib/openapi_first/builder.rb b/lib/openapi_first/builder.rb index b2cc430a..ec3d1c59 100644 --- a/lib/openapi_first/builder.rb +++ b/lib/openapi_first/builder.rb @@ -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? ) @@ -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 ) diff --git a/lib/openapi_first/configuration.rb b/lib/openapi_first/configuration.rb index f2b1b4ae..84ca088a 100644 --- a/lib/openapi_first/configuration.rb +++ b/lib/openapi_first/configuration.rb @@ -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 diff --git a/lib/openapi_first/router.rb b/lib/openapi_first/router.rb index 9f04ec8a..8d841bf2 100644 --- a/lib/openapi_first/router.rb +++ b/lib/openapi_first/router.rb @@ -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 @@ -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) + } else @static[path] ||= {} end diff --git a/lib/openapi_first/router/path_template.rb b/lib/openapi_first/router/path_template.rb index 2a3726f1..0b0fef0e 100644 --- a/lib/openapi_first/router/path_template.rb +++ b/lib/openapi_first/router/path_template.rb @@ -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 @@ -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(/(? '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