adjusting validation library format and adding test cases
Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com>
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
+35
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"openapi": "3.0.0",
|
||||
"info": { "title": "Test", "version": "1.0.0" },
|
||||
"paths": {
|
||||
"/api/search": {
|
||||
"get": {
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "param",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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¶m=world',
|
||||
} as Request;
|
||||
const result = await parser.parse(request);
|
||||
expect(result.param).toEqual(['hello', 'world']);
|
||||
});
|
||||
|
||||
it('should throw for missing required parameters', async () => {
|
||||
(schema.parameters![0] as ParameterObject).required = true;
|
||||
const request = {
|
||||
url: 'http://localhost:8080/api/search',
|
||||
} as Request;
|
||||
await expect(
|
||||
parser.parse(request),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"[/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¶m[key2]=value2',
|
||||
} as Request;
|
||||
const result = await parser.parse(request);
|
||||
expect(result.param).toEqual({ key1: 'value1', key2: 'value2' });
|
||||
});
|
||||
|
||||
it('should throw for missing required parameters', async () => {
|
||||
(schema.parameters![0] as ParameterObject).required = true;
|
||||
const request = {
|
||||
url: 'http://localhost:8080/api/search',
|
||||
} as Request;
|
||||
await expect(
|
||||
parser.parse(request),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"[/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¶m=test¶m=456',
|
||||
});
|
||||
const response = createMockttpResponse({
|
||||
statusCode: 200,
|
||||
});
|
||||
|
||||
expect(await validator.validate(request, response)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws for missing required parameter', async () => {
|
||||
const requiredSchema = _.cloneDeep(explodeSchema);
|
||||
requiredSchema.paths['/api/search'].get.parameters[0].required =
|
||||
true;
|
||||
await mockSchema(requiredSchema);
|
||||
const request = createMockttpRequest({
|
||||
method: 'GET',
|
||||
url: '/api/search',
|
||||
});
|
||||
const response = createMockttpResponse({
|
||||
statusCode: 200,
|
||||
});
|
||||
|
||||
await expect(
|
||||
async () => await validator.validate(request, response),
|
||||
).rejects.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¶m=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¶m[1]=456¶m[2]=789',
|
||||
});
|
||||
const response = createMockttpResponse({
|
||||
statusCode: 200,
|
||||
});
|
||||
|
||||
expect(await validator.validate(request, response)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('spaceDelimited', () => {
|
||||
const spaceDelimitedSchema = _.cloneDeep(arraySchema);
|
||||
(
|
||||
spaceDelimitedSchema.paths['/api/search'].get
|
||||
.parameters[0] as ParameterObject
|
||||
).style = 'spaceDelimited';
|
||||
|
||||
it('accepts single parameter', async () => {
|
||||
await mockSchema(spaceDelimitedSchema);
|
||||
const request = createMockttpRequest({
|
||||
method: 'GET',
|
||||
url: '/api/search?param=123',
|
||||
});
|
||||
const response = createMockttpResponse({
|
||||
statusCode: 200,
|
||||
});
|
||||
|
||||
expect(await validator.validate(request, response)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts multiple parameters', async () => {
|
||||
await mockSchema(spaceDelimitedSchema);
|
||||
const request = createMockttpRequest({
|
||||
method: 'GET',
|
||||
url: '/api/search?param=123 test 456',
|
||||
});
|
||||
const response = createMockttpResponse({
|
||||
statusCode: 200,
|
||||
});
|
||||
|
||||
expect(await validator.validate(request, response)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws for missing required parameter', async () => {
|
||||
const requiredSchema = _.cloneDeep(spaceDelimitedSchema);
|
||||
requiredSchema.paths['/api/search'].get.parameters[0].required = true;
|
||||
await mockSchema(requiredSchema);
|
||||
const request = createMockttpRequest({
|
||||
method: 'GET',
|
||||
url: '/api/search',
|
||||
});
|
||||
const response = createMockttpResponse({
|
||||
statusCode: 200,
|
||||
});
|
||||
|
||||
await expect(
|
||||
async () => await validator.validate(request, response),
|
||||
).rejects.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,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user