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 3 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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ With `rdme`, you have access to a variety of tools to manage your API definition

- [Syncing](#syncing-an-api-definition-to-readme) 🦉
- [Validation](#validating-an-api-definition) ✅
- [Conversion](#converting-an-api-definition) ⏩
erunion marked this conversation as resolved.
Show resolved Hide resolved
- [Reduction](#reducing-an-api-definition) 📉
- [Inspection](#inspecting-an-api-definition) 🔍

Expand Down Expand Up @@ -250,6 +251,22 @@ rdme openapi:validate [url-or-local-path-to-file]

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

#### Converting an API definition

<!--alex ignore postman-postwoman-->
erunion marked this conversation as resolved.
Show resolved Hide resolved

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 incase you need to utilize this exclusive functionality outside of the context of those you can.
erunion marked this conversation as resolved.
Show resolved Hide resolved

#### Reducing an API Definition

We also offer a tool that allows you to reduce a large API definition down to a specific set of tags or paths (again, no ReadMe account required!). This can be useful if you're debugging a problematic schema somewhere, or if you have a file that is too big to maintain.
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 reduced file to',
erunion marked this conversation as resolved.
Show resolved Hide resolved
},
{
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 { bundledSpec, specPath, specType } = await prepareOas(spec, 'openapi:convert', { convertToLatest: true });
const parsedBundledSpec = JSON.parse(bundledSpec);

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(parsedBundledSpec, null, 2));

Command.debug('converted spec saved');

return Promise.resolve(chalk.green(`Your converted API definition has been saved to ${promptResults.outputPath}!`));
}
}
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
14 changes: 10 additions & 4 deletions src/lib/prepareOas.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { OASDocument } from 'oas/dist/rmoas.types';

import chalk from 'chalk';
import OASNormalize, { getAPIDefinitionType } from 'oas-normalize';
import ora from 'ora';
Expand Down Expand Up @@ -35,7 +37,7 @@ const capitalizeSpecType = (type: string) =>
*/
export default async function prepareOas(
path: string,
command: 'openapi' | 'openapi:inspect' | 'openapi:reduce' | 'openapi:validate',
command: 'openapi' | 'openapi:convert' | 'openapi:inspect' | 'openapi:reduce' | 'openapi:validate',
opts: {
/**
* Optionally convert the supplied or discovered API definition to the latest OpenAPI release.
Expand Down Expand Up @@ -65,13 +67,13 @@ export default async function prepareOas(

const fileFindingSpinner = ora({ text: 'Looking for API definitions...', ...oraOptions() }).start();

let action: 'inspect' | 'reduce' | 'upload' | 'validate';
let action: 'convert' | 'inspect' | 'reduce' | 'upload' | 'validate';
switch (command) {
case 'openapi':
action = 'upload';
break;
default:
action = command.split(':')[1] as 'inspect' | 'reduce' | 'validate';
action = command.split(':')[1] as 'convert' | 'inspect' | 'reduce' | 'validate';
}

const jsonAndYamlFiles = readdirRecursive('.', true).filter(
Expand Down Expand Up @@ -164,7 +166,7 @@ export default async function prepareOas(
});

// If we were supplied a Postman collection this will **always** convert it to OpenAPI 3.0.
const api = await oas.validate({ convertToLatest: opts.convertToLatest }).catch((err: Error) => {
const api: OASDocument = await oas.validate({ convertToLatest: opts.convertToLatest }).catch((err: Error) => {
spinner.fail();
debug(`raw validation error object: ${JSON.stringify(err)}`);
throw err;
Expand All @@ -188,6 +190,10 @@ export default async function prepareOas(
});

debug('spec bundled');
} else if (command === 'openapi:convert') {
// As `openapi:convert` is purely for converting a spec to OpenAPI we don't need to do any
// bundling work as those'll be handled in other commands.
bundledSpec = JSON.stringify(api);
erunion marked this conversation as resolved.
Show resolved Hide resolved
}

return {
Expand Down