Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Changed path_item_finder to detect more complex path template patterns #1

Merged
merged 5 commits into from
Aug 24, 2020
Merged
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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
/spec/reports/
/tmp/

# RubyMine
.idea

# rspec failure tracking
.rspec_status
Gemfile.lock
Gemfile.lock
47 changes: 41 additions & 6 deletions lib/openapi_parser/path_item_finder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,41 @@ def initialize(path_item_object, operation_object, original_path, path_params)
end
end

def parse_path_parameters(schema_path, request_path)
parameters = path_parameters(schema_path)
return nil if parameters.empty?

# If there are regex special characters in the path, the regex will
# be too permissive, so escape the non-parameter parts.
components = []
unprocessed = schema_path.dup
parameters.each do |parameter|
parts = unprocessed.partition(parameter)
components << Regexp.escape(parts[0]) unless parts[0] == ''
components << "(?<#{param_name(parameter)}>.+)"
unprocessed = parts[2]
end
components << Regexp.escape(unprocessed) unless unprocessed == ''

regex = components.join('')
matches = request_path.match(regex)
return nil unless matches

# Match up the captured names with the captured values as a hash
matches.names.zip(matches.captures).to_h
end

private
def path_parameters(schema_path)
# OAS3 follows a RFC6570 subset for URL templates
# https://swagger.io/docs/specification/serialization/#uri-templates
# A URL template param can be preceded optionally by a "." or ";", and can be succeeded optionally by a "*";
# this regex returns a match of the full parameter name with all of these modifiers. Ex: {;id*}
parameters = schema_path.scan(/(\{[\.;]*[^\{\*\}]+\**\})/)
# The `String#scan` method returns an array of arrays; we want an array of strings
parameters.collect { |param| param[0] }
end

# check if there is a identical path in the schema (without any param)
def matches_directly?(request_path, http_method)
@paths.path[request_path]&.operation(http_method)
Expand Down Expand Up @@ -70,8 +104,9 @@ def extract_params(splitted_request_path, splitted_schema_path)
splitted_request_path.zip(splitted_schema_path).reduce({}) do |result, zip_item|
request_path_item, schema_path_item = zip_item

if path_template?(schema_path_item)
result[param_name(schema_path_item)] = request_path_item
params = parse_path_parameters(schema_path_item, request_path_item)
if params
result.merge!(params)
else
return if schema_path_item != request_path_item
end
Expand All @@ -80,7 +115,7 @@ def extract_params(splitted_request_path, splitted_schema_path)
end
end

# find all matching patchs with parameters extracted
# find all matching paths with parameters extracted
# EXAMPLE:
# [
# ['/user/{id}/edit', { 'id' => 1 }],
Expand All @@ -94,7 +129,7 @@ def matching_paths_with_params(request_path, http_method)
splitted_schema_path = path.split('/')

next result if different_depth_or_method?(splitted_schema_path, splitted_request_path, path_item, http_method)

extracted_params = extract_params(splitted_request_path, splitted_schema_path)
result << [path, extracted_params] if extracted_params
result
Expand All @@ -105,12 +140,12 @@ def matching_paths_with_params(request_path, http_method)
# EXAMPLE: find_path_and_params('get', '/user/1') => ['/user/{id}', { 'id' => 1 }]
def find_path_and_params(http_method, request_path)
return [request_path, {}] if matches_directly?(request_path, http_method)

matching = matching_paths_with_params(request_path, http_method)

# if there are many matching paths, return the one with the smallest number of params
# (prefer /user/{id}/action over /user/{param_1}/{param_2} )
matching.min_by { |match| match[0].size }
matching.min_by { |match| match[0].size }
end

def parse_request_path(http_method, request_path)
Expand Down
50 changes: 43 additions & 7 deletions spec/data/petstore-expanded.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -248,13 +248,7 @@ paths:
$ref: '#/components/schemas/Error'
/animals/{id}:
parameters:
- name: id
in: path
description: ID of pet to fetch
required: true
schema:
type: integer
format: int64
- $ref: '#/components/parameters/petId'
- name: token
in: header
description: token to be passed as a header
Expand All @@ -277,6 +271,40 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Pet'
/animals/{groupId}/{id}.json:
parameters:
- $ref: '#/components/parameters/petId'
- name: groupId
in: path
description: group ID of the animal
required: true
schema:
type: integer
format: int64
style: simple
- name: token
in: header
description: token to be passed as a header
required: true
schema:
type: integer
format: int64
style: simple
get:
parameters:
- name: header_2
in: header
required: true
schema:
type: string
responses:
'200':
description: pet response
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'

