Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(openapi): exposing our spec conversion tooling to a new :convert command #717

Merged
merged 9 commits into from
Dec 19, 2022
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ With `rdme`, you have access to a variety of tools to manage your API definition
- [Validation](#validating-an-api-definition) ✅
- [Reduction](#reducing-an-api-definition) 📉
- [Inspection](#inspecting-an-api-definition) 🔍
- [Conversion](#converting-an-api-definition) ⏩

`rdme` supports [OpenAPI 3.1](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md), [OpenAPI 3.0](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md), and [Swagger 2.x](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/2.0.md).

Expand Down Expand Up @@ -262,12 +263,12 @@ The command will ask you a couple questions about how you wish to reduce the fil

- The input API definition is called `petstore.json`
- The file is reduced to only the `/pet/{id}` path and the `GET` and `PUT` methods
- The output file is called `petstore-reduced.json`
- The output file is called `petstore.reduced.json`

Here's what the resulting command looks like:

```
rdme openapi:reduce petstore.json --path /pet/{id} --method get --method put --out petstore-reduced.json
rdme openapi:reduce petstore.json --path /pet/{id} --method get --method put --out petstore.reduced.json
```

As with the `openapi` command, you can also [omit the file path](#omitting-the-file-path).
Expand All @@ -288,6 +289,22 @@ rdme openapi:inspect [url-or-local-path-to-file] --feature circularRefs --featur

As with the `openapi` command, you can also [omit the file path](#omitting-the-file-path).

#### Converting an API definition

<!--alex ignore postman-postwoman-->

You can also convert any Swagger or Postman Collection to an OpenAPI 3.0 definition.

```sh
rdme openapi:convert [url-or-local-path-to-file]
```

Similar to the `openapi` command, you can also [omit the file path](#omitting-the-file-path).

> **Note:**
>
> All of our OpenAPI commands already do this conversion automatically, but in case you need to utilize this exclusive functionality outside of the context of those, you can.

### Docs (a.k.a. Guides) 📖

The Markdown files will require YAML front matter with certain ReadMe documentation attributes. Check out [our docs](https://docs.readme.com/docs/rdme#markdown-file-setup) for more info on setting up your front matter.
Expand Down
3 changes: 3 additions & 0 deletions __tests__/__snapshots__/index.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Options

Related commands

$ rdme openapi:convert Convert a Swagger or Postman Collection to OpenAPI.
$ rdme openapi:inspect Analyze an OpenAPI/Swagger definition for various OpenAPI and ReadMe
feature usage.
$ rdme openapi:reduce Reduce an OpenAPI definition into a smaller subset.
Expand Down Expand Up @@ -67,6 +68,7 @@ Options

Related commands

$ rdme openapi:convert Convert a Swagger or Postman Collection to OpenAPI.
$ rdme openapi:inspect Analyze an OpenAPI/Swagger definition for various OpenAPI and ReadMe
feature usage.
$ rdme openapi:reduce Reduce an OpenAPI definition into a smaller subset.
Expand Down Expand Up @@ -104,6 +106,7 @@ Options

Related commands

$ rdme openapi:convert Convert a Swagger or Postman Collection to OpenAPI.
$ rdme openapi:inspect Analyze an OpenAPI/Swagger definition for various OpenAPI and ReadMe
feature usage.
$ rdme openapi:reduce Reduce an OpenAPI definition into a smaller subset.
Expand Down
81 changes: 81 additions & 0 deletions __tests__/cmds/openapi/convert.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import fs from 'fs';

import prompts from 'prompts';

import OpenAPIConvertCommand from '../../../src/cmds/openapi/convert';

const convert = new OpenAPIConvertCommand();

const successfulConversion = () => 'Your converted API definition has been saved to output.json!';

const testWorkingDir = process.cwd();

describe('rdme openapi:convert', () => {
afterEach(() => {
process.chdir(testWorkingDir);

jest.clearAllMocks();
});

describe('converting', () => {
it.each([
['Swagger 2.0', 'json', '2.0'],
['Swagger 2.0', 'yaml', '2.0'],
])('should support reducing a %s definition (format: %s)', async (_, format, specVersion) => {
const spec = require.resolve(`@readme/oas-examples/${specVersion}/${format}/petstore-simple.${format}`);

let reducedSpec;
fs.writeFileSync = jest.fn((fileName, data) => {
reducedSpec = JSON.parse(data as string);
});

prompts.inject(['output.json']);

await expect(
convert.run({
spec,
})
).resolves.toBe(successfulConversion());

expect(fs.writeFileSync).toHaveBeenCalledWith('output.json', expect.any(String));
expect(reducedSpec.tags).toHaveLength(1);
expect(Object.keys(reducedSpec.paths)).toStrictEqual(['/pet/{petId}']);
expect(Object.keys(reducedSpec.paths['/pet/{petId}'])).toStrictEqual(['get', 'post', 'delete']);
});

it('should convert with no prompts via opts', async () => {
const spec = 'petstore-simple.json';

let reducedSpec;
fs.writeFileSync = jest.fn((fileName, data) => {
reducedSpec = JSON.parse(data as string);
});

await expect(
convert.run({
spec,
workingDirectory: require.resolve(`@readme/oas-examples/2.0/json/${spec}`).replace(spec, ''),
out: 'output.json',
})
).resolves.toBe(successfulConversion());

expect(fs.writeFileSync).toHaveBeenCalledWith('output.json', expect.any(String));
expect(Object.keys(reducedSpec.paths)).toStrictEqual(['/pet/{petId}']);
expect(Object.keys(reducedSpec.paths['/pet/{petId}'])).toStrictEqual(['get', 'post', 'delete']);
});
});

describe('error handling', () => {
it.each([['json'], ['yaml']])('should fail if given an OpenAPI 3.0 definition (format: %s)', async format => {
const spec = require.resolve(`@readme/oas-examples/3.0/${format}/petstore.${format}`);

await expect(
convert.run({
spec,
})
).rejects.toStrictEqual(
new Error("Sorry, this API definition is already an OpenAPI definition and doesn't need to be converted.")
);
});
});
});
5 changes: 5 additions & 0 deletions __tests__/lib/__snapshots__/commands.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ exports[`utils #listByCategory should list commands by category 1`] = `
"hidden": false,
"name": "openapi",
},
{
"description": "Convert a Swagger or Postman Collection to OpenAPI.",
"hidden": false,
"name": "openapi:convert",
},
{
"description": "Analyze an OpenAPI/Swagger definition for various OpenAPI and ReadMe feature usage.",
"hidden": false,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@
"bump": "npm version -m 'build: %s release'",
"debug": "ts-node src/cli.ts",
"lint": "eslint . bin/rdme --ext .js,.ts",
"lint-docs": "npx alex . && npm run prettier:docs",
"lint-docs": "npx -y alex . && npm run prettier:docs",
erunion marked this conversation as resolved.
Show resolved Hide resolved
"postversion": "git tag $npm_package_version && ./bin/set-major-version-tag.js",
"prebuild": "rm -rf dist/",
"prepack": "npm run build",
Expand Down
2 changes: 2 additions & 0 deletions src/cmds/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import LogoutCommand from './logout';
import OASCommand from './oas';
import OpenCommand from './open';
import OpenAPICommand from './openapi';
import OpenAPIConvertCommand from './openapi/convert';
import OpenAPIInspectCommand from './openapi/inspect';
import OpenAPIReduceCommand from './openapi/reduce';
import OpenAPIValidateCommand from './openapi/validate';
Expand Down Expand Up @@ -47,6 +48,7 @@ const commands = {
open: OpenCommand,

openapi: OpenAPICommand,
'openapi:convert': OpenAPIConvertCommand,
'openapi:inspect': OpenAPIInspectCommand,
'openapi:reduce': OpenAPIReduceCommand,
'openapi:validate': OpenAPIValidateCommand,
Expand Down
90 changes: 90 additions & 0 deletions src/cmds/openapi/convert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { CommandOptions } from '../../lib/baseCommand';

import fs from 'fs';
import path from 'path';

import chalk from 'chalk';
import prompts from 'prompts';

import Command, { CommandCategories } from '../../lib/baseCommand';
import { checkFilePath } from '../../lib/checkFile';
import prepareOas from '../../lib/prepareOas';
import promptTerminal from '../../lib/promptWrapper';

export interface Options {
spec?: string;
out?: string;
workingDirectory?: string;
}

export default class OpenAPIConvertCommand extends Command {
constructor() {
super();

this.command = 'openapi:convert';
this.usage = 'openapi:convert [file|url] [options]';
this.description = 'Convert a Swagger or Postman Collection to OpenAPI.';
this.cmdCategory = CommandCategories.APIS;

this.hiddenArgs = ['spec'];
this.args = [
{
name: 'spec',
type: String,
defaultOption: true,
},
{
name: 'out',
type: String,
description: 'Output file path to write converted file to',
},
{
name: 'workingDirectory',
type: String,
description: 'Working directory (for usage with relative external references)',
},
];
}

async run(opts: CommandOptions<Options>) {
await super.run(opts);

const { spec, workingDirectory } = opts;

if (workingDirectory) {
process.chdir(workingDirectory);
}

const { preparedSpec, specPath, specType } = await prepareOas(spec, 'openapi:convert', { convertToLatest: true });
const parsedPreparedSpec = JSON.parse(preparedSpec);

if (specType === 'OpenAPI') {
throw new Error("Sorry, this API definition is already an OpenAPI definition and doesn't need to be converted.");
}

prompts.override({
outputPath: opts.out,
});

const promptResults = await promptTerminal([
{
type: 'text',
name: 'outputPath',
message: 'Enter the path to save your converted API definition to:',
initial: () => {
const extension = path.extname(specPath);
return `${path.basename(specPath).split(extension)[0]}.openapi${extension}`;
erunion marked this conversation as resolved.
Show resolved Hide resolved
},
validate: value => checkFilePath(value),
},
]);

Command.debug(`saving converted spec to ${promptResults.outputPath}`);

fs.writeFileSync(promptResults.outputPath, JSON.stringify(parsedPreparedSpec, null, 2));

Command.debug('converted spec saved');

return Promise.resolve(chalk.green(`Your converted API definition has been saved to ${promptResults.outputPath}!`));
}
}
4 changes: 2 additions & 2 deletions src/cmds/openapi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export default class OpenAPICommand extends Command {

// Reason we're hardcoding in command here is because `swagger` command
// relies on this and we don't want to use `swagger` in this function
const { bundledSpec, specPath, specType, specVersion } = await prepareOas(spec, 'openapi');
const { preparedSpec, specPath, specType, specVersion } = await prepareOas(spec, 'openapi');

if (useSpecVersion) {
Command.info(
Expand Down Expand Up @@ -218,7 +218,7 @@ export default class OpenAPICommand extends Command {
});
};

const registryUUID = await streamSpecToRegistry(bundledSpec);
const registryUUID = await streamSpecToRegistry(preparedSpec);

const options: RequestInit = {
headers: cleanHeaders(
Expand Down
6 changes: 3 additions & 3 deletions src/cmds/openapi/inspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,9 +242,9 @@ export default class OpenAPIInspectCommand extends Command {
process.chdir(workingDirectory);
}

const { bundledSpec, definitionVersion } = await prepareOas(spec, 'openapi:inspect', { convertToLatest: true });
const { preparedSpec, definitionVersion } = await prepareOas(spec, 'openapi:inspect', { convertToLatest: true });
this.definitionVersion = definitionVersion.version;
const parsedBundledSpec = JSON.parse(bundledSpec);
const parsedPreparedSpec = JSON.parse(preparedSpec);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this would have been hilarious if it was called parsedStringifiedSpec


const spinner = ora({ ...oraOptions() });
if (features?.length) {
Expand All @@ -257,7 +257,7 @@ export default class OpenAPIInspectCommand extends Command {
spinner.start('Analyzing your API definition for OpenAPI and ReadMe feature usage...');
}

const analysis = await analyzeOas(parsedBundledSpec).catch(err => {
const analysis = await analyzeOas(parsedPreparedSpec).catch(err => {
Command.debug(`analyzer err: ${err.message}`);
spinner.fail();
throw err;
Expand Down
14 changes: 7 additions & 7 deletions src/cmds/openapi/reduce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ export default class OpenAPIReduceCommand extends Command {
process.chdir(workingDirectory);
}

const { bundledSpec, specPath, specType } = await prepareOas(spec, 'openapi:reduce');
const parsedBundledSpec = JSON.parse(bundledSpec);
const { preparedSpec, specPath, specType } = await prepareOas(spec, 'openapi:reduce');
const parsedPreparedSpec = JSON.parse(preparedSpec);

if (specType !== 'OpenAPI') {
throw new Error('Sorry, this reducer feature in rdme only supports OpenAPI 3.0+ definitions.');
Expand Down Expand Up @@ -117,7 +117,7 @@ export default class OpenAPIReduceCommand extends Command {
choices: () => {
const tags: string[] = JSONPath({
path: '$..paths[*].tags',
json: parsedBundledSpec,
json: parsedPreparedSpec,
resultType: 'value',
}).flat();

Expand All @@ -133,7 +133,7 @@ export default class OpenAPIReduceCommand extends Command {
message: 'Choose which paths to reduce by:',
min: 1,
choices: () => {
return Object.keys(parsedBundledSpec.paths).map(p => ({
return Object.keys(parsedPreparedSpec.paths).map(p => ({
title: p,
value: p,
}));
Expand All @@ -147,7 +147,7 @@ export default class OpenAPIReduceCommand extends Command {
choices: (prev, values) => {
const paths: string[] = values.paths;
let methods = paths
.map((p: string) => Object.keys(parsedBundledSpec.paths[p] || {}))
.map((p: string) => Object.keys(parsedPreparedSpec.paths[p] || {}))
.flat()
.filter((method: string) => method.toLowerCase() !== 'parameters');

Expand All @@ -173,7 +173,7 @@ export default class OpenAPIReduceCommand extends Command {
message: 'Enter the path to save your reduced API definition to:',
initial: () => {
const extension = path.extname(specPath);
return `${path.basename(specPath).split(extension)[0]}-reduced${extension}`;
return `${path.basename(specPath).split(extension)[0]}.reduced${extension}`;
},
validate: value => checkFilePath(value),
},
Expand All @@ -196,7 +196,7 @@ export default class OpenAPIReduceCommand extends Command {
let reducedSpec;

try {
reducedSpec = oasReducer(parsedBundledSpec, {
reducedSpec = oasReducer(parsedPreparedSpec, {
tags: promptResults.tags || [],
paths: (promptResults.paths || []).reduce((acc: Record<string, string[]>, p: string) => {
acc[p] = promptResults.methods;
Expand Down
12 changes: 10 additions & 2 deletions src/lib/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,17 @@ const debugPackage = debugModule(config.get('cli'));
/**
* Wrapper for debug statements.
*/
function debug(input: string) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function debug(input: any) {
/* istanbul ignore next */
if (isGHA() && !isTest()) core.debug(`rdme: ${input}`);
if (isGHA() && !isTest()) {
if (typeof input === 'object') {
core.debug(`rdme: ${JSON.stringify(input)}`);
erunion marked this conversation as resolved.
Show resolved Hide resolved
} else {
core.debug(`rdme: ${input}`);
}
}

return debugPackage(input);
}

Expand Down
Loading