Skip to content

Commit

Permalink
feat(openapi): exposing our spec conversion tooling to a new `:conver…
Browse files Browse the repository at this point in the history
…t` command (#717)

* feat(openapi): exposing our spec conversion tooling to a new `:convert` command

* fix: alex issues

* fix: eslint issues

* Update README.md

Co-authored-by: Kanad Gupta <[email protected]>

* Update src/cmds/openapi/convert.ts

Co-authored-by: Kanad Gupta <[email protected]>

* chore: tweaking the reduce command a bit

* fix: pr feedback

* fix: pr feedback

* chore: revert change to package.json

Co-authored-by: Kanad Gupta <[email protected]>
  • Loading branch information
erunion and kanadgupta authored Dec 19, 2022
1 parent b5c64c8 commit 0482a1f
Show file tree
Hide file tree
Showing 11 changed files with 235 additions and 23 deletions.
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: 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}`;
},
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);

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)}`);
} else {
core.debug(`rdme: ${input}`);
}
}

return debugPackage(input);
}

Expand Down
Loading

0 comments on commit 0482a1f

Please sign in to comment.