Merge pull request #25538 from aramissennyeydd/openapi-tooling/custom-proxy-server
feat(openapi-tooling): validate schema in jest tests
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-search-backend': patch
|
||||
---
|
||||
|
||||
Fix to schema to allow arbitrary query parameters.
|
||||
@@ -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`.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
/// <reference types="node" />
|
||||
|
||||
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> = T[keyof T];
|
||||
// @public
|
||||
export const wrapInOpenApiTestServer: (app: Express_2) => Server | Express_2;
|
||||
|
||||
// @public
|
||||
export function wrapServer(app: Express_2): Promise<Server>;
|
||||
|
||||
// Warnings were encountered during analysis:
|
||||
//
|
||||
// src/router.d.ts:8:5 - (ae-undocumented) Missing documentation for "get".
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<string, mockttp.CompletedRequest> = {};
|
||||
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;
|
||||
}
|
||||
}
|
||||
+29
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 - ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<ParameterObject, 'schema'> & {
|
||||
schema: ReferencelessSchemaObject;
|
||||
};
|
||||
|
||||
class BaseParameterParser {
|
||||
ajv: Ajv;
|
||||
operation: Operation;
|
||||
parameters: Record<string, ReferencelessParameterObject> = {};
|
||||
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<Record<string, any>>
|
||||
{
|
||||
constructor(operation: Operation, options: ParserOptions) {
|
||||
super('query', operation, options);
|
||||
}
|
||||
async parse(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const remainingQueryParameters = new Set<string>(searchParams.keys());
|
||||
const queryParameters: Record<string, any> = {};
|
||||
|
||||
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<string, ParameterObject>,
|
||||
remainingQueryParameters: Set<string>,
|
||||
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<string, string> = {};
|
||||
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<string, string> = {};
|
||||
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<string, any> = {};
|
||||
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<Record<string, any>>
|
||||
{
|
||||
constructor(operation: Operation, options: ParserOptions) {
|
||||
super('header', operation, options);
|
||||
}
|
||||
async parse(request: Request) {
|
||||
const headerParameters: Record<string, any> = {};
|
||||
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<Record<string, any>>
|
||||
{
|
||||
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<string, any> = {};
|
||||
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<string, string> = {};
|
||||
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<string, any> = {};
|
||||
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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>): 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<JsonObject | undefined>;
|
||||
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({});
|
||||
});
|
||||
});
|
||||
@@ -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<JsonObject | undefined>
|
||||
{
|
||||
operation: Operation;
|
||||
constructor(operation: Operation) {
|
||||
this.operation = operation;
|
||||
}
|
||||
async parse(request: Request): Promise<JsonObject | undefined> {
|
||||
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<JsonObject | undefined>
|
||||
{
|
||||
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<JsonObject | undefined> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<JsonObject | undefined>;
|
||||
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"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<JsonObject | undefined>
|
||||
{
|
||||
operation: Operation;
|
||||
constructor(operation: Operation) {
|
||||
this.operation = operation;
|
||||
}
|
||||
async parse(response: Response): Promise<JsonObject | undefined> {
|
||||
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<JsonObject | undefined>
|
||||
{
|
||||
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<JsonObject | undefined> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<T> {
|
||||
parse(request: Request): Promise<T>;
|
||||
}
|
||||
export interface ResponseParser<T> {
|
||||
parse(response: Response): Promise<T>;
|
||||
}
|
||||
|
||||
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<void>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
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<string, string>;
|
||||
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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Server> {
|
||||
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 !!!
|
||||
|
||||
@@ -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"
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user