From 5d8114c1d66dbbc8c4fac44e2e3d37fe1c57b089 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Fri, 5 Jul 2024 17:13:56 -0400 Subject: [PATCH] feat(openapi-tooling): custom validation server for test cases Signed-off-by: aramissennyeydd --- packages/backend-openapi-utils/package.json | 3 + packages/backend-openapi-utils/src/index.ts | 2 +- .../backend-openapi-utils/src/proxy/setup.ts | 71 +++ .../src/schema/validation.ts | 587 ++++++++++++++++++ .../backend-openapi-utils/src/testUtils.ts | 17 + .../src/service/createRouter.test.ts | 4 +- .../src/schema/openapi.generated.ts | 2 +- .../search-backend/src/schema/openapi.yaml | 2 +- .../search-backend/src/service/router.test.ts | 6 +- yarn.lock | 126 +++- 10 files changed, 810 insertions(+), 10 deletions(-) create mode 100644 packages/backend-openapi-utils/src/proxy/setup.ts create mode 100644 packages/backend-openapi-utils/src/schema/validation.ts diff --git a/packages/backend-openapi-utils/package.json b/packages/backend-openapi-utils/package.json index 8f6fd1b6eb..a173cbf17b 100644 --- a/packages/backend-openapi-utils/package.json +++ b/packages/backend-openapi-utils/package.json @@ -33,15 +33,18 @@ "test": "backstage-cli package test" }, "dependencies": { + "@apidevtools/swagger-parser": "^10.1.0", "@backstage/backend-plugin-api": "workspace:^", "@backstage/errors": "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", "json-schema-to-ts": "^3.0.0", "lodash": "^4.17.21", + "mockttp": "^3.13.0", "openapi-merge": "^1.3.2", "openapi3-ts": "^3.1.2" }, 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..a7205a883a --- /dev/null +++ b/packages/backend-openapi-utils/src/proxy/setup.ts @@ -0,0 +1,71 @@ +/* + * 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'; + +export class Proxy { + server: mockttp.Mockttp; + #openRequests: Record = {}; + requestResponsePairs = new Map< + mockttp.CompletedRequest, + mockttp.CompletedResponse + >(); + validator: OpenApiProxyValidator; + constructor() { + this.server = mockttp.getLocal(); + this.validator = new OpenApiProxyValidator(); + } + + async setup() { + await this.server.start(); + this.server + .forAnyRequest() + .thenForwardTo(`http://localhost:${process.env.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]; + try { + this.validator.validate(request, response); + } catch (err) { + console.error(err); + } + }); + } + + async initialize() { + await this.validator.initialize( + `http://localhost:${process.env.PORT}/openapi.json`, + ); + } + + stop() { + if (Object.keys(this.#openRequests).length > 0) { + throw new Error('There are still open requests'); + } + this.server.stop(); + } + + get url() { + return this.server.proxyEnv.HTTP_PROXY; + } +} 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..104482a485 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/validation.ts @@ -0,0 +1,587 @@ +/* + * 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, + ParameterObject, + ResponseObject, + SchemaObject, +} from 'openapi3-ts'; +import Ajv from 'ajv'; +import Parser from '@apidevtools/swagger-parser'; + +const ajv = new Ajv({ allErrors: true }); // options can be passed, e.g. {allErrors: true} + +interface RequestResponsePair { + request: CompletedRequest; + response: CompletedResponse; +} + +interface ValidatorParams { + pair: RequestResponsePair; + operationSchema: OperationObject; + path: string; +} + +interface Validator { + validate(pair: ValidatorParams): Promise; +} + +class RequestErrorFactory { + static createRequestError(request: CompletedRequest, message: string): Error { + return new Error(`[${request.url} (${request.method})]: ${message}`); + } +} + +export class ParameterValidator implements Validator { + schema: OpenAPIObject; + cache: Record = {}; + constructor(schema: OpenAPIObject) { + this.schema = schema; + } + + async validate({ + pair: { request, response }, + operationSchema, + path, + }: ValidatorParams) { + if (response.statusCode === 400) { + // If the response is a 400, then the request is invalid and we shouldn't validate the parameters + return; + } + const parameters = operationSchema.parameters; + const queryParameters: Record = {}; + const headerParameters: Record = {}; + const pathParameters: Record = {}; + for (const parameter of parameters || []) { + if ('$ref' in parameter) { + throw RequestErrorFactory.createRequestError( + request, + 'Reference objects are not supported', + ); + } + if (parameter.in === 'query') { + queryParameters[parameter.name] = parameter; + } + if (parameter.in === 'header') { + headerParameters[parameter.name] = parameter; + } + if (parameter.in === 'path') { + pathParameters[parameter.name] = parameter; + } + } + this.validateQueryParameters(queryParameters, request); + this.validateHeaderParameters(headerParameters, request); + this.validatePathParameters(pathParameters, request, path); + } + + validateQueryParameters( + queryParameters: Record, + request: CompletedRequest, + ) { + const { searchParams } = new URL(request.url); + for (const [name, parameter] of Object.entries(queryParameters)) { + if (!parameter.schema) { + throw RequestErrorFactory.createRequestError( + request, + 'Schema not found for query parameter', + ); + } + if ('$ref' in parameter.schema) { + throw RequestErrorFactory.createRequestError( + request, + 'Reference objects are not supported for parameters', + ); + } + let param: any | null = this.#findQueryParameters( + request, + queryParameters, + searchParams, + name, + ); + if (parameter.schema.type !== 'array' && Array.isArray(param)) { + param = param.length > 0 ? param[0] : undefined; + } + + if (!param && parameter.required) { + throw RequestErrorFactory.createRequestError( + request, + `Required query parameter ${name} not found`, + ); + } else if (!param && !parameter.required) { + continue; + } + if (parameter.schema.type === 'integer') { + // Try to parse the integer as AJV won't do it for us. + param = parseInt(param, 10); + } + const validate = ajv.compile(parameter.schema); + const valid = validate(param); + if (!valid) { + console.log(param); + console.error(validate.errors); + throw RequestErrorFactory.createRequestError( + request, + 'Query parameter validation failed', + ); + } + } + } + + #findQueryParameters( + request: CompletedRequest, + parameters: Record, + searchParams: URLSearchParams, + name: string, + ) { + const parameter = parameters[name]; + const schema = parameter.schema as SchemaObject; + if (schema.type === 'array') { + if (parameter.style === 'form' || !parameter.style) { + if (parameter.explode || typeof parameter.explode === 'undefined') { + if (!searchParams.has(name) && searchParams.has(`${name}[0]`)) { + const values: string[] = []; + let index = 0; + while (searchParams.has(`${name}[${index}]`)) { + values.push(searchParams.get(`${name}[${index}]`)!); + index++; + } + return values; + } + return searchParams.getAll(name); + } + if (!searchParams.has(name) && searchParams.has(`${name}[]`)) { + return searchParams.getAll(`${name}[]`); + } + return searchParams.get(name)?.split(','); + } else if (parameter.style === 'spaceDelimited') { + return searchParams.get(name)?.split(' '); + } else if (parameter.style === 'pipeDelimited') { + return searchParams.get(name)?.split('|'); + } + throw RequestErrorFactory.createRequestError( + request, + 'Unsupported style for array parameter', + ); + } + if (schema.type === 'object') { + if (parameter.style === 'form' || !parameter.style) { + if (parameter.explode) { + const obj: Record = {}; + for (const [key, value] of searchParams.entries()) { + if (this.#matchesOtherQueryParameters(parameters, key)) { + continue; + } + obj[key] = value; + } + console.log(obj); + return obj; + } + const obj: Record = {}; + const value = searchParams.get(name); + if (value) { + const parts = value.split(','); + if (parts.length % 2 !== 0) { + throw RequestErrorFactory.createRequestError( + request, + 'Invalid object parameter', + ); + } + for (let i = 0; i < parts.length; i += 2) { + obj[parts[i]] = parts[i + 1]; + } + } + return obj; + } else if (parameter.style === 'deepObject') { + const obj: Record = {}; + for (const [key, value] of searchParams.entries()) { + if (key.startsWith(`${name}[`)) { + const parts = key.split('['); + let currentLayer = obj; + for (let partIndex = 0; partIndex < parts.length - 1; partIndex++) { + const part = parts[partIndex]; + const objKey = part.split(']')[0]; + if (!currentLayer[objKey]) { + currentLayer[objKey] = {}; + } + currentLayer = currentLayer[objKey]; + } + currentLayer[parts[parts.length - 1].split(']')[0]] = value; + } + } + return obj; + } + throw RequestErrorFactory.createRequestError( + request, + 'Unsupported style for object parameter', + ); + } + // For everything else, just return the value. + return searchParams.getAll(name); + } + + #matchesOtherQueryParameters( + parameters: Record, + nameToMatch: string, + ) { + for (const [name] of Object.entries(parameters)) { + if (name === nameToMatch) { + return true; + } + } + return false; + } + + validateHeaderParameters( + headerParameters: Record, + request: CompletedRequest, + ) { + for (const [name, parameter] of Object.entries(headerParameters)) { + if (!request.headers[name]) { + throw RequestErrorFactory.createRequestError( + request, + `Header parameter ${name} not found`, + ); + } + if (!parameter.schema) { + throw RequestErrorFactory.createRequestError( + request, + 'Schema not found for path parameter', + ); + } + if ('$ref' in parameter.schema) { + throw RequestErrorFactory.createRequestError( + request, + 'Reference objects are not supported for parameters', + ); + } + const validate = ajv.compile(parameter.schema); + const valid = validate(request.headers[name]); + + if (!valid) { + console.log(request.headers[name]); + console.error(validate.errors); + throw RequestErrorFactory.createRequestError( + request, + 'Header parameter validation failed', + ); + } + } + } + + validatePathParameters( + pathParameters: Record, + request: CompletedRequest, + path: string, + ) { + const { pathname } = new URL(request.url); + const params = parsePath({ request, path: pathname, schema: path }); + for (const [name, parameter] of Object.entries(pathParameters)) { + if (!params[name] && parameter.required) { + throw RequestErrorFactory.createRequestError( + request, + `Path parameter ${name} not found`, + ); + } + if (!parameter.schema) { + throw RequestErrorFactory.createRequestError( + request, + 'Schema not found for path parameter', + ); + } + if ('$ref' in parameter.schema) { + throw RequestErrorFactory.createRequestError( + request, + 'Reference objects are not supported for parameters', + ); + } + + const validate = ajv.compile(parameter.schema); + const valid = validate(params[name]); + + if (!valid) { + console.log(params); + console.error(validate.errors); + throw RequestErrorFactory.createRequestError( + request, + 'Path parameter validation failed', + ); + } + } + } +} + +function parsePath({ + request, + schema, + path, +}: { + request: CompletedRequest; + schema: string; + path: string; +}) { + const parts = path.split('/'); + const pathParts = schema.split('/'); + if (parts.length !== pathParts.length) { + throw RequestErrorFactory.createRequestError( + request, + 'Path parts do not match', + ); + } + const params: Record = {}; + for (let i = 0; i < parts.length; i++) { + if (pathParts[i] === parts[i]) { + continue; + } + if (pathParts[i].startsWith('{') && pathParts[i].endsWith('}')) { + params[pathParts[i].slice(1, -1)] = parts[i]; + continue; + } + break; + } + return params; +} + +export class RequestBodyValidator implements Validator { + schema: OpenAPIObject; + constructor(schema: OpenAPIObject) { + this.schema = schema; + } + + async validate({ + pair: { request, response }, + operationSchema, + }: ValidatorParams) { + if (response.statusCode === 400) { + // If the response is a 400, then the request is invalid and we shouldn't validate the request body + return; + } + const requestBody = operationSchema.requestBody; + const bodyText = await request.body.getText(); + if (!requestBody && bodyText?.length) { + throw RequestErrorFactory.createRequestError( + request, + `No request body found for ${request.url}`, + ); + } else if (!requestBody && !bodyText?.length) { + // If there is no request body in the schema and no body in the request, then the request is valid + return; + } + if ('$ref' in requestBody!) { + throw RequestErrorFactory.createRequestError( + request, + 'Reference objects are not supported', + ); + } + if (!requestBody!.content) { + throw RequestErrorFactory.createRequestError( + request, + 'No content found in request body', + ); + } + if (!requestBody!.content['application/json']) { + throw RequestErrorFactory.createRequestError( + request, + 'No application/json content type found in request body', + ); + } + const contentType = request.headers['content-type']; + if (!contentType) { + throw RequestErrorFactory.createRequestError( + request, + 'Content type not found in request', + ); + } + if (contentType !== 'application/json') { + throw RequestErrorFactory.createRequestError( + request, + 'Content type is not application/json', + ); + } + const schema = requestBody!.content['application/json'].schema; + if (!schema) { + throw RequestErrorFactory.createRequestError( + request, + 'No schema found in request body', + ); + } + if ('$ref' in schema) { + throw RequestErrorFactory.createRequestError( + request, + 'Reference objects are not supported', + ); + } + + const validate = ajv.compile(schema); + const body = await request.body.getJson(); + const valid = validate(body); + if (!valid) { + console.log(body); + console.error(validate.errors); + throw RequestErrorFactory.createRequestError( + request, + `Request body validation failed.`, + ); + } + } +} + +export class ResponseBodyValidator implements Validator { + schema: OpenAPIObject; + constructor(schema: OpenAPIObject) { + this.schema = schema; + } + + async validate(pair: ValidatorParams) { + const { + pair: { response, request }, + operationSchema, + } = pair; + const responseSchema = this.findResponseSchema(operationSchema, response); + if (!responseSchema) { + throw RequestErrorFactory.createRequestError( + request, + `No response schema found for ${response.statusCode}`, + ); + } + const body = await response.body.getText(); + if (!responseSchema.content && body?.length) { + throw RequestErrorFactory.createRequestError( + request, + 'No content found in response', + ); + } else if (!responseSchema.content && !body?.length) { + // If there is no content in the response schema and no body in the response, then the response is valid + return; + } + if (!responseSchema.content!['application/json']) { + throw RequestErrorFactory.createRequestError( + request, + 'No application/json content type found in response', + ); + } + const schema = responseSchema.content!['application/json'].schema; + if (!schema) { + throw RequestErrorFactory.createRequestError( + request, + 'No schema found in response', + ); + } + if ('$ref' in schema) { + throw RequestErrorFactory.createRequestError( + request, + 'Reference objects are not supported', + ); + } + + const validate = ajv.compile(schema); + const valid = validate(await response.body.getJson()); + if (!valid) { + console.log(await response.body.getJson()); + console.error(validate.errors); + throw RequestErrorFactory.createRequestError( + request, + 'Response body validation failed', + ); + } + } + + private findResponseSchema( + operationSchema: OperationObject, + response: CompletedResponse, + ): ResponseObject | undefined { + const { statusCode } = response; + return operationSchema.responses?.[statusCode]; + } +} + +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), + ]; + } + + validate(request: CompletedRequest, response: CompletedResponse) { + const operation = this.findOperation(request); + if (!operation) { + throw RequestErrorFactory.createRequestError( + request, + `No operation schema found for ${request.url}`, + ); + } + + const [path, operationSchema] = operation; + + const validators = this.validators!; + for (const validator of validators) { + validator.validate({ + pair: { request, response }, + operationSchema, + path, + }); + } + } + + private findOperation( + request: CompletedRequest, + ): [string, OperationObject] | undefined { + const { url } = request; + const { pathname } = new URL(url); + + const parts = pathname.split('/'); + for (const [path, schema] of Object.entries(this.schema!.paths)) { + const pathParts = path.split('/'); + if (parts.length !== pathParts.length) { + continue; + } + let found = true; + for (let i = 0; i < parts.length; i++) { + if (pathParts[i] === parts[i]) { + continue; + } + if (pathParts[i].startsWith('{') && pathParts[i].endsWith('}')) { + continue; + } + found = false; + break; + } + if (!found) { + continue; + } + let matchingOperationType: OperationObject | undefined = undefined; + for (const [operationType, operation] of Object.entries(schema)) { + if (operationType === request.method.toLowerCase()) { + matchingOperationType = operation as OperationObject; + break; + } + } + if (!matchingOperationType) { + continue; + } + return [path, matchingOperationType]; + } + + return undefined; + } +} diff --git a/packages/backend-openapi-utils/src/testUtils.ts b/packages/backend-openapi-utils/src/testUtils.ts index 2ac5575fc4..f96a0b1b76 100644 --- a/packages/backend-openapi-utils/src/testUtils.ts +++ b/packages/backend-openapi-utils/src/testUtils.ts @@ -15,6 +15,23 @@ */ import { Express } from 'express'; import { Server } from 'http'; +import { Proxy } from './proxy/setup'; + +const proxy = new Proxy(); + +beforeAll(async () => { + await proxy.setup(); +}); + +afterAll(() => { + proxy.stop(); +}); + +export async function wrapServer(app: Express): Promise { + const server = app.listen(+process.env.PORT!); + await proxy.initialize(); + return { ...server, address: () => new URL(proxy.url) } as any; +} /** * !!! THIS CURRENTLY ONLY SUPPORTS SUPERTEST !!! 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/src/schema/openapi.generated.ts b/plugins/search-backend/src/schema/openapi.generated.ts index 8ad27546d6..56aaeb2bbe 100644 --- a/plugins/search-backend/src/schema/openapi.generated.ts +++ b/plugins/search-backend/src/schema/openapi.generated.ts @@ -207,7 +207,7 @@ export const spec = { name: 'filters', in: 'query', required: false, - style: 'deepObject', + style: 'form', explode: true, allowReserved: true, schema: { diff --git a/plugins/search-backend/src/schema/openapi.yaml b/plugins/search-backend/src/schema/openapi.yaml index 358f94fcf6..a49b783b64 100644 --- a/plugins/search-backend/src/schema/openapi.yaml +++ b/plugins/search-backend/src/schema/openapi.yaml @@ -139,7 +139,7 @@ paths: - name: filters in: query required: false - style: deepObject + style: form explode: true allowReserved: true schema: 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 f5184e9149..036ac5c852 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3738,16 +3738,19 @@ __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:^" "@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 json-schema-to-ts: ^3.0.0 lodash: ^4.17.21 + mockttp: ^3.13.0 openapi-merge: ^1.3.2 openapi3-ts: ^3.1.2 supertest: ^7.0.0 @@ -10313,6 +10316,15 @@ __metadata: languageName: node linkType: hard +"@httptoolkit/httpolyglot@npm:^2.2.1": + version: 2.2.1 + resolution: "@httptoolkit/httpolyglot@npm:2.2.1" + dependencies: + "@types/node": "*" + checksum: 5b3882657e37953bd7089d91ac6cd24cec36480deab114e6b69a4b3d9e4ab09db568500e5e96713869fb4a8fe40b5ecc1661cc39ee621ef40ed0e38b55e0257e + languageName: node + linkType: hard + "@httptoolkit/subscriptions-transport-ws@npm:^0.11.2": version: 0.11.2 resolution: "@httptoolkit/subscriptions-transport-ws@npm:0.11.2" @@ -20715,7 +20727,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: @@ -21339,6 +21351,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" @@ -22111,6 +22132,13 @@ __metadata: languageName: node linkType: hard +"brotli-wasm@npm:^3.0.0": + version: 3.0.1 + resolution: "brotli-wasm@npm:3.0.1" + checksum: 48191b27265de8ffc59c940f9efef3a931448b6a15c26a4e360192fc3f0968e073c11fe0926510d019c305cc1d9c6d65df4d3e5752648a91cb0bbcccff7a8460 + languageName: node + linkType: hard + "browser-headers@npm:^0.4.1": version: 0.4.1 resolution: "browser-headers@npm:0.4.1" @@ -24995,6 +25023,15 @@ __metadata: languageName: node linkType: hard +"destroyable-server@npm:^1.0.2": + version: 1.0.2 + resolution: "destroyable-server@npm:1.0.2" + dependencies: + "@types/node": "*" + checksum: 81fd70b9132d43c3633a7a819adfe1fc68b52a55154ff8a36f42f4655e7b71b8468559888caadfd324c1aa824f0d236796a8f356e8a00e7438649e647ea654b2 + languageName: node + linkType: hard + "detect-indent@npm:^6.0.0": version: 6.1.0 resolution: "detect-indent@npm:6.1.0" @@ -29301,6 +29338,17 @@ __metadata: languageName: node linkType: hard +"http-encoding@npm:^2.0.1": + version: 2.0.1 + resolution: "http-encoding@npm:2.0.1" + dependencies: + brotli-wasm: ^3.0.0 + pify: ^5.0.0 + zstd-codec: ^0.1.5 + checksum: c34a1cd81ad1c08e6c6aba5aef3f4d4bc4a6c84f8b3511776eb62006beeee48a104ce1630e3c8497f66d5c0913195dea596e776336dd5a598bd7fe06d27e1395 + languageName: node + linkType: hard + "http-errors@npm:2.0.0, http-errors@npm:^2.0.0": version: 2.0.0 resolution: "http-errors@npm:2.0.0" @@ -29459,6 +29507,16 @@ __metadata: languageName: node linkType: hard +"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: e95e55e22c6fd61182ce81fecb9b7da3af680d479febe8ad870d05f7ebbc9f076e455193766f4e7934e50913bf1d8da3ba121fb5cd2928892390b58cf9d5c509 + languageName: node + linkType: hard + "https-browserify@npm:^1.0.0": version: 1.0.0 resolution: "https-browserify@npm:1.0.0" @@ -34535,6 +34593,58 @@ __metadata: languageName: node linkType: hard +"mockttp@npm:^3.13.0": + 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.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 + common-tags: ^1.8.0 + connect: ^3.7.0 + cors: ^2.8.4 + cors-gate: ^1.1.3 + cross-fetch: ^3.1.5 + 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: ^2.0.1 + http2-wrapper: ^2.2.1 + https-proxy-agent: ^5.0.1 + isomorphic-ws: ^4.0.1 + lodash: ^4.16.4 + lru-cache: ^7.14.0 + native-duplexpair: ^1.0.0 + node-forge: ^1.2.1 + pac-proxy-agent: ^7.0.0 + parse-multipart-data: ^1.4.0 + performance-now: ^2.1.0 + 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: 96b90e0515e7ac1b73954e9e01010424d51d9563f8e850e620b06ba864bf064401e1a1af89e103724b956bfa3bee790cb452366df100bf01122f333e04c3aee8 + languageName: node + linkType: hard + "mockttp@npm:^3.9.1": version: 3.9.4 resolution: "mockttp@npm:3.9.4" @@ -43704,6 +43814,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" @@ -45259,6 +45376,13 @@ __metadata: languageName: node linkType: hard +"zstd-codec@npm:^0.1.5": + version: 0.1.5 + resolution: "zstd-codec@npm:0.1.5" + checksum: ba62bf643c3ca9759fedc090b73a0c3b1e506364fcae902a70b112c1f5b30bc6aabff3184808cc4430f2ab6644cabae979368152ae908c1d8ef39cd8c3223c85 + languageName: node + linkType: hard + "zwitch@npm:^2.0.0": version: 2.0.2 resolution: "zwitch@npm:2.0.2"