Merge pull request #22350 from backstage/openapi-tooling/fix-generate-client

feat(repo-tools): Rework command structure and standardize `openapi generate-client`
This commit is contained in:
Patrik Oldsberg
2024-02-06 13:41:16 +01:00
committed by GitHub
21 changed files with 355 additions and 194 deletions
+84 -31
View File
@@ -15,7 +15,8 @@ Commands:
api-reports [options] [paths...]
type-deps
generate-catalog-info [options]
schema [command]
package [command]
repo [command]
help [command]
```
@@ -48,10 +49,23 @@ Options:
-h, --help
```
### `backstage-repo-tools schema`
### `backstage-repo-tools package`
```
Usage: backstage-repo-tools schema [options] [command] [command]
Usage: backstage-repo-tools package [options] [command] [command]
Options:
-h, --help
Commands:
schema [command]
help [command]
```
### `backstage-repo-tools package schema`
```
Usage: backstage-repo-tools package schema [options] [command] [command]
Options:
-h, --help
@@ -61,65 +75,104 @@ Commands:
help [command]
```
### `backstage-repo-tools schema openapi`
### `backstage-repo-tools package schema openapi`
```
Usage: backstage-repo-tools schema openapi [options] [command] [command]
Usage: backstage-repo-tools package schema openapi [options] [command] [command]
Options:
-h, --help
Commands:
init
generate [options]
help [command]
```
### `backstage-repo-tools package schema openapi generate`
```
Usage: backstage-repo-tools package schema openapi generate [options]
Options:
--client-package [package]
-h, --help
```
### `backstage-repo-tools package schema openapi init`
```
Usage: backstage-repo-tools package schema openapi init [options]
Options:
-h, --help
```
### `backstage-repo-tools repo`
```
Usage: backstage-repo-tools repo [options] [command] [command]
Options:
-h, --help
Commands:
schema [command]
help [command]
```
### `backstage-repo-tools repo schema`
```
Usage: backstage-repo-tools repo schema [options] [command] [command]
Options:
-h, --help
Commands:
openapi [command]
help [command]
```
### `backstage-repo-tools repo schema openapi`
```
Usage: backstage-repo-tools repo schema openapi [options] [command] [command]
Options:
-h, --help
Commands:
verify [paths...]
generate [paths...]
lint [options] [paths...]
test [options] [paths...]
init <paths...>
help [command]
```
### `backstage-repo-tools schema openapi generate`
### `backstage-repo-tools repo schema openapi lint`
```
Usage: backstage-repo-tools schema openapi generate [options] [paths...]
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`
```
Usage: backstage-repo-tools schema openapi lint [options] [paths...]
Usage: backstage-repo-tools repo schema openapi lint [options] [paths...]
Options:
--strict
-h, --help
```
### `backstage-repo-tools schema openapi test`
### `backstage-repo-tools repo schema openapi test`
```
Usage: backstage-repo-tools schema openapi test [options] [paths...]
Usage: backstage-repo-tools repo schema openapi test [options] [paths...]
Options:
--update
-h, --help
```
### `backstage-repo-tools schema openapi verify`
### `backstage-repo-tools repo schema openapi verify`
```
Usage: backstage-repo-tools schema openapi verify [options] [paths...]
Usage: backstage-repo-tools repo schema openapi verify [options] [paths...]
Options:
-h, --help
+58 -29
View File
@@ -18,12 +18,59 @@ import { assertError } from '@backstage/errors';
import { Command } from 'commander';
import { exitWithError } from '../lib/errors';
function registerSchemaCommand(program: Command) {
function registerPackageCommand(program: Command) {
const command = program
.command('package [command]')
.description('Various tools for working with specific packages.');
const schemaCommand = command
.command('schema [command]')
.description(
"Various tools for working with specific packages' API schema",
);
const openApiCommand = schemaCommand
.command('openapi [command]')
.description('Tooling for OpenAPI schema');
openApiCommand
.command('init')
.description(
'Initialize any required files to use the OpenAPI tooling for this package.',
)
.action(
lazy(() =>
import('./package/schema/openapi/init').then(m => m.singleCommand),
),
);
openApiCommand
.command('generate')
.option(
'--client-package [package]',
'Top-level path to where the client should be generated, ie packages/catalog-client.',
)
.option('--server')
.description(
'Command to generate a client and/or a server stub from an OpenAPI spec.',
)
.action(
lazy(() =>
import('./package/schema/openapi/generate').then(m => m.command),
),
);
}
function registerRepoCommand(program: Command) {
const command = program
.command('repo [command]')
.description('Tools for working across your entire repository.');
const schemaCommand = command
.command('schema [command]')
.description('Various tools for working with API schema');
const openApiCommand = command
const openApiCommand = schemaCommand
.command('openapi [command]')
.description('Tooling for OpenApi schema');
@@ -33,16 +80,9 @@ function registerSchemaCommand(program: Command) {
'Verify that all OpenAPI schemas are valid and have a matching `schemas/openapi.generated.ts` file.',
)
.action(
lazy(() => import('./openapi/schema/verify').then(m => m.bulkCommand)),
);
openApiCommand
.command('generate [paths...]')
.description(
'Generates a Typescript file from an OpenAPI yaml spec. For use with the `@backstage/backend-openapi-utils` ApiRouter type.',
)
.action(
lazy(() => import('./openapi/schema/generate').then(m => m.bulkCommand)),
lazy(() =>
import('./repo/schema/openapi/verify').then(m => m.bulkCommand),
),
);
openApiCommand
@@ -52,27 +92,16 @@ function registerSchemaCommand(program: Command) {
'--strict',
'Fail on any linting severity messages, not just errors.',
)
.action(lazy(() => import('./openapi/lint').then(m => m.bulkCommand)));
.action(
lazy(() => import('./repo/schema/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)));
openApiCommand
.command('generate-client')
.requiredOption('--input-spec <file>')
.requiredOption('--output-directory <directory>')
.action(
lazy(() =>
import('./openapi/client/generate').then(m => m.singleCommand),
),
lazy(() => import('./repo/schema/openapi/test').then(m => m.bulkCommand)),
);
}
@@ -139,8 +168,8 @@ export function registerCommands(program: Command) {
),
),
);
registerSchemaCommand(program);
registerPackageCommand(program);
registerRepoCommand(program);
}
// Wraps an action function so that it always exits and handles errors
@@ -16,16 +16,23 @@
import chalk from 'chalk';
import { resolve } from 'path';
import { OPENAPI_IGNORE_FILES, OUTPUT_PATH } from '../constants';
import { paths as cliPaths } from '../../../lib/paths';
import {
OPENAPI_IGNORE_FILES,
OUTPUT_PATH,
} from '../../../../../lib/openapi/constants';
import { paths as cliPaths } from '../../../../../lib/paths';
import { mkdirpSync } from 'fs-extra';
import fs from 'fs-extra';
import { exec } from '../../../lib/exec';
import { exec } from '../../../../../lib/exec';
import { resolvePackagePath } from '@backstage/backend-common';
import { getPathToCurrentOpenApiSpec } from '../../../../../lib/openapi/helpers';
async function generate(spec: string, outputDirectory: string) {
const resolvedOpenapiPath = resolve(spec);
const resolvedOutputDirectory = resolve(outputDirectory, OUTPUT_PATH);
async function generate(outputDirectory: string) {
const resolvedOpenapiPath = await getPathToCurrentOpenApiSpec();
const resolvedOutputDirectory = cliPaths.resolveTargetRoot(
outputDirectory,
OUTPUT_PATH,
);
mkdirpSync(resolvedOutputDirectory);
await fs.mkdirp(resolvedOutputDirectory);
@@ -80,19 +87,15 @@ async function generate(spec: string, outputDirectory: string) {
});
}
export async function singleCommand({
inputSpec,
outputDirectory,
}: {
inputSpec: string;
outputDirectory: string;
}): Promise<void> {
export async function command(outputPackage: string): Promise<void> {
try {
await generate(inputSpec, outputDirectory);
console.log(chalk.green(`Generated client for ${inputSpec}`));
await generate(outputPackage);
console.log(
chalk.green(`Generated client in ${outputPackage}/${OUTPUT_PATH}`),
);
} catch (err) {
console.log();
console.log(chalk.red(`Client generation failed in ${outputDirectory}:`));
console.log(chalk.red(`Client generation failed:`));
console.log(err);
process.exit(1);
@@ -0,0 +1,34 @@
/*
* 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 chalk from 'chalk';
import { OptionValues } from 'commander';
import { command as generateClient } from './client';
import { command as generateServer } from './server';
export async function command(opts: OptionValues) {
if (!opts.clientPackage && !opts.server) {
console.log(
chalk.red('Either --client-package or --server must be defined.'),
);
process.exit(1);
}
if (opts.clientPackage) {
await generateClient(opts.clientPackage);
}
if (opts.server) {
await generateServer();
}
}
@@ -17,30 +17,19 @@
import fs from 'fs-extra';
import YAML from 'js-yaml';
import chalk from 'chalk';
import { resolve } from 'path';
import { paths as cliPaths } from '../../../lib/paths';
import { runner } from '../runner';
import { TS_SCHEMA_PATH, YAML_SCHEMA_PATH } from '../constants';
import { paths as cliPaths } from '../../../../../lib/paths';
import { TS_SCHEMA_PATH } from '../../../../../lib/openapi/constants';
import { promisify } from 'util';
import { exec as execCb } from 'child_process';
import { getPathToCurrentOpenApiSpec } from '../../../../../lib/openapi/helpers';
const exec = promisify(execCb);
async function generate(
directoryPath: string,
config?: { skipMissingYamlFile: boolean },
) {
const { skipMissingYamlFile } = config ?? {};
const openapiPath = resolve(directoryPath, YAML_SCHEMA_PATH);
if (!(await fs.pathExists(openapiPath))) {
if (skipMissingYamlFile) {
return;
}
throw new Error(`Could not find ${YAML_SCHEMA_PATH} in root of directory.`);
}
async function generate() {
const openapiPath = await getPathToCurrentOpenApiSpec();
const yaml = YAML.load(await fs.readFile(openapiPath, 'utf8'));
const tsPath = resolve(directoryPath, TS_SCHEMA_PATH);
const tsPath = cliPaths.resolveTarget(TS_SCHEMA_PATH);
// The first set of comment slashes allow for the eslint notice plugin to run
// with onNonMatchingHeader: 'replace', as is the case in the open source
@@ -63,33 +52,19 @@ export const createOpenApiRouter = async (
await exec(`yarn backstage-cli package lint --fix ${tsPath}`);
if (await cliPaths.resolveTargetRoot('node_modules/.bin/prettier')) {
await exec(`yarn prettier --write ${tsPath}`);
await exec(`yarn prettier --write ${tsPath}`, {
cwd: cliPaths.targetRoot,
});
}
}
export async function bulkCommand(paths: string[] = []): Promise<void> {
const resultsList = await runner(paths, (dir: string) =>
generate(dir, { skipMissingYamlFile: true }),
);
let failed = false;
for (const { relativeDir, resultText } of resultsList) {
if (resultText) {
console.log();
console.log(
chalk.red(
`OpenAPI yaml to Typescript generation failed in ${relativeDir}:`,
),
);
console.log(resultText.trimStart());
failed = true;
}
}
if (failed) {
process.exit(1);
} else {
export async function command(): Promise<void> {
try {
await generate();
console.log(chalk.green('Generated all files.'));
} catch (err) {
console.log(chalk.red(`OpenAPI server stub generation failed.`));
console.log(err.message);
process.exit(1);
}
}
@@ -14,27 +14,30 @@
* 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 { YAML_SCHEMA_PATH } from '../../../../lib/openapi/constants';
import { paths as cliPaths } from '../../../../lib/paths';
import chalk from 'chalk';
import { exec } from '../../../lib/exec';
import { exec } from '../../../../lib/exec';
import {
getPathToCurrentOpenApiSpec,
getRelativePathToFile,
} from '../../../../lib/openapi/helpers';
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))) {
async function init() {
try {
await getPathToCurrentOpenApiSpec();
} catch (err) {
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'`,
`OpenAPI.yaml not found in ${YAML_SCHEMA_PATH}. Please create one and retry this command.`,
);
}
const opticConfigFilePath = join(directoryPath, 'optic.yml');
const opticConfigFilePath = await getRelativePathToFile('optic.yml');
if (await fs.pathExists(opticConfigFilePath)) {
throw new Error(`This directory already has an optic.yml file. Exiting.`);
}
@@ -45,7 +48,9 @@ async function init(directoryPath: string) {
capture:
${YAML_SCHEMA_PATH}:
# 🔧 Runnable example with simple get requests.
# Run with "PORT=3000 optic capture ${YAML_SCHEMA_PATH} --update interactive" in '${directoryPath}'
# Run with "PORT=3000 optic capture ${YAML_SCHEMA_PATH} --update interactive" in '${
cliPaths.targetDir
}'
# 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.
@@ -64,27 +69,13 @@ capture:
}
}
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) {
export async function singleCommand() {
try {
await init();
console.log(chalk.green(`Successfully configured.`));
} catch (err) {
console.log(chalk.red(`OpenAPI tooling initialization failed.`));
console.log(err.message);
process.exit(1);
} else {
console.log(chalk.green(`All directories have already been configured.`));
}
}
@@ -24,24 +24,19 @@ import { Yaml } from '@stoplight/spectral-parsers';
import ruleset from '@apisyouwonthate/style-guide';
import fs from 'fs-extra';
import chalk from 'chalk';
import { resolve } from 'path';
import { runner } from './runner';
import { YAML_SCHEMA_PATH } from './constants';
import { runner } from '../../../../lib/runner';
import { oas } from '@stoplight/spectral-rulesets';
import { DiagnosticSeverity } from '@stoplight/types';
import { pretty } from '@stoplight/spectral-formatters';
import { getPathToOpenApiSpec } from '../../../../lib/openapi/helpers';
async function lint(
directoryPath: string,
config?: { skipMissingYamlFile: boolean; strict: boolean },
) {
const { skipMissingYamlFile, strict } = config ?? {};
const openapiPath = resolve(directoryPath, YAML_SCHEMA_PATH);
if (!(await fs.pathExists(openapiPath))) {
if (skipMissingYamlFile) {
return;
}
throw new Error(`Could not find a file at ${openapiPath}.`);
async function lint(directoryPath: string, config?: { strict: boolean }) {
const { strict } = config ?? {};
let openapiPath = '';
try {
openapiPath = await getPathToOpenApiSpec(directoryPath);
} catch {
return;
}
const openapiFileContent = await fs.readFile(openapiPath, 'utf8');
@@ -90,7 +85,7 @@ export async function bulkCommand(
options: { strict?: boolean },
): Promise<void> {
const resultsList = await runner(paths, (dir: string) =>
lint(dir, { skipMissingYamlFile: true, strict: !!options.strict }),
lint(dir, { strict: !!options.strict }),
);
let failed = false;
@@ -17,18 +17,22 @@
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';
import { runner } from '../../../../lib/runner';
import { YAML_SCHEMA_PATH } from '../../../../lib/openapi/constants';
import { paths as cliPaths } from '../../../../lib/paths';
import { exec } from '../../../../lib/exec';
import { getPathToOpenApiSpec } from '../../../../lib/openapi/helpers';
async function test(
directoryPath: string,
{ port }: { port: number },
options?: { update?: boolean },
) {
const openapiPath = join(directoryPath, YAML_SCHEMA_PATH);
if (!(await fs.pathExists(openapiPath))) {
let openapiPath = join(directoryPath, YAML_SCHEMA_PATH);
try {
openapiPath = await getPathToOpenApiSpec(directoryPath);
} catch {
// OpenAPI schema doesn't exist.
return;
}
const opticConfigFilePath = join(directoryPath, 'optic.yml');
@@ -21,13 +21,21 @@ import { join } from 'path';
import chalk from 'chalk';
import { relative as relativePath, resolve as resolvePath } from 'path';
import Parser from '@apidevtools/swagger-parser';
import { runner } from '../runner';
import { paths as cliPaths } from '../../../lib/paths';
import { TS_MODULE, TS_SCHEMA_PATH, YAML_SCHEMA_PATH } from '../constants';
import { runner } from '../../../../lib/runner';
import { paths as cliPaths } from '../../../../lib/paths';
import {
TS_MODULE,
TS_SCHEMA_PATH,
YAML_SCHEMA_PATH,
} from '../../../../lib/openapi/constants';
import { getPathToOpenApiSpec } from '../../../../lib/openapi/helpers';
async function verify(directoryPath: string) {
const openapiPath = join(directoryPath, YAML_SCHEMA_PATH);
if (!(await fs.pathExists(openapiPath))) {
let openapiPath = '';
try {
openapiPath = await getPathToOpenApiSpec(directoryPath);
} catch {
// Unable to find spec at path.
return;
}
@@ -47,7 +55,7 @@ async function verify(directoryPath: string) {
if (!isEqual(schema.spec, yaml)) {
const path = relativePath(cliPaths.targetRoot, directoryPath);
throw new Error(
`\`${YAML_SCHEMA_PATH}\` and \`${TS_SCHEMA_PATH}\` do not match. Please run \`yarn backstage-repo-tools schema openapi generate ${path}\` to regenerate \`${TS_SCHEMA_PATH}\`.`,
`\`${YAML_SCHEMA_PATH}\` and \`${TS_SCHEMA_PATH}\` do not match. Please run \`yarn backstage-repo-tools package schema openapi generate\` from '${path}' to regenerate \`${TS_SCHEMA_PATH}\`.`,
);
}
}
@@ -0,0 +1,43 @@
/*
* 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 { pathExists } from 'fs-extra';
import { paths } from '../paths';
import { YAML_SCHEMA_PATH } from './constants';
import { resolve } from 'path';
export const getPathToFile = async (directory: string, filename: string) => {
return resolve(directory, filename);
};
export const getRelativePathToFile = async (filename: string) => {
return await getPathToFile(paths.targetDir, filename);
};
export const assertExists = async (path: string) => {
if (!(await pathExists(path))) {
throw new Error(`Could not find ${path}.`);
}
return path;
};
export const getPathToOpenApiSpec = async (directory: string) => {
return await assertExists(await getPathToFile(directory, YAML_SCHEMA_PATH));
};
export const getPathToCurrentOpenApiSpec = async () => {
return await assertExists(await getRelativePathToFile(YAML_SCHEMA_PATH));
};
@@ -14,10 +14,10 @@
* limitations under the License.
*/
import { resolvePackagePaths } from '../../lib/paths';
import { resolvePackagePaths } from './paths';
import pLimit from 'p-limit';
import { relative as relativePath } from 'path';
import { paths as cliPaths } from '../../lib/paths';
import { paths as cliPaths } from './paths';
import portFinder from 'portfinder';
export async function runner(