Merge pull request #19955 from sennyeya/optic-integration-oa

feat: add support for validating OpenAPI specs against test runs
This commit is contained in:
Patrik Oldsberg
2023-10-24 10:54:22 +02:00
committed by GitHub
29 changed files with 2841 additions and 297 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-catalog-backend': patch
'@backstage/plugin-search-backend': patch
---
Update the OpenAPI spec with more complete error responses and request bodies using Optic. Also, updates the test cases to use the new `supertest` pass through from `@backstage/backend-openapi-utils`.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/repo-tools': minor
---
Adds a new command `schema openapi test` that performs runtime validation of your OpenAPI specs using your test data. Under the hood, we're using Optic to perform this check, really cool work by them!
To use this new command, you will have to run `yarn add @useoptic/optic` in the root of your repo.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-openapi-utils': patch
---
Adds a new function `wrapInOpenApiTestServer` that allows for proxied requests at runtime. This will support the new `yarn backstage-repo-tools schema openapi test` command.
+1
View File
@@ -291,6 +291,7 @@ Preprarer
productional
Protobuf
proxying
proxied
Proxying
pseudonymized
pubsub
+4
View File
@@ -222,6 +222,10 @@ jobs:
BACKSTAGE_TEST_DATABASE_POSTGRES9_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres9.ports[5432] }}
BACKSTAGE_TEST_DATABASE_MYSQL8_CONNECTION_STRING: mysql://root:root@localhost:${{ job.services.mysql8.ports[3306] }}/ignored
# We run the test cases before verifying the specs to prevent any failing tests from causing errors.
- name: verify openapi specs against test cases
run: yarn backstage-repo-tools schema openapi test
- name: ensure clean working directory
run: |
if files=$(git ls-files --exclude-standard --others --modified) && [[ -z "$files" ]]; then
+41
View File
@@ -0,0 +1,41 @@
## OpenAPI Validation using Test Cases
This is primarily performed by `backstage-repo-tools schema openapi test`. Any errors found in the generated specs can be either
1. Fixed manually, this is usually relevant for request body or response body changes.
2. Fixed automatically with `backstage-repo-tools schema openapi test --update`.
3. Fixing the test case. This can happen where a response is mocked as
```ts
it('should return the right value', () => {
// We will assume that this is the actual response and update the spec accordingly.
// Ideally, this should be a fully populated return value.
const entity: Entity = {} as any;
app.get('/test', () => {
return entity;
});
const response = await request(app).get('/test');
expect(response.body).toEqual(entity);
});
```
will cause an invalid spec validation. The return value doesn't have all properties as defined in the type. Try to avoid this if possible. Something better would be,
```ts
it('should return the right value', () => {
// We will assume that this is the actual response and update the spec accordingly.
// Ideally, this should be a fully populated return value.
const entity: Entity = {
apiVersion: 'a1',
kind: 'k1',
metadata: { name: 'n1' },
};
app.get('/test', () => {
return entity;
});
const response = await request(app).get('/test');
expect(response.body).toEqual(entity);
});
```
Additionally, for more advanced use cases, you can run `yarn optic capture {PATH_TO_OPENAPI_FILE} --update interactive` and go through the prompts on the screen. Under the hood, the test validation + updating is done by [Optic](https://github.com/opticdev/optic), a great project around supporting OpenAPI specs and development. You can find additional options [here](https://www.useoptic.com/docs/verify-openapi).
+2 -1
View File
@@ -57,7 +57,8 @@
"version": "1.19.0",
"dependencies": {
"@backstage/errors": "workspace:^",
"@manypkg/get-packages": "^1.1.3"
"@manypkg/get-packages": "^1.1.3",
"@useoptic/optic": "^0.50.10"
},
"devDependencies": {
"@backstage/cli": "workspace:*",
+1 -1
View File
@@ -65,7 +65,7 @@ export function createRouter() {
### Why am I getting `unknown` as the type for a response?
This can happen when you have a `charset` defined in your `response.content` section. Something like `response.content[ 'application/json; charset=utf-8:']` will cause this issue.
This can happen when you have a `charset` defined in your `response.content` section. Something like `response.content['application/json; charset=utf-8:']` will cause this issue.
## INTERNAL
@@ -5,6 +5,7 @@
```ts
import type { ContentObject } from 'openapi3-ts';
import type core from 'express-serve-static-core';
import { Express as Express_2 } from 'express';
import { FromSchema } from 'json-schema-to-ts';
import { JSONSchema7 } from 'json-schema-to-ts';
import { middleware } from 'express-openapi-validator';
@@ -16,6 +17,7 @@ import { RequestHandler } from 'express';
import type { ResponseObject } from 'openapi3-ts';
import { Router } from 'express';
import type { SchemaObject } from 'openapi3-ts';
import { Server } from 'http';
// @public
export interface ApiRouter<Doc extends RequiredDoc> extends Router {
@@ -708,4 +710,7 @@ type UnknownIfNever<P> = [P] extends [never] ? unknown : P;
// @public
type ValueOf<T> = T[keyof T];
// @public
export const wrapInOpenApiTestServer: (app: Express_2) => Server | Express_2;
```
@@ -32,3 +32,4 @@ export type {
} from './utility';
export type { ApiRouter } from './router';
export { createValidatedOpenApiRouter } from './stub';
export { wrapInOpenApiTestServer } from './testUtils';
@@ -27,7 +27,6 @@ describe('createRouter', () => {
status: 'available',
photoUrls: [],
};
it('does NOT override originalUrl and basePath after execution', async () => {
expect.assertions(2);
const router = createValidatedOpenApiRouter(singlePathSpec);
@@ -0,0 +1,36 @@
/*
* Copyright 2023 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 { Express } from 'express';
import { Server } from 'http';
/**
* !!! THIS CURRENTLY ONLY SUPPORTS SUPERTEST !!!
* Running against supertest, we need some way to hit the optic proxy. This ensures that
* that happens at runtime when in the context of a `yarn optic capture` command.
* @param app - Express router that would be passed to supertest's `request`.
* @returns A wrapper around the express router (or the router untouched) that still works with supertest.
* @public
*/
export const wrapInOpenApiTestServer = (app: Express): Server | Express => {
if (process.env.OPTIC_PROXY) {
const server = app.listen(+process.env.PORT!);
return {
...server,
address: () => new URL(process.env.OPTIC_PROXY!),
} as any;
}
return app;
};
+21
View File
@@ -73,6 +73,8 @@ Commands:
verify [paths...]
generate [paths...]
lint [options] [paths...]
test [options] [paths...]
init <paths...>
help [command]
```
@@ -85,6 +87,15 @@ Options:
-h, --help
```
### `backstage-repo-tools schema openapi init`
```
Usage: backstage-repo-tools schema openapi init [options] <paths...>
Options:
-h, --help
```
### `backstage-repo-tools schema openapi lint`
```
@@ -95,6 +106,16 @@ Options:
-h, --help
```
### `backstage-repo-tools schema openapi test`
```
Usage: backstage-repo-tools schema openapi test [options] [paths...]
Options:
--update
-h, --help
```
### `backstage-repo-tools schema openapi verify`
```
+3 -1
View File
@@ -43,7 +43,7 @@
"@stoplight/spectral-formatters": "^1.1.0",
"@stoplight/spectral-functions": "^1.7.2",
"@stoplight/spectral-parsers": "^1.0.2",
"@stoplight/spectral-rulesets": "^1.16.0",
"@stoplight/spectral-rulesets": "^1.18.0",
"@stoplight/spectral-runtime": "^1.1.2",
"@stoplight/types": "^13.14.0",
"chalk": "^4.0.0",
@@ -56,6 +56,7 @@
"lodash": "^4.17.21",
"minimatch": "^5.1.1",
"p-limit": "^3.0.2",
"portfinder": "^1.0.32",
"ts-node": "^10.0.0",
"yaml-diff-patch": "^2.0.0"
},
@@ -71,6 +72,7 @@
"@microsoft/api-extractor-model": "*",
"@microsoft/tsdoc": "*",
"@microsoft/tsdoc-config": "*",
"@useoptic/optic": "^0.50.7",
"prettier": "^2.8.1",
"typescript": "> 3.0.0"
},
+11
View File
@@ -49,6 +49,17 @@ function registerSchemaCommand(program: Command) {
'Fail on any linting severity messages, not just errors.',
)
.action(lazy(() => import('./openapi/lint').then(m => m.bulkCommand)));
openApiCommand
.command('test [paths...]')
.description('Test OpenAPI schemas against written tests')
.option('--update', 'Update the spec on failure.')
.action(lazy(() => import('./openapi/test').then(m => m.bulkCommand)));
openApiCommand
.command('init <paths...>')
.description('Creates any config needed for the test command.')
.action(lazy(() => import('./openapi/test/init').then(m => m.default)));
}
export function registerCommands(program: Command) {
@@ -18,21 +18,40 @@ import { resolvePackagePaths } from '../../lib/paths';
import pLimit from 'p-limit';
import { relative as relativePath } from 'path';
import { paths as cliPaths } from '../../lib/paths';
import portFinder from 'portfinder';
export async function runner(
paths: string[],
command: (dir: string) => Promise<void>,
command: (dir: string, options?: { port: number }) => Promise<void>,
options?: {
concurrencyLimit: number;
startingPort?: number;
},
) {
const packages = await resolvePackagePaths({ paths });
const limit = pLimit(5);
const limit = pLimit(options?.concurrencyLimit ?? 5);
let port =
options?.startingPort &&
(await portFinder.getPortPromise({
// Prevent collisions with optic which runs 8000->8999
port: options.startingPort,
stopPort: options.startingPort + 1_000,
}));
const resultsList = await Promise.all(
packages.map(pkg =>
limit(async () => {
let resultText = '';
try {
if (port)
port =
options?.startingPort &&
(await portFinder.getPortPromise({
// Prevent collisions with optic which runs 8000->8999
port: port + 1,
stopPort: options.startingPort + 1_000,
}));
console.log(`## Processing ${pkg}`);
await command(pkg);
await command(pkg, port ? { port } : undefined);
} catch (err) {
resultText = err.message;
}
@@ -0,0 +1,118 @@
/*
* Copyright 2023 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 fs from 'fs-extra';
import { join } from 'path';
import chalk from 'chalk';
import { runner } from '../runner';
import { YAML_SCHEMA_PATH } from '../constants';
import { paths as cliPaths } from '../../../lib/paths';
import { exec } from '../../../lib/exec';
async function test(
directoryPath: string,
{ port }: { port: number },
options?: { update?: boolean },
) {
const openapiPath = join(directoryPath, YAML_SCHEMA_PATH);
if (!(await fs.pathExists(openapiPath))) {
return;
}
const opticConfigFilePath = join(directoryPath, 'optic.yml');
if (!(await fs.pathExists(opticConfigFilePath))) {
return;
}
let opticLocation = '';
try {
opticLocation = (
await exec(`yarn bin optic`, [], { cwd: cliPaths.ownRoot })
).stdout as string;
} catch (err) {
throw new Error(
`Failed to find an Optic CLI installation, ensure that you have @useoptic/optic installed in the root of your repo. If not, run yarn add @useoptic/optic from the root of your repo.`,
);
}
try {
await exec(
`${opticLocation.trim()} capture`,
[
YAML_SCHEMA_PATH,
'--server-override',
`http://localhost:${port}`,
options?.update ? '--update' : '',
],
{
cwd: directoryPath,
env: {
...process.env,
PORT: `${port}`,
},
},
);
} catch (err) {
// Optic outputs the actual results to stdout, but that will not be added to the message by default.
err.message = err.stderr + err.stdout;
err.message = (err.message as string)
.split('\n')
.map(e => e.replace(/.{1} Sending requests to server/, ''))
// Remove any lines that are emitted during processing and only show output.
.filter(e => !e.includes('PASS'))
.filter(e => e.trim())
.join('\n');
throw err;
}
if (
(await cliPaths.resolveTargetRoot('node_modules/.bin/prettier')) &&
options?.update
) {
await exec(`yarn prettier`, ['--write', openapiPath]);
}
}
export async function bulkCommand(
paths: string[] = [],
options: { update?: boolean },
): Promise<void> {
const resultsList = await runner(
paths,
(dir, runnerOptions) => test(dir, runnerOptions!, options),
{
concurrencyLimit: 1,
startingPort: 9_000,
},
);
let failed = false;
for (const { relativeDir, resultText } of resultsList) {
if (resultText) {
console.log();
console.log(
chalk.red(
`OpenAPI runtime validation against tests failed in ${relativeDir}:`,
),
);
console.log(resultText.trimStart());
failed = true;
}
}
if (failed) {
process.exit(1);
} else {
console.log(chalk.green('Verified all specifications against test data.'));
}
}
@@ -0,0 +1,90 @@
/*
* Copyright 2023 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 fs from 'fs-extra';
import { join } from 'path';
import { YAML_SCHEMA_PATH } from './../constants';
import { paths as cliPaths } from '../../../lib/paths';
import { runner } from '../runner';
import chalk from 'chalk';
import { exec } from '../../../lib/exec';
const ROUTER_TEST_PATHS = [
'src/service/router.test.ts',
'src/service/createRouter.test.ts',
];
async function init(directoryPath: string) {
const openapiPath = join(directoryPath, YAML_SCHEMA_PATH);
if (!(await fs.pathExists(openapiPath))) {
throw new Error(
`You do not have an OpenAPI YAML file at ${openapiPath}. Please create one and retry this command. If you already have existing test cases for your router, see 'backstage-repo-tools schema openapi test --update'`,
);
}
const opticConfigFilePath = join(directoryPath, 'optic.yml');
if (!(await fs.pathExists(opticConfigFilePath))) {
throw new Error(`This directory already has an optic.yml file. Exiting.`);
}
await fs.writeFile(
opticConfigFilePath,
`ruleset:
- breaking-changes
capture:
${YAML_SCHEMA_PATH}:
# 🔧 Runnable example with simple get requests.
# Run with "PORT=3000 optic capture ${YAML_SCHEMA_PATH} --update interactive" in '${directoryPath}'
# 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 ${ROUTER_TEST_PATHS.map(
e => `"${e}"`,
).join(',')}
`,
);
if (await cliPaths.resolveTargetRoot('node_modules/.bin/prettier')) {
await exec(`yarn prettier`, ['--write', opticConfigFilePath]);
}
}
export default async function initCommand(paths: string[] = []) {
const resultsList = await runner(paths, dir => init(dir), {
concurrencyLimit: 5,
});
let failed = false;
for (const { relativeDir, resultText } of resultsList) {
if (resultText) {
console.log();
console.log(
chalk.red(`Failed to initialize ${relativeDir} for OpenAPI commands.`),
);
console.log(resultText.trimStart());
failed = true;
}
}
if (failed) {
process.exit(1);
} else {
console.log(chalk.green(`All directories have already been configured.`));
}
}
+33
View File
@@ -0,0 +1,33 @@
/*
* Copyright 2023 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 { promisify } from 'util';
import { ExecOptions, exec as execCb } from 'child_process';
const execPromise = promisify(execCb);
export const exec = (
command: string,
options: string[] = [],
execOptions?: ExecOptions,
) => {
return execPromise(
[
command,
...options.filter(e => e).map(e => (e.startsWith('-') ? e : `"${e}"`)),
].join(' '),
execOptions,
);
};
+15
View File
@@ -0,0 +1,15 @@
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
@@ -191,12 +191,69 @@ export const spec = {
},
},
requestBodies: {},
responses: {},
responses: {
ErrorResponse: {
description: 'An error response from the backend.',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error',
},
},
},
},
},
schemas: {
Error: {
type: 'object',
properties: {
error: {
type: 'object',
properties: {
name: {
type: 'string',
},
message: {
type: 'string',
},
stack: {
type: 'string',
},
code: {
type: 'string',
},
},
required: ['name', 'message'],
},
request: {
type: 'object',
properties: {
method: {
type: 'string',
},
url: {
type: 'string',
},
},
required: ['method', 'url'],
},
response: {
type: 'object',
properties: {
statusCode: {
type: 'number',
},
},
required: ['statusCode'],
},
},
required: ['error', 'response'],
},
JsonObject: {
type: 'object',
properties: {},
description: 'A type representing all allowed JSON object values.',
additionalProperties: true,
},
MapStringString: {
type: 'object',
@@ -698,38 +755,6 @@ export const spec = {
required: ['type', 'target'],
additionalProperties: false,
},
SerializedError: {
allOf: [
{
$ref: '#/components/schemas/JsonObject',
},
{
type: 'object',
properties: {
code: {
type: 'string',
description:
'A custom code (not necessarily the same as an HTTP response code); may not be present',
},
stack: {
type: 'string',
description: 'A stringified stack trace; may not be present',
},
message: {
type: 'string',
description: 'The message of the exception that was thrown',
},
name: {
type: 'string',
description: 'The name of the exception that was thrown',
},
},
required: ['message', 'name'],
},
],
description: 'The serialized form of an Error.',
additionalProperties: false,
},
EntitiesQueryResponse: {
type: 'object',
properties: {
@@ -777,6 +802,12 @@ export const spec = {
'200': {
description: 'Refreshed',
},
'400': {
$ref: '#/components/responses/ErrorResponse',
},
default: {
$ref: '#/components/responses/ErrorResponse',
},
},
security: [
{},
@@ -829,6 +860,12 @@ export const spec = {
},
},
},
'400': {
$ref: '#/components/responses/ErrorResponse',
},
default: {
$ref: '#/components/responses/ErrorResponse',
},
},
security: [
{},
@@ -882,6 +919,12 @@ export const spec = {
},
},
},
'400': {
$ref: '#/components/responses/ErrorResponse',
},
default: {
$ref: '#/components/responses/ErrorResponse',
},
},
security: [
{},
@@ -902,6 +945,12 @@ export const spec = {
'204': {
description: 'Deleted successfully.',
},
'400': {
$ref: '#/components/responses/ErrorResponse',
},
default: {
$ref: '#/components/responses/ErrorResponse',
},
},
security: [
{},
@@ -931,6 +980,12 @@ export const spec = {
},
},
},
'400': {
$ref: '#/components/responses/ErrorResponse',
},
default: {
$ref: '#/components/responses/ErrorResponse',
},
},
security: [
{},
@@ -966,6 +1021,12 @@ export const spec = {
},
},
},
'400': {
$ref: '#/components/responses/ErrorResponse',
},
default: {
$ref: '#/components/responses/ErrorResponse',
},
},
security: [
{},
@@ -1002,6 +1063,12 @@ export const spec = {
},
},
},
'400': {
$ref: '#/components/responses/ErrorResponse',
},
default: {
$ref: '#/components/responses/ErrorResponse',
},
},
security: [
{},
@@ -1015,7 +1082,7 @@ export const spec = {
},
],
requestBody: {
required: true,
required: false,
content: {
'application/json': {
schema: {
@@ -1072,6 +1139,12 @@ export const spec = {
},
},
},
'400': {
$ref: '#/components/responses/ErrorResponse',
},
default: {
$ref: '#/components/responses/ErrorResponse',
},
},
security: [
{},
@@ -1139,6 +1212,12 @@ export const spec = {
},
},
},
'400': {
$ref: '#/components/responses/ErrorResponse',
},
default: {
$ref: '#/components/responses/ErrorResponse',
},
},
security: [
{},
@@ -1203,6 +1282,44 @@ export const spec = {
},
},
},
'201': {
description: '201 response',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
location: {
type: 'object',
properties: {
id: {
type: 'string',
},
type: {
type: 'string',
},
target: {
type: 'string',
},
},
required: ['id', 'type', 'target'],
},
entities: {
type: 'array',
items: {},
},
},
required: ['location', 'entities'],
},
},
},
},
'400': {
$ref: '#/components/responses/ErrorResponse',
},
default: {
$ref: '#/components/responses/ErrorResponse',
},
},
security: [
{},
@@ -1264,6 +1381,9 @@ export const spec = {
},
},
},
default: {
$ref: '#/components/responses/ErrorResponse',
},
},
security: [
{},
@@ -1289,6 +1409,9 @@ export const spec = {
},
},
},
default: {
$ref: '#/components/responses/ErrorResponse',
},
},
security: [
{},
@@ -1315,6 +1438,12 @@ export const spec = {
'204': {
description: 'No content',
},
'400': {
$ref: '#/components/responses/ErrorResponse',
},
default: {
$ref: '#/components/responses/ErrorResponse',
},
},
security: [
{},
@@ -1350,6 +1479,12 @@ export const spec = {
},
},
},
'400': {
$ref: '#/components/responses/ErrorResponse',
},
default: {
$ref: '#/components/responses/ErrorResponse',
},
},
security: [
{},
@@ -1387,32 +1522,31 @@ export const spec = {
responses: {
'200': {
description: 'Ok',
},
'400': {
description: '400 response',
content: {
'application/json': {
'application/json; charset=utf-8': {
schema: {
anyOf: [
{
type: 'object',
properties: {
errors: {
$ref: '#/components/schemas/SerializedError',
},
},
required: ['errors'],
},
{
type: 'object',
properties: {
errors: {
type: 'array',
items: {
$ref: '#/components/schemas/SerializedError',
type: 'object',
properties: {
errors: {
type: 'array',
items: {
type: 'object',
properties: {
name: {
type: 'string',
},
message: {
type: 'string',
},
},
required: ['name', 'message'],
},
required: ['errors'],
},
],
},
required: ['errors'],
},
},
},
+180 -71
View File
@@ -1,5 +1,4 @@
openapi: 3.0.3
info:
title: '@backstage/plugin-catalog-backend'
version: '1'
@@ -8,10 +7,8 @@ info:
name: Apache-2.0
url: http://www.apache.org/licenses/LICENSE-2.0.html
contact: {}
servers:
- url: /
components:
examples: {}
headers: {}
@@ -73,10 +70,15 @@ components:
items:
type: string
examples:
'Get name and the entire relations collection':
value: ['metadata.name', 'relations']
'Get kind, name and namespace':
value: ['kind', 'metadata.name', 'metadata.namespace']
Get name and the entire relations collection:
value:
- metadata.name
- relations
Get kind, name and namespace:
value:
- kind
- metadata.name
- metadata.namespace
filter:
name: filter
in: query
@@ -88,11 +90,12 @@ components:
items:
type: string
examples:
'Get groups':
value: ['kind=group']
'Get orphaned components':
Get groups:
value:
['kind=component,metadata.annotations.backstage.io/orphan=true']
- kind=group
Get orphaned components:
value:
- kind=component,metadata.annotations.backstage.io/orphan=true
offset:
name: offset
in: query
@@ -125,17 +128,63 @@ components:
explode: true
style: form
examples:
'Order ascending by name':
value: ['metadata.name,asc']
'Order descending by owner':
value: ['spec.owner,desc']
Order ascending by name:
value:
- metadata.name,asc
Order descending by owner:
value:
- spec.owner,desc
requestBodies: {}
responses: {}
responses:
ErrorResponse:
description: An error response from the backend.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
schemas:
Error:
type: object
properties:
error:
type: object
properties:
name:
type: string
message:
type: string
stack:
type: string
code:
type: string
required:
- name
- message
request:
type: object
properties:
method:
type: string
url:
type: string
required:
- method
- url
response:
type: object
properties:
statusCode:
type: number
required:
- statusCode
required:
- error
- response
JsonObject:
type: object
properties: {}
description: A type representing all allowed JSON object values.
additionalProperties: true
MapStringString:
type: object
properties: {}
@@ -350,7 +399,6 @@ components:
count:
type: number
additionalProperties: false
EntityFacetsResponse:
type: object
properties:
@@ -593,28 +641,6 @@ components:
- type
- target
additionalProperties: false
SerializedError:
allOf:
- $ref: '#/components/schemas/JsonObject'
- type: object
properties:
code:
type: string
description: A custom code (not necessarily the same as an HTTP response code); may not be present
stack:
type: string
description: A stringified stack trace; may not be present
message:
type: string
description: The message of the exception that was thrown
name:
type: string
description: The name of the exception that was thrown
required:
- message
- name
description: The serialized form of an Error.
additionalProperties: false
EntitiesQueryResponse:
type: object
properties:
@@ -622,8 +648,7 @@ components:
type: array
items:
$ref: '#/components/schemas/Entity'
description: |-
The list of entities paginated by a specific filter.
description: The list of entities paginated by a specific filter.
totalItems:
type: number
pageInfo:
@@ -631,12 +656,10 @@ components:
properties:
nextCursor:
type: string
description: |-
The cursor for the next batch of entities.
description: The cursor for the next batch of entities.
prevCursor:
type: string
description: |-
The cursor for the previous batch of entities.
description: The cursor for the previous batch of entities.
additionalProperties: false
securitySchemes:
JWT:
@@ -651,6 +674,10 @@ paths:
responses:
'200':
description: Refreshed
'400':
$ref: '#/components/responses/ErrorResponse'
default:
$ref: '#/components/responses/ErrorResponse'
security:
- {}
- JWT: []
@@ -684,6 +711,10 @@ paths:
type: array
items:
$ref: '#/components/schemas/Entity'
'400':
$ref: '#/components/responses/ErrorResponse'
default:
$ref: '#/components/responses/ErrorResponse'
security:
- {}
- JWT: []
@@ -712,6 +743,10 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Entity'
'400':
$ref: '#/components/responses/ErrorResponse'
default:
$ref: '#/components/responses/ErrorResponse'
security:
- {}
- JWT: []
@@ -723,6 +758,10 @@ paths:
responses:
'204':
description: Deleted successfully.
'400':
$ref: '#/components/responses/ErrorResponse'
default:
$ref: '#/components/responses/ErrorResponse'
security:
- {}
- JWT: []
@@ -739,6 +778,10 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Entity'
'400':
$ref: '#/components/responses/ErrorResponse'
default:
$ref: '#/components/responses/ErrorResponse'
security:
- {}
- JWT: []
@@ -757,6 +800,10 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/EntityAncestryResponse'
'400':
$ref: '#/components/responses/ErrorResponse'
default:
$ref: '#/components/responses/ErrorResponse'
security:
- {}
- JWT: []
@@ -775,13 +822,17 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/EntitiesBatchResponse'
'400':
$ref: '#/components/responses/ErrorResponse'
default:
$ref: '#/components/responses/ErrorResponse'
security:
- {}
- JWT: []
parameters:
- $ref: '#/components/parameters/fields'
requestBody:
required: true
required: false
content:
application/json:
schema:
@@ -798,14 +849,17 @@ paths:
items:
type: string
examples:
'Fetch Backstage entities':
Fetch Backstage entities:
value:
entityRefs:
['component:default/backstage', 'api:default/backstage']
'Fetch annotations for backstage entity':
- component:default/backstage
- api:default/backstage
Fetch annotations for backstage entity:
value:
entityRefs: ['component:default/backstage']
fields: ['metadata.annotations']
entityRefs:
- component:default/backstage
fields:
- metadata.annotations
/entities/by-query:
get:
operationId: GetEntitiesByQuery
@@ -817,6 +871,10 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/EntitiesQueryResponse'
'400':
$ref: '#/components/responses/ErrorResponse'
default:
$ref: '#/components/responses/ErrorResponse'
security:
- {}
- JWT: []
@@ -855,6 +913,10 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/EntityFacetsResponse'
'400':
$ref: '#/components/responses/ErrorResponse'
default:
$ref: '#/components/responses/ErrorResponse'
security:
- {}
- JWT: []
@@ -868,10 +930,12 @@ paths:
items:
type: string
examples:
'Entities by kind':
value: ['kind']
'Entities by spec type':
value: ['spec.type']
Entities by kind:
value:
- kind
Entities by spec type:
value:
- spec.type
- $ref: '#/components/parameters/filter'
/locations:
post:
@@ -896,6 +960,36 @@ paths:
required:
- entities
- location
'201':
description: 201 response
content:
application/json:
schema:
type: object
properties:
location:
type: object
properties:
id:
type: string
type:
type: string
target:
type: string
required:
- id
- type
- target
entities:
type: array
items: {}
required:
- location
- entities
'400':
$ref: '#/components/responses/ErrorResponse'
default:
$ref: '#/components/responses/ErrorResponse'
security:
- {}
- JWT: []
@@ -937,6 +1031,8 @@ paths:
$ref: '#/components/schemas/Location'
required:
- data
default:
$ref: '#/components/responses/ErrorResponse'
security:
- {}
- JWT: []
@@ -952,6 +1048,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Location'
default:
$ref: '#/components/responses/ErrorResponse'
security:
- {}
- JWT: []
@@ -968,6 +1066,10 @@ paths:
responses:
'204':
description: No content
'400':
$ref: '#/components/responses/ErrorResponse'
default:
$ref: '#/components/responses/ErrorResponse'
security:
- {}
- JWT: []
@@ -989,6 +1091,10 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/AnalyzeLocationResponse'
'400':
$ref: '#/components/responses/ErrorResponse'
default:
$ref: '#/components/responses/ErrorResponse'
security:
- {}
- JWT: []
@@ -1013,24 +1119,27 @@ paths:
responses:
'200':
description: Ok
'400':
description: 400 response
content:
application/json:
application/json; charset=utf-8:
schema:
anyOf:
- type: object
properties:
errors:
$ref: '#/components/schemas/SerializedError'
required:
- errors
- type: object
properties:
errors:
type: array
items:
$ref: '#/components/schemas/SerializedError'
required:
- errors
type: object
properties:
errors:
type: array
items:
type: object
properties:
name:
type: string
message:
type: string
required:
- name
- message
required:
- errors
security:
- {}
- JWT: []
@@ -22,6 +22,7 @@ import {
ANNOTATION_LOCATION,
ANNOTATION_ORIGIN_LOCATION,
Entity,
stringifyEntityRef,
} from '@backstage/catalog-model';
import express from 'express';
import request from 'supertest';
@@ -38,12 +39,14 @@ import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common/a
import { CatalogProcessingOrchestrator } from '../processing/types';
import { z } from 'zod';
import { encodeCursor } from './util';
import { wrapInOpenApiTestServer } from '@backstage/backend-openapi-utils';
import { Server } from 'http';
describe('createRouter readonly disabled', () => {
let entitiesCatalog: jest.Mocked<EntitiesCatalog>;
let locationService: jest.Mocked<LocationService>;
let orchestrator: jest.Mocked<CatalogProcessingOrchestrator>;
let app: express.Express;
let app: express.Express | Server;
let refreshService: RefreshService;
beforeAll(async () => {
@@ -72,7 +75,7 @@ describe('createRouter readonly disabled', () => {
config: new ConfigReader(undefined),
permissionIntegrationRouter: express.Router(),
});
app = express().use(router);
app = wrapInOpenApiTestServer(express().use(router));
});
beforeEach(() => {
@@ -412,15 +415,27 @@ describe('createRouter readonly disabled', () => {
});
it('can fetch entities by refs', async () => {
const entity: Entity = {} as any;
const entity: Entity = {
apiVersion: 'a',
kind: 'component',
metadata: {
name: 'a',
},
};
const entityRef = stringifyEntityRef(entity);
entitiesCatalog.entitiesBatch.mockResolvedValue({ items: [entity] });
const response = await request(app)
.post('/entities/by-refs')
.set('Content-Type', 'application/json')
.send('{"entityRefs":["a"],"fields":["b"]}');
.send(
JSON.stringify({
entityRefs: [entityRef],
fields: ['metadata.name'],
}),
);
expect(entitiesCatalog.entitiesBatch).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.entitiesBatch).toHaveBeenCalledWith({
entityRefs: ['a'],
entityRefs: [entityRef],
fields: expect.any(Function),
});
expect(response.status).toEqual(200);
@@ -24,10 +24,10 @@ import { parseEntityFilterParams } from './parseEntityFilterParams';
import { parseEntityOrderFieldParams } from './parseEntityOrderFieldParams';
import { parseEntityTransformParams } from './parseEntityTransformParams';
import { spec } from '../../schema/openapi.generated';
import { QueryParameters } from '@backstage/backend-openapi-utils';
import { internal } from '@backstage/backend-openapi-utils';
export function parseQueryEntitiesParams(
params: QueryParameters<typeof spec, '/entities/by-query', 'get'>,
params: internal.QuerySchema<typeof spec, '/entities/by-query', 'get'>,
): Omit<QueryEntitiesRequest, 'authorizationToken' | 'limit'> {
const fields = parseEntityTransformParams(params);
+15
View File
@@ -0,0 +1,15 @@
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
@@ -42,8 +42,58 @@ export const spec = {
headers: {},
parameters: {},
requestBodies: {},
responses: {},
responses: {
ErrorResponse: {
description: 'An error response from the backend.',
content: {
'application/json; charset=utf-8': {
schema: {
$ref: '#/components/schemas/Error',
},
},
},
},
},
schemas: {
Error: {
type: 'object',
properties: {
error: {
type: 'object',
properties: {
name: {
type: 'string',
},
message: {
type: 'string',
},
},
required: ['name', 'message'],
},
request: {
type: 'object',
properties: {
method: {
type: 'string',
},
url: {
type: 'string',
},
},
required: ['method', 'url'],
},
response: {
type: 'object',
properties: {
statusCode: {
type: 'number',
},
},
required: ['statusCode'],
},
},
required: ['error', 'request', 'response'],
},
JsonObject: {
type: 'object',
properties: {},
@@ -132,25 +182,8 @@ export const spec = {
},
},
},
'400': {
description: 'Bad request',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
error: {
type: 'object',
properties: {
message: {
type: 'string',
},
},
},
},
},
},
},
default: {
$ref: '#/components/responses/ErrorResponse',
},
},
security: [
+44 -16
View File
@@ -1,5 +1,4 @@
openapi: 3.0.3
info:
title: '@backstage/plugin-search-backend'
version: '1'
@@ -8,17 +7,56 @@ info:
name: Apache-2.0
url: http://www.apache.org/licenses/LICENSE-2.0.html
contact: {}
servers:
- url: /
components:
examples: {}
headers: {}
parameters: {}
requestBodies: {}
responses: {}
responses:
ErrorResponse:
description: An error response from the backend.
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Error'
schemas:
Error:
type: object
properties:
error:
type: object
properties:
name:
type: string
message:
type: string
required:
- name
- message
request:
type: object
properties:
method:
type: string
url:
type: string
required:
- method
- url
response:
type: object
properties:
statusCode:
type: number
required:
- statusCode
required:
- error
- request
- response
JsonObject:
type: object
properties: {}
@@ -85,18 +123,8 @@ paths:
type: integer
required:
- results
'400':
description: Bad request
content:
application/json:
schema:
type: object
properties:
error:
type: object
properties:
message:
type: string
default:
$ref: '#/components/responses/ErrorResponse'
security:
- {}
- JWT: []
@@ -22,6 +22,8 @@ import { SearchEngine } from '@backstage/plugin-search-common';
import express from 'express';
import request from 'supertest';
import { createRouter } from './router';
import { wrapInOpenApiTestServer } from '@backstage/backend-openapi-utils';
import { Server } from 'http';
const mockPermissionEvaluator: PermissionEvaluator = {
authorize: () => {
@@ -33,7 +35,7 @@ const mockPermissionEvaluator: PermissionEvaluator = {
};
describe('createRouter', () => {
let app: express.Express;
let app: express.Express | Server;
let mockSearchEngine: jest.Mocked<SearchEngine>;
beforeAll(async () => {
@@ -65,7 +67,7 @@ describe('createRouter', () => {
permissions: mockPermissionEvaluator,
logger,
});
app = express().use(router);
app = wrapInOpenApiTestServer(express().use(router));
});
beforeEach(() => {
@@ -78,6 +80,7 @@ describe('createRouter', () => {
mockSearchEngine.query.mockRejectedValueOnce(error);
const response = await request(app).get('/query');
console.log((response as any).text);
expect(response.status).toEqual(500);
expect(response.body).toMatchObject(
+1911 -119
View File
File diff suppressed because it is too large Load Diff