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): adding a command to resolve circular and recursive references #1063

Open
wants to merge 32 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
59bb8f2
Adding refs to the list of openapi commands
Nov 11, 2024
d82700a
Code to execute the openapi:refs command
Nov 11, 2024
099a6f1
style: apply Prettier formatting
Nov 11, 2024
95208cf
Add 'openapi:refs' command and update related test snapshots
Nov 11, 2024
6249347
docs(openapi) command description
Nov 14, 2024
0a5f141
fix(openapi) use of a premade solution for processing a file
Nov 14, 2024
03c092c
Merge branch 'next' into oleh/openapi-adding-command-to-solve-circula…
olehshh Nov 14, 2024
3660917
chore: update package-lock.json
Nov 19, 2024
7ca5197
Merge branch 'next' of https://github.com/readmeio/rdme into oleh/ope…
Nov 19, 2024
597ece5
refactor: align code with recent updates
Nov 19, 2024
0b87569
Merge branch 'oleh/openapi-adding-command-to-solve-circularity-and-re…
Nov 19, 2024
3039ea0
chore: minor correction
Nov 19, 2024
dca7b9f
Merge branch 'next' into oleh/openapi-adding-command-to-solve-circula…
olehshh Nov 19, 2024
349b9f8
Merge branch 'next' into oleh/openapi-adding-command-to-solve-circula…
olehshh Nov 19, 2024
88bd34e
Merge branch 'next' into oleh/openapi-adding-command-to-solve-circula…
olehshh Nov 19, 2024
0a3c4e0
Merge branch 'next' into oleh/openapi-adding-command-to-solve-circula…
olehshh Nov 19, 2024
0c0027e
test: openapi:refs
Nov 19, 2024
18f7b4e
Merge branch 'oleh/openapi-adding-command-to-solve-circularity-and-re…
Nov 19, 2024
8fcc065
Merge branch 'next' into oleh/openapi-adding-command-to-solve-circula…
olehshh Nov 20, 2024
9fa0ca3
Merge branch 'next' into oleh/openapi-adding-command-to-solve-circula…
olehshh Nov 20, 2024
a4b1fc0
Merge branch 'next' into oleh/openapi-adding-command-to-solve-circula…
olehshh Nov 21, 2024
c0aa486
Merge remote-tracking branch 'origin/next' into oleh/openapi-adding-c…
Nov 22, 2024
d938932
docs: updating documentation after merge
Nov 22, 2024
44571ae
chore: small text change
Nov 22, 2024
3f1795b
Merge branch 'next' into oleh/openapi-adding-command-to-solve-circula…
olehshh Nov 25, 2024
b01b18a
refactor: align code with recent updates
Nov 25, 2024
1d1c7e2
feat: add check and display appropriate message for files that cannot…
Nov 25, 2024
1a25b65
test: unresolvable files
Nov 25, 2024
751c52a
chore: gaps
Nov 29, 2024
19875c1
feat: processing of circularity that cannot be processed
Nov 29, 2024
43f2c82
test: unprocessable files
Nov 29, 2024
37f4d5b
chore: correction
Nov 29, 2024
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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ With `rdme`, you have access to a variety of tools to manage your API definition
- [Reduction](#reducing-an-api-definition) 📉
- [Inspection](#inspecting-an-api-definition) 🔍
- [Conversion](#converting-an-api-definition) ⏩
- [References](#simplifying-circular-references-in-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 @@ -323,6 +324,24 @@ Similar to the `openapi` command, you can also [omit the file path](#omitting-th
> [!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.

#### Simplifying Circular References in API definition

This command addresses limitations in ReadMe’s support for circular or recursive references within OpenAPI specifications. It automatically identifies and replaces these references with simplified object schemas, ensuring compatibility for seamless display in the ReadMe platform. As a result, instead of displaying an empty form, as would occur with schemas containing such references, you will receive a flattened representation of the object, showing what the object can potentially contain, including references to itself.

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

The `openapi:refs` command processes an OpenAPI file by analyzing its schemas for circular or recursive references. When such references are found, they are replaced with object schemas where feasible, restructuring the specification to remove unsupported circular and self-references.

To review any unresolved references, you can use the `openapi:inspect` command.

After processing, the command will prompt you to specify a file path for saving the modified API definition. By default, it will create a `.openapi.json` version of the original input file.

> [!NOTE]
> Complex circular references may require manual inspection and may not be fully resolved.
> A maximum of five iterations will attempt to remove references; unresolved references beyond this limit will require further analysis.

### 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
18 changes: 18 additions & 0 deletions __tests__/__snapshots__/index.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ Related commands
$ 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.
$ rdme openapi:refs The script resolves circular and recursive
references in OpenAPI by replacing them with object
schemas. However, not all circular references can
be resolved. You can run the openapi:inspect
command to identify which references remain
unresolved.
$ rdme openapi:validate Validate your OpenAPI/Swagger definition.
"
`;
Expand Down Expand Up @@ -92,6 +98,12 @@ Related commands
$ 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.
$ rdme openapi:refs The script resolves circular and recursive
references in OpenAPI by replacing them with object
schemas. However, not all circular references can
be resolved. You can run the openapi:inspect
command to identify which references remain
unresolved.
$ rdme openapi:validate Validate your OpenAPI/Swagger definition.
"
`;
Expand Down Expand Up @@ -140,6 +152,12 @@ Related commands
$ 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.
$ rdme openapi:refs The script resolves circular and recursive
references in OpenAPI by replacing them with object
schemas. However, not all circular references can
be resolved. You can run the openapi:inspect
command to identify which references remain
unresolved.
$ rdme openapi:validate Validate your OpenAPI/Swagger definition.
"
`;
Expand Down
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 @@ -49,6 +49,11 @@ exports[`utils > #listByCategory > should list commands by category 1`] = `
"hidden": false,
"name": "openapi:validate",
},
{
"description": "The script resolves circular and recursive references in OpenAPI by replacing them with object schemas. However, not all circular references can be resolved. You can run the openapi:inspect command to identify which references remain unresolved.",
"hidden": false,
"name": "openapi:refs",
},
{
"description": "Alias for \`rdme openapi:validate\` [deprecated].",
"hidden": true,
Expand Down
2 changes: 2 additions & 0 deletions src/cmds/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import OpenAPIConvertCommand from './openapi/convert.js';
import OpenAPICommand from './openapi/index.js';
import OpenAPIInspectCommand from './openapi/inspect.js';
import OpenAPIReduceCommand from './openapi/reduce.js';
import OpenAPISolvingCircularityAndRecursiveness from './openapi/refs.js';
import OpenAPIValidateCommand from './openapi/validate.js';
import ValidateAliasCommand from './validate.js';
import CreateVersionCommand from './versions/create.js';
Expand Down Expand Up @@ -47,6 +48,7 @@ const commands = {
'openapi:inspect': OpenAPIInspectCommand,
'openapi:reduce': OpenAPIReduceCommand,
'openapi:validate': OpenAPIValidateCommand,
'openapi:refs': OpenAPISolvingCircularityAndRecursiveness,

validate: ValidateAliasCommand,
whoami: WhoAmICommand,
Expand Down
245 changes: 245 additions & 0 deletions src/cmds/openapi/refs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
/* eslint-disable no-param-reassign */
import type { ZeroAuthCommandOptions } from '../../lib/baseCommand.js';
import type { OASDocument } from 'oas/types';
import type { IJsonSchema } from 'openapi-types';

import fs from 'node:fs';
import path from 'node:path';

import prompts from 'prompts';

import analyzeOas from '../../lib/analyzeOas.js';
import Command, { CommandCategories } from '../../lib/baseCommand.js';
import prepareOas from '../../lib/prepareOas.js';
import promptTerminal from '../../lib/promptWrapper.js';
import { validateFilePath } from '../../lib/validatePromptInput.js';

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

type SchemaCollection = Record<string, IJsonSchema>;

class OpenAPISolvingCircularityAndRecursiveness extends Command {
constructor() {
super();
this.command = 'openapi:refs';
this.usage = 'openapi:refs [file]';
this.description =
'The script resolves circular and recursive references in OpenAPI by replacing them with object schemas. However, not all circular references can be resolved. You can run the openapi:inspect command to identify which references remain unresolved.';
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',
},
];
}

/**
* Identifies circular references in the OpenAPI document.
* @param {OASDocument} document - The OpenAPI document to analyze.
* @returns {Promise<string[]>} A list of circular reference paths.
*/
static async getCircularRefsFromOas(document: OASDocument): Promise<string[]> {
try {
const analysis = await analyzeOas(document);
const circularRefs = analysis.openapi.circularRefs;
return Array.isArray(circularRefs.locations) ? circularRefs.locations : [];
} catch (error) {
return [`Error analyzing OpenAPI document: ${error}`];
}
}

/**
* Replaces a reference in a schema with an object if it's circular or recursive.
* @param {IJsonSchema} schema - The schema to process.
* @param {string[]} circularRefs - List of circular reference paths.
* @param {string} schemaName - The name of the schema being processed.
* @returns {IJsonSchema} The modified schema or the original.
*/
static replaceRefWithObject(schema: IJsonSchema, circularRefs: string[], schemaName: string): IJsonSchema {
if (schema.$ref) {
const refSchemaName = schema.$ref.split('/').pop() as string;
const isCircular = circularRefs.some(refPath => refPath.includes(refSchemaName));
const isRecursive = schemaName === refSchemaName;

if (schemaName.includes('Ref') && (isCircular || isRecursive)) {
return { type: 'object' } as IJsonSchema;
}
}

return schema;
}

/**
* Recursively replaces references in schemas, transforming circular references to objects.
* @param {IJsonSchema} schema - The schema to process.
* @param {string[]} circularRefs - List of circular reference paths.
* @param {string} schemaName - The name of the schema being processed.
*/
static replaceReferencesInSchema(schema: IJsonSchema, circularRefs: string[], schemaName: string) {
if (schema.type === 'object' && schema.properties) {
for (const prop of Object.keys(schema.properties)) {
let property = JSON.parse(JSON.stringify(schema.properties[prop]));
property = OpenAPISolvingCircularityAndRecursiveness.replaceRefWithObject(property, circularRefs, schemaName);
schema.properties[prop] = property;

// Handle arrays with item references
if (property.type === 'array' && property.items) {
property.items = JSON.parse(JSON.stringify(property.items));
property.items = OpenAPISolvingCircularityAndRecursiveness.replaceRefWithObject(
property.items,
circularRefs,
schemaName,
);
OpenAPISolvingCircularityAndRecursiveness.replaceReferencesInSchema(property.items, circularRefs, schemaName);
}
}
}
}

/**
* Replaces circular references within a collection of schemas.
* @param {SchemaCollection} schemas - Collection of schemas to modify.
* @param {string[]} circularRefs - List of circular reference paths.
*/
static replaceCircularRefs(schemas: SchemaCollection, circularRefs: string[]): void {
const createdRefs = new Set<string>();

function replaceRef(schemaName: string, propertyName: string, refSchemaName: string) {
schemas[schemaName]!.properties![propertyName] = { $ref: `#/components/schemas/${refSchemaName}` } as IJsonSchema;
}

function createRefSchema(originalSchemaName: string, refSchemaName: string) {
if (!createdRefs.has(refSchemaName) && schemas[originalSchemaName]) {
schemas[refSchemaName] = {
type: 'object',
properties: { ...schemas[originalSchemaName].properties },
} as IJsonSchema;
OpenAPISolvingCircularityAndRecursiveness.replaceReferencesInSchema(
schemas[refSchemaName],
circularRefs,
refSchemaName,
);
createdRefs.add(refSchemaName);
}
}

circularRefs.forEach(refPath => {
const refParts = refPath.split('/');
if (refParts.length < 6) {
throw new Error(`Invalid reference path: ${refPath}`);
}

const schemaName = refParts[3];
const propertyName = refParts[5];
const schema = schemas[schemaName];
const property = schema?.properties?.[propertyName];

if (!schema || !property) {
throw new Error(`Schema or property not found for path: ${refPath}`);
}

// Handle references within items in an array
let refSchemaName: string | undefined;
if (
refParts.length > 6 &&
refParts[6] === 'items' &&
property.type === 'array' &&
property.items &&
typeof property.items === 'object'
) {
const itemsRefSchemaName = (property.items as IJsonSchema).$ref?.split('/')[3];
if (itemsRefSchemaName) {
refSchemaName = `${itemsRefSchemaName}Ref`;
property.items = { $ref: `#/components/schemas/${refSchemaName}` } as IJsonSchema;
createRefSchema(itemsRefSchemaName, refSchemaName);
}
} else {
// Handle direct reference
refSchemaName = property.$ref?.split('/')[3];
if (refSchemaName) {
const newRefSchemaName = `${refSchemaName}Ref`;
replaceRef(schemaName, propertyName, newRefSchemaName);
createRefSchema(refSchemaName, newRefSchemaName);
}
}
});
}

/**
* The main execution method for the command.
* @param {ZeroAuthCommandOptions<Options>} opts - Command options.
* @returns {Promise<string>} Result message.
*/
async run(opts: ZeroAuthCommandOptions<Options>): Promise<string> {
await super.run(opts);
const { spec } = opts;
if (!spec) {
return 'File path is required.';
}

const { preparedSpec, specPath } = await prepareOas(spec, 'openapi:refs', { convertToLatest: true });
const openApiData = JSON.parse(preparedSpec);
const circularRefs = await OpenAPISolvingCircularityAndRecursiveness.getCircularRefsFromOas(openApiData);

if (circularRefs.length === 0) {
return 'The file does not contain circular or recursive references.';
}

if (openApiData.components?.schemas && circularRefs.length > 0) {
OpenAPISolvingCircularityAndRecursiveness.replaceCircularRefs(openApiData.components.schemas, circularRefs);

let remainingCircularRefs = await OpenAPISolvingCircularityAndRecursiveness.getCircularRefsFromOas(openApiData);
let iterationCount = 0;

while (remainingCircularRefs.length > 0 && iterationCount < 5) {
OpenAPISolvingCircularityAndRecursiveness.replaceCircularRefs(
openApiData.components.schemas,
remainingCircularRefs,
);
remainingCircularRefs = remainingCircularRefs.length > 0 ? [] : remainingCircularRefs;
iterationCount += 1;
}

if (iterationCount >= 5) {
return 'Maximum iteration limit reached. Some circular references may remain unresolved.';
}
}

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

const promptResults = await promptTerminal([
{
type: 'text',
name: 'outputPath',
message: 'Enter the path to save your processed API definition to:',
initial: () => {
const extension = path.extname(specPath);
return `${path.basename(specPath).split(extension)[0]}.openapi.json`;
},
validate: value => validateFilePath(value),
},
]);

Command.debug(`Saving processed spec to ${promptResults.outputPath}`);
fs.writeFileSync(promptResults.outputPath, JSON.stringify(openApiData, null, 2), 'utf8');
Command.debug('Processed spec saved');

return `Your API definition has been processed and saved to ${promptResults.outputPath}!`;
}
}

export default OpenAPISolvingCircularityAndRecursiveness;
6 changes: 3 additions & 3 deletions src/lib/prepareOas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ const capitalizeSpecType = (type: string) =>
*/
export default async function prepareOas(
path: string | undefined,
command: 'openapi:convert' | 'openapi:inspect' | 'openapi:reduce' | 'openapi:validate' | 'openapi',
command: 'openapi:convert' | 'openapi:inspect' | 'openapi:reduce' | 'openapi:refs' | 'openapi:validate' | 'openapi',
opts: {
/**
* Optionally convert the supplied or discovered API definition to the latest OpenAPI release.
Expand Down Expand Up @@ -84,13 +84,13 @@ export default async function prepareOas(

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

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

const jsonAndYamlFiles = readdirRecursive('.', true).filter(
Expand Down