components:
parameters:
test:
Expand All @@ -289,6 +317,14 @@ components:
format: int32
test_ref:
$ref: '#/components/parameters/test'
petId:
in: path
name: id
description: ID of pet to fetch
required: true
schema:
type: integer
format: int64
schemas:
Pet:
allOf:
Expand Down
77 changes: 77 additions & 0 deletions spec/openapi_parser/path_item_finder_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,75 @@
RSpec.describe OpenAPIParser::PathItemFinder do
let(:root) { OpenAPIParser.parse(petstore_schema, {}) }

describe 'parse_path_parameters' do
subject { OpenAPIParser::PathItemFinder.new(root.paths) }

it 'matches a single parameter with no additional characters' do
result = subject.parse_path_parameters('{id}', '123')
expect(result).to eq({'id' => '123'})
end

it 'matches a single parameter with extension' do
result = subject.parse_path_parameters('{id}.json', '123.json')
expect(result).to eq({'id' => '123'})
end

it 'matches a single parameter with additional characters' do
result = subject.parse_path_parameters('stuff_{id}_hoge', 'stuff_123_hoge')
expect(result).to eq({'id' => '123'})
end

it 'matches multiple parameters with additional characters' do
result = subject.parse_path_parameters('{stuff_with_underscores-and-hyphens}_{id}_hoge', '4_123_hoge')
expect(result).to eq({'stuff_with_underscores-and-hyphens' => '4', 'id' => '123'})
end

# Open API spec does not specifically define what characters are acceptable as a parameter name,
# so allow everything in between {}.
it 'matches when parameter contains regex characters' do
result = subject.parse_path_parameters('{id?}.json', '123.json')
expect(result).to eq({'id?' => '123'})

result = subject.parse_path_parameters('{id.}.json', '123.json')
expect(result).to eq({'id.' => '123'})

result = subject.parse_path_parameters('{{id}}.json', '{123}.json')
expect(result).to eq({'id' => '123'})
end

it 'fails to match' do
result = subject.parse_path_parameters('stuff_{id}_', '123')
expect(result).to be_nil

result = subject.parse_path_parameters('{p1}-{p2}.json', 'foo.json')
expect(result).to be_nil

result = subject.parse_path_parameters('{p1}.json', 'foo.txt')
expect(result).to be_nil

result = subject.parse_path_parameters('{{id}}.json', '123.json')
expect(result).to be_nil
end

it 'fails to match when a Regex escape character is used in the path' do
result = subject.parse_path_parameters('{id}.json', '123-json')
expect(result).to be_nil
end

it 'fails to match no input' do
result = subject.parse_path_parameters('', '')
expect(result).to be_nil
end

it 'matches when the last character of the variable is the same as the next character' do
result = subject.parse_path_parameters('{p1}schedule', 'adminsschedule')
expect(result).to eq({'p1' => 'admins'})

result = subject.parse_path_parameters('{p1}schedule', 'usersschedule')
expect(result).to eq({'p1' => 'users'})
end
end

describe 'find' do
subject { OpenAPIParser::PathItemFinder.new(root.paths) }

Expand All @@ -28,6 +97,14 @@
expect(result.path_params['param_2']).to eq '123'
end

it 'matches path items that end in a file extension' do
result = subject.operation_object(:get, '/animals/123/456.json')
expect(result.original_path).to eq('/animals/{groupId}/{id}.json')
expect(result.operation_object.object_reference).to eq root.find_object('#/paths/~1animals~1{groupId}~1{id}.json/get').object_reference
expect(result.path_params['groupId']).to eq '123'
expect(result.path_params['id']).to eq '456'
end

it 'ignores invalid HTTP methods' do
expect(subject.operation_object(:exit, '/pets')).to eq(nil)
expect(subject.operation_object(:blah, '/pets')).to eq(nil)
Expand Down