adding test for request and response validation and adjusting error messages
Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com>
This commit is contained in:
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"openapi": "3.0.0",
|
||||
"info": { "title": "Test", "version": "1.0.0" },
|
||||
"paths": {
|
||||
"/api/search": {
|
||||
"post": {
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -23,7 +23,8 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"openapi": "3.0.0",
|
||||
"info": { "title": "Test", "version": "1.0.0" },
|
||||
"paths": {
|
||||
"/api/item/{id}": {
|
||||
"get": {
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,20 @@ import { Operation } from './types';
|
||||
|
||||
export class OperationError extends Error {
|
||||
constructor(operation: Operation, message: string) {
|
||||
super(`[${operation.path} (${operation.method})]: ${message}`);
|
||||
super(
|
||||
`["${operation.method.toLocaleUpperCase('en-US')} ${
|
||||
operation.path
|
||||
}"] ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class OperationResponseError extends Error {
|
||||
constructor(operation: Operation, response: Response, message: string) {
|
||||
super(
|
||||
`["${operation.method.toLocaleUpperCase('en-US')} ${operation.path}" (${
|
||||
response.status
|
||||
})]: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,11 @@
|
||||
|
||||
import _ from 'lodash';
|
||||
import withQueryParameter from './__fixtures__/schemas/withQueryParameter.json';
|
||||
import { QueryParameterParser } from './parameter-validation';
|
||||
import withPathParameter from './__fixtures__/schemas/withPathParameter.json';
|
||||
import {
|
||||
PathParameterParser,
|
||||
QueryParameterParser,
|
||||
} from './parameter-validation';
|
||||
import { OperationObject, ParameterObject } from 'openapi3-ts';
|
||||
import Ajv from 'ajv';
|
||||
import { Operation } from './types';
|
||||
@@ -54,7 +58,7 @@ describe('query parameters', () => {
|
||||
await expect(
|
||||
parser.parse(request),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"[/api/search (get)]: Unexpected query parameters: extra"`,
|
||||
`"["GET /api/search"] Unexpected query parameters: extra"`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -66,7 +70,7 @@ describe('query parameters', () => {
|
||||
await expect(
|
||||
parser.parse(request),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"[/api/search (get)]: Required query parameter param not found"`,
|
||||
`"["GET /api/search"] Required query parameter param not found"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -90,7 +94,7 @@ describe('query parameters', () => {
|
||||
await expect(
|
||||
parser.parse(request),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"[/api/search (get)]: Query parameter validation failed"`,
|
||||
`"["GET /api/search"] Query parameter validation failed"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -132,7 +136,7 @@ describe('query parameters', () => {
|
||||
await expect(
|
||||
parser.parse(request),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"[/api/search (get)]: Required query parameter param not found"`,
|
||||
`"["GET /api/search"] Required query parameter param not found"`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -143,7 +147,7 @@ describe('query parameters', () => {
|
||||
await expect(
|
||||
parser.parse(request),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"[/api/search (get)]: Unexpected query parameters: extra"`,
|
||||
`"["GET /api/search"] Unexpected query parameters: extra"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -177,7 +181,7 @@ describe('query parameters', () => {
|
||||
await expect(
|
||||
parser.parse(request),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"[/api/search (get)]: Required query parameter param not found"`,
|
||||
`"["GET /api/search"] Required query parameter param not found"`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -188,7 +192,7 @@ describe('query parameters', () => {
|
||||
await expect(
|
||||
parser.parse(request),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"[/api/search (get)]: Unexpected query parameters: extra"`,
|
||||
`"["GET /api/search"] Unexpected query parameters: extra"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -215,7 +219,7 @@ describe('query parameters', () => {
|
||||
await expect(
|
||||
parser.parse(request),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"[/api/search (get)]: Required query parameter param not found"`,
|
||||
`"["GET /api/search"] Required query parameter param not found"`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -226,7 +230,7 @@ describe('query parameters', () => {
|
||||
await expect(
|
||||
parser.parse(request),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"[/api/search (get)]: Unexpected query parameters: extra"`,
|
||||
`"["GET /api/search"] Unexpected query parameters: extra"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -252,7 +256,7 @@ describe('query parameters', () => {
|
||||
await expect(
|
||||
parser.parse(request),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"[/api/search (get)]: Required query parameter param not found"`,
|
||||
`"["GET /api/search"] Required query parameter param not found"`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -263,7 +267,7 @@ describe('query parameters', () => {
|
||||
await expect(
|
||||
parser.parse(request),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"[/api/search (get)]: Unexpected query parameters: extra"`,
|
||||
`"["GET /api/search"] Unexpected query parameters: extra"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -304,7 +308,7 @@ describe('query parameters', () => {
|
||||
await expect(
|
||||
parser.parse(request),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"[/api/search (get)]: Required query parameter param not found"`,
|
||||
`"["GET /api/search"] Required query parameter param not found"`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -361,7 +365,7 @@ describe('query parameters', () => {
|
||||
await expect(
|
||||
parser.parse(request),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"[/api/search (get)]: Required query parameter param not found"`,
|
||||
`"["GET /api/search"] Required query parameter param not found"`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -372,7 +376,7 @@ describe('query parameters', () => {
|
||||
await expect(
|
||||
parser.parse(request),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"[/api/search (get)]: Unexpected query parameters: extra"`,
|
||||
`"["GET /api/search"] Unexpected query parameters: extra"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -416,7 +420,7 @@ describe('query parameters', () => {
|
||||
await expect(
|
||||
parser.parse(request),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"[/api/search (get)]: Required query parameter param not found"`,
|
||||
`"["GET /api/search"] Required query parameter param not found"`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -427,7 +431,7 @@ describe('query parameters', () => {
|
||||
await expect(
|
||||
parser.parse(request),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"[/api/search (get)]: Unexpected query parameters: extra"`,
|
||||
`"["GET /api/search"] Unexpected query parameters: extra"`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -456,3 +460,66 @@ describe('query parameters', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('path parameters', () => {
|
||||
let operation: Operation;
|
||||
let parser: PathParameterParser;
|
||||
let schema: (typeof withPathParameter)['paths']['/api/item/{id}']['get'];
|
||||
|
||||
beforeEach(() => {
|
||||
schema = _.cloneDeep(withPathParameter.paths['/api/item/{id}'].get);
|
||||
operation = {
|
||||
schema: schema as OperationObject,
|
||||
path: '/api/item/{id}',
|
||||
method: 'get',
|
||||
};
|
||||
parser = new PathParameterParser(operation, { ajv });
|
||||
});
|
||||
describe('primitives', () => {
|
||||
describe('string', () => {
|
||||
it('should parse a string', async () => {
|
||||
const request = {
|
||||
url: 'http://localhost:8080/api/item/test',
|
||||
} as Request;
|
||||
const result = await parser.parse(request);
|
||||
expect(result.id).toBe('test');
|
||||
});
|
||||
|
||||
it('should throw an error if the parameter is required but missing', async () => {
|
||||
(schema.parameters![0] as ParameterObject).required = true;
|
||||
const request = {
|
||||
url: 'http://localhost:8080/api/item',
|
||||
} as Request;
|
||||
await expect(
|
||||
parser.parse(request),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"["GET /api/item/{id}"] Path parts do not match"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('number', () => {
|
||||
beforeEach(() => {
|
||||
schema.parameters![0].schema.type = 'number';
|
||||
});
|
||||
it('should parse a number', async () => {
|
||||
const request = {
|
||||
url: 'http://localhost:8080/api/item/42',
|
||||
} as Request;
|
||||
const result = await parser.parse(request);
|
||||
expect(result.id).toBe(42);
|
||||
});
|
||||
|
||||
it('should throw an error if the parameter is not a number', async () => {
|
||||
const request = {
|
||||
url: 'http://localhost:8080/api/item/hello',
|
||||
} as Request;
|
||||
await expect(
|
||||
parser.parse(request),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"["GET /api/item/{id}"] Path parameter validation failed"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,7 +36,11 @@ class BaseParameterParser {
|
||||
ajv: Ajv;
|
||||
operation: Operation;
|
||||
parameters: Record<string, ReferencelessParameterObject> = {};
|
||||
constructor(operation: Operation, options: ParserOptions) {
|
||||
constructor(
|
||||
parameterIn: string,
|
||||
operation: Operation,
|
||||
options: ParserOptions,
|
||||
) {
|
||||
this.ajv = options.ajv;
|
||||
this.operation = operation;
|
||||
const { schema, path, method } = operation;
|
||||
@@ -60,18 +64,31 @@ class BaseParameterParser {
|
||||
'Reference objects are not supported for parameters',
|
||||
);
|
||||
}
|
||||
if (parameter.in === 'query') {
|
||||
if (parameter.in === parameterIn) {
|
||||
this.parameters[parameter.name] =
|
||||
parameter as ReferencelessParameterObject;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
optimisticallyParseValue(value: string, schema: SchemaObject) {
|
||||
if (schema.type === 'integer') {
|
||||
return parseInt(value, 10);
|
||||
}
|
||||
if (schema.type === 'number') {
|
||||
return parseFloat(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export class QueryParameterParser
|
||||
extends BaseParameterParser
|
||||
implements RequestParser<Record<string, any>>
|
||||
{
|
||||
constructor(operation: Operation, options: ParserOptions) {
|
||||
super('query', operation, options);
|
||||
}
|
||||
async parse(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const remainingQueryParameters = new Set<string>(searchParams.keys());
|
||||
@@ -127,13 +144,8 @@ export class QueryParameterParser
|
||||
} 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);
|
||||
if (param) {
|
||||
param = this.optimisticallyParseValue(param, parameter.schema);
|
||||
}
|
||||
const validate = this.ajv.compile(parameter.schema);
|
||||
const valid = validate(param);
|
||||
@@ -295,6 +307,9 @@ export class HeaderParameterParser
|
||||
extends BaseParameterParser
|
||||
implements RequestParser<Record<string, any>>
|
||||
{
|
||||
constructor(operation: Operation, options: ParserOptions) {
|
||||
super('header', operation, options);
|
||||
}
|
||||
async parse(request: Request) {
|
||||
const headerParameters: Record<string, any> = {};
|
||||
for (const [name, parameter] of Object.entries(this.parameters)) {
|
||||
@@ -339,15 +354,19 @@ export class PathParameterParser
|
||||
extends BaseParameterParser
|
||||
implements RequestParser<Record<string, any>>
|
||||
{
|
||||
constructor(operation: Operation, options: ParserOptions) {
|
||||
super('path', operation, options);
|
||||
}
|
||||
async parse(request: Request) {
|
||||
const { pathname } = new URL(request.url);
|
||||
const params = this.parsePath({
|
||||
path: pathname,
|
||||
schema: this.operation.path,
|
||||
});
|
||||
const pathParameters: Record<string, string> = {};
|
||||
const pathParameters: Record<string, any> = {};
|
||||
for (const [name, parameter] of Object.entries(this.parameters)) {
|
||||
if (!params[name] && parameter.required) {
|
||||
let param: string | number = params[name];
|
||||
if (!param && parameter.required) {
|
||||
throw new OperationError(
|
||||
this.operation,
|
||||
`Path parameter ${name} not found`,
|
||||
@@ -356,8 +375,12 @@ export class PathParameterParser
|
||||
continue;
|
||||
}
|
||||
|
||||
if (param) {
|
||||
param = this.optimisticallyParseValue(param, parameter.schema);
|
||||
}
|
||||
|
||||
const validate = this.ajv.compile(parameter.schema);
|
||||
const valid = validate(params[name]);
|
||||
const valid = validate(param);
|
||||
|
||||
if (!valid) {
|
||||
throw new OperationError(
|
||||
@@ -365,7 +388,7 @@ export class PathParameterParser
|
||||
'Path parameter validation failed',
|
||||
);
|
||||
}
|
||||
pathParameters[name] = params[name];
|
||||
pathParameters[name] = param;
|
||||
}
|
||||
return pathParameters;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import withJsonRequestBody from './__fixtures__/schemas/withJsonRequestBody.json';
|
||||
import { RequestBodyParser } from './request-body-validation';
|
||||
import Ajv from 'ajv';
|
||||
import { Operation, RequestParser } from './types';
|
||||
import _ from 'lodash';
|
||||
import { OperationObject, RequestBodyObject } from 'openapi3-ts';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
|
||||
const ajv = new Ajv();
|
||||
|
||||
function toRequest(body?: object, headers?: Record<string, string>): Request {
|
||||
return {
|
||||
text: async () => JSON.stringify(body),
|
||||
json: async () => body,
|
||||
url: '/api/search',
|
||||
method: 'post',
|
||||
headers: new Headers({ 'content-type': 'application/json', ...headers }),
|
||||
} as Request;
|
||||
}
|
||||
|
||||
describe('request body', () => {
|
||||
let operation: Operation;
|
||||
let parser: RequestParser<JsonObject | undefined>;
|
||||
let schema: (typeof withJsonRequestBody)['paths']['/api/search']['post'];
|
||||
beforeEach(() => {
|
||||
schema = _.cloneDeep(withJsonRequestBody.paths['/api/search'].post);
|
||||
operation = {
|
||||
method: 'post',
|
||||
schema: schema as OperationObject,
|
||||
path: '/api/search',
|
||||
};
|
||||
parser = new RequestBodyParser(operation, {
|
||||
ajv,
|
||||
});
|
||||
});
|
||||
it('should validate request body', async () => {
|
||||
const requestBody = {
|
||||
query: 'test',
|
||||
};
|
||||
const result = await parser.parse(toRequest(requestBody));
|
||||
expect(result).toEqual(requestBody);
|
||||
});
|
||||
|
||||
it('should throw error if request body is not valid', async () => {
|
||||
const requestBody = {
|
||||
query: 1,
|
||||
};
|
||||
await expect(
|
||||
parser.parse(toRequest(requestBody)),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"["POST /api/search"] Request body validation failed."`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if request body is required but missing', async () => {
|
||||
(schema.requestBody as RequestBodyObject).required = true;
|
||||
parser = RequestBodyParser.fromOperation(operation, {
|
||||
ajv,
|
||||
});
|
||||
await expect(
|
||||
parser.parse(toRequest()),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"["POST /api/search"] No request body found for /api/search"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -18,24 +18,51 @@ import { JsonObject } from '@backstage/types';
|
||||
import { Operation, ParserOptions, RequestParser } from './types';
|
||||
import { ValidateFunction } from 'ajv';
|
||||
import { OperationError } from './errors';
|
||||
import { RequestBodyObject, SchemaObject } from 'openapi3-ts';
|
||||
|
||||
class DisabledRequestBodyParser
|
||||
implements RequestParser<JsonObject | undefined>
|
||||
{
|
||||
operation: Operation;
|
||||
constructor(operation: Operation) {
|
||||
this.operation = operation;
|
||||
}
|
||||
async parse(request: Request): Promise<JsonObject | undefined> {
|
||||
const bodyText = await request.text();
|
||||
if (bodyText?.length) {
|
||||
throw new OperationError(
|
||||
this.operation,
|
||||
'Received a body but no schema was found',
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
export class RequestBodyParser
|
||||
implements RequestParser<JsonObject | undefined>
|
||||
{
|
||||
operation: Operation;
|
||||
validate:
|
||||
| { fn: ValidateFunction; disabled: false }
|
||||
| {
|
||||
fn: undefined;
|
||||
disabled: true;
|
||||
};
|
||||
disabled: boolean = false;
|
||||
validate!: ValidateFunction;
|
||||
schema!: SchemaObject;
|
||||
requestBodySchema!: RequestBodyObject;
|
||||
|
||||
static fromOperation(operation: Operation, options: ParserOptions) {
|
||||
return operation.schema.requestBody
|
||||
? new RequestBodyParser(operation, options)
|
||||
: new DisabledRequestBodyParser(operation);
|
||||
}
|
||||
|
||||
constructor(operation: Operation, options: ParserOptions) {
|
||||
this.operation = operation;
|
||||
const { schema: operationSchema } = this.operation;
|
||||
const requestBody = operationSchema.requestBody;
|
||||
|
||||
if (!requestBody) {
|
||||
this.validate = { disabled: true, fn: undefined };
|
||||
return;
|
||||
throw new OperationError(
|
||||
this.operation,
|
||||
'No request body found in operation',
|
||||
);
|
||||
}
|
||||
|
||||
if ('$ref' in requestBody!) {
|
||||
@@ -69,27 +96,17 @@ export class RequestBodyParser
|
||||
'Reference objects are not supported',
|
||||
);
|
||||
}
|
||||
this.validate = {
|
||||
disabled: false,
|
||||
fn: options.ajv.compile(operation.schema),
|
||||
};
|
||||
this.validate = options.ajv.compile(schema);
|
||||
this.schema = schema;
|
||||
this.requestBodySchema = requestBody;
|
||||
}
|
||||
async parse(request: Request): Promise<JsonObject | undefined> {
|
||||
const { disabled, fn } = this.validate;
|
||||
const bodyText = await request.text();
|
||||
if (!disabled && bodyText?.length) {
|
||||
if (this.requestBodySchema.required && !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 =
|
||||
@@ -101,10 +118,8 @@ export class RequestBodyParser
|
||||
);
|
||||
}
|
||||
const body = (await request.json()) as JsonObject;
|
||||
const valid = fn!(body);
|
||||
const valid = this.validate(body);
|
||||
if (!valid) {
|
||||
console.log(body);
|
||||
console.error(fn!.errors);
|
||||
throw new OperationError(
|
||||
this.operation,
|
||||
`Request body validation failed.`,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import withJsonResponseBody from './__fixtures__/schemas/withJsonResponseBody.json';
|
||||
import { Operation, ResponseParser } from './types';
|
||||
import { ResponseBodyParser } from './response-body-validation';
|
||||
import Ajv from 'ajv';
|
||||
import { OperationObject, ResponsesObject } from 'openapi3-ts';
|
||||
import _ from 'lodash';
|
||||
|
||||
const ajv = new Ajv();
|
||||
|
||||
function toResponse(body?: object): Response {
|
||||
return {
|
||||
json: async () => body,
|
||||
text: async () => JSON.stringify(body),
|
||||
status: 200,
|
||||
} as Response;
|
||||
}
|
||||
|
||||
describe('response body', () => {
|
||||
let operation: Operation;
|
||||
let parser: ResponseParser<JsonObject | undefined>;
|
||||
let schema: (typeof withJsonResponseBody)['paths']['/api/search']['get'];
|
||||
beforeEach(() => {
|
||||
schema = _.cloneDeep(withJsonResponseBody.paths['/api/search'].get);
|
||||
operation = {
|
||||
path: '/api/search',
|
||||
method: 'get',
|
||||
schema: schema as OperationObject,
|
||||
};
|
||||
parser = ResponseBodyParser.fromOperation(operation, {
|
||||
ajv,
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate response body', async () => {
|
||||
const responseBody = {
|
||||
results: [{ id: 'test' }],
|
||||
};
|
||||
const result = await parser.parse(toResponse(responseBody));
|
||||
expect(result).toEqual(responseBody);
|
||||
});
|
||||
|
||||
it('should throw error if response body is not valid', async () => {
|
||||
const responseBody = {
|
||||
result: 1,
|
||||
};
|
||||
await expect(
|
||||
parser.parse(toResponse(responseBody)),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"["GET /api/search" (200)]: Response body validation failed"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if response body is required but missing', async () => {
|
||||
(schema.responses as ResponsesObject)['200'].required = true;
|
||||
parser = ResponseBodyParser.fromOperation(operation, {
|
||||
ajv,
|
||||
});
|
||||
await expect(
|
||||
parser.parse(toResponse()),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"["GET /api/search" (200)]: Response body is required but missing"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -16,22 +16,46 @@
|
||||
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { Operation, ParserOptions, ResponseParser } from './types';
|
||||
import { OperationError } from './errors';
|
||||
import { OperationError, OperationResponseError } from './errors';
|
||||
import Ajv from 'ajv';
|
||||
import { OperationObject, ResponseObject } from 'openapi3-ts';
|
||||
|
||||
class DisabledResponseBodyParser
|
||||
implements ResponseParser<JsonObject | undefined>
|
||||
{
|
||||
operation: Operation;
|
||||
constructor(operation: Operation) {
|
||||
this.operation = operation;
|
||||
}
|
||||
async parse(response: Response): Promise<JsonObject | undefined> {
|
||||
const body = await response.text();
|
||||
if (body?.length) {
|
||||
throw new OperationError(
|
||||
this.operation,
|
||||
'Received a body but no schema was found',
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class ResponseBodyParser
|
||||
implements ResponseParser<JsonObject | undefined>
|
||||
{
|
||||
operation: Operation;
|
||||
ajv: Ajv;
|
||||
|
||||
static fromOperation(operation: Operation, options: ParserOptions) {
|
||||
return operation.schema.responses &&
|
||||
Object.keys(operation.schema.responses).length
|
||||
? new ResponseBodyParser(operation, options)
|
||||
: new DisabledResponseBodyParser(operation);
|
||||
}
|
||||
|
||||
constructor(operation: Operation, options: ParserOptions) {
|
||||
this.operation = operation;
|
||||
this.ajv = options.ajv;
|
||||
const responseSchemas = operation.schema.responses;
|
||||
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;
|
||||
@@ -55,15 +79,30 @@ export class ResponseBodyParser
|
||||
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
|
||||
if (!responseSchema?.content && !body?.length) {
|
||||
// If there is no content in the response schema and no body in the response, then the response is valid.
|
||||
// eg 204 No Content
|
||||
return undefined;
|
||||
}
|
||||
if (!responseSchema?.content!['application/json']) {
|
||||
throw new OperationError(
|
||||
if (!responseSchema) {
|
||||
throw new OperationResponseError(
|
||||
this.operation,
|
||||
response,
|
||||
`No schema found.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!responseSchema?.content && body?.length) {
|
||||
throw new OperationResponseError(
|
||||
this.operation,
|
||||
response,
|
||||
'Received a body but no schema was found',
|
||||
);
|
||||
}
|
||||
if (!responseSchema?.content!['application/json']) {
|
||||
throw new OperationResponseError(
|
||||
this.operation,
|
||||
response,
|
||||
'No application/json content type found in response',
|
||||
);
|
||||
}
|
||||
@@ -72,18 +111,31 @@ export class ResponseBodyParser
|
||||
throw new OperationError(this.operation, 'No schema found in response');
|
||||
}
|
||||
if ('$ref' in schema) {
|
||||
throw new OperationError(
|
||||
throw new OperationResponseError(
|
||||
this.operation,
|
||||
response,
|
||||
'Reference objects are not supported',
|
||||
);
|
||||
}
|
||||
|
||||
if (!schema.required && !body?.length) {
|
||||
throw new OperationResponseError(
|
||||
this.operation,
|
||||
response,
|
||||
'Response body is required but missing',
|
||||
);
|
||||
} else if (!schema.required && !body?.length) {
|
||||
// If there is no content in the response schema and no body in the response, then the response is valid
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const validate = this.ajv.compile(schema);
|
||||
const jsonBody = (await response.json()) as JsonObject;
|
||||
const valid = validate(jsonBody);
|
||||
if (!valid) {
|
||||
throw new OperationError(
|
||||
throw new OperationResponseError(
|
||||
this.operation,
|
||||
response,
|
||||
'Response body validation failed',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,8 +112,8 @@ describe('OpenApiProxyValidator', () => {
|
||||
|
||||
await expect(
|
||||
async () => await validator.validate(request, response),
|
||||
).rejects.toMatchInlineSnapshot(
|
||||
`[Error: [/api/search (GET)]: Received a body but no schema was found]`,
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"["GET /api/search"] Received a body but no schema was found"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -149,8 +149,8 @@ describe('OpenApiProxyValidator', () => {
|
||||
|
||||
await expect(
|
||||
async () => await validator.validate(request, response),
|
||||
).rejects.toMatchInlineSnapshot(
|
||||
`[Error: [/api/search (GET)]: Unexpected query parameters: test]`,
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"["GET /api/search"] Unexpected query parameters: test"`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -166,8 +166,8 @@ describe('OpenApiProxyValidator', () => {
|
||||
|
||||
await expect(
|
||||
async () => await validator.validate(request, response),
|
||||
).rejects.toMatchInlineSnapshot(
|
||||
`[Error: [/api/search (GET)]: Query parameter validation failed]`,
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"["GET /api/search"] Query parameter validation failed"`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -205,8 +205,8 @@ describe('OpenApiProxyValidator', () => {
|
||||
|
||||
await expect(
|
||||
async () => await validator.validate(request, response),
|
||||
).rejects.toMatchInlineSnapshot(
|
||||
`[Error: [/api/search (GET)]: Unexpected query parameters: param]`,
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"["GET /api/search"] Unexpected query parameters: param"`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -224,8 +224,8 @@ describe('OpenApiProxyValidator', () => {
|
||||
|
||||
await expect(
|
||||
async () => await validator.validate(request, response),
|
||||
).rejects.toMatchInlineSnapshot(
|
||||
`[Error: [/api/search (GET)]: Required query parameter param not found]`,
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"["GET /api/search"] Required query parameter param not found"`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -241,8 +241,8 @@ describe('OpenApiProxyValidator', () => {
|
||||
|
||||
await expect(
|
||||
async () => await validator.validate(request, response),
|
||||
).rejects.toMatchInlineSnapshot(
|
||||
`[Error: [/api/search (GET)]: Invalid object parameter]`,
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"["GET /api/search"] Invalid object parameter"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -265,8 +265,8 @@ describe('OpenApiProxyValidator', () => {
|
||||
|
||||
await expect(
|
||||
async () => await validator.validate(request, response),
|
||||
).rejects.toMatchInlineSnapshot(
|
||||
`[Error: [/api/search (GET)]: Unexpected query parameters: param[t]`,
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"["GET /api/search"] Unexpected query parameters: param[t"`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -284,8 +284,8 @@ describe('OpenApiProxyValidator', () => {
|
||||
|
||||
await expect(
|
||||
async () => await validator.validate(request, response),
|
||||
).rejects.toMatchInlineSnapshot(
|
||||
`[Error: [/api/search (GET)]: Required query parameter param not found]`,
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"["GET /api/search"] Required query parameter param not found"`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -300,8 +300,8 @@ describe('OpenApiProxyValidator', () => {
|
||||
});
|
||||
await expect(
|
||||
async () => await validator.validate(request, response),
|
||||
).rejects.toMatchInlineSnapshot(
|
||||
`[Error: [/api/search (GET)]: Invalid object parameter]`,
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"["GET /api/search"] Invalid object parameter"`,
|
||||
);
|
||||
});
|
||||
describe('explode', () => {
|
||||
@@ -369,7 +369,7 @@ describe('OpenApiProxyValidator', () => {
|
||||
await expect(
|
||||
async () => await validator.validate(request, response),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"[/api/search (GET)]: Invalid object parameter"`,
|
||||
`"["GET /api/search"] Invalid object parameter"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -428,8 +428,8 @@ describe('OpenApiProxyValidator', () => {
|
||||
|
||||
await expect(
|
||||
async () => await validator.validate(request, response),
|
||||
).rejects.toMatchInlineSnapshot(
|
||||
`[Error: [/api/search (GET)]: Required query parameter param not found]`,
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"["GET /api/search"] Required query parameter param not found"`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -445,8 +445,8 @@ describe('OpenApiProxyValidator', () => {
|
||||
|
||||
await expect(
|
||||
async () => await validator.validate(request, response),
|
||||
).rejects.toMatchInlineSnapshot(
|
||||
`[Error: [/api/search (GET)]: Unexpected query parameters: param[]]`,
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"["GET /api/search"] Unexpected query parameters: param[]"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -499,8 +499,8 @@ describe('OpenApiProxyValidator', () => {
|
||||
|
||||
await expect(
|
||||
async () => await validator.validate(request, response),
|
||||
).rejects.toMatchInlineSnapshot(
|
||||
`[Error: [/api/search (GET)]: Required query parameter param not found]`,
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"["GET /api/search"] Required query parameter param not found"`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -516,8 +516,8 @@ describe('OpenApiProxyValidator', () => {
|
||||
|
||||
await expect(
|
||||
async () => await validator.validate(request, response),
|
||||
).rejects.toMatchInlineSnapshot(
|
||||
`[Error: [/api/search (GET)]: Array parameter should not have multiple values]`,
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"["GET /api/search"] Array parameter should not have multiple values"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -609,8 +609,8 @@ describe('OpenApiProxyValidator', () => {
|
||||
|
||||
await expect(
|
||||
async () => await validator.validate(request, response),
|
||||
).rejects.toMatchInlineSnapshot(
|
||||
`[Error: [/api/search (GET)]: Required query parameter param not found]`,
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"["GET /api/search"] Required query parameter param not found"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -662,8 +662,8 @@ describe('OpenApiProxyValidator', () => {
|
||||
|
||||
await expect(
|
||||
async () => await validator.validate(request, response),
|
||||
).rejects.toMatchInlineSnapshot(
|
||||
`[Error: [/api/search (GET)]: Required query parameter param not found]`,
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"["GET /api/search"] Required query parameter param not found"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -703,8 +703,8 @@ describe('OpenApiProxyValidator', () => {
|
||||
|
||||
await expect(
|
||||
async () => await validator.validate(request, response),
|
||||
).rejects.toMatchInlineSnapshot(
|
||||
`[Error: [/api/search (GET)]: Response body validation failed]`,
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"["GET /api/search" (200)]: Response body is required but missing"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ class RequestBodyValidator implements Validator {
|
||||
|
||||
async validate({ pair, operation }: ValidatorParams) {
|
||||
const { request } = pair;
|
||||
const parser = new RequestBodyParser(operation, { ajv });
|
||||
const parser = RequestBodyParser.fromOperation(operation, { ajv });
|
||||
const fetchRequest = mockttpToFetchRequest(request);
|
||||
await parser.parse(fetchRequest);
|
||||
}
|
||||
@@ -54,7 +54,7 @@ class ResponseBodyValidator implements Validator {
|
||||
|
||||
async validate({ pair, operation }: ValidatorParams) {
|
||||
const { response } = pair;
|
||||
const parser = new ResponseBodyParser(operation, { ajv });
|
||||
const parser = ResponseBodyParser.fromOperation(operation, { ajv });
|
||||
const fetchResponse = mockttpToFetchResponse(response);
|
||||
await parser.parse(fetchResponse);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user