diff --git a/packages/backend-openapi-utils/package.json b/packages/backend-openapi-utils/package.json index a173cbf17b..5fb1f98e1b 100644 --- a/packages/backend-openapi-utils/package.json +++ b/packages/backend-openapi-utils/package.json @@ -35,7 +35,9 @@ "dependencies": { "@apidevtools/swagger-parser": "^10.1.0", "@backstage/backend-plugin-api": "workspace:^", + "@backstage/backend-test-utils": "workspace:^", "@backstage/errors": "workspace:^", + "@backstage/types": "workspace:^", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5", "ajv": "^8.16.0", @@ -45,6 +47,7 @@ "json-schema-to-ts": "^3.0.0", "lodash": "^4.17.21", "mockttp": "^3.13.0", + "msw": "^1.0.0", "openapi-merge": "^1.3.2", "openapi3-ts": "^3.1.2" }, diff --git a/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonResponseBody.json b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonResponseBody.json new file mode 100644 index 0000000000..378ea0a168 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonResponseBody.json @@ -0,0 +1,35 @@ +{ + "openapi": "3.0.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": { + "/api/search": { + "get": { + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withQueryParameter.json b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withQueryParameter.json new file mode 100644 index 0000000000..6f0db99697 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withQueryParameter.json @@ -0,0 +1,25 @@ +{ + "openapi": "3.0.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": { + "/api/search": { + "get": { + "responses": { + "200": { + "description": "OK" + } + }, + "parameters": [ + { + "name": "param", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + } + } +} diff --git a/packages/backend-openapi-utils/src/schema/errors.ts b/packages/backend-openapi-utils/src/schema/errors.ts new file mode 100644 index 0000000000..aab5210f46 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/errors.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Operation } from './types'; + +export class OperationError extends Error { + constructor(operation: Operation, message: string) { + super(`[${operation.path} (${operation.method})]: ${message}`); + } +} diff --git a/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts b/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts new file mode 100644 index 0000000000..2fb8f371b1 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts @@ -0,0 +1,458 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import _ from 'lodash'; +import withQueryParameter from './__fixtures__/schemas/withQueryParameter.json'; +import { QueryParameterParser } from './parameter-validation'; +import { OperationObject, ParameterObject } from 'openapi3-ts'; +import Ajv from 'ajv'; +import { Operation } from './types'; + +const ajv = new Ajv(); + +describe('query parameters', () => { + let operation: Operation; + let parser: QueryParameterParser; + let schema: (typeof withQueryParameter)['paths']['/api/search']['get']; + + beforeEach(() => { + schema = _.cloneDeep(withQueryParameter.paths['/api/search'].get); + operation = { + schema: schema as OperationObject, + path: '/api/search', + method: 'get', + }; + parser = new QueryParameterParser(operation, { ajv }); + }); + describe('primitives', () => { + describe('string', () => { + it('should parse a string', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=hello', + } as Request; + const result = await parser.parse(request); + expect(result.param).toBe('hello'); + }); + + it('should throw an error if there are extra parameters', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=hello&extra=world', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Unexpected query parameters: extra"`, + ); + }); + + it('should throw an error if the parameter is required but missing', async () => { + (schema.parameters![0] as ParameterObject).required = true; + const request = { + url: 'http://localhost:8080/api/search', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Required query parameter param not found"`, + ); + }); + }); + + describe('number', () => { + beforeEach(() => { + schema.parameters![0].schema.type = 'number'; + }); + it('should parse a number', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=42', + } as Request; + const result = await parser.parse(request); + expect(result.param).toBe(42); + }); + + it('should throw an error if the parameter is not a number', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=hello', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Query parameter validation failed"`, + ); + }); + }); + }); + + describe('arrays', () => { + beforeEach(() => { + schema.parameters![0].schema.type = 'array'; + }); + describe('form', () => { + beforeEach(() => { + (schema.parameters![0] as ParameterObject).style = 'form'; + }); + describe('explode=true', () => { + beforeEach(() => { + (schema.parameters![0] as ParameterObject).explode = true; + }); + it('should parse a form array with a single element', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=hello', + } as Request; + const result = await parser.parse(request); + expect(result.param).toEqual(['hello']); + }); + + it('should parse a form array with multiple elements', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=hello¶m=world', + } as Request; + const result = await parser.parse(request); + expect(result.param).toEqual(['hello', 'world']); + }); + + it('should throw for missing required parameters', async () => { + (schema.parameters![0] as ParameterObject).required = true; + const request = { + url: 'http://localhost:8080/api/search', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Required query parameter param not found"`, + ); + }); + + it('should throw for extra parameters', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=hello&extra=world', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Unexpected query parameters: extra"`, + ); + }); + }); + + describe('explode=false', () => { + beforeEach(() => { + (schema.parameters![0] as ParameterObject).explode = false; + }); + + it('should parse a form array with a single element', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=hello', + } as Request; + const result = await parser.parse(request); + expect(result.param).toEqual(['hello']); + }); + + it('should parse a form array with multiple elements', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=hello,world', + } as Request; + const result = await parser.parse(request); + expect(result.param).toEqual(['hello', 'world']); + }); + + it('should throw for missing required parameters', async () => { + (schema.parameters![0] as ParameterObject).required = true; + const request = { + url: 'http://localhost:8080/api/search', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Required query parameter param not found"`, + ); + }); + + it('should throw for extra parameters', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=hello&extra=world', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Unexpected query parameters: extra"`, + ); + }); + }); + }); + + describe('spaceDelimited', () => { + beforeEach(() => { + (schema.parameters![0] as ParameterObject).style = 'spaceDelimited'; + }); + + it('should parse a space separated array', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=hello%20world', + } as Request; + const result = await parser.parse(request); + expect(result.param).toEqual(['hello', 'world']); + }); + + it('should throw for missing required parameters', async () => { + (schema.parameters![0] as ParameterObject).required = true; + const request = { + url: 'http://localhost:8080/api/search', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Required query parameter param not found"`, + ); + }); + + it('should throw for extra parameters', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=hello%20world&extra=world', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Unexpected query parameters: extra"`, + ); + }); + }); + + describe('pipeDelimited', () => { + beforeEach(() => { + (schema.parameters![0] as ParameterObject).style = 'pipeDelimited'; + }); + + it('should parse a pipe separated array', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=hello|world', + } as Request; + const result = await parser.parse(request); + expect(result.param).toEqual(['hello', 'world']); + }); + + it('should throw for missing required parameters', async () => { + (schema.parameters![0] as ParameterObject).required = true; + const request = { + url: 'http://localhost:8080/api/search', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Required query parameter param not found"`, + ); + }); + + it('should throw for extra parameters', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=hello|world&extra=world', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Unexpected query parameters: extra"`, + ); + }); + }); + }); + + describe('objects', () => { + describe('form', () => { + beforeEach(() => { + schema.parameters![0].schema.type = 'object'; + }); + + describe('explode=true', () => { + beforeEach(() => { + (schema.parameters![0] as ParameterObject).explode = true; + }); + + it('should parse a form object with a single key', async () => { + const request = { + url: 'http://localhost:8080/api/search?key=value', + } as Request; + const result = await parser.parse(request); + expect(result.param).toEqual({ key: 'value' }); + }); + + it('should parse a form object with multiple keys', async () => { + const request = { + url: 'http://localhost:8080/api/search?key1=value1&key2=value2', + } as Request; + const result = await parser.parse(request); + expect(result.param).toEqual({ key1: 'value1', key2: 'value2' }); + }); + + it('should throw for missing required parameters', async () => { + (schema.parameters![0] as ParameterObject).required = true; + const request = { + url: 'http://localhost:8080/api/search', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Required query parameter param not found"`, + ); + }); + + it('should respect other parameter encodings', async () => { + const parameter = { + name: 'extra', + in: 'query', + style: 'form', + explode: false, + schema: { type: 'array' }, + required: false, + } as ParameterObject; + schema.parameters!.push(parameter as any); + parser = new QueryParameterParser(operation, { ajv }); + const request = { + url: 'http://localhost:8080/api/search?key=value&otherkey=value2&extra=hello,world', + } as Request; + + const result = await parser.parse(request); + expect(result.param).toEqual({ + key: 'value', + otherkey: 'value2', + }); + expect(result.extra).toEqual(['hello', 'world']); + }); + }); + + describe('explode=false', () => { + beforeEach(() => { + (schema.parameters![0] as ParameterObject).explode = false; + }); + + it('should parse a form object with a single key', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=key,value', + } as Request; + const result = await parser.parse(request); + expect(result.param).toEqual({ key: 'value' }); + }); + + it('should parse a form object with multiple keys', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=key1,value1,key2,value2', + } as Request; + const result = await parser.parse(request); + expect(result.param).toEqual({ key1: 'value1', key2: 'value2' }); + }); + + it('should throw for missing required parameters', async () => { + (schema.parameters![0] as ParameterObject).required = true; + const request = { + url: 'http://localhost:8080/api/search', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Required query parameter param not found"`, + ); + }); + + it('should throw for extra parameters', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=key,value&extra=world', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Unexpected query parameters: extra"`, + ); + }); + }); + }); + + describe('deepObject', () => { + beforeEach(() => { + (schema.parameters![0] as ParameterObject).style = 'deepObject'; + (schema.parameters![0] as ParameterObject).explode = true; + (schema.parameters![0] as ParameterObject).schema = { + type: 'object', + properties: { + key: { + type: 'string', + }, + }, + }; + }); + + it('should parse a deep object', async () => { + const request = { + url: 'http://localhost:8080/api/search?param[key]=value', + } as Request; + const result = await parser.parse(request); + expect(result.param).toEqual({ key: 'value' }); + }); + + it('should parse a deep object with multiple keys', async () => { + const request = { + url: 'http://localhost:8080/api/search?param[key1]=value1¶m[key2]=value2', + } as Request; + const result = await parser.parse(request); + expect(result.param).toEqual({ key1: 'value1', key2: 'value2' }); + }); + + it('should throw for missing required parameters', async () => { + (schema.parameters![0] as ParameterObject).required = true; + const request = { + url: 'http://localhost:8080/api/search', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Required query parameter param not found"`, + ); + }); + + it('should throw for extra parameters', async () => { + const request = { + url: 'http://localhost:8080/api/search?param[key]=value&extra=world', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Unexpected query parameters: extra"`, + ); + }); + + it('should handle nested objects', async () => { + (schema.parameters![0] as ParameterObject).schema = { + type: 'object', + properties: { + key: { + type: 'object', + properties: { + subkey: { + type: 'string', + }, + }, + required: ['subkey'], + }, + }, + }; + parser = new QueryParameterParser(operation, { ajv }); + const request = { + url: 'http://localhost:8080/api/search?param[key][subkey]=value', + } as Request; + const result = await parser.parse(request); + expect(result.param).toEqual({ key: { subkey: 'value' } }); + }); + }); + }); +}); diff --git a/packages/backend-openapi-utils/src/schema/parameter-validation.ts b/packages/backend-openapi-utils/src/schema/parameter-validation.ts new file mode 100644 index 0000000000..15570e120c --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/parameter-validation.ts @@ -0,0 +1,420 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { OpenAPIObject, ParameterObject, SchemaObject } from 'openapi3-ts'; +import { + Operation, + ParserOptions, + RequestParser, + Validator, + ValidatorParams, +} from './types'; +import Ajv from 'ajv'; +import { OperationError } from './errors'; +import { mockttpToFetchRequest } from './utils'; + +type ReferencelessSchemaObject = SchemaObject & { $ref?: never }; + +type ReferencelessParameterObject = Omit & { + schema: ReferencelessSchemaObject; +}; + +class BaseParameterParser { + ajv: Ajv; + operation: Operation; + parameters: Record = {}; + constructor(operation: Operation, options: ParserOptions) { + this.ajv = options.ajv; + this.operation = operation; + const { schema, path, method } = operation; + const parameters = schema.parameters || []; + for (const parameter of parameters) { + if ('$ref' in parameter) { + throw new Error( + `[(${method}) ${path}] Reference objects are not supported`, + ); + } + + if (!parameter.schema) { + throw new OperationError( + operation, + 'Schema not found for path parameter', + ); + } + if ('$ref' in parameter.schema) { + throw new OperationError( + this.operation, + 'Reference objects are not supported for parameters', + ); + } + if (parameter.in === 'query') { + this.parameters[parameter.name] = + parameter as ReferencelessParameterObject; + } + } + } +} + +export class QueryParameterParser + extends BaseParameterParser + implements RequestParser> +{ + async parse(request: Request) { + const { searchParams } = new URL(request.url); + const remainingQueryParameters = new Set(searchParams.keys()); + const queryParameters: Record = {}; + const parameterIterator = Object.entries(this.parameters).toSorted( + ([_, parameter]) => { + if (parameter.schema.type !== 'object') { + return -1; + } + if (parameter.style === 'form' || !parameter.style) { + if (parameter.explode || typeof parameter.explode === 'undefined') { + return 1; + } + return 0; + } + return 0; + }, + ); + for (const [name, parameter] of parameterIterator) { + if (!parameter.schema) { + throw new OperationError( + this.operation, + 'Schema not found for query parameter', + ); + } + if ('$ref' in parameter.schema) { + throw new OperationError( + this.operation, + 'Reference objects are not supported for parameters', + ); + } + // eslint-disable-next-line prefer-const + let [param, indices]: [any | null, string[]] = this.#findQueryParameters( + this.parameters, + queryParameters, + searchParams, + name, + ); + if (!!param) { + indices.forEach(index => remainingQueryParameters.delete(index)); + } + if (parameter.schema.type !== 'array' && Array.isArray(param)) { + param = param.length > 0 ? param[0] : undefined; + } + if ( + parameter.required && + !indices.some(index => searchParams.has(index)) + ) { + throw new OperationError( + this.operation, + `Required query parameter ${name} not found`, + ); + } else if (!param && !parameter.required) { + continue; + } + if (parameter.schema.type === 'integer') { + // Try to parse the integer as AJV won't do it for us. + param = parseInt(param, 10); + } + if (parameter.schema.type === 'number') { + // Try to parse the number as AJV won't do it for us. + param = parseFloat(param); + } + const validate = this.ajv.compile(parameter.schema); + const valid = validate(param); + if (!valid) { + throw new OperationError( + this.operation, + 'Query parameter validation failed', + ); + } + queryParameters[name] = param; + } + if (remainingQueryParameters.size > 0) { + throw new OperationError( + this.operation, + `Unexpected query parameters: ${Array.from( + remainingQueryParameters, + ).join(', ')}`, + ); + } + return queryParameters; + } + + #findQueryParameters( + parameters: Record, + currentQueryParameters: Record, + searchParams: URLSearchParams, + name: string, + ): [any | null, string[]] { + const parameter = parameters[name]; + const schema = parameter.schema as SchemaObject; + + const getIfExists = (key: string) => + searchParams.has(key) ? searchParams.getAll(key) : null; + + if (schema.type === 'array') { + if (parameter.style === 'form' || !parameter.style) { + if (parameter.explode || typeof parameter.explode === 'undefined') { + if (!searchParams.has(name) && searchParams.has(`${name}[0]`)) { + const values: string[] = []; + const indices: string[] = []; + let index = 0; + while (searchParams.has(`${name}[${index}]`)) { + values.push(searchParams.get(`${name}[${index}]`)!); + indices.push(`${name}[${index}]`); + index++; + } + return [values, indices]; + } + return [getIfExists(name), [name]]; + } + if (!searchParams.has(name) && searchParams.has(`${name}[]`)) { + return [searchParams.get(`${name}[]`)?.split(','), [`${name}[]`]]; + } + if (searchParams.has(name) && searchParams.getAll(name).length > 1) { + throw new OperationError( + this.operation, + 'Array parameter should not have multiple values', + ); + } + return [searchParams.get(name)?.split(','), [name]]; + } else if (parameter.style === 'spaceDelimited') { + return [searchParams.get(name)?.split(' '), [name]]; + } else if (parameter.style === 'pipeDelimited') { + return [searchParams.get(name)?.split('|'), [name]]; + } + throw new OperationError( + this.operation, + 'Unsupported style for array parameter', + ); + } + if (schema.type === 'object') { + if (parameter.style === 'form' || !parameter.style) { + if (parameter.explode) { + const obj: Record = {}; + const indices: string[] = []; + for (const [key, value] of searchParams.entries()) { + if ( + this.#matchesOtherQueryParameters(currentQueryParameters, key) + ) { + continue; + } + indices.push(key); + obj[key] = value; + } + return [obj, indices]; + } + const obj: Record = {}; + const value = searchParams.get(name); + if (value) { + const parts = value.split(','); + if (parts.length % 2 !== 0) { + throw new OperationError( + this.operation, + 'Invalid object parameter', + ); + } + for (let i = 0; i < parts.length; i += 2) { + obj[parts[i]] = parts[i + 1]; + } + } + return [obj, [name]]; + } else if (parameter.style === 'deepObject') { + const obj: Record = {}; + const indices: string[] = []; + for (const [key, value] of searchParams.entries()) { + if (key.startsWith(`${name}[`)) { + indices.push(key); + const parts = key.split('['); + let currentLayer = obj; + for (let partIndex = 1; partIndex < parts.length - 1; partIndex++) { + const part = parts[partIndex]; + if (!part.includes(']')) { + throw new OperationError( + this.operation, + 'Invalid object parameter', + ); + } + const objKey = part.split(']')[0]; + if (!currentLayer[objKey]) { + currentLayer[objKey] = {}; + } + currentLayer = currentLayer[objKey]; + } + const lastPart = parts[parts.length - 1]; + if (!lastPart.includes(']')) { + throw new OperationError( + this.operation, + 'Invalid object parameter', + ); + } + currentLayer[lastPart.split(']')[0]] = value; + } + } + return [obj, indices]; + } + throw new OperationError( + this.operation, + 'Unsupported style for object parameter', + ); + } + // For everything else, just return the value. + return [getIfExists(name), [name]]; + } + + #matchesOtherQueryParameters( + parameters: Record, + nameToMatch: string, + ) { + for (const [name] of Object.entries(parameters)) { + if (name === nameToMatch) { + return true; + } + } + return false; + } +} + +export class HeaderParameterParser + extends BaseParameterParser + implements RequestParser> +{ + async parse(request: Request) { + const headerParameters: Record = {}; + for (const [name, parameter] of Object.entries(this.parameters)) { + const header = request.headers.get(name); + if (!header) { + if (parameter.required) { + throw new OperationError( + this.operation, + `Header parameter ${name} not found`, + ); + } + continue; + } + if (!parameter.schema) { + throw new OperationError( + this.operation, + 'Schema not found for header parameter', + ); + } + if ('$ref' in parameter.schema) { + throw new OperationError( + this.operation, + 'Reference objects are not supported for parameters', + ); + } + const validate = this.ajv.compile(parameter.schema); + const valid = validate(header); + + if (!valid) { + throw new OperationError( + this.operation, + 'Header parameter validation failed', + ); + } + headerParameters[name] = header; + } + return headerParameters; + } +} + +export class PathParameterParser + extends BaseParameterParser + implements RequestParser> +{ + async parse(request: Request) { + const { pathname } = new URL(request.url); + const params = this.parsePath({ + path: pathname, + schema: this.operation.path, + }); + const pathParameters: Record = {}; + for (const [name, parameter] of Object.entries(this.parameters)) { + if (!params[name] && parameter.required) { + throw new OperationError( + this.operation, + `Path parameter ${name} not found`, + ); + } else if (!params[name] && !parameter.required) { + continue; + } + + const validate = this.ajv.compile(parameter.schema); + const valid = validate(params[name]); + + if (!valid) { + throw new OperationError( + this.operation, + 'Path parameter validation failed', + ); + } + pathParameters[name] = params[name]; + } + return pathParameters; + } + + parsePath({ schema, path }: { 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'); + } + const params: Record = {}; + for (let i = 0; i < parts.length; i++) { + if (pathParts[i] === parts[i]) { + continue; + } + if (pathParts[i].startsWith('{') && pathParts[i].endsWith('}')) { + params[pathParts[i].slice(1, -1)] = parts[i]; + continue; + } + break; + } + return params; + } +} + +export class ParameterValidator implements Validator { + schema: OpenAPIObject; + cache: Record = {}; + constructor(schema: OpenAPIObject) { + this.schema = schema; + } + + async validate({ pair: { request, response }, operation }: ValidatorParams) { + if (response.statusCode === 400) { + // If the response is a 400, then the request is invalid and we shouldn't validate the parameters + return; + } + + const ajv = new Ajv(); + const queryParser = new QueryParameterParser(operation, { ajv }); + const headerParser = new HeaderParameterParser(operation, { ajv }); + const pathParser = new PathParameterParser(operation, { ajv }); + + const fetchRequest = mockttpToFetchRequest(request); + + await Promise.all([ + queryParser.parse(fetchRequest), + headerParser.parse(fetchRequest), + pathParser.parse(fetchRequest), + ]); + } +} diff --git a/packages/backend-openapi-utils/src/schema/request-body-validation.ts b/packages/backend-openapi-utils/src/schema/request-body-validation.ts new file mode 100644 index 0000000000..f35b8f07c7 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/request-body-validation.ts @@ -0,0 +1,115 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { JsonObject } from '@backstage/types'; +import { Operation, ParserOptions, RequestParser } from './types'; +import { ValidateFunction } from 'ajv'; +import { OperationError } from './errors'; + +export class RequestBodyParser + implements RequestParser +{ + operation: Operation; + validate: + | { fn: ValidateFunction; disabled: false } + | { + fn: undefined; + disabled: true; + }; + constructor(operation: Operation, options: ParserOptions) { + this.operation = operation; + const { schema: operationSchema } = this.operation; + const requestBody = operationSchema.requestBody; + if (!requestBody) { + this.validate = { disabled: true, fn: undefined }; + return; + } + + if ('$ref' in requestBody!) { + throw new OperationError( + this.operation, + 'Reference objects are not supported', + ); + } + if (!requestBody!.content) { + throw new OperationError( + this.operation, + 'No content found in request body', + ); + } + if (!requestBody!.content['application/json']) { + throw new OperationError( + this.operation, + 'No application/json content type found in request body', + ); + } + const schema = requestBody!.content['application/json'].schema; + if (!schema) { + throw new OperationError( + this.operation, + 'No JSON schema found in request body', + ); + } + if ('$ref' in schema) { + throw new OperationError( + this.operation, + 'Reference objects are not supported', + ); + } + this.validate = { + disabled: false, + fn: options.ajv.compile(operation.schema), + }; + } + async parse(request: Request): Promise { + const { disabled, fn } = this.validate; + const bodyText = await request.text(); + if (!disabled && bodyText?.length) { + throw new OperationError( + this.operation, + `No request body found for ${request.url}`, + ); + } else if (disabled && !bodyText?.length) { + // If there is no request body in the schema and no body in the request, then the request is valid + return undefined; + } else if (disabled && bodyText?.length) { + throw new OperationError( + this.operation, + 'Received a body but no schema was found', + ); + } + + const contentType = + request.headers.get('content-type') || 'application/json'; + if (contentType !== 'application/json') { + throw new OperationError( + this.operation, + 'Content type is not application/json', + ); + } + const body = (await request.json()) as JsonObject; + const valid = fn!(body); + if (!valid) { + console.log(body); + console.error(fn!.errors); + throw new OperationError( + this.operation, + `Request body validation failed.`, + ); + } + return body; + } +} diff --git a/packages/backend-openapi-utils/src/schema/response-body-validation.ts b/packages/backend-openapi-utils/src/schema/response-body-validation.ts new file mode 100644 index 0000000000..d947a21970 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/response-body-validation.ts @@ -0,0 +1,102 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { JsonObject } from '@backstage/types'; +import { Operation, ParserOptions, ResponseParser } from './types'; +import { OperationError } from './errors'; +import Ajv from 'ajv'; +import { OperationObject, ResponseObject } from 'openapi3-ts'; + +export class ResponseBodyParser + implements ResponseParser +{ + operation: Operation; + ajv: Ajv; + constructor(operation: Operation, options: ParserOptions) { + this.operation = operation; + this.ajv = options.ajv; + const responseSchemas = operation.schema.responses; + if (!Object.keys(responseSchemas).length) { + throw new OperationError(this.operation, `No response schemas found`); + } + for (const [statusCode, schema] of Object.entries(responseSchemas)) { + if (!schema.content) { + continue; + } else if (!schema.content['application/json']) { + throw new OperationError( + this.operation, + `No application/json content type found in response for status code ${statusCode}`, + ); + } else if ('$ref' in schema.content['application/json'].schema) { + throw new OperationError( + this.operation, + 'Reference objects are not supported', + ); + } + } + } + + async parse(response: Response): Promise { + const body = await response.text(); + const responseSchema = this.findResponseSchema( + this.operation.schema, + response, + ); + if (!responseSchema?.content && body?.length) { + throw new OperationError(this.operation, 'No content found in response'); + } else if (!responseSchema?.content && !body?.length) { + // If there is no content in the response schema and no body in the response, then the response is valid + return undefined; + } + if (!responseSchema?.content!['application/json']) { + throw new OperationError( + this.operation, + 'No application/json content type found in response', + ); + } + const schema = responseSchema.content!['application/json'].schema; + if (!schema) { + throw new OperationError(this.operation, 'No schema found in response'); + } + if ('$ref' in schema) { + throw new OperationError( + this.operation, + 'Reference objects are not supported', + ); + } + + const validate = this.ajv.compile(schema); + const jsonBody = (await response.json()) as JsonObject; + const valid = validate(jsonBody); + if (!valid) { + throw new OperationError( + this.operation, + 'Response body validation failed', + ); + } + return jsonBody; + } + + private findResponseSchema( + operationSchema: OperationObject, + response: Response, + ): ResponseObject | undefined { + const { status } = response; + return ( + operationSchema.responses?.[status] ?? operationSchema.responses?.default + ); + } +} diff --git a/packages/backend-openapi-utils/src/schema/types.ts b/packages/backend-openapi-utils/src/schema/types.ts new file mode 100644 index 0000000000..7de1e1c57e --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/types.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CompletedRequest, CompletedResponse } from 'mockttp'; +import { OperationObject } from 'openapi3-ts'; +import Ajv from 'ajv'; + +export interface RequestParser { + parse(request: Request): Promise; +} +export interface ResponseParser { + parse(response: Response): Promise; +} + +export interface ParserOptions { + ajv: Ajv; +} + +export interface Operation { + schema: OperationObject; + path: string; + method: string; +} + +export interface RequestResponsePair { + request: CompletedRequest; + response: CompletedResponse; +} + +export interface ValidatorParams { + pair: RequestResponsePair; + operation: Operation; +} + +export interface Validator { + validate(pair: ValidatorParams): Promise; +} diff --git a/packages/backend-openapi-utils/src/schema/utils.ts b/packages/backend-openapi-utils/src/schema/utils.ts new file mode 100644 index 0000000000..cc00d00095 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/utils.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { CompletedRequest, CompletedResponse } from 'mockttp'; + +export function mockttpToFetchRequest(request: CompletedRequest) { + const headers = new Headers(request.rawHeaders); + return { + url: request.url, + method: request.method, + headers, + json: () => request.body.getJson(), + text: () => request.body.getText(), + } as Request; +} +export function mockttpToFetchResponse(response: CompletedResponse) { + const headers = new Headers(response.rawHeaders); + return { + status: response.statusCode, + headers, + json: () => response.body?.getJson(), + text: () => response.body?.getText(), + } as Response; +} diff --git a/packages/backend-openapi-utils/src/schema/validation.test.ts b/packages/backend-openapi-utils/src/schema/validation.test.ts new file mode 100644 index 0000000000..1c440fa7b5 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/validation.test.ts @@ -0,0 +1,711 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { OpenApiProxyValidator } from './validation'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +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'; + +const server = setupServer(); + +function createMockttpRequest(request: { + method: string; + url: string; + headers?: Record; + body?: object; +}): CompletedRequest { + return { + method: request.method, + url: `http://localhost:8080${request.url}`, + headers: { 'content-type': 'application/json', ...request.headers }, + body: { + getText: async () => JSON.stringify(request.body), + getJson: async () => request.body, + } as CompletedBody, + } as CompletedRequest; +} + +function createMockttpResponse(response: { + statusCode: number; + headers?: Record; + body?: object; +}): CompletedResponse { + return { + statusCode: response.statusCode, + headers: response.headers, + body: response.body + ? ({ + getText: async () => JSON.stringify(response.body), + getJson: async () => response.body, + } as CompletedBody) + : undefined, + } as CompletedResponse; +} + +describe('OpenApiProxyValidator', () => { + setupRequestMockHandlers(server); + let validator: OpenApiProxyValidator; + + async function mockSchema(schema: any) { + server.use( + rest.get('http://localhost:7000/openapi.json', (_req, res, ctx) => + res(ctx.json(schema)), + ), + ); + await validator.initialize('http://localhost:7000/openapi.json'); + } + + beforeEach(async () => { + validator = new OpenApiProxyValidator(); + }); + + describe('request body', () => { + it('validates a JSON request body', async () => { + await mockSchema(withResponseBody); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search', + }); + const response = createMockttpResponse({ + statusCode: 200, + headers: { + 'content-type': 'application/json', + }, + body: { results: [] }, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + + it('throws for missing request body per schema', async () => { + await mockSchema(withResponseBody); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search', + body: { id: '123' }, + }); + const response = createMockttpResponse({ + statusCode: 200, + headers: { + 'content-type': 'application/json', + }, + body: { results: [] }, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Received a body but no schema was found]`, + ); + }); + }); + + describe('query parameters', () => { + describe('primitives', () => { + describe('string', () => { + it('accepts valid parameter', async () => { + await mockSchema(withQueryParameter); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=abc', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + }); + describe('number', () => { + const schema = _.cloneDeep(withQueryParameter); + schema.paths['/api/search'].get.parameters[0].schema.type = 'number'; + it('throws for a missing required parameter', async () => { + await mockSchema(schema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?test=123', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Unexpected query parameters: test]`, + ); + }); + + it('throws for invalid parameter', async () => { + await mockSchema(schema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=abc', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Query parameter validation failed]`, + ); + }); + + it('accepts valid parameter', async () => { + await mockSchema(schema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + }); + }); + + describe('object', () => { + describe('deepObject', () => { + const schema = _.cloneDeep(withQueryParameter); + schema.paths['/api/search'].get.parameters[0].schema.type = 'object'; + ( + schema.paths['/api/search'].get.parameters[0] as ParameterObject + ).style = 'deepObject'; + it('throws for invalid parameter (not an object)', async () => { + await mockSchema(schema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Unexpected query parameters: param]`, + ); + }); + + it('throws for missing required property', async () => { + const requiredSchema = _.cloneDeep(schema); + requiredSchema.paths['/api/search'].get.parameters[0].required = true; + await mockSchema(requiredSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Required query parameter param not found]`, + ); + }); + + it('throws for invalid format', async () => { + await mockSchema(schema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param[t=123', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Invalid object parameter]`, + ); + }); + }); + + describe('form', () => { + const schema = _.cloneDeep(withQueryParameter); + schema.paths['/api/search'].get.parameters[0].schema.type = 'object'; + ( + schema.paths['/api/search'].get.parameters[0] as ParameterObject + ).style = 'form'; + it('throws for invalid parameter (not an object)', async () => { + await mockSchema(schema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param[t=123', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Unexpected query parameters: param[t]`, + ); + }); + + it('throws for missing required property', async () => { + const requiredSchema = _.cloneDeep(schema); + requiredSchema.paths['/api/search'].get.parameters[0].required = true; + await mockSchema(requiredSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Required query parameter param not found]`, + ); + }); + + it('throws for invalid format', async () => { + await mockSchema(schema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Invalid object parameter]`, + ); + }); + describe('explode', () => { + const explodeSchema = _.cloneDeep(schema); + ( + explodeSchema.paths['/api/search'].get + .parameters[0] as ParameterObject + ).explode = true; + it('accepts valid parameter', async () => { + await mockSchema(explodeSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123,test,456', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + + it('accepts multiple parameters', async () => { + await mockSchema(explodeSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?test=123&myparam=test&otherparam=456', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + }); + + describe('no explode', () => { + const noExplodeSchema = _.cloneDeep(schema); + ( + noExplodeSchema.paths['/api/search'].get + .parameters[0] as ParameterObject + ).explode = false; + it('accepts valid parameter', async () => { + await mockSchema(schema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123,test,456,param', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + + it('throws for invalid parameter', async () => { + await mockSchema(schema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123,test,456', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (GET)]: Invalid object parameter"`, + ); + }); + }); + }); + }); + + describe('array', () => { + const arraySchema = _.cloneDeep(withQueryParameter); + arraySchema.paths['/api/search'].get.parameters[0].schema.type = 'array'; + describe('form', () => { + describe('explode', () => { + const explodeSchema = _.cloneDeep(arraySchema); + ( + explodeSchema.paths['/api/search'].get + .parameters[0] as ParameterObject + ).explode = true; + + it('accepts single parameter', async () => { + await mockSchema(explodeSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + + it('accepts multiple parameters', async () => { + await mockSchema(explodeSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123¶m=test¶m=456', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + + it('throws for missing required parameter', async () => { + const requiredSchema = _.cloneDeep(explodeSchema); + requiredSchema.paths['/api/search'].get.parameters[0].required = + true; + await mockSchema(requiredSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Required query parameter param not found]`, + ); + }); + + it('throws for invalid parameter', async () => { + await mockSchema(explodeSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param[]=123', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Unexpected query parameters: param[]]`, + ); + }); + }); + + describe('no explode', () => { + const noExplodeSchema = _.cloneDeep(arraySchema); + ( + noExplodeSchema.paths['/api/search'].get + .parameters[0] as ParameterObject + ).explode = false; + + it('accepts single parameter', async () => { + await mockSchema(noExplodeSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + + it('accepts multiple parameters', async () => { + await mockSchema(noExplodeSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123,456,789', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + + it('throws for missing required parameter', async () => { + const requiredSchema = _.cloneDeep(noExplodeSchema); + requiredSchema.paths['/api/search'].get.parameters[0].required = + true; + await mockSchema(requiredSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Required query parameter param not found]`, + ); + }); + + it('throws for invalid parameter', async () => { + await mockSchema(noExplodeSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123¶m=456', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Array parameter should not have multiple values]`, + ); + }); + }); + + describe('compatible with qs', () => { + const noExplodeSchema = _.cloneDeep(arraySchema); + ( + noExplodeSchema.paths['/api/search'].get + .parameters[0] as ParameterObject + ).explode = false; + + const explodeSchema = _.cloneDeep(arraySchema); + ( + explodeSchema.paths['/api/search'].get + .parameters[0] as ParameterObject + ).explode = true; + it('accepts the [] syntax', async () => { + await mockSchema(noExplodeSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param[]=123,456,789', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + + it('accepts the array index syntax', async () => { + await mockSchema(explodeSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param[0]=123¶m[1]=456¶m[2]=789', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + }); + }); + + describe('spaceDelimited', () => { + const spaceDelimitedSchema = _.cloneDeep(arraySchema); + ( + spaceDelimitedSchema.paths['/api/search'].get + .parameters[0] as ParameterObject + ).style = 'spaceDelimited'; + + it('accepts single parameter', async () => { + await mockSchema(spaceDelimitedSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + + it('accepts multiple parameters', async () => { + await mockSchema(spaceDelimitedSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123 test 456', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + + it('throws for missing required parameter', async () => { + const requiredSchema = _.cloneDeep(spaceDelimitedSchema); + requiredSchema.paths['/api/search'].get.parameters[0].required = true; + await mockSchema(requiredSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Required query parameter param not found]`, + ); + }); + }); + + describe('pipeDelimited', () => { + const pipeDelimitedSchema = _.cloneDeep(arraySchema); + ( + pipeDelimitedSchema.paths['/api/search'].get + .parameters[0] as ParameterObject + ).style = 'pipeDelimited'; + + it('accepts single parameter', async () => { + await mockSchema(pipeDelimitedSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + + it('accepts multiple parameters', async () => { + await mockSchema(pipeDelimitedSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123|test|456', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + + it('throws for missing required parameter', async () => { + const requiredSchema = _.cloneDeep(pipeDelimitedSchema); + requiredSchema.paths['/api/search'].get.parameters[0].required = true; + await mockSchema(requiredSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Required query parameter param not found]`, + ); + }); + }); + }); + }); + + describe('response body', () => { + it('validates a JSON response body', async () => { + await mockSchema(withResponseBody); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search', + }); + const response = createMockttpResponse({ + statusCode: 200, + headers: { + 'content-type': 'application/json', + }, + body: { results: [] }, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + + it('throws for missing response body per schema', async () => { + await mockSchema(withResponseBody); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search', + }); + const response = createMockttpResponse({ + statusCode: 200, + headers: { + 'content-type': 'application/json', + }, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Response body validation failed]`, + ); + }); + }); +}); diff --git a/packages/backend-openapi-utils/src/schema/validation.ts b/packages/backend-openapi-utils/src/schema/validation.ts index 9f2f22b9ac..b5a96e050a 100644 --- a/packages/backend-openapi-utils/src/schema/validation.ts +++ b/packages/backend-openapi-utils/src/schema/validation.ts @@ -23,489 +23,40 @@ import { } from 'openapi3-ts'; import Ajv from 'ajv'; import Parser from '@apidevtools/swagger-parser'; +import { Operation, Validator, ValidatorParams } from './types'; +import { ParameterValidator } from './parameter-validation'; +import { OperationError } from './errors'; +import { RequestBodyParser } from './request-body-validation'; +import { mockttpToFetchRequest, mockttpToFetchResponse } from './utils'; +import { ResponseBodyParser } from './response-body-validation'; const ajv = new Ajv({ allErrors: true }); // options can be passed, e.g. {allErrors: true} -interface RequestResponsePair { - request: CompletedRequest; - response: CompletedResponse; -} - -interface ValidatorParams { - pair: RequestResponsePair; - operationSchema: OperationObject; - path: string; -} - -interface Validator { - validate(pair: ValidatorParams): Promise; -} - -class RequestErrorFactory { - static createRequestError(request: CompletedRequest, message: string): Error { - return new Error(`[${request.url} (${request.method})]: ${message}`); - } -} - -export class ParameterValidator implements Validator { - schema: OpenAPIObject; - cache: Record = {}; - constructor(schema: OpenAPIObject) { - this.schema = schema; - } - - async validate({ - pair: { request, response }, - operationSchema, - path, - }: ValidatorParams) { - if (response.statusCode === 400) { - // If the response is a 400, then the request is invalid and we shouldn't validate the parameters - return; - } - const parameters = operationSchema.parameters; - const queryParameters: Record = {}; - const headerParameters: Record = {}; - const pathParameters: Record = {}; - for (const parameter of parameters || []) { - if ('$ref' in parameter) { - throw RequestErrorFactory.createRequestError( - request, - 'Reference objects are not supported', - ); - } - if (parameter.in === 'query') { - queryParameters[parameter.name] = parameter; - } - if (parameter.in === 'header') { - headerParameters[parameter.name] = parameter; - } - if (parameter.in === 'path') { - pathParameters[parameter.name] = parameter; - } - } - this.validateQueryParameters(queryParameters, request); - this.validateHeaderParameters(headerParameters, request); - this.validatePathParameters(pathParameters, request, path); - } - - validateQueryParameters( - queryParameters: Record, - request: CompletedRequest, - ) { - const { searchParams } = new URL(request.url); - for (const [name, parameter] of Object.entries(queryParameters)) { - if (!parameter.schema) { - throw RequestErrorFactory.createRequestError( - request, - 'Schema not found for query parameter', - ); - } - if ('$ref' in parameter.schema) { - throw RequestErrorFactory.createRequestError( - request, - 'Reference objects are not supported for parameters', - ); - } - let param: any | null = this.#findQueryParameters( - request, - queryParameters, - searchParams, - name, - ); - if (parameter.schema.type !== 'array' && Array.isArray(param)) { - param = param.length > 0 ? param[0] : undefined; - } - - if (!param && parameter.required) { - throw RequestErrorFactory.createRequestError( - request, - `Required query parameter ${name} not found`, - ); - } else if (!param && !parameter.required) { - continue; - } - if (parameter.schema.type === 'integer') { - // Try to parse the integer as AJV won't do it for us. - param = parseInt(param, 10); - } - const validate = ajv.compile(parameter.schema); - const valid = validate(param); - if (!valid) { - console.log(param); - console.error(validate.errors); - throw RequestErrorFactory.createRequestError( - request, - 'Query parameter validation failed', - ); - } - } - } - - #findQueryParameters( - request: CompletedRequest, - parameters: Record, - searchParams: URLSearchParams, - name: string, - ) { - const parameter = parameters[name]; - const schema = parameter.schema as SchemaObject; - if (schema.type === 'array') { - if (parameter.style === 'form' || !parameter.style) { - if (parameter.explode || typeof parameter.explode === 'undefined') { - if (!searchParams.has(name) && searchParams.has(`${name}[0]`)) { - const values: string[] = []; - let index = 0; - while (searchParams.has(`${name}[${index}]`)) { - values.push(searchParams.get(`${name}[${index}]`)!); - index++; - } - return values; - } - return searchParams.getAll(name); - } - if (!searchParams.has(name) && searchParams.has(`${name}[]`)) { - return searchParams.getAll(`${name}[]`); - } - return searchParams.get(name)?.split(','); - } else if (parameter.style === 'spaceDelimited') { - return searchParams.get(name)?.split(' '); - } else if (parameter.style === 'pipeDelimited') { - return searchParams.get(name)?.split('|'); - } - throw RequestErrorFactory.createRequestError( - request, - 'Unsupported style for array parameter', - ); - } - if (schema.type === 'object') { - if (parameter.style === 'form' || !parameter.style) { - if (parameter.explode) { - const obj: Record = {}; - for (const [key, value] of searchParams.entries()) { - if (this.#matchesOtherQueryParameters(parameters, key)) { - continue; - } - obj[key] = value; - } - console.log(obj); - return obj; - } - const obj: Record = {}; - const value = searchParams.get(name); - if (value) { - const parts = value.split(','); - if (parts.length % 2 !== 0) { - throw RequestErrorFactory.createRequestError( - request, - 'Invalid object parameter', - ); - } - for (let i = 0; i < parts.length; i += 2) { - obj[parts[i]] = parts[i + 1]; - } - } - return obj; - } else if (parameter.style === 'deepObject') { - const obj: Record = {}; - for (const [key, value] of searchParams.entries()) { - if (key.startsWith(`${name}[`)) { - const parts = key.split('['); - let currentLayer = obj; - for (let partIndex = 0; partIndex < parts.length - 1; partIndex++) { - const part = parts[partIndex]; - const objKey = part.split(']')[0]; - if (!currentLayer[objKey]) { - currentLayer[objKey] = {}; - } - currentLayer = currentLayer[objKey]; - } - currentLayer[parts[parts.length - 1].split(']')[0]] = value; - } - } - return obj; - } - throw RequestErrorFactory.createRequestError( - request, - 'Unsupported style for object parameter', - ); - } - // For everything else, just return the value. - return searchParams.getAll(name); - } - - #matchesOtherQueryParameters( - parameters: Record, - nameToMatch: string, - ) { - for (const [name] of Object.entries(parameters)) { - if (name === nameToMatch) { - return true; - } - } - return false; - } - - validateHeaderParameters( - headerParameters: Record, - request: CompletedRequest, - ) { - for (const [name, parameter] of Object.entries(headerParameters)) { - if (!request.headers[name]) { - throw RequestErrorFactory.createRequestError( - request, - `Header parameter ${name} not found`, - ); - } - if (!parameter.schema) { - throw RequestErrorFactory.createRequestError( - request, - 'Schema not found for path parameter', - ); - } - if ('$ref' in parameter.schema) { - throw RequestErrorFactory.createRequestError( - request, - 'Reference objects are not supported for parameters', - ); - } - const validate = ajv.compile(parameter.schema); - const valid = validate(request.headers[name]); - - if (!valid) { - console.log(request.headers[name]); - console.error(validate.errors); - throw RequestErrorFactory.createRequestError( - request, - 'Header parameter validation failed', - ); - } - } - } - - validatePathParameters( - pathParameters: Record, - request: CompletedRequest, - path: string, - ) { - const { pathname } = new URL(request.url); - const params = parsePath({ request, path: pathname, schema: path }); - for (const [name, parameter] of Object.entries(pathParameters)) { - if (!params[name] && parameter.required) { - throw RequestErrorFactory.createRequestError( - request, - `Path parameter ${name} not found`, - ); - } - if (!parameter.schema) { - throw RequestErrorFactory.createRequestError( - request, - 'Schema not found for path parameter', - ); - } - if ('$ref' in parameter.schema) { - throw RequestErrorFactory.createRequestError( - request, - 'Reference objects are not supported for parameters', - ); - } - - const validate = ajv.compile(parameter.schema); - const valid = validate(params[name]); - - if (!valid) { - console.log(params); - console.error(validate.errors); - throw RequestErrorFactory.createRequestError( - request, - 'Path parameter validation failed', - ); - } - } - } -} - -function parsePath({ - request, - schema, - path, -}: { - request: CompletedRequest; - schema: string; - path: string; -}) { - const parts = path.split('/'); - const pathParts = schema.split('/'); - if (parts.length !== pathParts.length) { - throw RequestErrorFactory.createRequestError( - request, - 'Path parts do not match', - ); - } - const params: Record = {}; - for (let i = 0; i < parts.length; i++) { - if (pathParts[i] === parts[i]) { - continue; - } - if (pathParts[i].startsWith('{') && pathParts[i].endsWith('}')) { - params[pathParts[i].slice(1, -1)] = parts[i]; - continue; - } - break; - } - return params; -} - -export class RequestBodyValidator implements Validator { +class RequestBodyValidator implements Validator { schema: OpenAPIObject; constructor(schema: OpenAPIObject) { this.schema = schema; } - async validate({ - pair: { request, response }, - operationSchema, - }: ValidatorParams) { - if (response.statusCode === 400) { - // If the response is a 400, then the request is invalid and we shouldn't validate the request body - return; - } - const requestBody = operationSchema.requestBody; - const bodyText = await request.body.getText(); - if (!requestBody && bodyText?.length) { - throw RequestErrorFactory.createRequestError( - request, - `No request body found for ${request.url}`, - ); - } else if (!requestBody && !bodyText?.length) { - // If there is no request body in the schema and no body in the request, then the request is valid - return; - } - if ('$ref' in requestBody!) { - throw RequestErrorFactory.createRequestError( - request, - 'Reference objects are not supported', - ); - } - if (!requestBody!.content) { - throw RequestErrorFactory.createRequestError( - request, - 'No content found in request body', - ); - } - if (!requestBody!.content['application/json']) { - throw RequestErrorFactory.createRequestError( - request, - 'No application/json content type found in request body', - ); - } - const contentType = request.headers['content-type']; - if (!contentType) { - throw RequestErrorFactory.createRequestError( - request, - 'Content type not found in request', - ); - } - if (contentType !== 'application/json') { - throw RequestErrorFactory.createRequestError( - request, - 'Content type is not application/json', - ); - } - const schema = requestBody!.content['application/json'].schema; - if (!schema) { - throw RequestErrorFactory.createRequestError( - request, - 'No schema found in request body', - ); - } - if ('$ref' in schema) { - throw RequestErrorFactory.createRequestError( - request, - 'Reference objects are not supported', - ); - } - - const validate = ajv.compile(schema); - const body = await request.body.getJson(); - const valid = validate(body); - if (!valid) { - console.log(body); - console.error(validate.errors); - throw RequestErrorFactory.createRequestError( - request, - `Request body validation failed.`, - ); - } + async validate({ pair, operation }: ValidatorParams) { + const { request } = pair; + const parser = new RequestBodyParser(operation, { ajv }); + const fetchRequest = mockttpToFetchRequest(request); + await parser.parse(fetchRequest); } } -export class ResponseBodyValidator implements Validator { +class ResponseBodyValidator implements Validator { schema: OpenAPIObject; constructor(schema: OpenAPIObject) { this.schema = schema; } - async validate(pair: ValidatorParams) { - const { - pair: { response, request }, - operationSchema, - } = pair; - const responseSchema = this.findResponseSchema(operationSchema, response); - if (!responseSchema) { - throw RequestErrorFactory.createRequestError( - request, - `No response schema found for ${response.statusCode}`, - ); - } - const body = await response.body.getText(); - if (!responseSchema.content && body?.length) { - throw RequestErrorFactory.createRequestError( - request, - 'No content found in response', - ); - } else if (!responseSchema.content && !body?.length) { - // If there is no content in the response schema and no body in the response, then the response is valid - return; - } - if (!responseSchema.content!['application/json']) { - throw RequestErrorFactory.createRequestError( - request, - 'No application/json content type found in response', - ); - } - const schema = responseSchema.content!['application/json'].schema; - if (!schema) { - throw RequestErrorFactory.createRequestError( - request, - 'No schema found in response', - ); - } - if ('$ref' in schema) { - throw RequestErrorFactory.createRequestError( - request, - 'Reference objects are not supported', - ); - } - - const validate = ajv.compile(schema); - const valid = validate(await response.body.getJson()); - if (!valid) { - console.log(await response.body.getJson()); - console.error(validate.errors); - throw RequestErrorFactory.createRequestError( - request, - 'Response body validation failed', - ); - } - } - - private findResponseSchema( - operationSchema: OperationObject, - response: CompletedResponse, - ): ResponseObject | undefined { - const { statusCode } = response; - return operationSchema.responses?.[statusCode]; + async validate({ pair, operation }: ValidatorParams) { + const { response } = pair; + const parser = new ResponseBodyParser(operation, { ajv }); + const fetchResponse = mockttpToFetchResponse(response); + await parser.parse(fetchResponse); } } @@ -518,28 +69,28 @@ export class OpenApiProxyValidator { this.validators = [ new ParameterValidator(this.schema), new RequestBodyValidator(this.schema), - // new ResponseBodyValidator(this.schema), + new ResponseBodyValidator(this.schema), ]; } async validate(request: CompletedRequest, response: CompletedResponse) { - const operation = this.findOperation(request); - if (!operation) { - throw RequestErrorFactory.createRequestError( - request, + const operationPathTuple = this.findOperation(request); + if (!operationPathTuple) { + throw new OperationError( + { path: request.path, method: request.method } as Operation, `No operation schema found for ${request.url}`, ); } - const [path, operationSchema] = operation; + const [path, operationSchema] = operationPathTuple; + const operation = { path, method: request.method, schema: operationSchema }; const validators = this.validators!; await Promise.all( validators.map(validator => validator.validate({ pair: { request, response }, - operationSchema, - path, + operation, }), ), ); diff --git a/yarn.lock b/yarn.lock index 036ac5c852..bcc6a951fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3740,8 +3740,10 @@ __metadata: dependencies: "@apidevtools/swagger-parser": ^10.1.0 "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/types": "workspace:^" "@types/express": ^4.17.6 "@types/express-serve-static-core": ^4.17.5 ajv: ^8.16.0 @@ -3751,6 +3753,7 @@ __metadata: json-schema-to-ts: ^3.0.0 lodash: ^4.17.21 mockttp: ^3.13.0 + msw: ^1.0.0 openapi-merge: ^1.3.2 openapi3-ts: ^3.1.2 supertest: ^7.0.0