diff --git a/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonRequestBody.json b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonRequestBody.json new file mode 100644 index 0000000000..c5eda7ebb4 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonRequestBody.json @@ -0,0 +1,29 @@ +{ + "openapi": "3.0.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": { + "/api/search": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "query": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + } + } +} diff --git a/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonResponseBody.json b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonResponseBody.json index 378ea0a168..6243a640b9 100644 --- a/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonResponseBody.json +++ b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonResponseBody.json @@ -23,7 +23,8 @@ } } } - } + }, + "additionalProperties": false } } } diff --git a/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withPathParameter.json b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withPathParameter.json new file mode 100644 index 0000000000..98e25d0975 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withPathParameter.json @@ -0,0 +1,25 @@ +{ + "openapi": "3.0.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": { + "/api/item/{id}": { + "get": { + "responses": { + "200": { + "description": "OK" + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + } + } +} diff --git a/packages/backend-openapi-utils/src/schema/errors.ts b/packages/backend-openapi-utils/src/schema/errors.ts index aab5210f46..44de054c53 100644 --- a/packages/backend-openapi-utils/src/schema/errors.ts +++ b/packages/backend-openapi-utils/src/schema/errors.ts @@ -18,6 +18,20 @@ import { Operation } from './types'; export class OperationError extends Error { constructor(operation: Operation, message: string) { - super(`[${operation.path} (${operation.method})]: ${message}`); + super( + `["${operation.method.toLocaleUpperCase('en-US')} ${ + operation.path + }"] ${message}`, + ); + } +} + +export class OperationResponseError extends Error { + constructor(operation: Operation, response: Response, message: string) { + super( + `["${operation.method.toLocaleUpperCase('en-US')} ${operation.path}" (${ + response.status + })]: ${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 index 2fb8f371b1..673f22f99b 100644 --- a/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts +++ b/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts @@ -16,7 +16,11 @@ import _ from 'lodash'; import withQueryParameter from './__fixtures__/schemas/withQueryParameter.json'; -import { QueryParameterParser } from './parameter-validation'; +import withPathParameter from './__fixtures__/schemas/withPathParameter.json'; +import { + PathParameterParser, + QueryParameterParser, +} from './parameter-validation'; import { OperationObject, ParameterObject } from 'openapi3-ts'; import Ajv from 'ajv'; import { Operation } from './types'; @@ -54,7 +58,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Unexpected query parameters: extra"`, + `"["GET /api/search"] Unexpected query parameters: extra"`, ); }); @@ -66,7 +70,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Required query parameter param not found"`, + `"["GET /api/search"] Required query parameter param not found"`, ); }); }); @@ -90,7 +94,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Query parameter validation failed"`, + `"["GET /api/search"] Query parameter validation failed"`, ); }); }); @@ -132,7 +136,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Required query parameter param not found"`, + `"["GET /api/search"] Required query parameter param not found"`, ); }); @@ -143,7 +147,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Unexpected query parameters: extra"`, + `"["GET /api/search"] Unexpected query parameters: extra"`, ); }); }); @@ -177,7 +181,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Required query parameter param not found"`, + `"["GET /api/search"] Required query parameter param not found"`, ); }); @@ -188,7 +192,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Unexpected query parameters: extra"`, + `"["GET /api/search"] Unexpected query parameters: extra"`, ); }); }); @@ -215,7 +219,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Required query parameter param not found"`, + `"["GET /api/search"] Required query parameter param not found"`, ); }); @@ -226,7 +230,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Unexpected query parameters: extra"`, + `"["GET /api/search"] Unexpected query parameters: extra"`, ); }); }); @@ -252,7 +256,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Required query parameter param not found"`, + `"["GET /api/search"] Required query parameter param not found"`, ); }); @@ -263,7 +267,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Unexpected query parameters: extra"`, + `"["GET /api/search"] Unexpected query parameters: extra"`, ); }); }); @@ -304,7 +308,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Required query parameter param not found"`, + `"["GET /api/search"] Required query parameter param not found"`, ); }); @@ -361,7 +365,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Required query parameter param not found"`, + `"["GET /api/search"] Required query parameter param not found"`, ); }); @@ -372,7 +376,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Unexpected query parameters: extra"`, + `"["GET /api/search"] Unexpected query parameters: extra"`, ); }); }); @@ -416,7 +420,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Required query parameter param not found"`, + `"["GET /api/search"] Required query parameter param not found"`, ); }); @@ -427,7 +431,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Unexpected query parameters: extra"`, + `"["GET /api/search"] Unexpected query parameters: extra"`, ); }); @@ -456,3 +460,66 @@ describe('query parameters', () => { }); }); }); + +describe('path parameters', () => { + let operation: Operation; + let parser: PathParameterParser; + let schema: (typeof withPathParameter)['paths']['/api/item/{id}']['get']; + + beforeEach(() => { + schema = _.cloneDeep(withPathParameter.paths['/api/item/{id}'].get); + operation = { + schema: schema as OperationObject, + path: '/api/item/{id}', + method: 'get', + }; + parser = new PathParameterParser(operation, { ajv }); + }); + describe('primitives', () => { + describe('string', () => { + it('should parse a string', async () => { + const request = { + url: 'http://localhost:8080/api/item/test', + } as Request; + const result = await parser.parse(request); + expect(result.id).toBe('test'); + }); + + 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/item', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/item/{id}"] Path parts do not match"`, + ); + }); + }); + + describe('number', () => { + beforeEach(() => { + schema.parameters![0].schema.type = 'number'; + }); + it('should parse a number', async () => { + const request = { + url: 'http://localhost:8080/api/item/42', + } as Request; + const result = await parser.parse(request); + expect(result.id).toBe(42); + }); + + it('should throw an error if the parameter is not a number', async () => { + const request = { + url: 'http://localhost:8080/api/item/hello', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/item/{id}"] Path parameter validation failed"`, + ); + }); + }); + }); +}); diff --git a/packages/backend-openapi-utils/src/schema/parameter-validation.ts b/packages/backend-openapi-utils/src/schema/parameter-validation.ts index 15570e120c..ddc0efbe45 100644 --- a/packages/backend-openapi-utils/src/schema/parameter-validation.ts +++ b/packages/backend-openapi-utils/src/schema/parameter-validation.ts @@ -36,7 +36,11 @@ class BaseParameterParser { ajv: Ajv; operation: Operation; parameters: Record = {}; - constructor(operation: Operation, options: ParserOptions) { + constructor( + parameterIn: string, + operation: Operation, + options: ParserOptions, + ) { this.ajv = options.ajv; this.operation = operation; const { schema, path, method } = operation; @@ -60,18 +64,31 @@ class BaseParameterParser { 'Reference objects are not supported for parameters', ); } - if (parameter.in === 'query') { + if (parameter.in === parameterIn) { this.parameters[parameter.name] = parameter as ReferencelessParameterObject; } } } + + optimisticallyParseValue(value: string, schema: SchemaObject) { + if (schema.type === 'integer') { + return parseInt(value, 10); + } + if (schema.type === 'number') { + return parseFloat(value); + } + return value; + } } export class QueryParameterParser extends BaseParameterParser implements RequestParser> { + constructor(operation: Operation, options: ParserOptions) { + super('query', operation, options); + } async parse(request: Request) { const { searchParams } = new URL(request.url); const remainingQueryParameters = new Set(searchParams.keys()); @@ -127,13 +144,8 @@ export class QueryParameterParser } 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); + if (param) { + param = this.optimisticallyParseValue(param, parameter.schema); } const validate = this.ajv.compile(parameter.schema); const valid = validate(param); @@ -295,6 +307,9 @@ export class HeaderParameterParser extends BaseParameterParser implements RequestParser> { + constructor(operation: Operation, options: ParserOptions) { + super('header', operation, options); + } async parse(request: Request) { const headerParameters: Record = {}; for (const [name, parameter] of Object.entries(this.parameters)) { @@ -339,15 +354,19 @@ export class PathParameterParser extends BaseParameterParser implements RequestParser> { + constructor(operation: Operation, options: ParserOptions) { + super('path', operation, options); + } async parse(request: Request) { const { pathname } = new URL(request.url); const params = this.parsePath({ path: pathname, schema: this.operation.path, }); - const pathParameters: Record = {}; + const pathParameters: Record = {}; for (const [name, parameter] of Object.entries(this.parameters)) { - if (!params[name] && parameter.required) { + let param: string | number = params[name]; + if (!param && parameter.required) { throw new OperationError( this.operation, `Path parameter ${name} not found`, @@ -356,8 +375,12 @@ export class PathParameterParser continue; } + if (param) { + param = this.optimisticallyParseValue(param, parameter.schema); + } + const validate = this.ajv.compile(parameter.schema); - const valid = validate(params[name]); + const valid = validate(param); if (!valid) { throw new OperationError( @@ -365,7 +388,7 @@ export class PathParameterParser 'Path parameter validation failed', ); } - pathParameters[name] = params[name]; + pathParameters[name] = param; } return pathParameters; } diff --git a/packages/backend-openapi-utils/src/schema/request-body-validation.test.ts b/packages/backend-openapi-utils/src/schema/request-body-validation.test.ts new file mode 100644 index 0000000000..a4452763fc --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/request-body-validation.test.ts @@ -0,0 +1,82 @@ +/* + * 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 withJsonRequestBody from './__fixtures__/schemas/withJsonRequestBody.json'; +import { RequestBodyParser } from './request-body-validation'; +import Ajv from 'ajv'; +import { Operation, RequestParser } from './types'; +import _ from 'lodash'; +import { OperationObject, RequestBodyObject } from 'openapi3-ts'; +import { JsonObject } from '@backstage/types'; + +const ajv = new Ajv(); + +function toRequest(body?: object, headers?: Record): Request { + return { + text: async () => JSON.stringify(body), + json: async () => body, + url: '/api/search', + method: 'post', + headers: new Headers({ 'content-type': 'application/json', ...headers }), + } as Request; +} + +describe('request body', () => { + let operation: Operation; + let parser: RequestParser; + let schema: (typeof withJsonRequestBody)['paths']['/api/search']['post']; + beforeEach(() => { + schema = _.cloneDeep(withJsonRequestBody.paths['/api/search'].post); + operation = { + method: 'post', + schema: schema as OperationObject, + path: '/api/search', + }; + parser = new RequestBodyParser(operation, { + ajv, + }); + }); + it('should validate request body', async () => { + const requestBody = { + query: 'test', + }; + const result = await parser.parse(toRequest(requestBody)); + expect(result).toEqual(requestBody); + }); + + it('should throw error if request body is not valid', async () => { + const requestBody = { + query: 1, + }; + await expect( + parser.parse(toRequest(requestBody)), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["POST /api/search"] Request body validation failed."`, + ); + }); + + it('should throw error if request body is required but missing', async () => { + (schema.requestBody as RequestBodyObject).required = true; + parser = RequestBodyParser.fromOperation(operation, { + ajv, + }); + await expect( + parser.parse(toRequest()), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["POST /api/search"] No request body found for /api/search"`, + ); + }); +}); diff --git a/packages/backend-openapi-utils/src/schema/request-body-validation.ts b/packages/backend-openapi-utils/src/schema/request-body-validation.ts index f35b8f07c7..22873c8100 100644 --- a/packages/backend-openapi-utils/src/schema/request-body-validation.ts +++ b/packages/backend-openapi-utils/src/schema/request-body-validation.ts @@ -18,24 +18,51 @@ import { JsonObject } from '@backstage/types'; import { Operation, ParserOptions, RequestParser } from './types'; import { ValidateFunction } from 'ajv'; import { OperationError } from './errors'; +import { RequestBodyObject, SchemaObject } from 'openapi3-ts'; +class DisabledRequestBodyParser + implements RequestParser +{ + operation: Operation; + constructor(operation: Operation) { + this.operation = operation; + } + async parse(request: Request): Promise { + const bodyText = await request.text(); + if (bodyText?.length) { + throw new OperationError( + this.operation, + 'Received a body but no schema was found', + ); + } + return undefined; + } +} export class RequestBodyParser implements RequestParser { operation: Operation; - validate: - | { fn: ValidateFunction; disabled: false } - | { - fn: undefined; - disabled: true; - }; + disabled: boolean = false; + validate!: ValidateFunction; + schema!: SchemaObject; + requestBodySchema!: RequestBodyObject; + + static fromOperation(operation: Operation, options: ParserOptions) { + return operation.schema.requestBody + ? new RequestBodyParser(operation, options) + : new DisabledRequestBodyParser(operation); + } + 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; + throw new OperationError( + this.operation, + 'No request body found in operation', + ); } if ('$ref' in requestBody!) { @@ -69,27 +96,17 @@ export class RequestBodyParser 'Reference objects are not supported', ); } - this.validate = { - disabled: false, - fn: options.ajv.compile(operation.schema), - }; + this.validate = options.ajv.compile(schema); + this.schema = schema; + this.requestBodySchema = requestBody; } async parse(request: Request): Promise { - const { disabled, fn } = this.validate; const bodyText = await request.text(); - if (!disabled && bodyText?.length) { + if (this.requestBodySchema.required && !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 = @@ -101,10 +118,8 @@ export class RequestBodyParser ); } const body = (await request.json()) as JsonObject; - const valid = fn!(body); + const valid = this.validate(body); if (!valid) { - console.log(body); - console.error(fn!.errors); throw new OperationError( this.operation, `Request body validation failed.`, diff --git a/packages/backend-openapi-utils/src/schema/response-body-validation.test.ts b/packages/backend-openapi-utils/src/schema/response-body-validation.test.ts new file mode 100644 index 0000000000..9ad47b8a19 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/response-body-validation.test.ts @@ -0,0 +1,81 @@ +/* + * 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 withJsonResponseBody from './__fixtures__/schemas/withJsonResponseBody.json'; +import { Operation, ResponseParser } from './types'; +import { ResponseBodyParser } from './response-body-validation'; +import Ajv from 'ajv'; +import { OperationObject, ResponsesObject } from 'openapi3-ts'; +import _ from 'lodash'; + +const ajv = new Ajv(); + +function toResponse(body?: object): Response { + return { + json: async () => body, + text: async () => JSON.stringify(body), + status: 200, + } as Response; +} + +describe('response body', () => { + let operation: Operation; + let parser: ResponseParser; + let schema: (typeof withJsonResponseBody)['paths']['/api/search']['get']; + beforeEach(() => { + schema = _.cloneDeep(withJsonResponseBody.paths['/api/search'].get); + operation = { + path: '/api/search', + method: 'get', + schema: schema as OperationObject, + }; + parser = ResponseBodyParser.fromOperation(operation, { + ajv, + }); + }); + + it('should validate response body', async () => { + const responseBody = { + results: [{ id: 'test' }], + }; + const result = await parser.parse(toResponse(responseBody)); + expect(result).toEqual(responseBody); + }); + + it('should throw error if response body is not valid', async () => { + const responseBody = { + result: 1, + }; + await expect( + parser.parse(toResponse(responseBody)), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search" (200)]: Response body validation failed"`, + ); + }); + + it('should throw error if response body is required but missing', async () => { + (schema.responses as ResponsesObject)['200'].required = true; + parser = ResponseBodyParser.fromOperation(operation, { + ajv, + }); + await expect( + parser.parse(toResponse()), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search" (200)]: Response body is required but missing"`, + ); + }); +}); diff --git a/packages/backend-openapi-utils/src/schema/response-body-validation.ts b/packages/backend-openapi-utils/src/schema/response-body-validation.ts index d947a21970..116fc888e8 100644 --- a/packages/backend-openapi-utils/src/schema/response-body-validation.ts +++ b/packages/backend-openapi-utils/src/schema/response-body-validation.ts @@ -16,22 +16,46 @@ import { JsonObject } from '@backstage/types'; import { Operation, ParserOptions, ResponseParser } from './types'; -import { OperationError } from './errors'; +import { OperationError, OperationResponseError } from './errors'; import Ajv from 'ajv'; import { OperationObject, ResponseObject } from 'openapi3-ts'; +class DisabledResponseBodyParser + implements ResponseParser +{ + operation: Operation; + constructor(operation: Operation) { + this.operation = operation; + } + async parse(response: Response): Promise { + const body = await response.text(); + if (body?.length) { + throw new OperationError( + this.operation, + 'Received a body but no schema was found', + ); + } + return undefined; + } +} + export class ResponseBodyParser implements ResponseParser { operation: Operation; ajv: Ajv; + + static fromOperation(operation: Operation, options: ParserOptions) { + return operation.schema.responses && + Object.keys(operation.schema.responses).length + ? new ResponseBodyParser(operation, options) + : new DisabledResponseBodyParser(operation); + } + 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; @@ -55,15 +79,30 @@ export class ResponseBodyParser 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 + 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. + // eg 204 No Content return undefined; } - if (!responseSchema?.content!['application/json']) { - throw new OperationError( + if (!responseSchema) { + throw new OperationResponseError( this.operation, + response, + `No schema found.`, + ); + } + + if (!responseSchema?.content && body?.length) { + throw new OperationResponseError( + this.operation, + response, + 'Received a body but no schema was found', + ); + } + if (!responseSchema?.content!['application/json']) { + throw new OperationResponseError( + this.operation, + response, 'No application/json content type found in response', ); } @@ -72,18 +111,31 @@ export class ResponseBodyParser throw new OperationError(this.operation, 'No schema found in response'); } if ('$ref' in schema) { - throw new OperationError( + throw new OperationResponseError( this.operation, + response, 'Reference objects are not supported', ); } + if (!schema.required && !body?.length) { + throw new OperationResponseError( + this.operation, + response, + 'Response body is required but missing', + ); + } else if (!schema.required && !body?.length) { + // If there is no content in the response schema and no body in the response, then the response is valid + return undefined; + } + const validate = this.ajv.compile(schema); const jsonBody = (await response.json()) as JsonObject; const valid = validate(jsonBody); if (!valid) { - throw new OperationError( + throw new OperationResponseError( this.operation, + response, 'Response body validation failed', ); } diff --git a/packages/backend-openapi-utils/src/schema/validation.test.ts b/packages/backend-openapi-utils/src/schema/validation.test.ts index 1c440fa7b5..0fab287f1e 100644 --- a/packages/backend-openapi-utils/src/schema/validation.test.ts +++ b/packages/backend-openapi-utils/src/schema/validation.test.ts @@ -112,8 +112,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Received a body but no schema was found]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Received a body but no schema was found"`, ); }); }); @@ -149,8 +149,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Unexpected query parameters: test]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Unexpected query parameters: test"`, ); }); @@ -166,8 +166,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Query parameter validation failed]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Query parameter validation failed"`, ); }); @@ -205,8 +205,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Unexpected query parameters: param]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Unexpected query parameters: param"`, ); }); @@ -224,8 +224,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Required query parameter param not found]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Required query parameter param not found"`, ); }); @@ -241,8 +241,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Invalid object parameter]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Invalid object parameter"`, ); }); }); @@ -265,8 +265,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Unexpected query parameters: param[t]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Unexpected query parameters: param[t"`, ); }); @@ -284,8 +284,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Required query parameter param not found]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Required query parameter param not found"`, ); }); @@ -300,8 +300,8 @@ describe('OpenApiProxyValidator', () => { }); await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Invalid object parameter]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Invalid object parameter"`, ); }); describe('explode', () => { @@ -369,7 +369,7 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (GET)]: Invalid object parameter"`, + `"["GET /api/search"] Invalid object parameter"`, ); }); }); @@ -428,8 +428,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Required query parameter param not found]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Required query parameter param not found"`, ); }); @@ -445,8 +445,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Unexpected query parameters: param[]]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Unexpected query parameters: param[]"`, ); }); }); @@ -499,8 +499,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Required query parameter param not found]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Required query parameter param not found"`, ); }); @@ -516,8 +516,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Array parameter should not have multiple values]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Array parameter should not have multiple values"`, ); }); }); @@ -609,8 +609,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Required query parameter param not found]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Required query parameter param not found"`, ); }); }); @@ -662,8 +662,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Required query parameter param not found]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Required query parameter param not found"`, ); }); }); @@ -703,8 +703,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Response body validation failed]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search" (200)]: Response body is required but missing"`, ); }); }); diff --git a/packages/backend-openapi-utils/src/schema/validation.ts b/packages/backend-openapi-utils/src/schema/validation.ts index b5a96e050a..45255a2ea5 100644 --- a/packages/backend-openapi-utils/src/schema/validation.ts +++ b/packages/backend-openapi-utils/src/schema/validation.ts @@ -40,7 +40,7 @@ class RequestBodyValidator implements Validator { async validate({ pair, operation }: ValidatorParams) { const { request } = pair; - const parser = new RequestBodyParser(operation, { ajv }); + const parser = RequestBodyParser.fromOperation(operation, { ajv }); const fetchRequest = mockttpToFetchRequest(request); await parser.parse(fetchRequest); } @@ -54,7 +54,7 @@ class ResponseBodyValidator implements Validator { async validate({ pair, operation }: ValidatorParams) { const { response } = pair; - const parser = new ResponseBodyParser(operation, { ajv }); + const parser = ResponseBodyParser.fromOperation(operation, { ajv }); const fetchResponse = mockttpToFetchResponse(response); await parser.parse(fetchResponse); }