Merge pull request #25538 from aramissennyeydd/openapi-tooling/custom-proxy-server

feat(openapi-tooling): validate schema in jest tests
This commit is contained in:
Patrik Oldsberg
2024-10-08 10:51:08 +02:00
committed by GitHub
29 changed files with 3086 additions and 91 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-backend': patch
---
Fix to schema to allow arbitrary query parameters.
+5
View File
@@ -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".
+1 -1
View File
@@ -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;
}
}
@@ -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"
}
}
}
}
}
}
@@ -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"
}
}
]
}
}
}
}
@@ -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&param=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&param[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&param=test&param=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&param=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&param[1]=456&param[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 !!!
-15
View File
@@ -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(() => {
-15
View File
@@ -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,
},
},
],
},
},
+10 -1
View File
@@ -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(() => {
+67 -52
View File
@@ -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