diff --git a/.changeset/eleven-beds-play.md b/.changeset/eleven-beds-play.md new file mode 100644 index 0000000000..fd51922cb4 --- /dev/null +++ b/.changeset/eleven-beds-play.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-backend': patch +--- + +Fix to schema to allow arbitrary query parameters. diff --git a/.changeset/smart-jobs-sit.md b/.changeset/smart-jobs-sit.md new file mode 100644 index 0000000000..edd21e67b5 --- /dev/null +++ b/.changeset/smart-jobs-sit.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-openapi-utils': minor +--- + +Improved support for OpenAPI validation during Jest tests. Now, OpenAPI validation can happen as you are writing your Jest tests - you no longer have to run `repo schema openapi test`. diff --git a/packages/backend-openapi-utils/package.json b/packages/backend-openapi-utils/package.json index 8f6fd1b6eb..6c391e8f70 100644 --- a/packages/backend-openapi-utils/package.json +++ b/packages/backend-openapi-utils/package.json @@ -33,20 +33,27 @@ "test": "backstage-cli package test" }, "dependencies": { + "@apidevtools/swagger-parser": "^10.1.0", "@backstage/backend-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", + "@backstage/types": "workspace:^", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5", + "ajv": "^8.16.0", "express": "^4.17.1", "express-openapi-validator": "^5.0.4", "express-promise-router": "^4.1.0", + "get-port": "^5.1.1", "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" }, "devDependencies": { "@backstage/cli": "workspace:^", + "@backstage/test-utils": "workspace:^", "supertest": "^7.0.0" } } diff --git a/packages/backend-openapi-utils/report.api.md b/packages/backend-openapi-utils/report.api.md index a0495f39b5..f543a9cfb7 100644 --- a/packages/backend-openapi-utils/report.api.md +++ b/packages/backend-openapi-utils/report.api.md @@ -3,6 +3,8 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +/// + import type { ContentObject } from 'openapi3-ts'; import type core from 'express-serve-static-core'; import { Express as Express_2 } from 'express'; @@ -718,6 +720,9 @@ type ValueOf = T[keyof T]; // @public export const wrapInOpenApiTestServer: (app: Express_2) => Server | Express_2; +// @public +export function wrapServer(app: Express_2): Promise; + // Warnings were encountered during analysis: // // src/router.d.ts:8:5 - (ae-undocumented) Missing documentation for "get". diff --git a/packages/backend-openapi-utils/src/index.ts b/packages/backend-openapi-utils/src/index.ts index 9f5ccdc03b..57d9f755c2 100644 --- a/packages/backend-openapi-utils/src/index.ts +++ b/packages/backend-openapi-utils/src/index.ts @@ -32,4 +32,4 @@ export type { } from './utility'; export type { ApiRouter } from './router'; export { createValidatedOpenApiRouter, getOpenApiSpecRoute } from './stub'; -export { wrapInOpenApiTestServer } from './testUtils'; +export { wrapInOpenApiTestServer, wrapServer } from './testUtils'; diff --git a/packages/backend-openapi-utils/src/proxy/setup.ts b/packages/backend-openapi-utils/src/proxy/setup.ts new file mode 100644 index 0000000000..c49f0d87de --- /dev/null +++ b/packages/backend-openapi-utils/src/proxy/setup.ts @@ -0,0 +1,74 @@ +/* + * 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 * as mockttp from 'mockttp'; +import { OpenApiProxyValidator } from '../schema/validation'; +import getPort from 'get-port'; +import { Server } from 'http'; + +export class Proxy { + server: mockttp.Mockttp; + #openRequests: Record = {}; + requestResponsePairs = new Map< + mockttp.CompletedRequest, + mockttp.CompletedResponse + >(); + validator: OpenApiProxyValidator; + public forwardTo: { port: number } = { port: 0 }; + express: { server: Server | undefined } = { server: undefined }; + constructor() { + this.server = mockttp.getLocal(); + this.validator = new OpenApiProxyValidator(); + } + + async setup() { + await this.server.start(); + this.forwardTo.port = await getPort(); + this.server + .forAnyRequest() + .thenForwardTo(`http://localhost:${this.forwardTo.port}`); + await this.server.on('request', request => { + this.#openRequests[request.id] = request; + }); + await this.server.on('response', response => { + const request = this.#openRequests[response.id]; + if (request) { + this.requestResponsePairs.set(request, response); + } + delete this.#openRequests[response.id]; + this.validator.validate(request, response); + }); + } + + async initialize(url: string, server: Server) { + await this.validator.initialize(`${url}/openapi.json`); + this.express.server = server; + } + + stop() { + if (Object.keys(this.#openRequests).length > 0) { + throw new Error('There are still open requests'); + } + this.server.stop(); + + // If this isn't expressly closed, it will cause a jest memory leak warning. + this.express.server?.close(); + } + + get url() { + return this.server.proxyEnv.HTTP_PROXY; + } +} 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 new file mode 100644 index 0000000000..6243a640b9 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonResponseBody.json @@ -0,0 +1,36 @@ +{ + "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" + } + } + } + } + }, + "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/__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..e1950bcce9 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/errors.ts @@ -0,0 +1,67 @@ +/* + * 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'; +import { ErrorObject } from 'ajv'; +import { humanifyAjvError } from './utils'; + +export class OperationError extends Error { + constructor(operation: Operation, message: string) { + 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}`, + ); + } +} + +export class OperationParsingError extends OperationError { + constructor(operation: Operation, type: string, errors: ErrorObject[]) { + super( + operation, + `${type} validation failed.\n - ${errors + .map(humanifyAjvError) + .join('\n - ')}`, + ); + } +} + +export class OperationParsingResponseError extends OperationResponseError { + constructor( + operation: Operation, + response: Response, + type: string, + errors: ErrorObject[], + ) { + super( + operation, + response, + `${type} validation failed.\n - ${errors + .map(humanifyAjvError) + .join('\n - ')}`, + ); + } +} 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..7a34c2c309 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts @@ -0,0 +1,614 @@ +/* + * 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 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'; + +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( + `"["GET /api/search"] 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( + `"["GET /api/search"] 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(` + "["GET /api/search"] Query parameter validation failed. + - Value should be of type number" + `); + }); + }); + }); + + 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( + `"["GET /api/search"] 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( + `"["GET /api/search"] 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( + `"["GET /api/search"] 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( + `"["GET /api/search"] 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( + `"["GET /api/search"] 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( + `"["GET /api/search"] 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( + `"["GET /api/search"] 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( + `"["GET /api/search"] 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( + `"["GET /api/search"] 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']); + }); + + it('should respect other object encodings', async () => { + const parameter = { + name: 'extra', + in: 'query', + style: 'deepObject', + explode: true, + schema: { type: 'object' }, + 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' }); + }); + + it('should throw if there are 2 form explode parameters', async () => { + const parameter = { + name: 'extra', + in: 'query', + style: 'form', + explode: true, + schema: { type: 'object' }, + 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; + + await expect(() => + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Ambiguous query parameters, you cannot have 2 form explode parameters"`, + ); + }); + }); + + 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( + `"["GET /api/search"] 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( + `"["GET /api/search"] 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( + `"["GET /api/search"] 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( + `"["GET /api/search"] 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' } }); + }); + }); + }); +}); + +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. + - Value should be of type number" + `); + }); + }); + }); + + describe('path parsing', () => { + it('should parse a path with a single parameters', async () => { + const parsedPath = PathParameterParser.parsePath({ + operation, + schema: '/api/item/{id}', + path: '/api/item/test123', + }); + expect(parsedPath).toEqual({ id: 'test123' }); + }); + it('should parse a path with multiple parameters', async () => { + const parsedPath = PathParameterParser.parsePath({ + operation, + schema: '/api/item/{id}/{name}', + path: '/api/item/42/test', + }); + // the string is expected here, but will be optimistically parsed as a number where it makes sense. + expect(parsedPath).toEqual({ id: '42', name: 'test' }); + }); + + it('should throw an error if the path does not have enough parts', async () => { + expect(() => + PathParameterParser.parsePath({ + operation, + schema: '/api/item/{id}', + path: '/api/item', + }), + ).toThrowErrorMatchingInlineSnapshot( + `"["GET /api/item/{id}"] Path parts do not match"`, + ); + }); + + it('should throw an error if the path has too many parts', async () => { + expect(() => + PathParameterParser.parsePath({ + operation, + schema: '/api/item/{id}', + path: '/api/item/test/123', + }), + ).toThrowErrorMatchingInlineSnapshot( + `"["GET /api/item/{id}"] Path parts do not match"`, + ); + }); + }); +}); diff --git a/packages/backend-openapi-utils/src/schema/parameter-validation.ts b/packages/backend-openapi-utils/src/schema/parameter-validation.ts new file mode 100644 index 0000000000..7a61578850 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/parameter-validation.ts @@ -0,0 +1,492 @@ +/* + * 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, OperationParsingError } 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( + parameterIn: string, + 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 === parameterIn) { + this.parameters[parameter.name] = + parameter as ReferencelessParameterObject; + } + } + } + + /** + * Attempt to transform a string value to its expected type, this allows Ajv to perform validation. As these are parameters, + * support for edge cases like nested type casting is not currently supported. + * @param value + * @param schema + * @returns + */ + optimisticallyParseValue(value: string, schema: SchemaObject) { + if (schema.type === 'integer') { + return parseInt(value, 10); + } + if (schema.type === 'number') { + return parseFloat(value); + } + if (schema.type === 'boolean') { + if (['true', 'false'].includes(value)) { + return value === 'true'; + } + throw new Error('Invalid boolean value must be either "true" or "false"'); + } + 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()); + const queryParameters: Record = {}; + + let parameterIterator = Object.entries(this.parameters); + + const isFormExplode = (parameter: ReferencelessParameterObject) => { + return ( + parameter.schema?.type === 'object' && + (parameter.style === 'form' || !parameter.style) && + parameter.explode + ); + }; + + const regularParameters = parameterIterator.filter( + ([_, parameter]) => !isFormExplode(parameter), + ); + + const formExplodeParameters = parameterIterator.filter(([_, parameter]) => + isFormExplode(parameter), + ); + + if (formExplodeParameters.length > 1) { + throw new OperationError( + this.operation, + 'Ambiguous query parameters, you cannot have 2 form explode parameters', + ); + } + + // Sort the parameters so that form explode parameters are processed last. + parameterIterator = [...regularParameters, ...formExplodeParameters]; + + 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, + remainingQueryParameters, + searchParams, + name, + ); + if (!!param) { + indices.forEach(index => remainingQueryParameters.delete(index)); + } + + // The query parameters can be either a single value or an array of values, try to wrangle them into the expected format if they're not explicitly an array. + 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 (param) { + // We do this here because all query parameters are strings but the schema will expect the real value. + param = this.optimisticallyParseValue(param, parameter.schema); + } + const validate = this.ajv.compile(parameter.schema); + const valid = validate(param); + if (!valid) { + throw new OperationParsingError( + this.operation, + 'Query parameter', + validate.errors!, + ); + } + queryParameters[name] = param; + } + if (remainingQueryParameters.size > 0) { + throw new OperationError( + this.operation, + `Unexpected query parameters: ${Array.from( + remainingQueryParameters, + ).join(', ')}`, + ); + } + return queryParameters; + } + + #findQueryParameters( + parameters: Record, + remainingQueryParameters: Set, + searchParams: URLSearchParams, + name: string, + ): [any | null, string[]] { + const parameter = parameters[name]; + const schema = parameter.schema as SchemaObject; + + // Since getAll will return an empty array if the key is not found, we need to check if the key exists first. + const getIfExists = (key: string) => + searchParams.has(key) ? searchParams.getAll(key) : null; + + if (schema.type === 'array') { + // Form is the default array format. + if ( + parameter.style === 'form' || + typeof parameter.style === 'undefined' + ) { + // As is explode = true. + if (parameter.explode || typeof parameter.explode === 'undefined') { + // Support for qs explode format. Every value is stored as a separate query parameter. + 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]; + } + // If not qs format, grab all values with the same name from search params. + return [getIfExists(name), [name]]; + } + // Add support for qs non-standard array format. This is helpful for search-backend, since that uses qs still. + if (!searchParams.has(name) && searchParams.has(`${name}[]`)) { + return [searchParams.get(`${name}[]`)?.split(','), [`${name}[]`]]; + } + // Non-explode arrays should be comma separated. + if (searchParams.has(name) && searchParams.getAll(name).length > 1) { + throw new OperationError( + this.operation, + 'Arrays must be comma separated in non-explode mode', + ); + } + 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') { + // Form is the default object format. + if ( + parameter.style === 'form' || + typeof parameter.style === 'undefined' + ) { + if (parameter.explode) { + // Object form/explode is a collection of disjoint keys, there's no mapping for what they are so we collect all of them. + // This means we need to run this as the last query parameter that is processed. + const obj: Record = {}; + const indices: string[] = []; + for (const [key, value] of searchParams.entries()) { + // Have we processed this query parameter as part of another parameter parsing? If not, consider it to be a part of this object. + if (!remainingQueryParameters.has(key)) { + continue; + } + indices.push(key); + obj[key] = value; + } + return [obj, indices]; + } + // For non-explode, the schema is comma separated key,value "pairs", so filter=key1,value1,key2,value2 would parse to {key1: value1, key2: value2}. + 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 query parameter, must have an even number of key-value pairs', + ); + } + for (let i = 0; i < parts.length; i += 2) { + obj[parts[i]] = parts[i + 1]; + } + } + return [obj, [name]]; + } else if (parameter.style === 'deepObject') { + // Deep object is a nested object structure, so we need to parse the keys to build the object. + // example: ?filter[key1]=value1&filter[key2]=value2 => { key1: value1, key2: value2 } + 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, missing closing bracket for key "${key}"`, + ); + } + 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, missing closing bracket for key "${key}"`, + ); + } + currentLayer[lastPart.split(']')[0]] = value; + } + } + return [obj, indices]; + } + throw new OperationError( + this.operation, + `Unsupported style for object parameter, "${parameter.style}"`, + ); + } + // For everything else, just return the value. + return [getIfExists(name), [name]]; + } +} + +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)) { + 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 OperationParsingError( + this.operation, + 'Header parameter', + validate.errors!, + ); + } + headerParameters[name] = header; + } + return headerParameters; + } +} + +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 = PathParameterParser.parsePath({ + operation: this.operation, + path: pathname, + schema: this.operation.path, + }); + const pathParameters: Record = {}; + for (const [name, parameter] of Object.entries(this.parameters)) { + let param: string | number | boolean = params[name]; + if (!param && parameter.required) { + throw new OperationError( + this.operation, + `Path parameter ${name} not found`, + ); + } else if (!params[name] && !parameter.required) { + continue; + } + + if (param) { + param = this.optimisticallyParseValue(param, parameter.schema); + } + + const validate = this.ajv.compile(parameter.schema); + const valid = validate(param); + + if (!valid) { + throw new OperationParsingError( + this.operation, + 'Path parameter', + validate.errors!, + ); + } + pathParameters[name] = param; + } + return pathParameters; + } + + static parsePath({ + operation, + schema, + path, + }: { + operation: Operation; + schema: string; + path: string; + }) { + const parts = path.split('/'); + const pathParts = schema.split('/'); + if (parts.length !== pathParts.length) { + throw new OperationError(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.test.ts b/packages/backend-openapi-utils/src/schema/request-body-validation.test.ts new file mode 100644 index 0000000000..807eaa4569 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/request-body-validation.test.ts @@ -0,0 +1,108 @@ +/* + * 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 { + ContentObject, + MediaTypeObject, + 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. + - "/query" should be of type string" + `); + }); + + 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"`, + ); + }); + + it('should throw error if request body is not application/json', async () => { + const request = toRequest({}, { 'content-type': 'text/plain' }); + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["POST /api/search"] Content type is not application/json"`, + ); + }); + + it('should NOT throw error if request body is not just application/json', async () => { + (schema.requestBody.content as ContentObject)[ + 'application/json; charset=utf-8' + ] = schema.requestBody.content['application/json'] as MediaTypeObject; + delete (schema.requestBody.content as ContentObject)['application/json']; + parser = new RequestBodyParser(operation, { + ajv, + }); + const request = toRequest({}); + expect(await parser.parse(request)).toEqual({}); + }); +}); 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..0075bae641 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/request-body-validation.ts @@ -0,0 +1,135 @@ +/* + * 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, OperationParsingError } 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; + 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) { + throw new OperationError( + this.operation, + 'No request body found in operation', + ); + } + + 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', + ); + } + const contentTypes = requestBody!.content; + const jsonContentType = Object.keys(contentTypes).find(contentType => + contentType.split(';').includes('application/json'), + ); + if (!jsonContentType) { + throw new OperationError( + this.operation, + 'No application/json content type found in request body', + ); + } + const schema = requestBody!.content[jsonContentType].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 = options.ajv.compile(schema); + this.schema = schema; + this.requestBodySchema = requestBody; + } + async parse(request: Request): Promise { + const bodyText = await request.text(); + if (this.requestBodySchema.required && !bodyText?.length) { + throw new OperationError( + this.operation, + `No request body found for ${request.url}`, + ); + } + + const contentType = + request.headers.get('content-type') || 'application/json'; + if (!contentType.split(';').includes('application/json')) { + throw new OperationError( + this.operation, + 'Content type is not application/json', + ); + } + const body = (await request.json()) as JsonObject; + const valid = this.validate(body); + if (!valid) { + throw new OperationParsingError( + this.operation, + `Request body`, + this.validate.errors!, + ); + } + return body; + } +} 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..6cd098b2e7 --- /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. + - The "result" property is not allowed" + `); + }); + + 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 new file mode 100644 index 0000000000..71a4512e75 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/response-body-validation.ts @@ -0,0 +1,169 @@ +/* + * 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, + OperationParsingResponseError, + 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; + for (const [statusCode, schema] of Object.entries(responseSchemas)) { + const contentTypes = schema.content; + if (!contentTypes) { + // Skip responses without content, eg 204 No Content. + continue; + } + const jsonContentType = Object.keys(contentTypes).find(contentType => + contentType.split(';').includes('application/json'), + ); + if (!jsonContentType) { + throw new OperationError( + this.operation, + `No application/json content type found in response for status code ${statusCode}`, + ); + } else if ('$ref' in contentTypes[jsonContentType].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) { + // 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) { + throw new OperationResponseError( + this.operation, + response, + `No schema found.`, + ); + } + + const contentTypes = responseSchema.content; + if (!contentTypes && body?.length) { + throw new OperationResponseError( + this.operation, + response, + 'Received a body but no schema was found', + ); + } + const jsonContentType = Object.keys(contentTypes ?? {}).find(contentType => + contentType.split(';').includes('application/json'), + ); + if (!jsonContentType) { + throw new OperationResponseError( + this.operation, + response, + 'No application/json content type found in response', + ); + } + const schema = responseSchema.content![jsonContentType].schema; + // This is a bit of type laziness. Ideally, this would be a type-narrowing function, but I wasn't able to get the types to work. + if (!schema) { + throw new OperationError(this.operation, 'No schema found in response'); + } + if ('$ref' in schema) { + 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 OperationParsingResponseError( + this.operation, + response, + 'Response body', + validate.errors!, + ); + } + return jsonBody; + } + + private findResponseSchema( + operationSchema: OperationObject, + { status }: Response, + ): ResponseObject | undefined { + 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..aaf3fc6c41 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/utils.ts @@ -0,0 +1,52 @@ +/* + * 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 { ErrorObject } from 'ajv'; + +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; +} + +export function humanifyAjvError(error: ErrorObject) { + switch (error.keyword) { + case 'required': + return `The "${error.params.missingProperty}" property is required`; + case 'type': + return `${ + error.instancePath ? `"${error.instancePath}"` : 'Value' + } should be of type ${error.params.type}`; + case 'additionalProperties': + return `The "${error.params.additionalProperty}" property is not allowed`; + default: + return error.message; + } +} 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..9c8c4589a3 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/validation.test.ts @@ -0,0 +1,823 @@ +/* + * 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 { findOperationByRequest, OpenApiProxyValidator } from './validation'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { registerMswTestHooks } from '@backstage/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 { OpenAPIObject, 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', () => { + registerMswTestHooks(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.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] 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.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] 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.toThrowErrorMatchingInlineSnapshot(` + "["GET /api/search"] Query parameter validation failed. + - Value should be of type number" + `); + }); + + 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.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] 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.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] 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.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Invalid object parameter, missing closing bracket for key "param[t""`, + ); + }); + }); + + 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.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] 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.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] 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.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Invalid object query parameter, must have an even number of key-value pairs"`, + ); + }); + 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( + `"["GET /api/search"] Invalid object query parameter, must have an even number of key-value pairs"`, + ); + }); + }); + }); + }); + + 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.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] 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.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] 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.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] 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.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Arrays must be comma separated in non-explode mode"`, + ); + }); + }); + + 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.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] 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.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] 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.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search" (200)]: Response body is required but missing"`, + ); + }); + }); +}); + +describe('findOperationByRequest', () => { + it('finds an operation by request', () => { + const schema = { + openapi: '3.0.0', + info: { + title: 'Test', + version: '1.0.0', + }, + paths: { + '/api/search': { + get: { + parameters: [], + }, + }, + }, + }; + const request = { + method: 'GET', + url: 'http://localhost:8080/api/search', + } as CompletedRequest; + expect(findOperationByRequest(schema as OpenAPIObject, request)).toEqual([ + '/api/search', + schema.paths['/api/search'].get, + ]); + }); + + it('finds an operation by request when there are multiple other paths', () => { + const schema = { + openapi: '3.0.0', + info: { + title: 'Test', + version: '1.0.0', + }, + paths: { + '/api/search': { + get: { + parameters: [], + }, + }, + '/api/catalog/by-ref': { + get: { + parameters: [], + }, + }, + }, + }; + const request = { + method: 'GET', + url: 'http://localhost:8080/api/search', + } as CompletedRequest; + expect(findOperationByRequest(schema as OpenAPIObject, request)).toEqual([ + '/api/search', + schema.paths['/api/search'].get, + ]); + }); + + it('finds an operation by request when there are path parameters', () => { + const schema = { + openapi: '3.0.0', + info: { + title: 'Test', + version: '1.0.0', + }, + paths: { + '/api/catalog/by-id/{id}': { + get: { + parameters: [], + }, + }, + }, + }; + const request = { + method: 'GET', + url: 'http://localhost:8080/api/catalog/by-id/123', + } as CompletedRequest; + expect(findOperationByRequest(schema as OpenAPIObject, request)).toEqual([ + '/api/catalog/by-id/{id}', + schema.paths['/api/catalog/by-id/{id}'].get, + ]); + }); + + it('finds an operation by request when there are somewhat overlapping path parameters', () => { + const schema = { + openapi: '3.0.0', + info: { + title: 'Test', + version: '1.0.0', + }, + paths: { + '/api/catalog/by-id/{id}': { + get: { + parameters: [], + }, + }, + '/api/catalog/by-id': { + get: { + parameters: [], + }, + }, + }, + }; + const request = { + method: 'GET', + url: 'http://localhost:8080/api/catalog/by-id/123', + } as CompletedRequest; + expect(findOperationByRequest(schema as OpenAPIObject, request)).toEqual([ + '/api/catalog/by-id/{id}', + schema.paths['/api/catalog/by-id/{id}'].get, + ]); + }); +}); diff --git a/packages/backend-openapi-utils/src/schema/validation.ts b/packages/backend-openapi-utils/src/schema/validation.ts new file mode 100644 index 0000000000..5f7c6fe353 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/validation.ts @@ -0,0 +1,149 @@ +/* + * 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 { OpenAPIObject, OperationObject } 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 }); + +class RequestBodyValidator implements Validator { + schema: OpenAPIObject; + constructor(schema: OpenAPIObject) { + this.schema = schema; + } + + async validate({ pair, operation }: ValidatorParams) { + const { request, response } = pair; + if (response.statusCode === 400) { + // If the response is a 400, then the request is invalid and we shouldn't validate the parameters + return; + } + + // NOTE: There may be a worthwhile optimization here to cache these results to avoid re-parsing the schema for every request. As is, I don't think this is a big deal. + const parser = RequestBodyParser.fromOperation(operation, { ajv }); + const fetchRequest = mockttpToFetchRequest(request); + await parser.parse(fetchRequest); + } +} + +class ResponseBodyValidator implements Validator { + schema: OpenAPIObject; + constructor(schema: OpenAPIObject) { + this.schema = schema; + } + + async validate({ pair, operation }: ValidatorParams) { + const { response } = pair; + // NOTE: There may be a worthwhile optimization here to cache these results to avoid re-parsing the schema for every request. As is, I don't think this is a big deal. + const parser = ResponseBodyParser.fromOperation(operation, { ajv }); + const fetchResponse = mockttpToFetchResponse(response); + await parser.parse(fetchResponse); + } +} + +/** + * Find an operation in an OpenAPI schema that matches a request. This is done by comparing the request URL to the paths in the schema. + * @param openApiSchema - The OpenAPI schema to search for the operation in. + * @param request - The request to find the operation for. + * @returns A tuple of the path and the operation object that matches the request. + */ +export function findOperationByRequest( + openApiSchema: OpenAPIObject, + request: CompletedRequest, +): [string, OperationObject] | undefined { + const { url } = request; + const { pathname } = new URL(url); + + const parts = pathname.split('/'); + for (const [path, schema] of Object.entries(openApiSchema.paths)) { + const pathParts = path.split('/'); + if (parts.length !== pathParts.length) { + continue; + } + let found = true; + for (let i = 0; i < parts.length; i++) { + if (pathParts[i] === parts[i]) { + continue; + } + // If the path part is a parameter, we can count it as a match. eg /api/{id} will match /api/1 + if (pathParts[i].startsWith('{') && pathParts[i].endsWith('}')) { + continue; + } + found = false; + break; + } + if (!found) { + continue; + } + let matchingOperationType: OperationObject | undefined = undefined; + for (const [operationType, operation] of Object.entries(schema)) { + if (operationType === request.method.toLowerCase()) { + matchingOperationType = operation as OperationObject; + break; + } + } + if (!matchingOperationType) { + continue; + } + return [path, matchingOperationType]; + } + + return undefined; +} + +export class OpenApiProxyValidator { + schema: OpenAPIObject | undefined; + validators: Validator[] | undefined; + + async initialize(url: string) { + this.schema = (await Parser.dereference(url)) as unknown as OpenAPIObject; + this.validators = [ + new ParameterValidator(this.schema), + new RequestBodyValidator(this.schema), + new ResponseBodyValidator(this.schema), + ]; + } + + async validate(request: CompletedRequest, response: CompletedResponse) { + const operationPathTuple = findOperationByRequest(this.schema!, request); + if (!operationPathTuple) { + throw new OperationError( + { path: request.path, method: request.method } as Operation, + `No operation schema found for ${request.url}`, + ); + } + + 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 }, + operation, + }), + ), + ); + } +} diff --git a/packages/backend-openapi-utils/src/testUtils.ts b/packages/backend-openapi-utils/src/testUtils.ts index 2ac5575fc4..11716050db 100644 --- a/packages/backend-openapi-utils/src/testUtils.ts +++ b/packages/backend-openapi-utils/src/testUtils.ts @@ -15,6 +15,47 @@ */ import { Express } from 'express'; import { Server } from 'http'; +import { Proxy } from './proxy/setup'; + +const proxiesToCleanup: Proxy[] = []; + +/** + * !!! THIS CURRENTLY ONLY SUPPORTS SUPERTEST !!! + * Setup a server with a custom OpenAPI proxy. This proxy will capture all requests and responses and make sure they + * conform to the spec. + * @param app - express server, needed to ensure we have the correct ports for the proxy. + * @returns - a configured HTTP server that should be used with supertest. + * @public + */ +export async function wrapServer(app: Express): Promise { + const proxy = new Proxy(); + proxiesToCleanup.push(proxy); + await proxy.setup(); + + const server = app.listen(proxy.forwardTo.port); + await proxy.initialize(`http://localhost:${proxy.forwardTo.port}`, server); + + return { ...server, address: () => new URL(proxy.url) } as any; +} + +let registered = false; +function registerHooks() { + if (typeof afterAll !== 'function' || typeof beforeAll !== 'function') { + return; + } + if (registered) { + return; + } + registered = true; + + afterAll(() => { + for (const proxy of proxiesToCleanup) { + proxy.stop(); + } + }); +} + +registerHooks(); /** * !!! THIS CURRENTLY ONLY SUPPORTS SUPERTEST !!! diff --git a/plugins/catalog-backend/optic.yml b/plugins/catalog-backend/optic.yml deleted file mode 100644 index dbbdefcf68..0000000000 --- a/plugins/catalog-backend/optic.yml +++ /dev/null @@ -1,15 +0,0 @@ -ruleset: - - breaking-changes -capture: - src/schema/openapi.yaml: - # 🔧 Runnable example with simple get requests. - # Run with "PORT=3000 optic capture src/schema/openapi.yaml --update interactive" in 'plugins/catalog-backend' - # You can change the server and the 'requests' section to experiment - server: - # This will not be used by 'backstage-repo-tools schema openapi test', but may be useful for interactive updates. - url: http://localhost:3000 - requests: - # â„šī¸ Requests should be sent to the Optic proxy, the address of which is injected into 'run.command's env as OPTIC_PROXY (or the value of 'run.proxy_variable'). - run: - # 🔧 Specify a command that will generate traffic - command: yarn backstage-cli package test --no-watch "src/service/router.test.ts" "src/service/createRouter.test.ts" diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index 9cc0717807..6654187526 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -38,7 +38,7 @@ import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common/a import { CatalogProcessingOrchestrator } from '../processing/types'; import { z } from 'zod'; import { decodeCursor, encodeCursor } from './util'; -import { wrapInOpenApiTestServer } from '@backstage/backend-openapi-utils'; +import { wrapServer } from '@backstage/backend-openapi-utils'; import { Server } from 'http'; import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; import { LocationAnalyzer } from '@backstage/plugin-catalog-node'; @@ -93,7 +93,7 @@ describe('createRouter readonly disabled', () => { locationAnalyzer, permissionsService: permissionsService, }); - app = wrapInOpenApiTestServer(express().use(router)); + app = await wrapServer(express().use(router)); }); beforeEach(() => { diff --git a/plugins/search-backend/optic.yml b/plugins/search-backend/optic.yml deleted file mode 100644 index 75f20f1bb8..0000000000 --- a/plugins/search-backend/optic.yml +++ /dev/null @@ -1,15 +0,0 @@ -ruleset: - - breaking-changes -capture: - src/schema/openapi.yaml: - # 🔧 Runnable example with simple get requests. - # Run with "PORT=3000 optic capture src/schema/openapi.yaml --update interactive" in 'plugins/search-backend' - # You can change the server and the 'requests' section to experiment - server: - # This will not be used by 'backstage-repo-tools schema openapi test', but may be useful for interactive updates. - url: http://localhost:3000 - requests: - # â„šī¸ Requests should be sent to the Optic proxy, the address of which is injected into 'run.command's env as OPTIC_PROXY (or the value of 'run.proxy_variable'). - run: - # 🔧 Specify a command that will generate traffic - command: yarn backstage-cli package test --no-watch "src/service/router.test.ts" "src/service/createRouter.test.ts" diff --git a/plugins/search-backend/src/schema/openapi.generated.ts b/plugins/search-backend/src/schema/openapi.generated.ts index 8ad27546d6..1e6adfb08f 100644 --- a/plugins/search-backend/src/schema/openapi.generated.ts +++ b/plugins/search-backend/src/schema/openapi.generated.ts @@ -208,7 +208,6 @@ export const spec = { in: 'query', required: false, style: 'deepObject', - explode: true, allowReserved: true, schema: { $ref: '#/components/schemas/JsonObject', @@ -244,6 +243,18 @@ export const spec = { type: 'integer', }, }, + { + name: 'unknown', + in: 'query', + required: false, + style: 'form', + explode: true, + allowReserved: true, + schema: { + type: 'object', + additionalProperties: true, + }, + }, ], }, }, diff --git a/plugins/search-backend/src/schema/openapi.yaml b/plugins/search-backend/src/schema/openapi.yaml index 358f94fcf6..cbf3db273a 100644 --- a/plugins/search-backend/src/schema/openapi.yaml +++ b/plugins/search-backend/src/schema/openapi.yaml @@ -140,7 +140,6 @@ paths: in: query required: false style: deepObject - explode: true allowReserved: true schema: # JsonObject is used here instead of the full ZOD schema definition as @@ -167,3 +166,13 @@ paths: allowReserved: true schema: type: integer + - name: unknown + in: query + required: false + # explode form is the equivalent to allow any extra query parameters + style: form + explode: true + allowReserved: true + schema: + type: object + additionalProperties: true diff --git a/plugins/search-backend/src/service/router.test.ts b/plugins/search-backend/src/service/router.test.ts index d22fc17e54..5b6900ede1 100644 --- a/plugins/search-backend/src/service/router.test.ts +++ b/plugins/search-backend/src/service/router.test.ts @@ -23,7 +23,7 @@ import { import express from 'express'; import request from 'supertest'; import { createRouter } from './router'; -import { wrapInOpenApiTestServer } from '@backstage/backend-openapi-utils'; +import { wrapServer } from '@backstage/backend-openapi-utils'; import { Server } from 'http'; import { mockCredentials, @@ -87,9 +87,7 @@ describe('createRouter', () => { auth: mockServices.auth(), httpAuth: mockServices.httpAuth(), }); - app = wrapInOpenApiTestServer( - express().use(router).use(mockErrorHandler()), - ); + app = await wrapServer(express().use(router).use(mockErrorHandler())); }); beforeEach(() => { diff --git a/yarn.lock b/yarn.lock index cd65c170d6..5b5084d189 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3738,16 +3738,23 @@ __metadata: version: 0.0.0-use.local resolution: "@backstage/backend-openapi-utils@workspace:packages/backend-openapi-utils" dependencies: + "@apidevtools/swagger-parser": ^10.1.0 "@backstage/backend-plugin-api": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@backstage/types": "workspace:^" "@types/express": ^4.17.6 "@types/express-serve-static-core": ^4.17.5 + ajv: ^8.16.0 express: ^4.17.1 express-openapi-validator: ^5.0.4 express-promise-router: ^4.1.0 + get-port: ^5.1.1 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 @@ -10305,12 +10312,12 @@ __metadata: languageName: node linkType: hard -"@httptoolkit/httpolyglot@npm:^2.0.1, @httptoolkit/httpolyglot@npm:^2.1.1": - version: 2.1.1 - resolution: "@httptoolkit/httpolyglot@npm:2.1.1" +"@httptoolkit/httpolyglot@npm:^2.0.1, @httptoolkit/httpolyglot@npm:^2.2.1": + version: 2.2.1 + resolution: "@httptoolkit/httpolyglot@npm:2.2.1" dependencies: - "@types/node": ^16.7.10 - checksum: 138ccd61355de334c509e2fc4ac9ade9e1aa6aa770ed2271e0bd1d883ed815eb742d0a4de37837edd03a9a243c05d6da32c5febe970f4518c46e2d76e6ff10d5 + "@types/node": "*" + checksum: 5b3882657e37953bd7089d91ac6cd24cec36480deab114e6b69a4b3d9e4ab09db568500e5e96713869fb4a8fe40b5ecc1661cc39ee621ef40ed0e38b55e0257e languageName: node linkType: hard @@ -18268,7 +18275,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^16.11.26, @types/node@npm:^16.7.10": +"@types/node@npm:^16.11.26": version: 16.18.112 resolution: "@types/node@npm:16.18.112" checksum: d634729e60d2e7bd951843fddf5fb59ae786ca707f384e8f90881b011076962b4e3fee3393e3be4be4fb7d86943893e0bda9650aea24ac692bdf407bf1a5d84d @@ -20716,7 +20723,7 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^8.0.0, ajv@npm:^8.10.0, ajv@npm:^8.11.0, ajv@npm:^8.12.0, ajv@npm:^8.17.1, ajv@npm:^8.6.0, ajv@npm:^8.6.3, ajv@npm:^8.9.0": +"ajv@npm:^8.0.0, ajv@npm:^8.10.0, ajv@npm:^8.11.0, ajv@npm:^8.12.0, ajv@npm:^8.16.0, ajv@npm:^8.17.1, ajv@npm:^8.6.0, ajv@npm:^8.6.3, ajv@npm:^8.9.0": version: 8.17.1 resolution: "ajv@npm:8.17.1" dependencies: @@ -21340,6 +21347,15 @@ __metadata: languageName: node linkType: hard +"async-mutex@npm:^0.5.0": + version: 0.5.0 + resolution: "async-mutex@npm:0.5.0" + dependencies: + tslib: ^2.4.0 + checksum: be1587f4875f3bb15e34e9fcce82eac2966daef4432c8d0046e61947fb9a1b95405284601bc7ce4869319249bc07c75100880191db6af11d1498931ac2a2f9ea + languageName: node + linkType: hard + "async-retry@npm:^1.3.3": version: 1.3.3 resolution: "async-retry@npm:1.3.3" @@ -21349,7 +21365,7 @@ __metadata: languageName: node linkType: hard -"async@npm:^2.6.2, async@npm:^2.6.4": +"async@npm:^2.6.4": version: 2.6.4 resolution: "async@npm:2.6.4" dependencies: @@ -22105,10 +22121,10 @@ __metadata: languageName: node linkType: hard -"brotli-wasm@npm:^1.1.0": - version: 1.3.1 - resolution: "brotli-wasm@npm:1.3.1" - checksum: ec2931a989ee6f0bb52c2aabf23a0d230232d3bd69fb68ee3dab9542fc9ae2d4085d0e5338f71520c25a4a26cf1cfc991ce02910c24d63d42c7915c5722a3713 +"brotli-wasm@npm:^3.0.0": + version: 3.0.1 + resolution: "brotli-wasm@npm:3.0.1" + checksum: 48191b27265de8ffc59c940f9efef3a931448b6a15c26a4e360192fc3f0968e073c11fe0926510d019c305cc1d9c6d65df4d3e5752648a91cb0bbcccff7a8460 languageName: node linkType: hard @@ -24672,7 +24688,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:^3.1.1, debug@npm:^3.2.7": +"debug@npm:^3.2.7": version: 3.2.7 resolution: "debug@npm:3.2.7" dependencies: @@ -24987,12 +25003,12 @@ __metadata: languageName: node linkType: hard -"destroyable-server@npm:^1.0.0": - version: 1.0.0 - resolution: "destroyable-server@npm:1.0.0" +"destroyable-server@npm:^1.0.2": + version: 1.0.2 + resolution: "destroyable-server@npm:1.0.2" dependencies: "@types/node": "*" - checksum: ac81b26f616a9d0aaa9cb759fa5a5a186f887025362329f7ddc909f53090f4aea0d1b75c4dda23e210faee536e2d7352de017261e1625b7b18108f0e630efa1f + checksum: 81fd70b9132d43c3633a7a819adfe1fc68b52a55154ff8a36f42f4655e7b71b8468559888caadfd324c1aa824f0d236796a8f356e8a00e7438649e647ea654b2 languageName: node linkType: hard @@ -29291,14 +29307,14 @@ __metadata: languageName: node linkType: hard -"http-encoding@npm:^1.5.1": - version: 1.5.1 - resolution: "http-encoding@npm:1.5.1" +"http-encoding@npm:^2.0.1": + version: 2.0.1 + resolution: "http-encoding@npm:2.0.1" dependencies: - brotli-wasm: ^1.1.0 + brotli-wasm: ^3.0.0 pify: ^5.0.0 - zstd-codec: ^0.1.4 - checksum: 534aa2facb0ae529fa88b9778867472247711626b90030fd4351572c6147fb5e895d9d2e305e7dc5cc993345f2fbdb17ca99345651bf76dbac39a07f552af2ac + zstd-codec: ^0.1.5 + checksum: c34a1cd81ad1c08e6c6aba5aef3f4d4bc4a6c84f8b3511776eb62006beeee48a104ce1630e3c8497f66d5c0913195dea596e776336dd5a598bd7fe06d27e1395 languageName: node linkType: hard @@ -29450,13 +29466,13 @@ __metadata: languageName: node linkType: hard -"http2-wrapper@npm:^2.2.0": - version: 2.2.0 - resolution: "http2-wrapper@npm:2.2.0" +"http2-wrapper@npm:^2.2.1": + version: 2.2.1 + resolution: "http2-wrapper@npm:2.2.1" dependencies: quick-lru: ^5.1.1 resolve-alpn: ^1.2.0 - checksum: 6fd20e5cb6a58151715b3581e06a62a47df943187d2d1f69e538a50cccb7175dd334ecfde7900a37d18f3e13a1a199518a2c211f39860e81e9a16210c199cfaa + checksum: e95e55e22c6fd61182ce81fecb9b7da3af680d479febe8ad870d05f7ebbc9f076e455193766f4e7934e50913bf1d8da3ba121fb5cd2928892390b58cf9d5c509 languageName: node linkType: hard @@ -34500,7 +34516,7 @@ __metadata: languageName: node linkType: hard -"mkdirp@npm:^0.5.1, mkdirp@npm:^0.5.4, mkdirp@npm:^0.5.5, mkdirp@npm:^0.5.6": +"mkdirp@npm:^0.5.1, mkdirp@npm:^0.5.4, mkdirp@npm:^0.5.6": version: 0.5.6 resolution: "mkdirp@npm:0.5.6" dependencies: @@ -34536,17 +34552,18 @@ __metadata: languageName: node linkType: hard -"mockttp@npm:^3.9.1": - version: 3.9.4 - resolution: "mockttp@npm:3.9.4" +"mockttp@npm:^3.13.0, mockttp@npm:^3.9.1": + version: 3.15.2 + resolution: "mockttp@npm:3.15.2" dependencies: "@graphql-tools/schema": ^8.5.0 "@graphql-tools/utils": ^8.8.0 - "@httptoolkit/httpolyglot": ^2.1.1 + "@httptoolkit/httpolyglot": ^2.2.1 "@httptoolkit/subscriptions-transport-ws": ^0.11.2 "@httptoolkit/websocket-stream": ^6.0.1 "@types/cors": ^2.8.6 "@types/node": "*" + async-mutex: ^0.5.0 base64-arraybuffer: ^0.1.5 body-parser: ^1.15.2 cacheable-lookup: ^6.0.0 @@ -34555,14 +34572,15 @@ __metadata: cors: ^2.8.4 cors-gate: ^1.1.3 cross-fetch: ^3.1.5 - destroyable-server: ^1.0.0 + destroyable-server: ^1.0.2 express: ^4.14.0 + fast-json-patch: ^3.1.1 graphql: ^14.0.2 || ^15.5 graphql-http: ^1.22.0 graphql-subscriptions: ^1.1.0 graphql-tag: ^2.12.6 - http-encoding: ^1.5.1 - http2-wrapper: ^2.2.0 + http-encoding: ^2.0.1 + http2-wrapper: ^2.2.1 https-proxy-agent: ^5.0.1 isomorphic-ws: ^4.0.1 lodash: ^4.16.4 @@ -34572,16 +34590,17 @@ __metadata: pac-proxy-agent: ^7.0.0 parse-multipart-data: ^1.4.0 performance-now: ^2.1.0 - portfinder: 1.0.28 + portfinder: ^1.0.32 read-tls-client-hello: ^1.0.0 semver: ^7.5.3 socks-proxy-agent: ^7.0.0 typed-error: ^3.0.2 + urlpattern-polyfill: ^8.0.0 uuid: ^8.3.2 ws: ^8.8.0 bin: mockttp: dist/admin/admin-bin.js - checksum: 2e0b984d77a94e6a754e44c85a7ff2ded13ba42fd6cabf125b677a8a57eff543c896bf3ecb522799d3efbe18733bf019fbe707044f098fdd5e8e4bc2c0b1df4f + checksum: 96b90e0515e7ac1b73954e9e01010424d51d9563f8e850e620b06ba864bf064401e1a1af89e103724b956bfa3bee790cb452366df100bf01122f333e04c3aee8 languageName: node linkType: hard @@ -37090,17 +37109,6 @@ __metadata: languageName: node linkType: hard -"portfinder@npm:1.0.28": - version: 1.0.28 - resolution: "portfinder@npm:1.0.28" - dependencies: - async: ^2.6.2 - debug: ^3.1.1 - mkdirp: ^0.5.5 - checksum: 91fef602f13f8f4c64385d0ad2a36cc9dc6be0b8d10a2628ee2c3c7b9917ab4fefb458815b82cea2abf4b785cd11c9b4e2d917ac6fa06f14b6fa880ca8f8928c - languageName: node - linkType: hard - "portfinder@npm:^1.0.28, portfinder@npm:^1.0.32": version: 1.0.32 resolution: "portfinder@npm:1.0.32" @@ -43705,6 +43713,13 @@ __metadata: languageName: node linkType: hard +"urlpattern-polyfill@npm:^8.0.0": + version: 8.0.2 + resolution: "urlpattern-polyfill@npm:8.0.2" + checksum: d2cc0905a613c77e330c426e8697ee522dd9640eda79ac51160a0f6350e103f09b8c327623880989f8ba7325e8d95267b745aa280fdcc2aead80b023e16bd09d + languageName: node + linkType: hard + "urlpattern-polyfill@npm:^9.0.0": version: 9.0.0 resolution: "urlpattern-polyfill@npm:9.0.0" @@ -45253,10 +45268,10 @@ __metadata: languageName: node linkType: hard -"zstd-codec@npm:^0.1.4": - version: 0.1.4 - resolution: "zstd-codec@npm:0.1.4" - checksum: 8689bc0defc4f387d1be990b8b8ca8ca56690d17dfc8dd4703db798465b92a21e64e54e886acfaa376147d9d07d879a68627b09fddc34a0c93f0dc5c610a790c +"zstd-codec@npm:^0.1.5": + version: 0.1.5 + resolution: "zstd-codec@npm:0.1.5" + checksum: ba62bf643c3ca9759fedc090b73a0c3b1e506364fcae902a70b112c1f5b30bc6aabff3184808cc4430f2ab6644cabae979368152ae908c1d8ef39cd8c3223c85 languageName: node linkType: hard