From 71ee97ddba4fd1154a8ed7ed24a5086f6ea3603a Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Sun, 7 Jul 2024 15:01:07 -0400 Subject: [PATCH] add tests for path calculations Signed-off-by: aramissennyeydd --- .../src/schema/parameter-validation.test.ts | 44 +++++++ .../src/schema/parameter-validation.ts | 15 ++- .../src/schema/validation.test.ts | 116 +++++++++++++++++- .../src/schema/validation.ts | 87 ++++++------- 4 files changed, 214 insertions(+), 48 deletions(-) diff --git a/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts b/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts index 673f22f99b..717256e266 100644 --- a/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts +++ b/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts @@ -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"`, + ); + }); + }); }); diff --git a/packages/backend-openapi-utils/src/schema/parameter-validation.ts b/packages/backend-openapi-utils/src/schema/parameter-validation.ts index ddc0efbe45..65e6439b7e 100644 --- a/packages/backend-openapi-utils/src/schema/parameter-validation.ts +++ b/packages/backend-openapi-utils/src/schema/parameter-validation.ts @@ -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 = {}; for (let i = 0; i < parts.length; i++) { diff --git a/packages/backend-openapi-utils/src/schema/validation.test.ts b/packages/backend-openapi-utils/src/schema/validation.test.ts index 0fab287f1e..3f172c16fe 100644 --- a/packages/backend-openapi-utils/src/schema/validation.test.ts +++ b/packages/backend-openapi-utils/src/schema/validation.test.ts @@ -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, + ]); + }); +}); diff --git a/packages/backend-openapi-utils/src/schema/validation.ts b/packages/backend-openapi-utils/src/schema/validation.ts index 45255a2ea5..51de5d574b 100644 --- a/packages/backend-openapi-utils/src/schema/validation.ts +++ b/packages/backend-openapi-utils/src/schema/validation.ts @@ -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; - } }