diff --git a/.changeset/eleven-beds-play.md b/.changeset/eleven-beds-play.md
new file mode 100644
index 0000000000..fd51922cb4
--- /dev/null
+++ b/.changeset/eleven-beds-play.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-search-backend': patch
+---
+
+Fix to schema to allow arbitrary query parameters.
diff --git a/.changeset/smart-jobs-sit.md b/.changeset/smart-jobs-sit.md
new file mode 100644
index 0000000000..edd21e67b5
--- /dev/null
+++ b/.changeset/smart-jobs-sit.md
@@ -0,0 +1,5 @@
+---
+'@backstage/backend-openapi-utils': minor
+---
+
+Improved support for OpenAPI validation during Jest tests. Now, OpenAPI validation can happen as you are writing your Jest tests - you no longer have to run `repo schema openapi test`.
diff --git a/packages/backend-openapi-utils/package.json b/packages/backend-openapi-utils/package.json
index 8f6fd1b6eb..6c391e8f70 100644
--- a/packages/backend-openapi-utils/package.json
+++ b/packages/backend-openapi-utils/package.json
@@ -33,20 +33,27 @@
"test": "backstage-cli package test"
},
"dependencies": {
+ "@apidevtools/swagger-parser": "^10.1.0",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
+ "@backstage/types": "workspace:^",
"@types/express": "^4.17.6",
"@types/express-serve-static-core": "^4.17.5",
+ "ajv": "^8.16.0",
"express": "^4.17.1",
"express-openapi-validator": "^5.0.4",
"express-promise-router": "^4.1.0",
+ "get-port": "^5.1.1",
"json-schema-to-ts": "^3.0.0",
"lodash": "^4.17.21",
+ "mockttp": "^3.13.0",
+ "msw": "^1.0.0",
"openapi-merge": "^1.3.2",
"openapi3-ts": "^3.1.2"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
+ "@backstage/test-utils": "workspace:^",
"supertest": "^7.0.0"
}
}
diff --git a/packages/backend-openapi-utils/report.api.md b/packages/backend-openapi-utils/report.api.md
index a0495f39b5..f543a9cfb7 100644
--- a/packages/backend-openapi-utils/report.api.md
+++ b/packages/backend-openapi-utils/report.api.md
@@ -3,6 +3,8 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
+///
+
import type { ContentObject } from 'openapi3-ts';
import type core from 'express-serve-static-core';
import { Express as Express_2 } from 'express';
@@ -718,6 +720,9 @@ type ValueOf = T[keyof T];
// @public
export const wrapInOpenApiTestServer: (app: Express_2) => Server | Express_2;
+// @public
+export function wrapServer(app: Express_2): Promise;
+
// Warnings were encountered during analysis:
//
// src/router.d.ts:8:5 - (ae-undocumented) Missing documentation for "get".
diff --git a/packages/backend-openapi-utils/src/index.ts b/packages/backend-openapi-utils/src/index.ts
index 9f5ccdc03b..57d9f755c2 100644
--- a/packages/backend-openapi-utils/src/index.ts
+++ b/packages/backend-openapi-utils/src/index.ts
@@ -32,4 +32,4 @@ export type {
} from './utility';
export type { ApiRouter } from './router';
export { createValidatedOpenApiRouter, getOpenApiSpecRoute } from './stub';
-export { wrapInOpenApiTestServer } from './testUtils';
+export { wrapInOpenApiTestServer, wrapServer } from './testUtils';
diff --git a/packages/backend-openapi-utils/src/proxy/setup.ts b/packages/backend-openapi-utils/src/proxy/setup.ts
new file mode 100644
index 0000000000..c49f0d87de
--- /dev/null
+++ b/packages/backend-openapi-utils/src/proxy/setup.ts
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as mockttp from 'mockttp';
+import { OpenApiProxyValidator } from '../schema/validation';
+import getPort from 'get-port';
+import { Server } from 'http';
+
+export class Proxy {
+ server: mockttp.Mockttp;
+ #openRequests: Record = {};
+ requestResponsePairs = new Map<
+ mockttp.CompletedRequest,
+ mockttp.CompletedResponse
+ >();
+ validator: OpenApiProxyValidator;
+ public forwardTo: { port: number } = { port: 0 };
+ express: { server: Server | undefined } = { server: undefined };
+ constructor() {
+ this.server = mockttp.getLocal();
+ this.validator = new OpenApiProxyValidator();
+ }
+
+ async setup() {
+ await this.server.start();
+ this.forwardTo.port = await getPort();
+ this.server
+ .forAnyRequest()
+ .thenForwardTo(`http://localhost:${this.forwardTo.port}`);
+ await this.server.on('request', request => {
+ this.#openRequests[request.id] = request;
+ });
+ await this.server.on('response', response => {
+ const request = this.#openRequests[response.id];
+ if (request) {
+ this.requestResponsePairs.set(request, response);
+ }
+ delete this.#openRequests[response.id];
+ this.validator.validate(request, response);
+ });
+ }
+
+ async initialize(url: string, server: Server) {
+ await this.validator.initialize(`${url}/openapi.json`);
+ this.express.server = server;
+ }
+
+ stop() {
+ if (Object.keys(this.#openRequests).length > 0) {
+ throw new Error('There are still open requests');
+ }
+ this.server.stop();
+
+ // If this isn't expressly closed, it will cause a jest memory leak warning.
+ this.express.server?.close();
+ }
+
+ get url() {
+ return this.server.proxyEnv.HTTP_PROXY;
+ }
+}
diff --git a/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonRequestBody.json b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonRequestBody.json
new file mode 100644
index 0000000000..c5eda7ebb4
--- /dev/null
+++ b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonRequestBody.json
@@ -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"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonResponseBody.json b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonResponseBody.json
new file mode 100644
index 0000000000..6243a640b9
--- /dev/null
+++ b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonResponseBody.json
@@ -0,0 +1,36 @@
+{
+ "openapi": "3.0.0",
+ "info": { "title": "Test", "version": "1.0.0" },
+ "paths": {
+ "/api/search": {
+ "get": {
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "results": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "additionalProperties": false
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withPathParameter.json b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withPathParameter.json
new file mode 100644
index 0000000000..98e25d0975
--- /dev/null
+++ b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withPathParameter.json
@@ -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"
+ }
+ }
+ ]
+ }
+ }
+ }
+}
diff --git a/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withQueryParameter.json b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withQueryParameter.json
new file mode 100644
index 0000000000..6f0db99697
--- /dev/null
+++ b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withQueryParameter.json
@@ -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"
+ }
+ }
+ ]
+ }
+ }
+ }
+}
diff --git a/packages/backend-openapi-utils/src/schema/errors.ts b/packages/backend-openapi-utils/src/schema/errors.ts
new file mode 100644
index 0000000000..e1950bcce9
--- /dev/null
+++ b/packages/backend-openapi-utils/src/schema/errors.ts
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Operation } from './types';
+import { ErrorObject } from 'ajv';
+import { humanifyAjvError } from './utils';
+
+export class OperationError extends Error {
+ constructor(operation: Operation, message: string) {
+ super(
+ `["${operation.method.toLocaleUpperCase('en-US')} ${
+ operation.path
+ }"] ${message}`,
+ );
+ }
+}
+
+export class OperationResponseError extends Error {
+ constructor(operation: Operation, response: Response, message: string) {
+ super(
+ `["${operation.method.toLocaleUpperCase('en-US')} ${operation.path}" (${
+ response.status
+ })]: ${message}`,
+ );
+ }
+}
+
+export class OperationParsingError extends OperationError {
+ constructor(operation: Operation, type: string, errors: ErrorObject[]) {
+ super(
+ operation,
+ `${type} validation failed.\n - ${errors
+ .map(humanifyAjvError)
+ .join('\n - ')}`,
+ );
+ }
+}
+
+export class OperationParsingResponseError extends OperationResponseError {
+ constructor(
+ operation: Operation,
+ response: Response,
+ type: string,
+ errors: ErrorObject[],
+ ) {
+ super(
+ operation,
+ response,
+ `${type} validation failed.\n - ${errors
+ .map(humanifyAjvError)
+ .join('\n - ')}`,
+ );
+ }
+}
diff --git a/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts b/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts
new file mode 100644
index 0000000000..7a34c2c309
--- /dev/null
+++ b/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts
@@ -0,0 +1,614 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import _ from 'lodash';
+import withQueryParameter from './__fixtures__/schemas/withQueryParameter.json';
+import withPathParameter from './__fixtures__/schemas/withPathParameter.json';
+import {
+ PathParameterParser,
+ QueryParameterParser,
+} from './parameter-validation';
+import { OperationObject, ParameterObject } from 'openapi3-ts';
+import Ajv from 'ajv';
+import { Operation } from './types';
+
+const ajv = new Ajv();
+
+describe('query parameters', () => {
+ let operation: Operation;
+ let parser: QueryParameterParser;
+ let schema: (typeof withQueryParameter)['paths']['/api/search']['get'];
+
+ beforeEach(() => {
+ schema = _.cloneDeep(withQueryParameter.paths['/api/search'].get);
+ operation = {
+ schema: schema as OperationObject,
+ path: '/api/search',
+ method: 'get',
+ };
+ parser = new QueryParameterParser(operation, { ajv });
+ });
+ describe('primitives', () => {
+ describe('string', () => {
+ it('should parse a string', async () => {
+ const request = {
+ url: 'http://localhost:8080/api/search?param=hello',
+ } as Request;
+ const result = await parser.parse(request);
+ expect(result.param).toBe('hello');
+ });
+
+ it('should throw an error if there are extra parameters', async () => {
+ const request = {
+ url: 'http://localhost:8080/api/search?param=hello&extra=world',
+ } as Request;
+ await expect(
+ parser.parse(request),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Unexpected query parameters: extra"`,
+ );
+ });
+
+ it('should throw an error if the parameter is required but missing', async () => {
+ (schema.parameters![0] as ParameterObject).required = true;
+ const request = {
+ url: 'http://localhost:8080/api/search',
+ } as Request;
+ await expect(
+ parser.parse(request),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Required query parameter param not found"`,
+ );
+ });
+ });
+
+ describe('number', () => {
+ beforeEach(() => {
+ schema.parameters![0].schema.type = 'number';
+ });
+ it('should parse a number', async () => {
+ const request = {
+ url: 'http://localhost:8080/api/search?param=42',
+ } as Request;
+ const result = await parser.parse(request);
+ expect(result.param).toBe(42);
+ });
+
+ it('should throw an error if the parameter is not a number', async () => {
+ const request = {
+ url: 'http://localhost:8080/api/search?param=hello',
+ } as Request;
+ await expect(parser.parse(request)).rejects
+ .toThrowErrorMatchingInlineSnapshot(`
+ "["GET /api/search"] Query parameter validation failed.
+ - Value should be of type number"
+ `);
+ });
+ });
+ });
+
+ describe('arrays', () => {
+ beforeEach(() => {
+ schema.parameters![0].schema.type = 'array';
+ });
+ describe('form', () => {
+ beforeEach(() => {
+ (schema.parameters![0] as ParameterObject).style = 'form';
+ });
+ describe('explode=true', () => {
+ beforeEach(() => {
+ (schema.parameters![0] as ParameterObject).explode = true;
+ });
+ it('should parse a form array with a single element', async () => {
+ const request = {
+ url: 'http://localhost:8080/api/search?param=hello',
+ } as Request;
+ const result = await parser.parse(request);
+ expect(result.param).toEqual(['hello']);
+ });
+
+ it('should parse a form array with multiple elements', async () => {
+ const request = {
+ url: 'http://localhost:8080/api/search?param=hello¶m=world',
+ } as Request;
+ const result = await parser.parse(request);
+ expect(result.param).toEqual(['hello', 'world']);
+ });
+
+ it('should throw for missing required parameters', async () => {
+ (schema.parameters![0] as ParameterObject).required = true;
+ const request = {
+ url: 'http://localhost:8080/api/search',
+ } as Request;
+ await expect(
+ parser.parse(request),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Required query parameter param not found"`,
+ );
+ });
+
+ it('should throw for extra parameters', async () => {
+ const request = {
+ url: 'http://localhost:8080/api/search?param=hello&extra=world',
+ } as Request;
+ await expect(
+ parser.parse(request),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Unexpected query parameters: extra"`,
+ );
+ });
+ });
+
+ describe('explode=false', () => {
+ beforeEach(() => {
+ (schema.parameters![0] as ParameterObject).explode = false;
+ });
+
+ it('should parse a form array with a single element', async () => {
+ const request = {
+ url: 'http://localhost:8080/api/search?param=hello',
+ } as Request;
+ const result = await parser.parse(request);
+ expect(result.param).toEqual(['hello']);
+ });
+
+ it('should parse a form array with multiple elements', async () => {
+ const request = {
+ url: 'http://localhost:8080/api/search?param=hello,world',
+ } as Request;
+ const result = await parser.parse(request);
+ expect(result.param).toEqual(['hello', 'world']);
+ });
+
+ it('should throw for missing required parameters', async () => {
+ (schema.parameters![0] as ParameterObject).required = true;
+ const request = {
+ url: 'http://localhost:8080/api/search',
+ } as Request;
+ await expect(
+ parser.parse(request),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Required query parameter param not found"`,
+ );
+ });
+
+ it('should throw for extra parameters', async () => {
+ const request = {
+ url: 'http://localhost:8080/api/search?param=hello&extra=world',
+ } as Request;
+ await expect(
+ parser.parse(request),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Unexpected query parameters: extra"`,
+ );
+ });
+ });
+ });
+
+ describe('spaceDelimited', () => {
+ beforeEach(() => {
+ (schema.parameters![0] as ParameterObject).style = 'spaceDelimited';
+ });
+
+ it('should parse a space separated array', async () => {
+ const request = {
+ url: 'http://localhost:8080/api/search?param=hello%20world',
+ } as Request;
+ const result = await parser.parse(request);
+ expect(result.param).toEqual(['hello', 'world']);
+ });
+
+ it('should throw for missing required parameters', async () => {
+ (schema.parameters![0] as ParameterObject).required = true;
+ const request = {
+ url: 'http://localhost:8080/api/search',
+ } as Request;
+ await expect(
+ parser.parse(request),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Required query parameter param not found"`,
+ );
+ });
+
+ it('should throw for extra parameters', async () => {
+ const request = {
+ url: 'http://localhost:8080/api/search?param=hello%20world&extra=world',
+ } as Request;
+ await expect(
+ parser.parse(request),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Unexpected query parameters: extra"`,
+ );
+ });
+ });
+
+ describe('pipeDelimited', () => {
+ beforeEach(() => {
+ (schema.parameters![0] as ParameterObject).style = 'pipeDelimited';
+ });
+
+ it('should parse a pipe separated array', async () => {
+ const request = {
+ url: 'http://localhost:8080/api/search?param=hello|world',
+ } as Request;
+ const result = await parser.parse(request);
+ expect(result.param).toEqual(['hello', 'world']);
+ });
+
+ it('should throw for missing required parameters', async () => {
+ (schema.parameters![0] as ParameterObject).required = true;
+ const request = {
+ url: 'http://localhost:8080/api/search',
+ } as Request;
+ await expect(
+ parser.parse(request),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Required query parameter param not found"`,
+ );
+ });
+
+ it('should throw for extra parameters', async () => {
+ const request = {
+ url: 'http://localhost:8080/api/search?param=hello|world&extra=world',
+ } as Request;
+ await expect(
+ parser.parse(request),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Unexpected query parameters: extra"`,
+ );
+ });
+ });
+ });
+
+ describe('objects', () => {
+ describe('form', () => {
+ beforeEach(() => {
+ schema.parameters![0].schema.type = 'object';
+ });
+
+ describe('explode=true', () => {
+ beforeEach(() => {
+ (schema.parameters![0] as ParameterObject).explode = true;
+ });
+
+ it('should parse a form object with a single key', async () => {
+ const request = {
+ url: 'http://localhost:8080/api/search?key=value',
+ } as Request;
+ const result = await parser.parse(request);
+ expect(result.param).toEqual({ key: 'value' });
+ });
+
+ it('should parse a form object with multiple keys', async () => {
+ const request = {
+ url: 'http://localhost:8080/api/search?key1=value1&key2=value2',
+ } as Request;
+ const result = await parser.parse(request);
+ expect(result.param).toEqual({ key1: 'value1', key2: 'value2' });
+ });
+
+ it('should throw for missing required parameters', async () => {
+ (schema.parameters![0] as ParameterObject).required = true;
+ const request = {
+ url: 'http://localhost:8080/api/search',
+ } as Request;
+ await expect(
+ parser.parse(request),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Required query parameter param not found"`,
+ );
+ });
+
+ it('should respect other parameter encodings', async () => {
+ const parameter = {
+ name: 'extra',
+ in: 'query',
+ style: 'form',
+ explode: false,
+ schema: { type: 'array' },
+ required: false,
+ } as ParameterObject;
+ schema.parameters!.push(parameter as any);
+ parser = new QueryParameterParser(operation, { ajv });
+ const request = {
+ url: 'http://localhost:8080/api/search?key=value&otherkey=value2&extra=hello,world',
+ } as Request;
+
+ const result = await parser.parse(request);
+ expect(result.param).toEqual({
+ key: 'value',
+ otherkey: 'value2',
+ });
+ expect(result.extra).toEqual(['hello', 'world']);
+ });
+
+ it('should respect other object encodings', async () => {
+ const parameter = {
+ name: 'extra',
+ in: 'query',
+ style: 'deepObject',
+ explode: true,
+ schema: { type: 'object' },
+ required: false,
+ } as ParameterObject;
+ schema.parameters!.push(parameter as any);
+ parser = new QueryParameterParser(operation, { ajv });
+ const request = {
+ url: 'http://localhost:8080/api/search?key=value&otherkey=value2&extra[hello]=world',
+ } as Request;
+
+ const result = await parser.parse(request);
+ expect(result.param).toEqual({
+ key: 'value',
+ otherkey: 'value2',
+ });
+ expect(result.extra).toEqual({ hello: 'world' });
+ });
+
+ it('should throw if there are 2 form explode parameters', async () => {
+ const parameter = {
+ name: 'extra',
+ in: 'query',
+ style: 'form',
+ explode: true,
+ schema: { type: 'object' },
+ required: false,
+ } as ParameterObject;
+ schema.parameters!.push(parameter as any);
+ parser = new QueryParameterParser(operation, { ajv });
+ const request = {
+ url: 'http://localhost:8080/api/search?key=value&otherkey=value2&extra[hello]=world',
+ } as Request;
+
+ await expect(() =>
+ parser.parse(request),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Ambiguous query parameters, you cannot have 2 form explode parameters"`,
+ );
+ });
+ });
+
+ describe('explode=false', () => {
+ beforeEach(() => {
+ (schema.parameters![0] as ParameterObject).explode = false;
+ });
+
+ it('should parse a form object with a single key', async () => {
+ const request = {
+ url: 'http://localhost:8080/api/search?param=key,value',
+ } as Request;
+ const result = await parser.parse(request);
+ expect(result.param).toEqual({ key: 'value' });
+ });
+
+ it('should parse a form object with multiple keys', async () => {
+ const request = {
+ url: 'http://localhost:8080/api/search?param=key1,value1,key2,value2',
+ } as Request;
+ const result = await parser.parse(request);
+ expect(result.param).toEqual({ key1: 'value1', key2: 'value2' });
+ });
+
+ it('should throw for missing required parameters', async () => {
+ (schema.parameters![0] as ParameterObject).required = true;
+ const request = {
+ url: 'http://localhost:8080/api/search',
+ } as Request;
+ await expect(
+ parser.parse(request),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Required query parameter param not found"`,
+ );
+ });
+
+ it('should throw for extra parameters', async () => {
+ const request = {
+ url: 'http://localhost:8080/api/search?param=key,value&extra=world',
+ } as Request;
+ await expect(
+ parser.parse(request),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Unexpected query parameters: extra"`,
+ );
+ });
+ });
+ });
+
+ describe('deepObject', () => {
+ beforeEach(() => {
+ (schema.parameters![0] as ParameterObject).style = 'deepObject';
+ (schema.parameters![0] as ParameterObject).explode = true;
+ (schema.parameters![0] as ParameterObject).schema = {
+ type: 'object',
+ properties: {
+ key: {
+ type: 'string',
+ },
+ },
+ };
+ });
+
+ it('should parse a deep object', async () => {
+ const request = {
+ url: 'http://localhost:8080/api/search?param[key]=value',
+ } as Request;
+ const result = await parser.parse(request);
+ expect(result.param).toEqual({ key: 'value' });
+ });
+
+ it('should parse a deep object with multiple keys', async () => {
+ const request = {
+ url: 'http://localhost:8080/api/search?param[key1]=value1¶m[key2]=value2',
+ } as Request;
+ const result = await parser.parse(request);
+ expect(result.param).toEqual({ key1: 'value1', key2: 'value2' });
+ });
+
+ it('should throw for missing required parameters', async () => {
+ (schema.parameters![0] as ParameterObject).required = true;
+ const request = {
+ url: 'http://localhost:8080/api/search',
+ } as Request;
+ await expect(
+ parser.parse(request),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Required query parameter param not found"`,
+ );
+ });
+
+ it('should throw for extra parameters', async () => {
+ const request = {
+ url: 'http://localhost:8080/api/search?param[key]=value&extra=world',
+ } as Request;
+ await expect(
+ parser.parse(request),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Unexpected query parameters: extra"`,
+ );
+ });
+
+ it('should handle nested objects', async () => {
+ (schema.parameters![0] as ParameterObject).schema = {
+ type: 'object',
+ properties: {
+ key: {
+ type: 'object',
+ properties: {
+ subkey: {
+ type: 'string',
+ },
+ },
+ required: ['subkey'],
+ },
+ },
+ };
+ parser = new QueryParameterParser(operation, { ajv });
+ const request = {
+ url: 'http://localhost:8080/api/search?param[key][subkey]=value',
+ } as Request;
+ const result = await parser.parse(request);
+ expect(result.param).toEqual({ key: { subkey: 'value' } });
+ });
+ });
+ });
+});
+
+describe('path parameters', () => {
+ let operation: Operation;
+ let parser: PathParameterParser;
+ let schema: (typeof withPathParameter)['paths']['/api/item/{id}']['get'];
+
+ beforeEach(() => {
+ schema = _.cloneDeep(withPathParameter.paths['/api/item/{id}'].get);
+ operation = {
+ schema: schema as OperationObject,
+ path: '/api/item/{id}',
+ method: 'get',
+ };
+ parser = new PathParameterParser(operation, { ajv });
+ });
+ describe('primitives', () => {
+ describe('string', () => {
+ it('should parse a string', async () => {
+ const request = {
+ url: 'http://localhost:8080/api/item/test',
+ } as Request;
+ const result = await parser.parse(request);
+ expect(result.id).toBe('test');
+ });
+
+ it('should throw an error if the parameter is required but missing', async () => {
+ (schema.parameters![0] as ParameterObject).required = true;
+ const request = {
+ url: 'http://localhost:8080/api/item',
+ } as Request;
+ await expect(
+ parser.parse(request),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/item/{id}"] Path parts do not match"`,
+ );
+ });
+ });
+
+ describe('number', () => {
+ beforeEach(() => {
+ schema.parameters![0].schema.type = 'number';
+ });
+ it('should parse a number', async () => {
+ const request = {
+ url: 'http://localhost:8080/api/item/42',
+ } as Request;
+ const result = await parser.parse(request);
+ expect(result.id).toBe(42);
+ });
+
+ it('should throw an error if the parameter is not a number', async () => {
+ const request = {
+ url: 'http://localhost:8080/api/item/hello',
+ } as Request;
+ await expect(parser.parse(request)).rejects
+ .toThrowErrorMatchingInlineSnapshot(`
+ "["GET /api/item/{id}"] Path parameter validation failed.
+ - Value should be of type number"
+ `);
+ });
+ });
+ });
+
+ describe('path parsing', () => {
+ it('should parse a path with a single parameters', async () => {
+ const parsedPath = PathParameterParser.parsePath({
+ operation,
+ schema: '/api/item/{id}',
+ path: '/api/item/test123',
+ });
+ expect(parsedPath).toEqual({ id: 'test123' });
+ });
+ it('should parse a path with multiple parameters', async () => {
+ const parsedPath = PathParameterParser.parsePath({
+ operation,
+ schema: '/api/item/{id}/{name}',
+ path: '/api/item/42/test',
+ });
+ // the string is expected here, but will be optimistically parsed as a number where it makes sense.
+ expect(parsedPath).toEqual({ id: '42', name: 'test' });
+ });
+
+ it('should throw an error if the path does not have enough parts', async () => {
+ expect(() =>
+ PathParameterParser.parsePath({
+ operation,
+ schema: '/api/item/{id}',
+ path: '/api/item',
+ }),
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/item/{id}"] Path parts do not match"`,
+ );
+ });
+
+ it('should throw an error if the path has too many parts', async () => {
+ expect(() =>
+ PathParameterParser.parsePath({
+ operation,
+ schema: '/api/item/{id}',
+ path: '/api/item/test/123',
+ }),
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/item/{id}"] Path parts do not match"`,
+ );
+ });
+ });
+});
diff --git a/packages/backend-openapi-utils/src/schema/parameter-validation.ts b/packages/backend-openapi-utils/src/schema/parameter-validation.ts
new file mode 100644
index 0000000000..7a61578850
--- /dev/null
+++ b/packages/backend-openapi-utils/src/schema/parameter-validation.ts
@@ -0,0 +1,492 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { OpenAPIObject, ParameterObject, SchemaObject } from 'openapi3-ts';
+import {
+ Operation,
+ ParserOptions,
+ RequestParser,
+ Validator,
+ ValidatorParams,
+} from './types';
+import Ajv from 'ajv';
+import { OperationError, OperationParsingError } from './errors';
+import { mockttpToFetchRequest } from './utils';
+
+type ReferencelessSchemaObject = SchemaObject & { $ref?: never };
+
+type ReferencelessParameterObject = Omit & {
+ schema: ReferencelessSchemaObject;
+};
+
+class BaseParameterParser {
+ ajv: Ajv;
+ operation: Operation;
+ parameters: Record = {};
+ constructor(
+ parameterIn: string,
+ operation: Operation,
+ options: ParserOptions,
+ ) {
+ this.ajv = options.ajv;
+ this.operation = operation;
+ const { schema, path, method } = operation;
+ const parameters = schema.parameters || [];
+ for (const parameter of parameters) {
+ if ('$ref' in parameter) {
+ throw new Error(
+ `[(${method}) ${path}] Reference objects are not supported`,
+ );
+ }
+
+ if (!parameter.schema) {
+ throw new OperationError(
+ operation,
+ 'Schema not found for path parameter',
+ );
+ }
+ if ('$ref' in parameter.schema) {
+ throw new OperationError(
+ this.operation,
+ 'Reference objects are not supported for parameters',
+ );
+ }
+ if (parameter.in === parameterIn) {
+ this.parameters[parameter.name] =
+ parameter as ReferencelessParameterObject;
+ }
+ }
+ }
+
+ /**
+ * Attempt to transform a string value to its expected type, this allows Ajv to perform validation. As these are parameters,
+ * support for edge cases like nested type casting is not currently supported.
+ * @param value
+ * @param schema
+ * @returns
+ */
+ optimisticallyParseValue(value: string, schema: SchemaObject) {
+ if (schema.type === 'integer') {
+ return parseInt(value, 10);
+ }
+ if (schema.type === 'number') {
+ return parseFloat(value);
+ }
+ if (schema.type === 'boolean') {
+ if (['true', 'false'].includes(value)) {
+ return value === 'true';
+ }
+ throw new Error('Invalid boolean value must be either "true" or "false"');
+ }
+ return value;
+ }
+}
+
+export class QueryParameterParser
+ extends BaseParameterParser
+ implements RequestParser>
+{
+ constructor(operation: Operation, options: ParserOptions) {
+ super('query', operation, options);
+ }
+ async parse(request: Request) {
+ const { searchParams } = new URL(request.url);
+ const remainingQueryParameters = new Set(searchParams.keys());
+ const queryParameters: Record = {};
+
+ let parameterIterator = Object.entries(this.parameters);
+
+ const isFormExplode = (parameter: ReferencelessParameterObject) => {
+ return (
+ parameter.schema?.type === 'object' &&
+ (parameter.style === 'form' || !parameter.style) &&
+ parameter.explode
+ );
+ };
+
+ const regularParameters = parameterIterator.filter(
+ ([_, parameter]) => !isFormExplode(parameter),
+ );
+
+ const formExplodeParameters = parameterIterator.filter(([_, parameter]) =>
+ isFormExplode(parameter),
+ );
+
+ if (formExplodeParameters.length > 1) {
+ throw new OperationError(
+ this.operation,
+ 'Ambiguous query parameters, you cannot have 2 form explode parameters',
+ );
+ }
+
+ // Sort the parameters so that form explode parameters are processed last.
+ parameterIterator = [...regularParameters, ...formExplodeParameters];
+
+ for (const [name, parameter] of parameterIterator) {
+ if (!parameter.schema) {
+ throw new OperationError(
+ this.operation,
+ 'Schema not found for query parameter',
+ );
+ }
+ if ('$ref' in parameter.schema) {
+ throw new OperationError(
+ this.operation,
+ 'Reference objects are not supported for parameters',
+ );
+ }
+ // eslint-disable-next-line prefer-const
+ let [param, indices]: [any | null, string[]] = this.#findQueryParameters(
+ this.parameters,
+ remainingQueryParameters,
+ searchParams,
+ name,
+ );
+ if (!!param) {
+ indices.forEach(index => remainingQueryParameters.delete(index));
+ }
+
+ // The query parameters can be either a single value or an array of values, try to wrangle them into the expected format if they're not explicitly an array.
+ if (parameter.schema.type !== 'array' && Array.isArray(param)) {
+ param = param.length > 0 ? param[0] : undefined;
+ }
+ if (
+ parameter.required &&
+ !indices.some(index => searchParams.has(index))
+ ) {
+ throw new OperationError(
+ this.operation,
+ `Required query parameter ${name} not found`,
+ );
+ } else if (!param && !parameter.required) {
+ continue;
+ }
+ if (param) {
+ // We do this here because all query parameters are strings but the schema will expect the real value.
+ param = this.optimisticallyParseValue(param, parameter.schema);
+ }
+ const validate = this.ajv.compile(parameter.schema);
+ const valid = validate(param);
+ if (!valid) {
+ throw new OperationParsingError(
+ this.operation,
+ 'Query parameter',
+ validate.errors!,
+ );
+ }
+ queryParameters[name] = param;
+ }
+ if (remainingQueryParameters.size > 0) {
+ throw new OperationError(
+ this.operation,
+ `Unexpected query parameters: ${Array.from(
+ remainingQueryParameters,
+ ).join(', ')}`,
+ );
+ }
+ return queryParameters;
+ }
+
+ #findQueryParameters(
+ parameters: Record,
+ remainingQueryParameters: Set,
+ searchParams: URLSearchParams,
+ name: string,
+ ): [any | null, string[]] {
+ const parameter = parameters[name];
+ const schema = parameter.schema as SchemaObject;
+
+ // Since getAll will return an empty array if the key is not found, we need to check if the key exists first.
+ const getIfExists = (key: string) =>
+ searchParams.has(key) ? searchParams.getAll(key) : null;
+
+ if (schema.type === 'array') {
+ // Form is the default array format.
+ if (
+ parameter.style === 'form' ||
+ typeof parameter.style === 'undefined'
+ ) {
+ // As is explode = true.
+ if (parameter.explode || typeof parameter.explode === 'undefined') {
+ // Support for qs explode format. Every value is stored as a separate query parameter.
+ if (!searchParams.has(name) && searchParams.has(`${name}[0]`)) {
+ const values: string[] = [];
+ const indices: string[] = [];
+ let index = 0;
+ while (searchParams.has(`${name}[${index}]`)) {
+ values.push(searchParams.get(`${name}[${index}]`)!);
+ indices.push(`${name}[${index}]`);
+ index++;
+ }
+ return [values, indices];
+ }
+ // If not qs format, grab all values with the same name from search params.
+ return [getIfExists(name), [name]];
+ }
+ // Add support for qs non-standard array format. This is helpful for search-backend, since that uses qs still.
+ if (!searchParams.has(name) && searchParams.has(`${name}[]`)) {
+ return [searchParams.get(`${name}[]`)?.split(','), [`${name}[]`]];
+ }
+ // Non-explode arrays should be comma separated.
+ if (searchParams.has(name) && searchParams.getAll(name).length > 1) {
+ throw new OperationError(
+ this.operation,
+ 'Arrays must be comma separated in non-explode mode',
+ );
+ }
+ return [searchParams.get(name)?.split(','), [name]];
+ } else if (parameter.style === 'spaceDelimited') {
+ return [searchParams.get(name)?.split(' '), [name]];
+ } else if (parameter.style === 'pipeDelimited') {
+ return [searchParams.get(name)?.split('|'), [name]];
+ }
+ throw new OperationError(
+ this.operation,
+ 'Unsupported style for array parameter',
+ );
+ }
+ if (schema.type === 'object') {
+ // Form is the default object format.
+ if (
+ parameter.style === 'form' ||
+ typeof parameter.style === 'undefined'
+ ) {
+ if (parameter.explode) {
+ // Object form/explode is a collection of disjoint keys, there's no mapping for what they are so we collect all of them.
+ // This means we need to run this as the last query parameter that is processed.
+ const obj: Record = {};
+ const indices: string[] = [];
+ for (const [key, value] of searchParams.entries()) {
+ // Have we processed this query parameter as part of another parameter parsing? If not, consider it to be a part of this object.
+ if (!remainingQueryParameters.has(key)) {
+ continue;
+ }
+ indices.push(key);
+ obj[key] = value;
+ }
+ return [obj, indices];
+ }
+ // For non-explode, the schema is comma separated key,value "pairs", so filter=key1,value1,key2,value2 would parse to {key1: value1, key2: value2}.
+ const obj: Record = {};
+ const value = searchParams.get(name);
+ if (value) {
+ const parts = value.split(',');
+ if (parts.length % 2 !== 0) {
+ throw new OperationError(
+ this.operation,
+ 'Invalid object query parameter, must have an even number of key-value pairs',
+ );
+ }
+ for (let i = 0; i < parts.length; i += 2) {
+ obj[parts[i]] = parts[i + 1];
+ }
+ }
+ return [obj, [name]];
+ } else if (parameter.style === 'deepObject') {
+ // Deep object is a nested object structure, so we need to parse the keys to build the object.
+ // example: ?filter[key1]=value1&filter[key2]=value2 => { key1: value1, key2: value2 }
+ const obj: Record = {};
+ const indices: string[] = [];
+ for (const [key, value] of searchParams.entries()) {
+ if (key.startsWith(`${name}[`)) {
+ indices.push(key);
+ const parts = key.split('[');
+ let currentLayer = obj;
+ for (let partIndex = 1; partIndex < parts.length - 1; partIndex++) {
+ const part = parts[partIndex];
+ if (!part.includes(']')) {
+ throw new OperationError(
+ this.operation,
+ `Invalid object parameter, missing closing bracket for key "${key}"`,
+ );
+ }
+ const objKey = part.split(']')[0];
+ if (!currentLayer[objKey]) {
+ currentLayer[objKey] = {};
+ }
+ currentLayer = currentLayer[objKey];
+ }
+ const lastPart = parts[parts.length - 1];
+ if (!lastPart.includes(']')) {
+ throw new OperationError(
+ this.operation,
+ `Invalid object parameter, missing closing bracket for key "${key}"`,
+ );
+ }
+ currentLayer[lastPart.split(']')[0]] = value;
+ }
+ }
+ return [obj, indices];
+ }
+ throw new OperationError(
+ this.operation,
+ `Unsupported style for object parameter, "${parameter.style}"`,
+ );
+ }
+ // For everything else, just return the value.
+ return [getIfExists(name), [name]];
+ }
+}
+
+export class HeaderParameterParser
+ extends BaseParameterParser
+ implements RequestParser>
+{
+ constructor(operation: Operation, options: ParserOptions) {
+ super('header', operation, options);
+ }
+ async parse(request: Request) {
+ const headerParameters: Record = {};
+ for (const [name, parameter] of Object.entries(this.parameters)) {
+ const header = request.headers.get(name);
+ if (!header) {
+ if (parameter.required) {
+ throw new OperationError(
+ this.operation,
+ `Header parameter ${name} not found`,
+ );
+ }
+ continue;
+ }
+ if (!parameter.schema) {
+ throw new OperationError(
+ this.operation,
+ 'Schema not found for header parameter',
+ );
+ }
+ if ('$ref' in parameter.schema) {
+ throw new OperationError(
+ this.operation,
+ 'Reference objects are not supported for parameters',
+ );
+ }
+ const validate = this.ajv.compile(parameter.schema);
+ const valid = validate(header);
+
+ if (!valid) {
+ throw new OperationParsingError(
+ this.operation,
+ 'Header parameter',
+ validate.errors!,
+ );
+ }
+ headerParameters[name] = header;
+ }
+ return headerParameters;
+ }
+}
+
+export class PathParameterParser
+ extends BaseParameterParser
+ implements RequestParser>
+{
+ constructor(operation: Operation, options: ParserOptions) {
+ super('path', operation, options);
+ }
+ async parse(request: Request) {
+ const { pathname } = new URL(request.url);
+ const params = PathParameterParser.parsePath({
+ operation: this.operation,
+ path: pathname,
+ schema: this.operation.path,
+ });
+ const pathParameters: Record = {};
+ for (const [name, parameter] of Object.entries(this.parameters)) {
+ let param: string | number | boolean = params[name];
+ if (!param && parameter.required) {
+ throw new OperationError(
+ this.operation,
+ `Path parameter ${name} not found`,
+ );
+ } else if (!params[name] && !parameter.required) {
+ continue;
+ }
+
+ if (param) {
+ param = this.optimisticallyParseValue(param, parameter.schema);
+ }
+
+ const validate = this.ajv.compile(parameter.schema);
+ const valid = validate(param);
+
+ if (!valid) {
+ throw new OperationParsingError(
+ this.operation,
+ 'Path parameter',
+ validate.errors!,
+ );
+ }
+ pathParameters[name] = param;
+ }
+ return pathParameters;
+ }
+
+ static parsePath({
+ operation,
+ schema,
+ path,
+ }: {
+ operation: Operation;
+ schema: string;
+ path: string;
+ }) {
+ const parts = path.split('/');
+ const pathParts = schema.split('/');
+ if (parts.length !== pathParts.length) {
+ throw new OperationError(operation, 'Path parts do not match');
+ }
+ const params: Record = {};
+ 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 = {};
+ 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),
+ ]);
+ }
+}
diff --git a/packages/backend-openapi-utils/src/schema/request-body-validation.test.ts b/packages/backend-openapi-utils/src/schema/request-body-validation.test.ts
new file mode 100644
index 0000000000..807eaa4569
--- /dev/null
+++ b/packages/backend-openapi-utils/src/schema/request-body-validation.test.ts
@@ -0,0 +1,108 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import withJsonRequestBody from './__fixtures__/schemas/withJsonRequestBody.json';
+import { RequestBodyParser } from './request-body-validation';
+import Ajv from 'ajv';
+import { Operation, RequestParser } from './types';
+import _ from 'lodash';
+import {
+ ContentObject,
+ MediaTypeObject,
+ OperationObject,
+ RequestBodyObject,
+} from 'openapi3-ts';
+import { JsonObject } from '@backstage/types';
+
+const ajv = new Ajv();
+
+function toRequest(body?: object, headers?: Record): 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;
+ let schema: (typeof withJsonRequestBody)['paths']['/api/search']['post'];
+ beforeEach(() => {
+ schema = _.cloneDeep(withJsonRequestBody.paths['/api/search'].post);
+ operation = {
+ method: 'post',
+ schema: schema as OperationObject,
+ path: '/api/search',
+ };
+ parser = new RequestBodyParser(operation, {
+ ajv,
+ });
+ });
+ it('should validate request body', async () => {
+ const requestBody = {
+ query: 'test',
+ };
+ const result = await parser.parse(toRequest(requestBody));
+ expect(result).toEqual(requestBody);
+ });
+
+ it('should throw error if request body is not valid', async () => {
+ const requestBody = {
+ query: 1,
+ };
+ await expect(parser.parse(toRequest(requestBody))).rejects
+ .toThrowErrorMatchingInlineSnapshot(`
+ "["POST /api/search"] Request body validation failed.
+ - "/query" should be of type string"
+ `);
+ });
+
+ it('should throw error if request body is required but missing', async () => {
+ (schema.requestBody as RequestBodyObject).required = true;
+ parser = RequestBodyParser.fromOperation(operation, {
+ ajv,
+ });
+ await expect(
+ parser.parse(toRequest()),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["POST /api/search"] No request body found for /api/search"`,
+ );
+ });
+
+ it('should throw error if request body is not application/json', async () => {
+ const request = toRequest({}, { 'content-type': 'text/plain' });
+ await expect(
+ parser.parse(request),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["POST /api/search"] Content type is not application/json"`,
+ );
+ });
+
+ it('should NOT throw error if request body is not just application/json', async () => {
+ (schema.requestBody.content as ContentObject)[
+ 'application/json; charset=utf-8'
+ ] = schema.requestBody.content['application/json'] as MediaTypeObject;
+ delete (schema.requestBody.content as ContentObject)['application/json'];
+ parser = new RequestBodyParser(operation, {
+ ajv,
+ });
+ const request = toRequest({});
+ expect(await parser.parse(request)).toEqual({});
+ });
+});
diff --git a/packages/backend-openapi-utils/src/schema/request-body-validation.ts b/packages/backend-openapi-utils/src/schema/request-body-validation.ts
new file mode 100644
index 0000000000..0075bae641
--- /dev/null
+++ b/packages/backend-openapi-utils/src/schema/request-body-validation.ts
@@ -0,0 +1,135 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { JsonObject } from '@backstage/types';
+import { Operation, ParserOptions, RequestParser } from './types';
+import { ValidateFunction } from 'ajv';
+import { OperationError, OperationParsingError } from './errors';
+import { RequestBodyObject, SchemaObject } from 'openapi3-ts';
+
+class DisabledRequestBodyParser
+ implements RequestParser
+{
+ operation: Operation;
+ constructor(operation: Operation) {
+ this.operation = operation;
+ }
+ async parse(request: Request): Promise {
+ 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
+{
+ operation: Operation;
+ disabled: boolean = false;
+ validate!: ValidateFunction;
+ schema!: SchemaObject;
+ requestBodySchema!: RequestBodyObject;
+
+ static fromOperation(operation: Operation, options: ParserOptions) {
+ return operation.schema.requestBody
+ ? new RequestBodyParser(operation, options)
+ : new DisabledRequestBodyParser(operation);
+ }
+
+ constructor(operation: Operation, options: ParserOptions) {
+ this.operation = operation;
+ const { schema: operationSchema } = this.operation;
+ const requestBody = operationSchema.requestBody;
+
+ if (!requestBody) {
+ throw new OperationError(
+ this.operation,
+ 'No request body found in operation',
+ );
+ }
+
+ if ('$ref' in requestBody!) {
+ throw new OperationError(
+ this.operation,
+ 'Reference objects are not supported',
+ );
+ }
+ if (!requestBody!.content) {
+ throw new OperationError(
+ this.operation,
+ 'No content found in request body',
+ );
+ }
+ const contentTypes = requestBody!.content;
+ const jsonContentType = Object.keys(contentTypes).find(contentType =>
+ contentType.split(';').includes('application/json'),
+ );
+ if (!jsonContentType) {
+ throw new OperationError(
+ this.operation,
+ 'No application/json content type found in request body',
+ );
+ }
+ const schema = requestBody!.content[jsonContentType].schema;
+ if (!schema) {
+ throw new OperationError(
+ this.operation,
+ 'No JSON schema found in request body',
+ );
+ }
+ if ('$ref' in schema) {
+ throw new OperationError(
+ this.operation,
+ 'Reference objects are not supported',
+ );
+ }
+ this.validate = options.ajv.compile(schema);
+ this.schema = schema;
+ this.requestBodySchema = requestBody;
+ }
+ async parse(request: Request): Promise {
+ const bodyText = await request.text();
+ if (this.requestBodySchema.required && !bodyText?.length) {
+ throw new OperationError(
+ this.operation,
+ `No request body found for ${request.url}`,
+ );
+ }
+
+ const contentType =
+ request.headers.get('content-type') || 'application/json';
+ if (!contentType.split(';').includes('application/json')) {
+ throw new OperationError(
+ this.operation,
+ 'Content type is not application/json',
+ );
+ }
+ const body = (await request.json()) as JsonObject;
+ const valid = this.validate(body);
+ if (!valid) {
+ throw new OperationParsingError(
+ this.operation,
+ `Request body`,
+ this.validate.errors!,
+ );
+ }
+ return body;
+ }
+}
diff --git a/packages/backend-openapi-utils/src/schema/response-body-validation.test.ts b/packages/backend-openapi-utils/src/schema/response-body-validation.test.ts
new file mode 100644
index 0000000000..6cd098b2e7
--- /dev/null
+++ b/packages/backend-openapi-utils/src/schema/response-body-validation.test.ts
@@ -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;
+ let schema: (typeof withJsonResponseBody)['paths']['/api/search']['get'];
+ beforeEach(() => {
+ schema = _.cloneDeep(withJsonResponseBody.paths['/api/search'].get);
+ operation = {
+ path: '/api/search',
+ method: 'get',
+ schema: schema as OperationObject,
+ };
+ parser = ResponseBodyParser.fromOperation(operation, {
+ ajv,
+ });
+ });
+
+ it('should validate response body', async () => {
+ const responseBody = {
+ results: [{ id: 'test' }],
+ };
+ const result = await parser.parse(toResponse(responseBody));
+ expect(result).toEqual(responseBody);
+ });
+
+ it('should throw error if response body is not valid', async () => {
+ const responseBody = {
+ result: 1,
+ };
+ await expect(parser.parse(toResponse(responseBody))).rejects
+ .toThrowErrorMatchingInlineSnapshot(`
+ "["GET /api/search" (200)]: Response body validation failed.
+ - The "result" property is not allowed"
+ `);
+ });
+
+ it('should throw error if response body is required but missing', async () => {
+ (schema.responses as ResponsesObject)['200'].required = true;
+ parser = ResponseBodyParser.fromOperation(operation, {
+ ajv,
+ });
+ await expect(
+ parser.parse(toResponse()),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search" (200)]: Response body is required but missing"`,
+ );
+ });
+});
diff --git a/packages/backend-openapi-utils/src/schema/response-body-validation.ts b/packages/backend-openapi-utils/src/schema/response-body-validation.ts
new file mode 100644
index 0000000000..71a4512e75
--- /dev/null
+++ b/packages/backend-openapi-utils/src/schema/response-body-validation.ts
@@ -0,0 +1,169 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { JsonObject } from '@backstage/types';
+import { Operation, ParserOptions, ResponseParser } from './types';
+import {
+ OperationError,
+ OperationParsingResponseError,
+ OperationResponseError,
+} from './errors';
+import Ajv from 'ajv';
+import { OperationObject, ResponseObject } from 'openapi3-ts';
+
+class DisabledResponseBodyParser
+ implements ResponseParser
+{
+ operation: Operation;
+ constructor(operation: Operation) {
+ this.operation = operation;
+ }
+ async parse(response: Response): Promise {
+ 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
+{
+ operation: Operation;
+ ajv: Ajv;
+
+ static fromOperation(operation: Operation, options: ParserOptions) {
+ return operation.schema.responses &&
+ Object.keys(operation.schema.responses).length
+ ? new ResponseBodyParser(operation, options)
+ : new DisabledResponseBodyParser(operation);
+ }
+
+ constructor(operation: Operation, options: ParserOptions) {
+ this.operation = operation;
+ this.ajv = options.ajv;
+ const responseSchemas = operation.schema.responses;
+ for (const [statusCode, schema] of Object.entries(responseSchemas)) {
+ const contentTypes = schema.content;
+ if (!contentTypes) {
+ // Skip responses without content, eg 204 No Content.
+ continue;
+ }
+ const jsonContentType = Object.keys(contentTypes).find(contentType =>
+ contentType.split(';').includes('application/json'),
+ );
+ if (!jsonContentType) {
+ throw new OperationError(
+ this.operation,
+ `No application/json content type found in response for status code ${statusCode}`,
+ );
+ } else if ('$ref' in contentTypes[jsonContentType].schema) {
+ throw new OperationError(
+ this.operation,
+ 'Reference objects are not supported',
+ );
+ }
+ }
+ }
+
+ async parse(response: Response): Promise {
+ const body = await response.text();
+ const responseSchema = this.findResponseSchema(
+ this.operation.schema,
+ response,
+ );
+ if (!responseSchema?.content && !body?.length) {
+ // If there is no content in the response schema and no body in the response, then the response is valid.
+ // eg 204 No Content
+ return undefined;
+ }
+ if (!responseSchema) {
+ throw new OperationResponseError(
+ this.operation,
+ response,
+ `No schema found.`,
+ );
+ }
+
+ const contentTypes = responseSchema.content;
+ if (!contentTypes && body?.length) {
+ throw new OperationResponseError(
+ this.operation,
+ response,
+ 'Received a body but no schema was found',
+ );
+ }
+ const jsonContentType = Object.keys(contentTypes ?? {}).find(contentType =>
+ contentType.split(';').includes('application/json'),
+ );
+ if (!jsonContentType) {
+ throw new OperationResponseError(
+ this.operation,
+ response,
+ 'No application/json content type found in response',
+ );
+ }
+ const schema = responseSchema.content![jsonContentType].schema;
+ // This is a bit of type laziness. Ideally, this would be a type-narrowing function, but I wasn't able to get the types to work.
+ if (!schema) {
+ throw new OperationError(this.operation, 'No schema found in response');
+ }
+ if ('$ref' in schema) {
+ throw new OperationResponseError(
+ this.operation,
+ response,
+ 'Reference objects are not supported',
+ );
+ }
+
+ if (!schema.required && !body?.length) {
+ throw new OperationResponseError(
+ this.operation,
+ response,
+ 'Response body is required but missing',
+ );
+ } else if (!schema.required && !body?.length) {
+ // If there is no content in the response schema and no body in the response, then the response is valid
+ return undefined;
+ }
+
+ const validate = this.ajv.compile(schema);
+ const jsonBody = (await response.json()) as JsonObject;
+ const valid = validate(jsonBody);
+ if (!valid) {
+ throw new OperationParsingResponseError(
+ this.operation,
+ response,
+ 'Response body',
+ validate.errors!,
+ );
+ }
+ return jsonBody;
+ }
+
+ private findResponseSchema(
+ operationSchema: OperationObject,
+ { status }: Response,
+ ): ResponseObject | undefined {
+ return (
+ operationSchema.responses?.[status] ?? operationSchema.responses?.default
+ );
+ }
+}
diff --git a/packages/backend-openapi-utils/src/schema/types.ts b/packages/backend-openapi-utils/src/schema/types.ts
new file mode 100644
index 0000000000..7de1e1c57e
--- /dev/null
+++ b/packages/backend-openapi-utils/src/schema/types.ts
@@ -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 {
+ parse(request: Request): Promise;
+}
+export interface ResponseParser {
+ parse(response: Response): Promise;
+}
+
+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;
+}
diff --git a/packages/backend-openapi-utils/src/schema/utils.ts b/packages/backend-openapi-utils/src/schema/utils.ts
new file mode 100644
index 0000000000..aaf3fc6c41
--- /dev/null
+++ b/packages/backend-openapi-utils/src/schema/utils.ts
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { CompletedRequest, CompletedResponse } from 'mockttp';
+import { ErrorObject } from 'ajv';
+
+export function mockttpToFetchRequest(request: CompletedRequest) {
+ const headers = new Headers(request.rawHeaders);
+ return {
+ url: request.url,
+ method: request.method,
+ headers,
+ json: () => request.body.getJson(),
+ text: () => request.body.getText(),
+ } as Request;
+}
+export function mockttpToFetchResponse(response: CompletedResponse) {
+ const headers = new Headers(response.rawHeaders);
+ return {
+ status: response.statusCode,
+ headers,
+ json: () => response.body?.getJson(),
+ text: () => response.body?.getText(),
+ } as Response;
+}
+
+export function humanifyAjvError(error: ErrorObject) {
+ switch (error.keyword) {
+ case 'required':
+ return `The "${error.params.missingProperty}" property is required`;
+ case 'type':
+ return `${
+ error.instancePath ? `"${error.instancePath}"` : 'Value'
+ } should be of type ${error.params.type}`;
+ case 'additionalProperties':
+ return `The "${error.params.additionalProperty}" property is not allowed`;
+ default:
+ return error.message;
+ }
+}
diff --git a/packages/backend-openapi-utils/src/schema/validation.test.ts b/packages/backend-openapi-utils/src/schema/validation.test.ts
new file mode 100644
index 0000000000..9c8c4589a3
--- /dev/null
+++ b/packages/backend-openapi-utils/src/schema/validation.test.ts
@@ -0,0 +1,823 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { findOperationByRequest, OpenApiProxyValidator } from './validation';
+import { rest } from 'msw';
+import { setupServer } from 'msw/node';
+import { registerMswTestHooks } from '@backstage/test-utils';
+import { CompletedBody, CompletedRequest, CompletedResponse } from 'mockttp';
+import withResponseBody from './__fixtures__/schemas/withJsonResponseBody.json';
+import withQueryParameter from './__fixtures__/schemas/withQueryParameter.json';
+import _ from 'lodash';
+import { OpenAPIObject, ParameterObject } from 'openapi3-ts';
+
+const server = setupServer();
+
+function createMockttpRequest(request: {
+ method: string;
+ url: string;
+ headers?: Record;
+ 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;
+ body?: object;
+}): CompletedResponse {
+ return {
+ statusCode: response.statusCode,
+ headers: response.headers,
+ body: response.body
+ ? ({
+ getText: async () => JSON.stringify(response.body),
+ getJson: async () => response.body,
+ } as CompletedBody)
+ : undefined,
+ } as CompletedResponse;
+}
+
+describe('OpenApiProxyValidator', () => {
+ registerMswTestHooks(server);
+ let validator: OpenApiProxyValidator;
+
+ async function mockSchema(schema: any) {
+ server.use(
+ rest.get('http://localhost:7000/openapi.json', (_req, res, ctx) =>
+ res(ctx.json(schema)),
+ ),
+ );
+ await validator.initialize('http://localhost:7000/openapi.json');
+ }
+
+ beforeEach(async () => {
+ validator = new OpenApiProxyValidator();
+ });
+
+ describe('request body', () => {
+ it('validates a JSON request body', async () => {
+ await mockSchema(withResponseBody);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ headers: {
+ 'content-type': 'application/json',
+ },
+ body: { results: [] },
+ });
+
+ expect(await validator.validate(request, response)).toBeUndefined();
+ });
+
+ it('throws for missing request body per schema', async () => {
+ await mockSchema(withResponseBody);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search',
+ body: { id: '123' },
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ headers: {
+ 'content-type': 'application/json',
+ },
+ body: { results: [] },
+ });
+
+ await expect(
+ async () => await validator.validate(request, response),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Received a body but no schema was found"`,
+ );
+ });
+ });
+
+ describe('query parameters', () => {
+ describe('primitives', () => {
+ describe('string', () => {
+ it('accepts valid parameter', async () => {
+ await mockSchema(withQueryParameter);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search?param=abc',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ expect(await validator.validate(request, response)).toBeUndefined();
+ });
+ });
+ describe('number', () => {
+ const schema = _.cloneDeep(withQueryParameter);
+ schema.paths['/api/search'].get.parameters[0].schema.type = 'number';
+ it('throws for a missing required parameter', async () => {
+ await mockSchema(schema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search?test=123',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ await expect(
+ async () => await validator.validate(request, response),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Unexpected query parameters: test"`,
+ );
+ });
+
+ it('throws for invalid parameter', async () => {
+ await mockSchema(schema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search?param=abc',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ await expect(async () => await validator.validate(request, response))
+ .rejects.toThrowErrorMatchingInlineSnapshot(`
+ "["GET /api/search"] Query parameter validation failed.
+ - Value should be of type number"
+ `);
+ });
+
+ it('accepts valid parameter', async () => {
+ await mockSchema(schema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search?param=123',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ expect(await validator.validate(request, response)).toBeUndefined();
+ });
+ });
+ });
+
+ describe('object', () => {
+ describe('deepObject', () => {
+ const schema = _.cloneDeep(withQueryParameter);
+ schema.paths['/api/search'].get.parameters[0].schema.type = 'object';
+ (
+ schema.paths['/api/search'].get.parameters[0] as ParameterObject
+ ).style = 'deepObject';
+ it('throws for invalid parameter (not an object)', async () => {
+ await mockSchema(schema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search?param=123',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ await expect(
+ async () => await validator.validate(request, response),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Unexpected query parameters: param"`,
+ );
+ });
+
+ it('throws for missing required property', async () => {
+ const requiredSchema = _.cloneDeep(schema);
+ requiredSchema.paths['/api/search'].get.parameters[0].required = true;
+ await mockSchema(requiredSchema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ await expect(
+ async () => await validator.validate(request, response),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Required query parameter param not found"`,
+ );
+ });
+
+ it('throws for invalid format', async () => {
+ await mockSchema(schema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search?param[t=123',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ await expect(
+ async () => await validator.validate(request, response),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Invalid object parameter, missing closing bracket for key "param[t""`,
+ );
+ });
+ });
+
+ describe('form', () => {
+ const schema = _.cloneDeep(withQueryParameter);
+ schema.paths['/api/search'].get.parameters[0].schema.type = 'object';
+ (
+ schema.paths['/api/search'].get.parameters[0] as ParameterObject
+ ).style = 'form';
+ it('throws for invalid parameter (not an object)', async () => {
+ await mockSchema(schema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search?param[t=123',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ await expect(
+ async () => await validator.validate(request, response),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Unexpected query parameters: param[t"`,
+ );
+ });
+
+ it('throws for missing required property', async () => {
+ const requiredSchema = _.cloneDeep(schema);
+ requiredSchema.paths['/api/search'].get.parameters[0].required = true;
+ await mockSchema(requiredSchema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ await expect(
+ async () => await validator.validate(request, response),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Required query parameter param not found"`,
+ );
+ });
+
+ it('throws for invalid format', async () => {
+ await mockSchema(schema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search?param=123',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+ await expect(
+ async () => await validator.validate(request, response),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Invalid object query parameter, must have an even number of key-value pairs"`,
+ );
+ });
+ describe('explode', () => {
+ const explodeSchema = _.cloneDeep(schema);
+ (
+ explodeSchema.paths['/api/search'].get
+ .parameters[0] as ParameterObject
+ ).explode = true;
+ it('accepts valid parameter', async () => {
+ await mockSchema(explodeSchema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search?param=123,test,456',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ expect(await validator.validate(request, response)).toBeUndefined();
+ });
+
+ it('accepts multiple parameters', async () => {
+ await mockSchema(explodeSchema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search?test=123&myparam=test&otherparam=456',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ expect(await validator.validate(request, response)).toBeUndefined();
+ });
+ });
+
+ describe('no explode', () => {
+ const noExplodeSchema = _.cloneDeep(schema);
+ (
+ noExplodeSchema.paths['/api/search'].get
+ .parameters[0] as ParameterObject
+ ).explode = false;
+ it('accepts valid parameter', async () => {
+ await mockSchema(schema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search?param=123,test,456,param',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ expect(await validator.validate(request, response)).toBeUndefined();
+ });
+
+ it('throws for invalid parameter', async () => {
+ await mockSchema(schema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search?param=123,test,456',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ await expect(
+ async () => await validator.validate(request, response),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Invalid object query parameter, must have an even number of key-value pairs"`,
+ );
+ });
+ });
+ });
+ });
+
+ describe('array', () => {
+ const arraySchema = _.cloneDeep(withQueryParameter);
+ arraySchema.paths['/api/search'].get.parameters[0].schema.type = 'array';
+ describe('form', () => {
+ describe('explode', () => {
+ const explodeSchema = _.cloneDeep(arraySchema);
+ (
+ explodeSchema.paths['/api/search'].get
+ .parameters[0] as ParameterObject
+ ).explode = true;
+
+ it('accepts single parameter', async () => {
+ await mockSchema(explodeSchema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search?param=123',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ expect(await validator.validate(request, response)).toBeUndefined();
+ });
+
+ it('accepts multiple parameters', async () => {
+ await mockSchema(explodeSchema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search?param=123¶m=test¶m=456',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ expect(await validator.validate(request, response)).toBeUndefined();
+ });
+
+ it('throws for missing required parameter', async () => {
+ const requiredSchema = _.cloneDeep(explodeSchema);
+ requiredSchema.paths['/api/search'].get.parameters[0].required =
+ true;
+ await mockSchema(requiredSchema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ await expect(
+ async () => await validator.validate(request, response),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Required query parameter param not found"`,
+ );
+ });
+
+ it('throws for invalid parameter', async () => {
+ await mockSchema(explodeSchema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search?param[]=123',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ await expect(
+ async () => await validator.validate(request, response),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Unexpected query parameters: param[]"`,
+ );
+ });
+ });
+
+ describe('no explode', () => {
+ const noExplodeSchema = _.cloneDeep(arraySchema);
+ (
+ noExplodeSchema.paths['/api/search'].get
+ .parameters[0] as ParameterObject
+ ).explode = false;
+
+ it('accepts single parameter', async () => {
+ await mockSchema(noExplodeSchema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search?param=123',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ expect(await validator.validate(request, response)).toBeUndefined();
+ });
+
+ it('accepts multiple parameters', async () => {
+ await mockSchema(noExplodeSchema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search?param=123,456,789',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ expect(await validator.validate(request, response)).toBeUndefined();
+ });
+
+ it('throws for missing required parameter', async () => {
+ const requiredSchema = _.cloneDeep(noExplodeSchema);
+ requiredSchema.paths['/api/search'].get.parameters[0].required =
+ true;
+ await mockSchema(requiredSchema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ await expect(
+ async () => await validator.validate(request, response),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Required query parameter param not found"`,
+ );
+ });
+
+ it('throws for invalid parameter', async () => {
+ await mockSchema(noExplodeSchema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search?param=123¶m=456',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ await expect(
+ async () => await validator.validate(request, response),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Arrays must be comma separated in non-explode mode"`,
+ );
+ });
+ });
+
+ describe('compatible with qs', () => {
+ const noExplodeSchema = _.cloneDeep(arraySchema);
+ (
+ noExplodeSchema.paths['/api/search'].get
+ .parameters[0] as ParameterObject
+ ).explode = false;
+
+ const explodeSchema = _.cloneDeep(arraySchema);
+ (
+ explodeSchema.paths['/api/search'].get
+ .parameters[0] as ParameterObject
+ ).explode = true;
+ it('accepts the [] syntax', async () => {
+ await mockSchema(noExplodeSchema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search?param[]=123,456,789',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ expect(await validator.validate(request, response)).toBeUndefined();
+ });
+
+ it('accepts the array index syntax', async () => {
+ await mockSchema(explodeSchema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search?param[0]=123¶m[1]=456¶m[2]=789',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ expect(await validator.validate(request, response)).toBeUndefined();
+ });
+ });
+ });
+
+ describe('spaceDelimited', () => {
+ const spaceDelimitedSchema = _.cloneDeep(arraySchema);
+ (
+ spaceDelimitedSchema.paths['/api/search'].get
+ .parameters[0] as ParameterObject
+ ).style = 'spaceDelimited';
+
+ it('accepts single parameter', async () => {
+ await mockSchema(spaceDelimitedSchema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search?param=123',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ expect(await validator.validate(request, response)).toBeUndefined();
+ });
+
+ it('accepts multiple parameters', async () => {
+ await mockSchema(spaceDelimitedSchema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search?param=123 test 456',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ expect(await validator.validate(request, response)).toBeUndefined();
+ });
+
+ it('throws for missing required parameter', async () => {
+ const requiredSchema = _.cloneDeep(spaceDelimitedSchema);
+ requiredSchema.paths['/api/search'].get.parameters[0].required = true;
+ await mockSchema(requiredSchema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ await expect(
+ async () => await validator.validate(request, response),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Required query parameter param not found"`,
+ );
+ });
+ });
+
+ describe('pipeDelimited', () => {
+ const pipeDelimitedSchema = _.cloneDeep(arraySchema);
+ (
+ pipeDelimitedSchema.paths['/api/search'].get
+ .parameters[0] as ParameterObject
+ ).style = 'pipeDelimited';
+
+ it('accepts single parameter', async () => {
+ await mockSchema(pipeDelimitedSchema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search?param=123',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ expect(await validator.validate(request, response)).toBeUndefined();
+ });
+
+ it('accepts multiple parameters', async () => {
+ await mockSchema(pipeDelimitedSchema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search?param=123|test|456',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ expect(await validator.validate(request, response)).toBeUndefined();
+ });
+
+ it('throws for missing required parameter', async () => {
+ const requiredSchema = _.cloneDeep(pipeDelimitedSchema);
+ requiredSchema.paths['/api/search'].get.parameters[0].required = true;
+ await mockSchema(requiredSchema);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ });
+
+ await expect(
+ async () => await validator.validate(request, response),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search"] Required query parameter param not found"`,
+ );
+ });
+ });
+ });
+ });
+
+ describe('response body', () => {
+ it('validates a JSON response body', async () => {
+ await mockSchema(withResponseBody);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ headers: {
+ 'content-type': 'application/json',
+ },
+ body: { results: [] },
+ });
+
+ expect(await validator.validate(request, response)).toBeUndefined();
+ });
+
+ it('throws for missing response body per schema', async () => {
+ await mockSchema(withResponseBody);
+ const request = createMockttpRequest({
+ method: 'GET',
+ url: '/api/search',
+ });
+ const response = createMockttpResponse({
+ statusCode: 200,
+ headers: {
+ 'content-type': 'application/json',
+ },
+ });
+
+ await expect(
+ async () => await validator.validate(request, response),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"["GET /api/search" (200)]: Response body is required but missing"`,
+ );
+ });
+ });
+});
+
+describe('findOperationByRequest', () => {
+ it('finds an operation by request', () => {
+ const schema = {
+ openapi: '3.0.0',
+ info: {
+ title: 'Test',
+ version: '1.0.0',
+ },
+ paths: {
+ '/api/search': {
+ get: {
+ parameters: [],
+ },
+ },
+ },
+ };
+ const request = {
+ method: 'GET',
+ url: 'http://localhost:8080/api/search',
+ } as CompletedRequest;
+ expect(findOperationByRequest(schema as OpenAPIObject, request)).toEqual([
+ '/api/search',
+ schema.paths['/api/search'].get,
+ ]);
+ });
+
+ it('finds an operation by request when there are multiple other paths', () => {
+ const schema = {
+ openapi: '3.0.0',
+ info: {
+ title: 'Test',
+ version: '1.0.0',
+ },
+ paths: {
+ '/api/search': {
+ get: {
+ parameters: [],
+ },
+ },
+ '/api/catalog/by-ref': {
+ get: {
+ parameters: [],
+ },
+ },
+ },
+ };
+ const request = {
+ method: 'GET',
+ url: 'http://localhost:8080/api/search',
+ } as CompletedRequest;
+ expect(findOperationByRequest(schema as OpenAPIObject, request)).toEqual([
+ '/api/search',
+ schema.paths['/api/search'].get,
+ ]);
+ });
+
+ it('finds an operation by request when there are path parameters', () => {
+ const schema = {
+ openapi: '3.0.0',
+ info: {
+ title: 'Test',
+ version: '1.0.0',
+ },
+ paths: {
+ '/api/catalog/by-id/{id}': {
+ get: {
+ parameters: [],
+ },
+ },
+ },
+ };
+ const request = {
+ method: 'GET',
+ url: 'http://localhost:8080/api/catalog/by-id/123',
+ } as CompletedRequest;
+ expect(findOperationByRequest(schema as OpenAPIObject, request)).toEqual([
+ '/api/catalog/by-id/{id}',
+ schema.paths['/api/catalog/by-id/{id}'].get,
+ ]);
+ });
+
+ it('finds an operation by request when there are somewhat overlapping path parameters', () => {
+ const schema = {
+ openapi: '3.0.0',
+ info: {
+ title: 'Test',
+ version: '1.0.0',
+ },
+ paths: {
+ '/api/catalog/by-id/{id}': {
+ get: {
+ parameters: [],
+ },
+ },
+ '/api/catalog/by-id': {
+ get: {
+ parameters: [],
+ },
+ },
+ },
+ };
+ const request = {
+ method: 'GET',
+ url: 'http://localhost:8080/api/catalog/by-id/123',
+ } as CompletedRequest;
+ expect(findOperationByRequest(schema as OpenAPIObject, request)).toEqual([
+ '/api/catalog/by-id/{id}',
+ schema.paths['/api/catalog/by-id/{id}'].get,
+ ]);
+ });
+});
diff --git a/packages/backend-openapi-utils/src/schema/validation.ts b/packages/backend-openapi-utils/src/schema/validation.ts
new file mode 100644
index 0000000000..5f7c6fe353
--- /dev/null
+++ b/packages/backend-openapi-utils/src/schema/validation.ts
@@ -0,0 +1,149 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { CompletedRequest, CompletedResponse } from 'mockttp';
+import { OpenAPIObject, OperationObject } from 'openapi3-ts';
+import Ajv from 'ajv';
+import Parser from '@apidevtools/swagger-parser';
+import { Operation, Validator, ValidatorParams } from './types';
+import { ParameterValidator } from './parameter-validation';
+import { OperationError } from './errors';
+import { RequestBodyParser } from './request-body-validation';
+import { mockttpToFetchRequest, mockttpToFetchResponse } from './utils';
+import { ResponseBodyParser } from './response-body-validation';
+
+const ajv = new Ajv({ allErrors: true });
+
+class RequestBodyValidator implements Validator {
+ schema: OpenAPIObject;
+ constructor(schema: OpenAPIObject) {
+ this.schema = schema;
+ }
+
+ async validate({ pair, operation }: ValidatorParams) {
+ const { request, response } = pair;
+ if (response.statusCode === 400) {
+ // If the response is a 400, then the request is invalid and we shouldn't validate the parameters
+ return;
+ }
+
+ // NOTE: There may be a worthwhile optimization here to cache these results to avoid re-parsing the schema for every request. As is, I don't think this is a big deal.
+ const parser = RequestBodyParser.fromOperation(operation, { ajv });
+ const fetchRequest = mockttpToFetchRequest(request);
+ await parser.parse(fetchRequest);
+ }
+}
+
+class ResponseBodyValidator implements Validator {
+ schema: OpenAPIObject;
+ constructor(schema: OpenAPIObject) {
+ this.schema = schema;
+ }
+
+ async validate({ pair, operation }: ValidatorParams) {
+ const { response } = pair;
+ // NOTE: There may be a worthwhile optimization here to cache these results to avoid re-parsing the schema for every request. As is, I don't think this is a big deal.
+ const parser = ResponseBodyParser.fromOperation(operation, { ajv });
+ const fetchResponse = mockttpToFetchResponse(response);
+ await parser.parse(fetchResponse);
+ }
+}
+
+/**
+ * Find an operation in an OpenAPI schema that matches a request. This is done by comparing the request URL to the paths in the schema.
+ * @param openApiSchema - The OpenAPI schema to search for the operation in.
+ * @param request - The request to find the operation for.
+ * @returns A tuple of the path and the operation object that matches the request.
+ */
+export function findOperationByRequest(
+ openApiSchema: OpenAPIObject,
+ request: CompletedRequest,
+): [string, OperationObject] | undefined {
+ const { url } = request;
+ const { pathname } = new URL(url);
+
+ const parts = pathname.split('/');
+ for (const [path, schema] of Object.entries(openApiSchema.paths)) {
+ const pathParts = path.split('/');
+ if (parts.length !== pathParts.length) {
+ continue;
+ }
+ let found = true;
+ for (let i = 0; i < parts.length; i++) {
+ if (pathParts[i] === parts[i]) {
+ continue;
+ }
+ // If the path part is a parameter, we can count it as a match. eg /api/{id} will match /api/1
+ if (pathParts[i].startsWith('{') && pathParts[i].endsWith('}')) {
+ continue;
+ }
+ found = false;
+ break;
+ }
+ if (!found) {
+ continue;
+ }
+ let matchingOperationType: OperationObject | undefined = undefined;
+ for (const [operationType, operation] of Object.entries(schema)) {
+ if (operationType === request.method.toLowerCase()) {
+ matchingOperationType = operation as OperationObject;
+ break;
+ }
+ }
+ if (!matchingOperationType) {
+ continue;
+ }
+ return [path, matchingOperationType];
+ }
+
+ return undefined;
+}
+
+export class OpenApiProxyValidator {
+ schema: OpenAPIObject | undefined;
+ validators: Validator[] | undefined;
+
+ async initialize(url: string) {
+ this.schema = (await Parser.dereference(url)) as unknown as OpenAPIObject;
+ this.validators = [
+ new ParameterValidator(this.schema),
+ new RequestBodyValidator(this.schema),
+ new ResponseBodyValidator(this.schema),
+ ];
+ }
+
+ async validate(request: CompletedRequest, response: CompletedResponse) {
+ const operationPathTuple = findOperationByRequest(this.schema!, request);
+ if (!operationPathTuple) {
+ throw new OperationError(
+ { path: request.path, method: request.method } as Operation,
+ `No operation schema found for ${request.url}`,
+ );
+ }
+
+ const [path, operationSchema] = operationPathTuple;
+ const operation = { path, method: request.method, schema: operationSchema };
+
+ const validators = this.validators!;
+ await Promise.all(
+ validators.map(validator =>
+ validator.validate({
+ pair: { request, response },
+ operation,
+ }),
+ ),
+ );
+ }
+}
diff --git a/packages/backend-openapi-utils/src/testUtils.ts b/packages/backend-openapi-utils/src/testUtils.ts
index 2ac5575fc4..11716050db 100644
--- a/packages/backend-openapi-utils/src/testUtils.ts
+++ b/packages/backend-openapi-utils/src/testUtils.ts
@@ -15,6 +15,47 @@
*/
import { Express } from 'express';
import { Server } from 'http';
+import { Proxy } from './proxy/setup';
+
+const proxiesToCleanup: Proxy[] = [];
+
+/**
+ * !!! THIS CURRENTLY ONLY SUPPORTS SUPERTEST !!!
+ * Setup a server with a custom OpenAPI proxy. This proxy will capture all requests and responses and make sure they
+ * conform to the spec.
+ * @param app - express server, needed to ensure we have the correct ports for the proxy.
+ * @returns - a configured HTTP server that should be used with supertest.
+ * @public
+ */
+export async function wrapServer(app: Express): Promise {
+ const proxy = new Proxy();
+ proxiesToCleanup.push(proxy);
+ await proxy.setup();
+
+ const server = app.listen(proxy.forwardTo.port);
+ await proxy.initialize(`http://localhost:${proxy.forwardTo.port}`, server);
+
+ return { ...server, address: () => new URL(proxy.url) } as any;
+}
+
+let registered = false;
+function registerHooks() {
+ if (typeof afterAll !== 'function' || typeof beforeAll !== 'function') {
+ return;
+ }
+ if (registered) {
+ return;
+ }
+ registered = true;
+
+ afterAll(() => {
+ for (const proxy of proxiesToCleanup) {
+ proxy.stop();
+ }
+ });
+}
+
+registerHooks();
/**
* !!! THIS CURRENTLY ONLY SUPPORTS SUPERTEST !!!
diff --git a/plugins/catalog-backend/optic.yml b/plugins/catalog-backend/optic.yml
deleted file mode 100644
index dbbdefcf68..0000000000
--- a/plugins/catalog-backend/optic.yml
+++ /dev/null
@@ -1,15 +0,0 @@
-ruleset:
- - breaking-changes
-capture:
- src/schema/openapi.yaml:
- # đ§ Runnable example with simple get requests.
- # Run with "PORT=3000 optic capture src/schema/openapi.yaml --update interactive" in 'plugins/catalog-backend'
- # You can change the server and the 'requests' section to experiment
- server:
- # This will not be used by 'backstage-repo-tools schema openapi test', but may be useful for interactive updates.
- url: http://localhost:3000
- requests:
- # âšī¸ Requests should be sent to the Optic proxy, the address of which is injected into 'run.command's env as OPTIC_PROXY (or the value of 'run.proxy_variable').
- run:
- # đ§ Specify a command that will generate traffic
- command: yarn backstage-cli package test --no-watch "src/service/router.test.ts" "src/service/createRouter.test.ts"
diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts
index 9cc0717807..6654187526 100644
--- a/plugins/catalog-backend/src/service/createRouter.test.ts
+++ b/plugins/catalog-backend/src/service/createRouter.test.ts
@@ -38,7 +38,7 @@ import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common/a
import { CatalogProcessingOrchestrator } from '../processing/types';
import { z } from 'zod';
import { decodeCursor, encodeCursor } from './util';
-import { wrapInOpenApiTestServer } from '@backstage/backend-openapi-utils';
+import { wrapServer } from '@backstage/backend-openapi-utils';
import { Server } from 'http';
import { mockCredentials, mockServices } from '@backstage/backend-test-utils';
import { LocationAnalyzer } from '@backstage/plugin-catalog-node';
@@ -93,7 +93,7 @@ describe('createRouter readonly disabled', () => {
locationAnalyzer,
permissionsService: permissionsService,
});
- app = wrapInOpenApiTestServer(express().use(router));
+ app = await wrapServer(express().use(router));
});
beforeEach(() => {
diff --git a/plugins/search-backend/optic.yml b/plugins/search-backend/optic.yml
deleted file mode 100644
index 75f20f1bb8..0000000000
--- a/plugins/search-backend/optic.yml
+++ /dev/null
@@ -1,15 +0,0 @@
-ruleset:
- - breaking-changes
-capture:
- src/schema/openapi.yaml:
- # đ§ Runnable example with simple get requests.
- # Run with "PORT=3000 optic capture src/schema/openapi.yaml --update interactive" in 'plugins/search-backend'
- # You can change the server and the 'requests' section to experiment
- server:
- # This will not be used by 'backstage-repo-tools schema openapi test', but may be useful for interactive updates.
- url: http://localhost:3000
- requests:
- # âšī¸ Requests should be sent to the Optic proxy, the address of which is injected into 'run.command's env as OPTIC_PROXY (or the value of 'run.proxy_variable').
- run:
- # đ§ Specify a command that will generate traffic
- command: yarn backstage-cli package test --no-watch "src/service/router.test.ts" "src/service/createRouter.test.ts"
diff --git a/plugins/search-backend/src/schema/openapi.generated.ts b/plugins/search-backend/src/schema/openapi.generated.ts
index 8ad27546d6..1e6adfb08f 100644
--- a/plugins/search-backend/src/schema/openapi.generated.ts
+++ b/plugins/search-backend/src/schema/openapi.generated.ts
@@ -208,7 +208,6 @@ export const spec = {
in: 'query',
required: false,
style: 'deepObject',
- explode: true,
allowReserved: true,
schema: {
$ref: '#/components/schemas/JsonObject',
@@ -244,6 +243,18 @@ export const spec = {
type: 'integer',
},
},
+ {
+ name: 'unknown',
+ in: 'query',
+ required: false,
+ style: 'form',
+ explode: true,
+ allowReserved: true,
+ schema: {
+ type: 'object',
+ additionalProperties: true,
+ },
+ },
],
},
},
diff --git a/plugins/search-backend/src/schema/openapi.yaml b/plugins/search-backend/src/schema/openapi.yaml
index 358f94fcf6..cbf3db273a 100644
--- a/plugins/search-backend/src/schema/openapi.yaml
+++ b/plugins/search-backend/src/schema/openapi.yaml
@@ -140,7 +140,6 @@ paths:
in: query
required: false
style: deepObject
- explode: true
allowReserved: true
schema:
# JsonObject is used here instead of the full ZOD schema definition as
@@ -167,3 +166,13 @@ paths:
allowReserved: true
schema:
type: integer
+ - name: unknown
+ in: query
+ required: false
+ # explode form is the equivalent to allow any extra query parameters
+ style: form
+ explode: true
+ allowReserved: true
+ schema:
+ type: object
+ additionalProperties: true
diff --git a/plugins/search-backend/src/service/router.test.ts b/plugins/search-backend/src/service/router.test.ts
index d22fc17e54..5b6900ede1 100644
--- a/plugins/search-backend/src/service/router.test.ts
+++ b/plugins/search-backend/src/service/router.test.ts
@@ -23,7 +23,7 @@ import {
import express from 'express';
import request from 'supertest';
import { createRouter } from './router';
-import { wrapInOpenApiTestServer } from '@backstage/backend-openapi-utils';
+import { wrapServer } from '@backstage/backend-openapi-utils';
import { Server } from 'http';
import {
mockCredentials,
@@ -87,9 +87,7 @@ describe('createRouter', () => {
auth: mockServices.auth(),
httpAuth: mockServices.httpAuth(),
});
- app = wrapInOpenApiTestServer(
- express().use(router).use(mockErrorHandler()),
- );
+ app = await wrapServer(express().use(router).use(mockErrorHandler()));
});
beforeEach(() => {
diff --git a/yarn.lock b/yarn.lock
index cd65c170d6..5b5084d189 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3738,16 +3738,23 @@ __metadata:
version: 0.0.0-use.local
resolution: "@backstage/backend-openapi-utils@workspace:packages/backend-openapi-utils"
dependencies:
+ "@apidevtools/swagger-parser": ^10.1.0
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/errors": "workspace:^"
+ "@backstage/test-utils": "workspace:^"
+ "@backstage/types": "workspace:^"
"@types/express": ^4.17.6
"@types/express-serve-static-core": ^4.17.5
+ ajv: ^8.16.0
express: ^4.17.1
express-openapi-validator: ^5.0.4
express-promise-router: ^4.1.0
+ get-port: ^5.1.1
json-schema-to-ts: ^3.0.0
lodash: ^4.17.21
+ mockttp: ^3.13.0
+ msw: ^1.0.0
openapi-merge: ^1.3.2
openapi3-ts: ^3.1.2
supertest: ^7.0.0
@@ -10305,12 +10312,12 @@ __metadata:
languageName: node
linkType: hard
-"@httptoolkit/httpolyglot@npm:^2.0.1, @httptoolkit/httpolyglot@npm:^2.1.1":
- version: 2.1.1
- resolution: "@httptoolkit/httpolyglot@npm:2.1.1"
+"@httptoolkit/httpolyglot@npm:^2.0.1, @httptoolkit/httpolyglot@npm:^2.2.1":
+ version: 2.2.1
+ resolution: "@httptoolkit/httpolyglot@npm:2.2.1"
dependencies:
- "@types/node": ^16.7.10
- checksum: 138ccd61355de334c509e2fc4ac9ade9e1aa6aa770ed2271e0bd1d883ed815eb742d0a4de37837edd03a9a243c05d6da32c5febe970f4518c46e2d76e6ff10d5
+ "@types/node": "*"
+ checksum: 5b3882657e37953bd7089d91ac6cd24cec36480deab114e6b69a4b3d9e4ab09db568500e5e96713869fb4a8fe40b5ecc1661cc39ee621ef40ed0e38b55e0257e
languageName: node
linkType: hard
@@ -18268,7 +18275,7 @@ __metadata:
languageName: node
linkType: hard
-"@types/node@npm:^16.11.26, @types/node@npm:^16.7.10":
+"@types/node@npm:^16.11.26":
version: 16.18.112
resolution: "@types/node@npm:16.18.112"
checksum: d634729e60d2e7bd951843fddf5fb59ae786ca707f384e8f90881b011076962b4e3fee3393e3be4be4fb7d86943893e0bda9650aea24ac692bdf407bf1a5d84d
@@ -20716,7 +20723,7 @@ __metadata:
languageName: node
linkType: hard
-"ajv@npm:^8.0.0, ajv@npm:^8.10.0, ajv@npm:^8.11.0, ajv@npm:^8.12.0, ajv@npm:^8.17.1, ajv@npm:^8.6.0, ajv@npm:^8.6.3, ajv@npm:^8.9.0":
+"ajv@npm:^8.0.0, ajv@npm:^8.10.0, ajv@npm:^8.11.0, ajv@npm:^8.12.0, ajv@npm:^8.16.0, ajv@npm:^8.17.1, ajv@npm:^8.6.0, ajv@npm:^8.6.3, ajv@npm:^8.9.0":
version: 8.17.1
resolution: "ajv@npm:8.17.1"
dependencies:
@@ -21340,6 +21347,15 @@ __metadata:
languageName: node
linkType: hard
+"async-mutex@npm:^0.5.0":
+ version: 0.5.0
+ resolution: "async-mutex@npm:0.5.0"
+ dependencies:
+ tslib: ^2.4.0
+ checksum: be1587f4875f3bb15e34e9fcce82eac2966daef4432c8d0046e61947fb9a1b95405284601bc7ce4869319249bc07c75100880191db6af11d1498931ac2a2f9ea
+ languageName: node
+ linkType: hard
+
"async-retry@npm:^1.3.3":
version: 1.3.3
resolution: "async-retry@npm:1.3.3"
@@ -21349,7 +21365,7 @@ __metadata:
languageName: node
linkType: hard
-"async@npm:^2.6.2, async@npm:^2.6.4":
+"async@npm:^2.6.4":
version: 2.6.4
resolution: "async@npm:2.6.4"
dependencies:
@@ -22105,10 +22121,10 @@ __metadata:
languageName: node
linkType: hard
-"brotli-wasm@npm:^1.1.0":
- version: 1.3.1
- resolution: "brotli-wasm@npm:1.3.1"
- checksum: ec2931a989ee6f0bb52c2aabf23a0d230232d3bd69fb68ee3dab9542fc9ae2d4085d0e5338f71520c25a4a26cf1cfc991ce02910c24d63d42c7915c5722a3713
+"brotli-wasm@npm:^3.0.0":
+ version: 3.0.1
+ resolution: "brotli-wasm@npm:3.0.1"
+ checksum: 48191b27265de8ffc59c940f9efef3a931448b6a15c26a4e360192fc3f0968e073c11fe0926510d019c305cc1d9c6d65df4d3e5752648a91cb0bbcccff7a8460
languageName: node
linkType: hard
@@ -24672,7 +24688,7 @@ __metadata:
languageName: node
linkType: hard
-"debug@npm:^3.1.1, debug@npm:^3.2.7":
+"debug@npm:^3.2.7":
version: 3.2.7
resolution: "debug@npm:3.2.7"
dependencies:
@@ -24987,12 +25003,12 @@ __metadata:
languageName: node
linkType: hard
-"destroyable-server@npm:^1.0.0":
- version: 1.0.0
- resolution: "destroyable-server@npm:1.0.0"
+"destroyable-server@npm:^1.0.2":
+ version: 1.0.2
+ resolution: "destroyable-server@npm:1.0.2"
dependencies:
"@types/node": "*"
- checksum: ac81b26f616a9d0aaa9cb759fa5a5a186f887025362329f7ddc909f53090f4aea0d1b75c4dda23e210faee536e2d7352de017261e1625b7b18108f0e630efa1f
+ checksum: 81fd70b9132d43c3633a7a819adfe1fc68b52a55154ff8a36f42f4655e7b71b8468559888caadfd324c1aa824f0d236796a8f356e8a00e7438649e647ea654b2
languageName: node
linkType: hard
@@ -29291,14 +29307,14 @@ __metadata:
languageName: node
linkType: hard
-"http-encoding@npm:^1.5.1":
- version: 1.5.1
- resolution: "http-encoding@npm:1.5.1"
+"http-encoding@npm:^2.0.1":
+ version: 2.0.1
+ resolution: "http-encoding@npm:2.0.1"
dependencies:
- brotli-wasm: ^1.1.0
+ brotli-wasm: ^3.0.0
pify: ^5.0.0
- zstd-codec: ^0.1.4
- checksum: 534aa2facb0ae529fa88b9778867472247711626b90030fd4351572c6147fb5e895d9d2e305e7dc5cc993345f2fbdb17ca99345651bf76dbac39a07f552af2ac
+ zstd-codec: ^0.1.5
+ checksum: c34a1cd81ad1c08e6c6aba5aef3f4d4bc4a6c84f8b3511776eb62006beeee48a104ce1630e3c8497f66d5c0913195dea596e776336dd5a598bd7fe06d27e1395
languageName: node
linkType: hard
@@ -29450,13 +29466,13 @@ __metadata:
languageName: node
linkType: hard
-"http2-wrapper@npm:^2.2.0":
- version: 2.2.0
- resolution: "http2-wrapper@npm:2.2.0"
+"http2-wrapper@npm:^2.2.1":
+ version: 2.2.1
+ resolution: "http2-wrapper@npm:2.2.1"
dependencies:
quick-lru: ^5.1.1
resolve-alpn: ^1.2.0
- checksum: 6fd20e5cb6a58151715b3581e06a62a47df943187d2d1f69e538a50cccb7175dd334ecfde7900a37d18f3e13a1a199518a2c211f39860e81e9a16210c199cfaa
+ checksum: e95e55e22c6fd61182ce81fecb9b7da3af680d479febe8ad870d05f7ebbc9f076e455193766f4e7934e50913bf1d8da3ba121fb5cd2928892390b58cf9d5c509
languageName: node
linkType: hard
@@ -34500,7 +34516,7 @@ __metadata:
languageName: node
linkType: hard
-"mkdirp@npm:^0.5.1, mkdirp@npm:^0.5.4, mkdirp@npm:^0.5.5, mkdirp@npm:^0.5.6":
+"mkdirp@npm:^0.5.1, mkdirp@npm:^0.5.4, mkdirp@npm:^0.5.6":
version: 0.5.6
resolution: "mkdirp@npm:0.5.6"
dependencies:
@@ -34536,17 +34552,18 @@ __metadata:
languageName: node
linkType: hard
-"mockttp@npm:^3.9.1":
- version: 3.9.4
- resolution: "mockttp@npm:3.9.4"
+"mockttp@npm:^3.13.0, mockttp@npm:^3.9.1":
+ version: 3.15.2
+ resolution: "mockttp@npm:3.15.2"
dependencies:
"@graphql-tools/schema": ^8.5.0
"@graphql-tools/utils": ^8.8.0
- "@httptoolkit/httpolyglot": ^2.1.1
+ "@httptoolkit/httpolyglot": ^2.2.1
"@httptoolkit/subscriptions-transport-ws": ^0.11.2
"@httptoolkit/websocket-stream": ^6.0.1
"@types/cors": ^2.8.6
"@types/node": "*"
+ async-mutex: ^0.5.0
base64-arraybuffer: ^0.1.5
body-parser: ^1.15.2
cacheable-lookup: ^6.0.0
@@ -34555,14 +34572,15 @@ __metadata:
cors: ^2.8.4
cors-gate: ^1.1.3
cross-fetch: ^3.1.5
- destroyable-server: ^1.0.0
+ destroyable-server: ^1.0.2
express: ^4.14.0
+ fast-json-patch: ^3.1.1
graphql: ^14.0.2 || ^15.5
graphql-http: ^1.22.0
graphql-subscriptions: ^1.1.0
graphql-tag: ^2.12.6
- http-encoding: ^1.5.1
- http2-wrapper: ^2.2.0
+ http-encoding: ^2.0.1
+ http2-wrapper: ^2.2.1
https-proxy-agent: ^5.0.1
isomorphic-ws: ^4.0.1
lodash: ^4.16.4
@@ -34572,16 +34590,17 @@ __metadata:
pac-proxy-agent: ^7.0.0
parse-multipart-data: ^1.4.0
performance-now: ^2.1.0
- portfinder: 1.0.28
+ portfinder: ^1.0.32
read-tls-client-hello: ^1.0.0
semver: ^7.5.3
socks-proxy-agent: ^7.0.0
typed-error: ^3.0.2
+ urlpattern-polyfill: ^8.0.0
uuid: ^8.3.2
ws: ^8.8.0
bin:
mockttp: dist/admin/admin-bin.js
- checksum: 2e0b984d77a94e6a754e44c85a7ff2ded13ba42fd6cabf125b677a8a57eff543c896bf3ecb522799d3efbe18733bf019fbe707044f098fdd5e8e4bc2c0b1df4f
+ checksum: 96b90e0515e7ac1b73954e9e01010424d51d9563f8e850e620b06ba864bf064401e1a1af89e103724b956bfa3bee790cb452366df100bf01122f333e04c3aee8
languageName: node
linkType: hard
@@ -37090,17 +37109,6 @@ __metadata:
languageName: node
linkType: hard
-"portfinder@npm:1.0.28":
- version: 1.0.28
- resolution: "portfinder@npm:1.0.28"
- dependencies:
- async: ^2.6.2
- debug: ^3.1.1
- mkdirp: ^0.5.5
- checksum: 91fef602f13f8f4c64385d0ad2a36cc9dc6be0b8d10a2628ee2c3c7b9917ab4fefb458815b82cea2abf4b785cd11c9b4e2d917ac6fa06f14b6fa880ca8f8928c
- languageName: node
- linkType: hard
-
"portfinder@npm:^1.0.28, portfinder@npm:^1.0.32":
version: 1.0.32
resolution: "portfinder@npm:1.0.32"
@@ -43705,6 +43713,13 @@ __metadata:
languageName: node
linkType: hard
+"urlpattern-polyfill@npm:^8.0.0":
+ version: 8.0.2
+ resolution: "urlpattern-polyfill@npm:8.0.2"
+ checksum: d2cc0905a613c77e330c426e8697ee522dd9640eda79ac51160a0f6350e103f09b8c327623880989f8ba7325e8d95267b745aa280fdcc2aead80b023e16bd09d
+ languageName: node
+ linkType: hard
+
"urlpattern-polyfill@npm:^9.0.0":
version: 9.0.0
resolution: "urlpattern-polyfill@npm:9.0.0"
@@ -45253,10 +45268,10 @@ __metadata:
languageName: node
linkType: hard
-"zstd-codec@npm:^0.1.4":
- version: 0.1.4
- resolution: "zstd-codec@npm:0.1.4"
- checksum: 8689bc0defc4f387d1be990b8b8ca8ca56690d17dfc8dd4703db798465b92a21e64e54e886acfaa376147d9d07d879a68627b09fddc34a0c93f0dc5c610a790c
+"zstd-codec@npm:^0.1.5":
+ version: 0.1.5
+ resolution: "zstd-codec@npm:0.1.5"
+ checksum: ba62bf643c3ca9759fedc090b73a0c3b1e506364fcae902a70b112c1f5b30bc6aabff3184808cc4430f2ab6644cabae979368152ae908c1d8ef39cd8c3223c85
languageName: node
linkType: hard