add tests for path calculations

Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com>
This commit is contained in:
aramissennyeydd
2024-07-07 15:01:07 -04:00
parent ea0b6b4262
commit 71ee97ddba
4 changed files with 214 additions and 48 deletions
@@ -522,4 +522,48 @@ describe('path parameters', () => {
});
});
});
describe('path parsing', () => {
it('should parse a path with a single parameters', async () => {
const parsedPath = PathParameterParser.parsePath({
operation,
schema: '/api/item/{id}',
path: '/api/item/test123',
});
expect(parsedPath).toEqual({ id: 'test123' });
});
it('should parse a path with multiple parameters', async () => {
const parsedPath = PathParameterParser.parsePath({
operation,
schema: '/api/item/{id}/{name}',
path: '/api/item/42/test',
});
// the string is expected here, but will be optimistically parsed as a number where it makes sense.
expect(parsedPath).toEqual({ id: '42', name: 'test' });
});
it('should throw an error if the path does not have enough parts', async () => {
expect(() =>
PathParameterParser.parsePath({
operation,
schema: '/api/item/{id}',
path: '/api/item',
}),
).toThrowErrorMatchingInlineSnapshot(
`"["GET /api/item/{id}"] Path parts do not match"`,
);
});
it('should throw an error if the path has too many parts', async () => {
expect(() =>
PathParameterParser.parsePath({
operation,
schema: '/api/item/{id}',
path: '/api/item/test/123',
}),
).toThrowErrorMatchingInlineSnapshot(
`"["GET /api/item/{id}"] Path parts do not match"`,
);
});
});
});
@@ -359,7 +359,8 @@ export class PathParameterParser
}
async parse(request: Request) {
const { pathname } = new URL(request.url);
const params = this.parsePath({
const params = PathParameterParser.parsePath({
operation: this.operation,
path: pathname,
schema: this.operation.path,
});
@@ -393,11 +394,19 @@ export class PathParameterParser
return pathParameters;
}
parsePath({ schema, path }: { schema: string; path: string }) {
static parsePath({
operation,
schema,
path,
}: {
operation: Operation;
schema: string;
path: string;
}) {
const parts = path.split('/');
const pathParts = schema.split('/');
if (parts.length !== pathParts.length) {
throw new OperationError(this.operation, 'Path parts do not match');
throw new OperationError(operation, 'Path parts do not match');
}
const params: Record<string, string> = {};
for (let i = 0; i < parts.length; i++) {
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { OpenApiProxyValidator } from './validation';
import { findOperationByRequest, OpenApiProxyValidator } from './validation';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
@@ -22,7 +22,7 @@ import { CompletedBody, CompletedRequest, CompletedResponse } from 'mockttp';
import withResponseBody from './__fixtures__/schemas/withJsonResponseBody.json';
import withQueryParameter from './__fixtures__/schemas/withQueryParameter.json';
import _ from 'lodash';
import { ParameterObject } from 'openapi3-ts';
import { OpenAPIObject, ParameterObject } from 'openapi3-ts';
const server = setupServer();
@@ -709,3 +709,115 @@ describe('OpenApiProxyValidator', () => {
});
});
});
describe('findOperationByRequest', () => {
it('finds an operation by request', () => {
const schema = {
openapi: '3.0.0',
info: {
title: 'Test',
version: '1.0.0',
},
paths: {
'/api/search': {
get: {
parameters: [],
},
},
},
};
const request = {
method: 'GET',
url: 'http://localhost:8080/api/search',
} as CompletedRequest;
expect(findOperationByRequest(schema as OpenAPIObject, request)).toEqual([
'/api/search',
schema.paths['/api/search'].get,
]);
});
it('finds an operation by request when there are multiple other paths', () => {
const schema = {
openapi: '3.0.0',
info: {
title: 'Test',
version: '1.0.0',
},
paths: {
'/api/search': {
get: {
parameters: [],
},
},
'/api/catalog/by-ref': {
get: {
parameters: [],
},
},
},
};
const request = {
method: 'GET',
url: 'http://localhost:8080/api/search',
} as CompletedRequest;
expect(findOperationByRequest(schema as OpenAPIObject, request)).toEqual([
'/api/search',
schema.paths['/api/search'].get,
]);
});
it('finds an operation by request when there are path parameters', () => {
const schema = {
openapi: '3.0.0',
info: {
title: 'Test',
version: '1.0.0',
},
paths: {
'/api/catalog/by-id/{id}': {
get: {
parameters: [],
},
},
},
};
const request = {
method: 'GET',
url: 'http://localhost:8080/api/catalog/by-id/123',
} as CompletedRequest;
expect(findOperationByRequest(schema as OpenAPIObject, request)).toEqual([
'/api/catalog/by-id/{id}',
schema.paths['/api/catalog/by-id/{id}'].get,
]);
});
it('finds an operation by request when there are somewhat overlapping path parameters', () => {
const schema = {
openapi: '3.0.0',
info: {
title: 'Test',
version: '1.0.0',
},
paths: {
'/api/catalog/by-id/{id}': {
get: {
parameters: [],
},
},
'/api/catalog/by-id': {
get: {
parameters: [],
},
},
},
};
const request = {
method: 'GET',
url: 'http://localhost:8080/api/catalog/by-id/123',
} as CompletedRequest;
expect(findOperationByRequest(schema as OpenAPIObject, request)).toEqual([
'/api/catalog/by-id/{id}',
schema.paths['/api/catalog/by-id/{id}'].get,
]);
});
});
@@ -60,6 +60,49 @@ class ResponseBodyValidator implements Validator {
}
}
export function findOperationByRequest(
openApiSchema: OpenAPIObject,
request: CompletedRequest,
): [string, OperationObject] | undefined {
const { url } = request;
const { pathname } = new URL(url);
const parts = pathname.split('/');
for (const [path, schema] of Object.entries(openApiSchema.paths)) {
const pathParts = path.split('/');
if (parts.length !== pathParts.length) {
continue;
}
let found = true;
for (let i = 0; i < parts.length; i++) {
if (pathParts[i] === parts[i]) {
continue;
}
if (pathParts[i].startsWith('{') && pathParts[i].endsWith('}')) {
continue;
}
found = false;
break;
}
if (!found) {
continue;
}
let matchingOperationType: OperationObject | undefined = undefined;
for (const [operationType, operation] of Object.entries(schema)) {
if (operationType === request.method.toLowerCase()) {
matchingOperationType = operation as OperationObject;
break;
}
}
if (!matchingOperationType) {
continue;
}
return [path, matchingOperationType];
}
return undefined;
}
export class OpenApiProxyValidator {
schema: OpenAPIObject | undefined;
validators: Validator[] | undefined;
@@ -74,7 +117,7 @@ export class OpenApiProxyValidator {
}
async validate(request: CompletedRequest, response: CompletedResponse) {
const operationPathTuple = this.findOperation(request);
const operationPathTuple = findOperationByRequest(this.schema!, request);
if (!operationPathTuple) {
throw new OperationError(
{ path: request.path, method: request.method } as Operation,
@@ -95,46 +138,4 @@ export class OpenApiProxyValidator {
),
);
}
private findOperation(
request: CompletedRequest,
): [string, OperationObject] | undefined {
const { url } = request;
const { pathname } = new URL(url);
const parts = pathname.split('/');
for (const [path, schema] of Object.entries(this.schema!.paths)) {
const pathParts = path.split('/');
if (parts.length !== pathParts.length) {
continue;
}
let found = true;
for (let i = 0; i < parts.length; i++) {
if (pathParts[i] === parts[i]) {
continue;
}
if (pathParts[i].startsWith('{') && pathParts[i].endsWith('}')) {
continue;
}
found = false;
break;
}
if (!found) {
continue;
}
let matchingOperationType: OperationObject | undefined = undefined;
for (const [operationType, operation] of Object.entries(schema)) {
if (operationType === request.method.toLowerCase()) {
matchingOperationType = operation as OperationObject;
break;
}
}
if (!matchingOperationType) {
continue;
}
return [path, matchingOperationType];
}
return undefined;
}
}