adjusting validation library format and adding test cases

Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com>
This commit is contained in:
aramissennyeydd
2024-07-07 13:04:16 -04:00
parent 5555a58d62
commit 3bf5285bd6
13 changed files with 2007 additions and 475 deletions
@@ -35,7 +35,9 @@
"dependencies": {
"@apidevtools/swagger-parser": "^10.1.0",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/types": "workspace:^",
"@types/express": "^4.17.6",
"@types/express-serve-static-core": "^4.17.5",
"ajv": "^8.16.0",
@@ -45,6 +47,7 @@
"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"
},
@@ -0,0 +1,35 @@
{
"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"
}
}
}
}
}
}
}
}
}
}
}
}
}
}
@@ -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,23 @@
/*
* 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';
export class OperationError extends Error {
constructor(operation: Operation, message: string) {
super(`[${operation.path} (${operation.method})]: ${message}`);
}
}
@@ -0,0 +1,458 @@
/*
* 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 { 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(
`"[/api/search (get)]: 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(
`"[/api/search (get)]: 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(
`"[/api/search (get)]: Query parameter validation failed"`,
);
});
});
});
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(
`"[/api/search (get)]: 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(
`"[/api/search (get)]: 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(
`"[/api/search (get)]: 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(
`"[/api/search (get)]: 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(
`"[/api/search (get)]: 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(
`"[/api/search (get)]: 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(
`"[/api/search (get)]: 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(
`"[/api/search (get)]: 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(
`"[/api/search (get)]: 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']);
});
});
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(
`"[/api/search (get)]: 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(
`"[/api/search (get)]: 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(
`"[/api/search (get)]: 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(
`"[/api/search (get)]: 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' } });
});
});
});
});
@@ -0,0 +1,420 @@
/*
* 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 } 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(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 === 'query') {
this.parameters[parameter.name] =
parameter as ReferencelessParameterObject;
}
}
}
}
export class QueryParameterParser
extends BaseParameterParser
implements RequestParser<Record<string, any>>
{
async parse(request: Request) {
const { searchParams } = new URL(request.url);
const remainingQueryParameters = new Set<string>(searchParams.keys());
const queryParameters: Record<string, any> = {};
const parameterIterator = Object.entries(this.parameters).toSorted(
([_, parameter]) => {
if (parameter.schema.type !== 'object') {
return -1;
}
if (parameter.style === 'form' || !parameter.style) {
if (parameter.explode || typeof parameter.explode === 'undefined') {
return 1;
}
return 0;
}
return 0;
},
);
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,
queryParameters,
searchParams,
name,
);
if (!!param) {
indices.forEach(index => remainingQueryParameters.delete(index));
}
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 (parameter.schema.type === 'integer') {
// Try to parse the integer as AJV won't do it for us.
param = parseInt(param, 10);
}
if (parameter.schema.type === 'number') {
// Try to parse the number as AJV won't do it for us.
param = parseFloat(param);
}
const validate = this.ajv.compile(parameter.schema);
const valid = validate(param);
if (!valid) {
throw new OperationError(
this.operation,
'Query parameter validation failed',
);
}
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>,
currentQueryParameters: Record<string, any>,
searchParams: URLSearchParams,
name: string,
): [any | null, string[]] {
const parameter = parameters[name];
const schema = parameter.schema as SchemaObject;
const getIfExists = (key: string) =>
searchParams.has(key) ? searchParams.getAll(key) : null;
if (schema.type === 'array') {
if (parameter.style === 'form' || !parameter.style) {
if (parameter.explode || typeof parameter.explode === 'undefined') {
if (!searchParams.has(name) && searchParams.has(`${name}[0]`)) {
const values: string[] = [];
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];
}
return [getIfExists(name), [name]];
}
if (!searchParams.has(name) && searchParams.has(`${name}[]`)) {
return [searchParams.get(`${name}[]`)?.split(','), [`${name}[]`]];
}
if (searchParams.has(name) && searchParams.getAll(name).length > 1) {
throw new OperationError(
this.operation,
'Array parameter should not have multiple values',
);
}
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') {
if (parameter.style === 'form' || !parameter.style) {
if (parameter.explode) {
const obj: Record<string, string> = {};
const indices: string[] = [];
for (const [key, value] of searchParams.entries()) {
if (
this.#matchesOtherQueryParameters(currentQueryParameters, key)
) {
continue;
}
indices.push(key);
obj[key] = value;
}
return [obj, indices];
}
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 parameter',
);
}
for (let i = 0; i < parts.length; i += 2) {
obj[parts[i]] = parts[i + 1];
}
}
return [obj, [name]];
} else if (parameter.style === 'deepObject') {
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',
);
}
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',
);
}
currentLayer[lastPart.split(']')[0]] = value;
}
}
return [obj, indices];
}
throw new OperationError(
this.operation,
'Unsupported style for object parameter',
);
}
// For everything else, just return the value.
return [getIfExists(name), [name]];
}
#matchesOtherQueryParameters(
parameters: Record<string, any>,
nameToMatch: string,
) {
for (const [name] of Object.entries(parameters)) {
if (name === nameToMatch) {
return true;
}
}
return false;
}
}
export class HeaderParameterParser
extends BaseParameterParser
implements RequestParser<Record<string, any>>
{
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 OperationError(
this.operation,
'Header parameter validation failed',
);
}
headerParameters[name] = header;
}
return headerParameters;
}
}
export class PathParameterParser
extends BaseParameterParser
implements RequestParser<Record<string, any>>
{
async parse(request: Request) {
const { pathname } = new URL(request.url);
const params = this.parsePath({
path: pathname,
schema: this.operation.path,
});
const pathParameters: Record<string, string> = {};
for (const [name, parameter] of Object.entries(this.parameters)) {
if (!params[name] && parameter.required) {
throw new OperationError(
this.operation,
`Path parameter ${name} not found`,
);
} else if (!params[name] && !parameter.required) {
continue;
}
const validate = this.ajv.compile(parameter.schema);
const valid = validate(params[name]);
if (!valid) {
throw new OperationError(
this.operation,
'Path parameter validation failed',
);
}
pathParameters[name] = params[name];
}
return pathParameters;
}
parsePath({ schema, path }: { schema: string; path: string }) {
const parts = path.split('/');
const pathParts = schema.split('/');
if (parts.length !== pathParts.length) {
throw new OperationError(this.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,115 @@
/*
* 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 } from './errors';
export class RequestBodyParser
implements RequestParser<JsonObject | undefined>
{
operation: Operation;
validate:
| { fn: ValidateFunction; disabled: false }
| {
fn: undefined;
disabled: true;
};
constructor(operation: Operation, options: ParserOptions) {
this.operation = operation;
const { schema: operationSchema } = this.operation;
const requestBody = operationSchema.requestBody;
if (!requestBody) {
this.validate = { disabled: true, fn: undefined };
return;
}
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',
);
}
if (!requestBody!.content['application/json']) {
throw new OperationError(
this.operation,
'No application/json content type found in request body',
);
}
const schema = requestBody!.content['application/json'].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 = {
disabled: false,
fn: options.ajv.compile(operation.schema),
};
}
async parse(request: Request): Promise<JsonObject | undefined> {
const { disabled, fn } = this.validate;
const bodyText = await request.text();
if (!disabled && bodyText?.length) {
throw new OperationError(
this.operation,
`No request body found for ${request.url}`,
);
} else if (disabled && !bodyText?.length) {
// If there is no request body in the schema and no body in the request, then the request is valid
return undefined;
} else if (disabled && bodyText?.length) {
throw new OperationError(
this.operation,
'Received a body but no schema was found',
);
}
const contentType =
request.headers.get('content-type') || 'application/json';
if (contentType !== 'application/json') {
throw new OperationError(
this.operation,
'Content type is not application/json',
);
}
const body = (await request.json()) as JsonObject;
const valid = fn!(body);
if (!valid) {
console.log(body);
console.error(fn!.errors);
throw new OperationError(
this.operation,
`Request body validation failed.`,
);
}
return body;
}
}
@@ -0,0 +1,102 @@
/*
* 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 } from './errors';
import Ajv from 'ajv';
import { OperationObject, ResponseObject } from 'openapi3-ts';
export class ResponseBodyParser
implements ResponseParser<JsonObject | undefined>
{
operation: Operation;
ajv: Ajv;
constructor(operation: Operation, options: ParserOptions) {
this.operation = operation;
this.ajv = options.ajv;
const responseSchemas = operation.schema.responses;
if (!Object.keys(responseSchemas).length) {
throw new OperationError(this.operation, `No response schemas found`);
}
for (const [statusCode, schema] of Object.entries(responseSchemas)) {
if (!schema.content) {
continue;
} else if (!schema.content['application/json']) {
throw new OperationError(
this.operation,
`No application/json content type found in response for status code ${statusCode}`,
);
} else if ('$ref' in schema.content['application/json'].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) {
throw new OperationError(this.operation, 'No content found in response');
} else if (!responseSchema?.content && !body?.length) {
// If there is no content in the response schema and no body in the response, then the response is valid
return undefined;
}
if (!responseSchema?.content!['application/json']) {
throw new OperationError(
this.operation,
'No application/json content type found in response',
);
}
const schema = responseSchema.content!['application/json'].schema;
if (!schema) {
throw new OperationError(this.operation, 'No schema found in response');
}
if ('$ref' in schema) {
throw new OperationError(
this.operation,
'Reference objects are not supported',
);
}
const validate = this.ajv.compile(schema);
const jsonBody = (await response.json()) as JsonObject;
const valid = validate(jsonBody);
if (!valid) {
throw new OperationError(
this.operation,
'Response body validation failed',
);
}
return jsonBody;
}
private findResponseSchema(
operationSchema: OperationObject,
response: Response,
): ResponseObject | undefined {
const { status } = response;
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,36 @@
/*
* 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';
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;
}
@@ -0,0 +1,711 @@
/*
* 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 { OpenApiProxyValidator } from './validation';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { setupRequestMockHandlers } from '@backstage/backend-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 { 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', () => {
setupRequestMockHandlers(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.toMatchInlineSnapshot(
`[Error: [/api/search (GET)]: 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.toMatchInlineSnapshot(
`[Error: [/api/search (GET)]: 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.toMatchInlineSnapshot(
`[Error: [/api/search (GET)]: Query parameter validation failed]`,
);
});
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.toMatchInlineSnapshot(
`[Error: [/api/search (GET)]: 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.toMatchInlineSnapshot(
`[Error: [/api/search (GET)]: 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.toMatchInlineSnapshot(
`[Error: [/api/search (GET)]: Invalid object parameter]`,
);
});
});
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.toMatchInlineSnapshot(
`[Error: [/api/search (GET)]: 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.toMatchInlineSnapshot(
`[Error: [/api/search (GET)]: 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.toMatchInlineSnapshot(
`[Error: [/api/search (GET)]: Invalid object parameter]`,
);
});
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(
`"[/api/search (GET)]: Invalid object parameter"`,
);
});
});
});
});
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.toMatchInlineSnapshot(
`[Error: [/api/search (GET)]: 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.toMatchInlineSnapshot(
`[Error: [/api/search (GET)]: 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.toMatchInlineSnapshot(
`[Error: [/api/search (GET)]: 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.toMatchInlineSnapshot(
`[Error: [/api/search (GET)]: Array parameter should not have multiple values]`,
);
});
});
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.toMatchInlineSnapshot(
`[Error: [/api/search (GET)]: 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.toMatchInlineSnapshot(
`[Error: [/api/search (GET)]: 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.toMatchInlineSnapshot(
`[Error: [/api/search (GET)]: Response body validation failed]`,
);
});
});
});
@@ -23,489 +23,40 @@ import {
} 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 }); // options can be passed, e.g. {allErrors: true}
interface RequestResponsePair {
request: CompletedRequest;
response: CompletedResponse;
}
interface ValidatorParams {
pair: RequestResponsePair;
operationSchema: OperationObject;
path: string;
}
interface Validator {
validate(pair: ValidatorParams): Promise<void>;
}
class RequestErrorFactory {
static createRequestError(request: CompletedRequest, message: string): Error {
return new Error(`[${request.url} (${request.method})]: ${message}`);
}
}
export class ParameterValidator implements Validator {
schema: OpenAPIObject;
cache: Record<string, any> = {};
constructor(schema: OpenAPIObject) {
this.schema = schema;
}
async validate({
pair: { request, response },
operationSchema,
path,
}: ValidatorParams) {
if (response.statusCode === 400) {
// If the response is a 400, then the request is invalid and we shouldn't validate the parameters
return;
}
const parameters = operationSchema.parameters;
const queryParameters: Record<string, ParameterObject> = {};
const headerParameters: Record<string, ParameterObject> = {};
const pathParameters: Record<string, ParameterObject> = {};
for (const parameter of parameters || []) {
if ('$ref' in parameter) {
throw RequestErrorFactory.createRequestError(
request,
'Reference objects are not supported',
);
}
if (parameter.in === 'query') {
queryParameters[parameter.name] = parameter;
}
if (parameter.in === 'header') {
headerParameters[parameter.name] = parameter;
}
if (parameter.in === 'path') {
pathParameters[parameter.name] = parameter;
}
}
this.validateQueryParameters(queryParameters, request);
this.validateHeaderParameters(headerParameters, request);
this.validatePathParameters(pathParameters, request, path);
}
validateQueryParameters(
queryParameters: Record<string, ParameterObject>,
request: CompletedRequest,
) {
const { searchParams } = new URL(request.url);
for (const [name, parameter] of Object.entries(queryParameters)) {
if (!parameter.schema) {
throw RequestErrorFactory.createRequestError(
request,
'Schema not found for query parameter',
);
}
if ('$ref' in parameter.schema) {
throw RequestErrorFactory.createRequestError(
request,
'Reference objects are not supported for parameters',
);
}
let param: any | null = this.#findQueryParameters(
request,
queryParameters,
searchParams,
name,
);
if (parameter.schema.type !== 'array' && Array.isArray(param)) {
param = param.length > 0 ? param[0] : undefined;
}
if (!param && parameter.required) {
throw RequestErrorFactory.createRequestError(
request,
`Required query parameter ${name} not found`,
);
} else if (!param && !parameter.required) {
continue;
}
if (parameter.schema.type === 'integer') {
// Try to parse the integer as AJV won't do it for us.
param = parseInt(param, 10);
}
const validate = ajv.compile(parameter.schema);
const valid = validate(param);
if (!valid) {
console.log(param);
console.error(validate.errors);
throw RequestErrorFactory.createRequestError(
request,
'Query parameter validation failed',
);
}
}
}
#findQueryParameters(
request: CompletedRequest,
parameters: Record<string, ParameterObject>,
searchParams: URLSearchParams,
name: string,
) {
const parameter = parameters[name];
const schema = parameter.schema as SchemaObject;
if (schema.type === 'array') {
if (parameter.style === 'form' || !parameter.style) {
if (parameter.explode || typeof parameter.explode === 'undefined') {
if (!searchParams.has(name) && searchParams.has(`${name}[0]`)) {
const values: string[] = [];
let index = 0;
while (searchParams.has(`${name}[${index}]`)) {
values.push(searchParams.get(`${name}[${index}]`)!);
index++;
}
return values;
}
return searchParams.getAll(name);
}
if (!searchParams.has(name) && searchParams.has(`${name}[]`)) {
return searchParams.getAll(`${name}[]`);
}
return searchParams.get(name)?.split(',');
} else if (parameter.style === 'spaceDelimited') {
return searchParams.get(name)?.split(' ');
} else if (parameter.style === 'pipeDelimited') {
return searchParams.get(name)?.split('|');
}
throw RequestErrorFactory.createRequestError(
request,
'Unsupported style for array parameter',
);
}
if (schema.type === 'object') {
if (parameter.style === 'form' || !parameter.style) {
if (parameter.explode) {
const obj: Record<string, string> = {};
for (const [key, value] of searchParams.entries()) {
if (this.#matchesOtherQueryParameters(parameters, key)) {
continue;
}
obj[key] = value;
}
console.log(obj);
return obj;
}
const obj: Record<string, string> = {};
const value = searchParams.get(name);
if (value) {
const parts = value.split(',');
if (parts.length % 2 !== 0) {
throw RequestErrorFactory.createRequestError(
request,
'Invalid object parameter',
);
}
for (let i = 0; i < parts.length; i += 2) {
obj[parts[i]] = parts[i + 1];
}
}
return obj;
} else if (parameter.style === 'deepObject') {
const obj: Record<string, any> = {};
for (const [key, value] of searchParams.entries()) {
if (key.startsWith(`${name}[`)) {
const parts = key.split('[');
let currentLayer = obj;
for (let partIndex = 0; partIndex < parts.length - 1; partIndex++) {
const part = parts[partIndex];
const objKey = part.split(']')[0];
if (!currentLayer[objKey]) {
currentLayer[objKey] = {};
}
currentLayer = currentLayer[objKey];
}
currentLayer[parts[parts.length - 1].split(']')[0]] = value;
}
}
return obj;
}
throw RequestErrorFactory.createRequestError(
request,
'Unsupported style for object parameter',
);
}
// For everything else, just return the value.
return searchParams.getAll(name);
}
#matchesOtherQueryParameters(
parameters: Record<string, ParameterObject>,
nameToMatch: string,
) {
for (const [name] of Object.entries(parameters)) {
if (name === nameToMatch) {
return true;
}
}
return false;
}
validateHeaderParameters(
headerParameters: Record<string, ParameterObject>,
request: CompletedRequest,
) {
for (const [name, parameter] of Object.entries(headerParameters)) {
if (!request.headers[name]) {
throw RequestErrorFactory.createRequestError(
request,
`Header parameter ${name} not found`,
);
}
if (!parameter.schema) {
throw RequestErrorFactory.createRequestError(
request,
'Schema not found for path parameter',
);
}
if ('$ref' in parameter.schema) {
throw RequestErrorFactory.createRequestError(
request,
'Reference objects are not supported for parameters',
);
}
const validate = ajv.compile(parameter.schema);
const valid = validate(request.headers[name]);
if (!valid) {
console.log(request.headers[name]);
console.error(validate.errors);
throw RequestErrorFactory.createRequestError(
request,
'Header parameter validation failed',
);
}
}
}
validatePathParameters(
pathParameters: Record<string, ParameterObject>,
request: CompletedRequest,
path: string,
) {
const { pathname } = new URL(request.url);
const params = parsePath({ request, path: pathname, schema: path });
for (const [name, parameter] of Object.entries(pathParameters)) {
if (!params[name] && parameter.required) {
throw RequestErrorFactory.createRequestError(
request,
`Path parameter ${name} not found`,
);
}
if (!parameter.schema) {
throw RequestErrorFactory.createRequestError(
request,
'Schema not found for path parameter',
);
}
if ('$ref' in parameter.schema) {
throw RequestErrorFactory.createRequestError(
request,
'Reference objects are not supported for parameters',
);
}
const validate = ajv.compile(parameter.schema);
const valid = validate(params[name]);
if (!valid) {
console.log(params);
console.error(validate.errors);
throw RequestErrorFactory.createRequestError(
request,
'Path parameter validation failed',
);
}
}
}
}
function parsePath({
request,
schema,
path,
}: {
request: CompletedRequest;
schema: string;
path: string;
}) {
const parts = path.split('/');
const pathParts = schema.split('/');
if (parts.length !== pathParts.length) {
throw RequestErrorFactory.createRequestError(
request,
'Path parts do not match',
);
}
const params: Record<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 RequestBodyValidator implements Validator {
class RequestBodyValidator implements Validator {
schema: OpenAPIObject;
constructor(schema: OpenAPIObject) {
this.schema = schema;
}
async validate({
pair: { request, response },
operationSchema,
}: ValidatorParams) {
if (response.statusCode === 400) {
// If the response is a 400, then the request is invalid and we shouldn't validate the request body
return;
}
const requestBody = operationSchema.requestBody;
const bodyText = await request.body.getText();
if (!requestBody && bodyText?.length) {
throw RequestErrorFactory.createRequestError(
request,
`No request body found for ${request.url}`,
);
} else if (!requestBody && !bodyText?.length) {
// If there is no request body in the schema and no body in the request, then the request is valid
return;
}
if ('$ref' in requestBody!) {
throw RequestErrorFactory.createRequestError(
request,
'Reference objects are not supported',
);
}
if (!requestBody!.content) {
throw RequestErrorFactory.createRequestError(
request,
'No content found in request body',
);
}
if (!requestBody!.content['application/json']) {
throw RequestErrorFactory.createRequestError(
request,
'No application/json content type found in request body',
);
}
const contentType = request.headers['content-type'];
if (!contentType) {
throw RequestErrorFactory.createRequestError(
request,
'Content type not found in request',
);
}
if (contentType !== 'application/json') {
throw RequestErrorFactory.createRequestError(
request,
'Content type is not application/json',
);
}
const schema = requestBody!.content['application/json'].schema;
if (!schema) {
throw RequestErrorFactory.createRequestError(
request,
'No schema found in request body',
);
}
if ('$ref' in schema) {
throw RequestErrorFactory.createRequestError(
request,
'Reference objects are not supported',
);
}
const validate = ajv.compile(schema);
const body = await request.body.getJson();
const valid = validate(body);
if (!valid) {
console.log(body);
console.error(validate.errors);
throw RequestErrorFactory.createRequestError(
request,
`Request body validation failed.`,
);
}
async validate({ pair, operation }: ValidatorParams) {
const { request } = pair;
const parser = new RequestBodyParser(operation, { ajv });
const fetchRequest = mockttpToFetchRequest(request);
await parser.parse(fetchRequest);
}
}
export class ResponseBodyValidator implements Validator {
class ResponseBodyValidator implements Validator {
schema: OpenAPIObject;
constructor(schema: OpenAPIObject) {
this.schema = schema;
}
async validate(pair: ValidatorParams) {
const {
pair: { response, request },
operationSchema,
} = pair;
const responseSchema = this.findResponseSchema(operationSchema, response);
if (!responseSchema) {
throw RequestErrorFactory.createRequestError(
request,
`No response schema found for ${response.statusCode}`,
);
}
const body = await response.body.getText();
if (!responseSchema.content && body?.length) {
throw RequestErrorFactory.createRequestError(
request,
'No content found in response',
);
} else if (!responseSchema.content && !body?.length) {
// If there is no content in the response schema and no body in the response, then the response is valid
return;
}
if (!responseSchema.content!['application/json']) {
throw RequestErrorFactory.createRequestError(
request,
'No application/json content type found in response',
);
}
const schema = responseSchema.content!['application/json'].schema;
if (!schema) {
throw RequestErrorFactory.createRequestError(
request,
'No schema found in response',
);
}
if ('$ref' in schema) {
throw RequestErrorFactory.createRequestError(
request,
'Reference objects are not supported',
);
}
const validate = ajv.compile(schema);
const valid = validate(await response.body.getJson());
if (!valid) {
console.log(await response.body.getJson());
console.error(validate.errors);
throw RequestErrorFactory.createRequestError(
request,
'Response body validation failed',
);
}
}
private findResponseSchema(
operationSchema: OperationObject,
response: CompletedResponse,
): ResponseObject | undefined {
const { statusCode } = response;
return operationSchema.responses?.[statusCode];
async validate({ pair, operation }: ValidatorParams) {
const { response } = pair;
const parser = new ResponseBodyParser(operation, { ajv });
const fetchResponse = mockttpToFetchResponse(response);
await parser.parse(fetchResponse);
}
}
@@ -518,28 +69,28 @@ export class OpenApiProxyValidator {
this.validators = [
new ParameterValidator(this.schema),
new RequestBodyValidator(this.schema),
// new ResponseBodyValidator(this.schema),
new ResponseBodyValidator(this.schema),
];
}
async validate(request: CompletedRequest, response: CompletedResponse) {
const operation = this.findOperation(request);
if (!operation) {
throw RequestErrorFactory.createRequestError(
request,
const operationPathTuple = this.findOperation(request);
if (!operationPathTuple) {
throw new OperationError(
{ path: request.path, method: request.method } as Operation,
`No operation schema found for ${request.url}`,
);
}
const [path, operationSchema] = operation;
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 },
operationSchema,
path,
operation,
}),
),
);
+3
View File
@@ -3740,8 +3740,10 @@ __metadata:
dependencies:
"@apidevtools/swagger-parser": ^10.1.0
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/types": "workspace:^"
"@types/express": ^4.17.6
"@types/express-serve-static-core": ^4.17.5
ajv: ^8.16.0
@@ -3751,6 +3753,7 @@ __metadata:
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