diff --git a/__tests__/__fixtures__/circular-references-oas/circular-reference-ref-only-resolved.json b/__tests__/__fixtures__/circular-references-oas/circular-reference-ref-only-resolved.json new file mode 100644 index 000000000..504b7c3f0 --- /dev/null +++ b/__tests__/__fixtures__/circular-references-oas/circular-reference-ref-only-resolved.json @@ -0,0 +1,31 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Cyclic Schema Example", + "version": "1.0.0" + }, + "paths": {}, + "components": { + "schemas": { + "ObjectA": { + "type": "object", + "properties": { + "referenceToB": { + "$ref": "#/components/schemas/ObjectB" + } + } + }, + "ObjectB": { + "$ref": "#/components/schemas/ObjectARef" + }, + "ObjectARef": { + "type": "object", + "properties": { + "referenceToB": { + "type": "object" + } + } + } + } + } +} diff --git a/__tests__/__fixtures__/circular-references-oas/circular-reference-ref-only.json b/__tests__/__fixtures__/circular-references-oas/circular-reference-ref-only.json new file mode 100644 index 000000000..5846a00ae --- /dev/null +++ b/__tests__/__fixtures__/circular-references-oas/circular-reference-ref-only.json @@ -0,0 +1,23 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Cyclic Schema Example", + "version": "1.0.0" + }, + "paths": {}, + "components": { + "schemas": { + "ObjectA": { + "type": "object", + "properties": { + "referenceToB": { + "$ref": "#/components/schemas/ObjectB" + } + } + }, + "ObjectB": { + "$ref": "#/components/schemas/ObjectA" + } + } + } +} diff --git a/__tests__/__fixtures__/circular-references-oas/circular-references-resolved.json b/__tests__/__fixtures__/circular-references-oas/circular-references-resolved.json new file mode 100644 index 000000000..ba3e42555 --- /dev/null +++ b/__tests__/__fixtures__/circular-references-oas/circular-references-resolved.json @@ -0,0 +1,76 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Cyclic Reference Example", + "version": "1.0.0" + }, + "paths": { + "/example": { + "get": { + "summary": "Example endpoint with cyclic references", + "responses": { + "200": { + "description": "Successful response" + } + } + } + } + }, + "components": { + "schemas": { + "ObjectC": { + "type": "object", + "properties": { + "test": { + "type": "string" + } + } + }, + "ObjectA": { + "type": "object", + "properties": { + "test": { + "type": "string" + }, + "relatedObject": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ObjectB" + } + } + } + }, + "ObjectB": { + "type": "object", + "properties": { + "test": { + "type": "string" + }, + "relatedObject": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ObjectARef" + } + }, + "test2": { + "$ref": "#/components/schemas/ObjectC" + } + } + }, + "ObjectARef": { + "type": "object", + "properties": { + "test": { + "type": "string" + }, + "relatedObject": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + } + } +} diff --git a/__tests__/__fixtures__/circular-references-oas/circular-references.json b/__tests__/__fixtures__/circular-references-oas/circular-references.json new file mode 100644 index 000000000..f6b10db2f --- /dev/null +++ b/__tests__/__fixtures__/circular-references-oas/circular-references.json @@ -0,0 +1,62 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Cyclic Reference Example", + "version": "1.0.0" + }, + "paths": { + "/example": { + "get": { + "summary": "Example endpoint with cyclic references", + "responses": { + "200": { + "description": "Successful response" + } + } + } + } + }, + "components": { + "schemas": { + "ObjectC": { + "type": "object", + "properties": { + "test": { + "type": "string" + } + } + }, + "ObjectA": { + "type": "object", + "properties": { + "test": { + "type": "string" + }, + "relatedObject": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ObjectB" + } + } + } + }, + "ObjectB": { + "type": "object", + "properties": { + "test": { + "type": "string" + }, + "relatedObject": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ObjectA" + } + }, + "test2": { + "$ref": "#/components/schemas/ObjectC" + } + } + } + } + } +} diff --git a/__tests__/__fixtures__/circular-references-oas/combined-cases-resolved.json b/__tests__/__fixtures__/circular-references-oas/combined-cases-resolved.json new file mode 100644 index 000000000..5ae4f9bac --- /dev/null +++ b/__tests__/__fixtures__/circular-references-oas/combined-cases-resolved.json @@ -0,0 +1,202 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "API with Cyclic References", + "version": "1.0.0" + }, + "paths": { + "/example": { + "get": { + "summary": "Retrieve data with cyclic references", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "nodeA": { + "$ref": "#/components/schemas/NodeA" + }, + "nodeC": { + "$ref": "#/components/schemas/NodeC" + }, + "nodeD": { + "$ref": "#/components/schemas/NodeD" + }, + "complexNode": { + "$ref": "#/components/schemas/ComplexNode" + }, + "selfReferencingNode": { + "$ref": "#/components/schemas/SelfReferencingNode" + }, + "deepRecursiveNode": { + "$ref": "#/components/schemas/DeepRecursiveNode" + } + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "NodeA": { + "type": "object", + "properties": { + "nodeB": { + "$ref": "#/components/schemas/NodeB" + } + } + }, + "NodeB": { + "type": "object", + "properties": { + "nodeA": { + "$ref": "#/components/schemas/NodeARef" + } + } + }, + "NodeC": { + "type": "object", + "properties": { + "self": { + "$ref": "#/components/schemas/NodeCRef" + } + } + }, + "NodeD": { + "type": "object", + "properties": { + "nodeE": { + "$ref": "#/components/schemas/NodeE" + } + } + }, + "NodeE": { + "type": "object", + "properties": { + "nodeD": { + "$ref": "#/components/schemas/NodeDRef" + } + } + }, + "ComplexNode": { + "type": "object", + "properties": { + "nodeF": { + "$ref": "#/components/schemas/NodeF" + } + } + }, + "NodeF": { + "type": "object", + "properties": { + "nodeG": { + "$ref": "#/components/schemas/NodeG" + } + } + }, + "NodeG": { + "type": "object", + "properties": { + "nodeH": { + "$ref": "#/components/schemas/NodeH" + } + } + }, + "NodeH": { + "type": "object", + "properties": { + "complexNode": { + "$ref": "#/components/schemas/ComplexNodeRef" + } + } + }, + "SelfReferencingNode": { + "type": "object", + "properties": { + "node": { + "type": "object" + } + } + }, + "DeepRecursiveNode": { + "type": "object", + "properties": { + "child": { + "$ref": "#/components/schemas/DeepRecursiveNodeRef" + } + } + }, + "DeepRecursiveNodeRef": { + "type": "object", + "properties": { + "child": { + "type": "object" + } + } + }, + "NodeARef": { + "type": "object", + "properties": { + "nodeB": { + "type": "object" + } + } + }, + "NodeCRef": { + "type": "object", + "properties": { + "self": { + "type": "object" + } + } + }, + "NodeDRef": { + "type": "object", + "properties": { + "nodeE": { + "type": "object" + } + } + }, + "ComplexNodeRef": { + "type": "object", + "properties": { + "nodeF": { + "$ref": "#/components/schemas/NodeFRef" + } + } + }, + "NodeFRef": { + "type": "object", + "properties": { + "nodeG": { + "$ref": "#/components/schemas/NodeGRef" + } + } + }, + "NodeGRef": { + "type": "object", + "properties": { + "nodeH": { + "$ref": "#/components/schemas/NodeHRef" + } + } + }, + "NodeHRef": { + "type": "object", + "properties": { + "complexNode": { + "type": "object" + } + } + } + } + } +} diff --git a/__tests__/__fixtures__/circular-references-oas/combined-cases.json b/__tests__/__fixtures__/circular-references-oas/combined-cases.json new file mode 100644 index 000000000..4564d62da --- /dev/null +++ b/__tests__/__fixtures__/circular-references-oas/combined-cases.json @@ -0,0 +1,138 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "API with Cyclic References", + "version": "1.0.0" + }, + "paths": { + "/example": { + "get": { + "summary": "Retrieve data with cyclic references", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "nodeA": { + "$ref": "#/components/schemas/NodeA" + }, + "nodeC": { + "$ref": "#/components/schemas/NodeC" + }, + "nodeD": { + "$ref": "#/components/schemas/NodeD" + }, + "complexNode": { + "$ref": "#/components/schemas/ComplexNode" + }, + "selfReferencingNode": { + "$ref": "#/components/schemas/SelfReferencingNode" + }, + "deepRecursiveNode": { + "$ref": "#/components/schemas/DeepRecursiveNode" + } + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "NodeA": { + "type": "object", + "properties": { + "nodeB": { + "$ref": "#/components/schemas/NodeB" + } + } + }, + "NodeB": { + "type": "object", + "properties": { + "nodeA": { + "$ref": "#/components/schemas/NodeA" + } + } + }, + "NodeC": { + "type": "object", + "properties": { + "self": { + "$ref": "#/components/schemas/NodeC" + } + } + }, + "NodeD": { + "type": "object", + "properties": { + "nodeE": { + "$ref": "#/components/schemas/NodeE" + } + } + }, + "NodeE": { + "type": "object", + "properties": { + "nodeD": { + "$ref": "#/components/schemas/NodeD" + } + } + }, + "ComplexNode": { + "type": "object", + "properties": { + "nodeF": { + "$ref": "#/components/schemas/NodeF" + } + } + }, + "NodeF": { + "type": "object", + "properties": { + "nodeG": { + "$ref": "#/components/schemas/NodeG" + } + } + }, + "NodeG": { + "type": "object", + "properties": { + "nodeH": { + "$ref": "#/components/schemas/NodeH" + } + } + }, + "NodeH": { + "type": "object", + "properties": { + "complexNode": { + "$ref": "#/components/schemas/ComplexNode" + } + } + }, + "SelfReferencingNode": { + "type": "object", + "properties": { + "node": { + "$ref": "#/components/schemas/SelfReferencingNode" + } + } + }, + "DeepRecursiveNode": { + "type": "object", + "properties": { + "child": { + "$ref": "#/components/schemas/DeepRecursiveNode" + } + } + } + } + } +} diff --git a/__tests__/__fixtures__/circular-references-oas/recursive-reference-resolved.json b/__tests__/__fixtures__/circular-references-oas/recursive-reference-resolved.json new file mode 100644 index 000000000..d58bb189c --- /dev/null +++ b/__tests__/__fixtures__/circular-references-oas/recursive-reference-resolved.json @@ -0,0 +1,51 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Recursive Reference Example", + "version": "1.0.0" + }, + "paths": { + "/example": { + "get": { + "summary": "Example endpoint with cyclic references", + "responses": { + "200": { + "description": "Successful response" + } + } + } + } + }, + "components": { + "schemas": { + "ObjectB": { + "type": "object", + "properties": { + "test": { + "type": "string" + }, + "relatedObject": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ObjectBRef" + } + } + } + }, + "ObjectBRef": { + "type": "object", + "properties": { + "test": { + "type": "string" + }, + "relatedObject": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + } + } +} diff --git a/__tests__/__fixtures__/circular-references-oas/recursive-reference.json b/__tests__/__fixtures__/circular-references-oas/recursive-reference.json new file mode 100644 index 000000000..243b4f5ef --- /dev/null +++ b/__tests__/__fixtures__/circular-references-oas/recursive-reference.json @@ -0,0 +1,37 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Recursive Reference Example", + "version": "1.0.0" + }, + "paths": { + "/example": { + "get": { + "summary": "Example endpoint with cyclic references", + "responses": { + "200": { + "description": "Successful response" + } + } + } + } + }, + "components": { + "schemas": { + "ObjectB": { + "type": "object", + "properties": { + "test": { + "type": "string" + }, + "relatedObject": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ObjectB" + } + } + } + } + } + } +} diff --git a/__tests__/__fixtures__/circular-references-oas/unresolvable-circular-reference-resolved.json b/__tests__/__fixtures__/circular-references-oas/unresolvable-circular-reference-resolved.json new file mode 100644 index 000000000..f3d915aab --- /dev/null +++ b/__tests__/__fixtures__/circular-references-oas/unresolvable-circular-reference-resolved.json @@ -0,0 +1,67 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Complex Cyclic Example", + "version": "1.0.0" + }, + "paths": { + "/example": { + "get": { + "summary": "Example endpoint with unresolved references", + "responses": { + "200": { + "description": "Successful response" + } + } + } + } + }, + "components": { + "schemas": { + "Entity": { + "type": "object", + "properties": { + "relatedEntity": { + "$ref": "#/components/schemas/RelatedEntity" + }, + "Entity": { + "$ref": "#/components/schemas/EntityRef" + } + } + }, + "RelatedEntity": { + "type": "object", + "properties": { + "entity": { + "$ref": "#/components/schemas/EntityRef" + }, + "RelatedEntity": { + "$ref": "#/components/schemas/RelatedEntityRef" + } + } + }, + "RelatedEntityRef": { + "type": "object", + "properties": { + "entity": { + "type": "object" + }, + "RelatedEntity": { + "type": "object" + } + } + }, + "EntityRef": { + "type": "object", + "properties": { + "relatedEntity": { + "$ref": "#/components/schemas/RelatedEntityRef" + }, + "Entity": { + "type": "object" + } + } + } + } + } +} diff --git a/__tests__/__fixtures__/circular-references-oas/unresolvable-circular-references.json b/__tests__/__fixtures__/circular-references-oas/unresolvable-circular-references.json new file mode 100644 index 000000000..82eb0cc9e --- /dev/null +++ b/__tests__/__fixtures__/circular-references-oas/unresolvable-circular-references.json @@ -0,0 +1,45 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Complex Cyclic Example", + "version": "1.0.0" + }, + "paths": { + "/example": { + "get": { + "summary": "Example endpoint with unresolved references", + "responses": { + "200": { + "description": "Successful response" + } + } + } + } + }, + "components": { + "schemas": { + "Entity": { + "type": "object", + "properties": { + "relatedEntity": { + "$ref": "#/components/schemas/RelatedEntity" + }, + "Entity": { + "$ref": "#/components/schemas/Entity" + } + } + }, + "RelatedEntity": { + "type": "object", + "properties": { + "entity": { + "$ref": "#/components/schemas/Entity" + }, + "RelatedEntity": { + "$ref": "#/components/schemas/RelatedEntity" + } + } + } + } + } +} diff --git a/__tests__/commands/openapi/refs.test.ts b/__tests__/commands/openapi/refs.test.ts new file mode 100644 index 000000000..90645642e --- /dev/null +++ b/__tests__/commands/openapi/refs.test.ts @@ -0,0 +1,158 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import prompts from 'prompts'; +import { describe, beforeAll, beforeEach, afterEach, it, expect, vi } from 'vitest'; + +import Command from '../../../src/commands/openapi/refs.js'; +import { runCommandAndReturnResult } from '../../helpers/oclif.js'; + +describe('openapi refs', () => { + let run: (args?: string[]) => Promise; + + beforeAll(() => { + run = runCommandAndReturnResult(Command); + }); + + beforeEach(() => { + vi.spyOn(console, 'info').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('comparison of OpenAPI files', () => { + it('should process circular references', async () => { + const inputFile = path.resolve('__tests__/__fixtures__/circular-references-oas/circular-references.json'); + const expectedOutputFile = path.resolve( + '__tests__/__fixtures__/circular-references-oas/circular-references-resolved.json', + ); + const defaultOutputFilePath = 'circular-references.openapi.json'; + + prompts.inject([defaultOutputFilePath]); + + let processedOutput; + fs.writeFileSync = vi.fn((fileName, data) => { + processedOutput = JSON.parse(data as string); + }); + + const result = await run([inputFile]); + + expect(result).toMatch(`Your API definition has been processed and saved to ${defaultOutputFilePath}!`); + + expect(fs.writeFileSync).toHaveBeenCalledWith(defaultOutputFilePath, expect.any(String)); + + const expectedOutput = JSON.parse(fs.readFileSync(expectedOutputFile, 'utf8')); + expect(processedOutput).toStrictEqual(expectedOutput); + }); + + it('should process recursive references', async () => { + const inputFile = path.resolve('__tests__/__fixtures__/circular-references-oas/recursive-reference.json'); + const expectedOutputFile = path.resolve( + '__tests__/__fixtures__/circular-references-oas/recursive-reference-resolved.json', + ); + const defaultOutputFilePath = 'recursive-reference.openapi.json'; + + prompts.inject([defaultOutputFilePath]); + + let processedOutput; + fs.writeFileSync = vi.fn((fileName, data) => { + processedOutput = JSON.parse(data as string); + }); + + const result = await run([inputFile]); + + expect(result).toMatch(`Your API definition has been processed and saved to ${defaultOutputFilePath}!`); + + expect(fs.writeFileSync).toHaveBeenCalledWith(defaultOutputFilePath, expect.any(String)); + + const expectedOutput = JSON.parse(fs.readFileSync(expectedOutputFile, 'utf8')); + expect(processedOutput).toStrictEqual(expectedOutput); + }); + + it('should replace circularity that cannot be processed with empty objects', async () => { + const inputFile = path.resolve( + '__tests__/__fixtures__/circular-references-oas/unresolvable-circular-references.json', + ); + const expectedOutputFile = path.resolve( + '__tests__/__fixtures__/circular-references-oas/unresolvable-circular-reference-resolved.json', + ); + const defaultOutputFilePath = 'unresolvable-circular-references.openapi.json'; + + prompts.inject([defaultOutputFilePath]); + + let processedOutput; + fs.writeFileSync = vi.fn((fileName, data) => { + processedOutput = JSON.parse(data as string); + }); + + const result = await run([inputFile]); + + expect(result).toMatch(`Your API definition has been processed and saved to ${defaultOutputFilePath}!`); + + expect(fs.writeFileSync).toHaveBeenCalledWith(defaultOutputFilePath, expect.any(String)); + + const expectedOutput = JSON.parse(fs.readFileSync(expectedOutputFile, 'utf8')); + expect(processedOutput).toStrictEqual(expectedOutput); + }); + + it('should process circular references if the schema does not contain properties', async () => { + const inputFile = path.resolve('__tests__/__fixtures__/circular-references-oas/circular-reference-ref-only.json'); + const expectedOutputFile = path.resolve( + '__tests__/__fixtures__/circular-references-oas/circular-reference-ref-only-resolved.json', + ); + const defaultOutputFilePath = 'circular-reference-ref-only.openapi.json'; + + prompts.inject([defaultOutputFilePath]); + + let processedOutput; + fs.writeFileSync = vi.fn((fileName, data) => { + processedOutput = JSON.parse(data as string); + }); + + const result = await run([inputFile]); + + expect(result).toMatch(`Your API definition has been processed and saved to ${defaultOutputFilePath}!`); + + expect(fs.writeFileSync).toHaveBeenCalledWith(defaultOutputFilePath, expect.any(String)); + + const expectedOutput = JSON.parse(fs.readFileSync(expectedOutputFile, 'utf8')); + expect(processedOutput).toStrictEqual(expectedOutput); + }); + + it('should handle a combination of recursiveness/nested circularity within a single file', async () => { + const inputFile = path.resolve('__tests__/__fixtures__/circular-references-oas/combined-cases.json'); + const expectedOutputFile = path.resolve( + '__tests__/__fixtures__/circular-references-oas/combined-cases-resolved.json', + ); + const defaultOutputFilePath = 'combined-cases.openapi.json'; + + prompts.inject([defaultOutputFilePath]); + + let processedOutput; + fs.writeFileSync = vi.fn((fileName, data) => { + processedOutput = JSON.parse(data as string); + }); + + const result = await run([inputFile]); + + expect(result).toMatch(`Your API definition has been processed and saved to ${defaultOutputFilePath}!`); + + expect(fs.writeFileSync).toHaveBeenCalledWith(defaultOutputFilePath, expect.any(String)); + + const expectedOutput = JSON.parse(fs.readFileSync(expectedOutputFile, 'utf8')); + expect(processedOutput).toStrictEqual(expectedOutput); + }); + }); + + describe('error handling', () => { + it.each([['json'], ['yaml']])('should fail if given a Swagger 2.0 definition (format: %s)', async format => { + const spec = require.resolve(`@readme/oas-examples/2.0/${format}/petstore.${format}`); + + await expect(run([spec])).rejects.toStrictEqual( + new Error('Sorry, this ref resolver feature in rdme only supports OpenAPI 3.0+ definitions.'), + ); + }); + }); +}); diff --git a/documentation/commands/openapi.md b/documentation/commands/openapi.md index 8a8a4cb8b..f2f6f7917 100644 --- a/documentation/commands/openapi.md +++ b/documentation/commands/openapi.md @@ -7,6 +7,7 @@ Manage your API definition (e.g., syncing, validation, analysis, conversion, etc * [`rdme openapi convert [SPEC]`](#rdme-openapi-convert-spec) * [`rdme openapi inspect [SPEC]`](#rdme-openapi-inspect-spec) * [`rdme openapi reduce [SPEC]`](#rdme-openapi-reduce-spec) +* [`rdme openapi refs [SPEC]`](#rdme-openapi-refs-spec) * [`rdme openapi validate [SPEC]`](#rdme-openapi-validate-spec) ## `rdme openapi [SPEC]` @@ -238,6 +239,46 @@ EXAMPLES $ rdme openapi reduce petstore.json --path /pet/{id} --method get --method put --out petstore.reduced.json ``` +## `rdme openapi refs [SPEC]` + +Resolves circular and recursive references in OpenAPI by replacing them with object schemas. + +``` +USAGE + $ rdme openapi refs [SPEC] [--out ] [--workingDirectory ] + +ARGUMENTS + SPEC A file/URL to your API definition + +FLAGS + --out= Output file path to write processed file to + --workingDirectory= Working directory (for usage with relative external references) + +DESCRIPTION + Resolves circular and recursive references in OpenAPI by replacing them with object schemas. + + This command provides a workaround for circular or recursive references within OpenAPI definitions so they can render + properly in ReadMe. It automatically identifies and replaces these references with simplified object schemas, ensuring + compatibility for seamless display in the ReadMe API Reference. 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. Complex circular references may + require manual inspection and may not be fully resolved. + +EXAMPLES + This will resolve circular and recursive references in the OpenAPI definition at the given file or URL: + + $ rdme openapi refs [url-or-local-path-to-file] + + You can omit the file name and `rdme` will scan your working directory (and any subdirectories) for OpenAPI files. + This approach will provide you with CLI prompts, so we do not recommend this technique in CI environments. + + $ rdme openapi refs + + If you wish to automate this command, you can pass in CLI arguments to bypass the prompts: + + $ rdme openapi refs petstore.json --out petstore.openapi.json +``` + ## `rdme openapi validate [SPEC]` Validate your OpenAPI/Swagger definition. diff --git a/src/commands/openapi/refs.ts b/src/commands/openapi/refs.ts new file mode 100644 index 000000000..b4f2ec39e --- /dev/null +++ b/src/commands/openapi/refs.ts @@ -0,0 +1,405 @@ +/* eslint-disable no-param-reassign */ +import type { OASDocument } from 'oas/types'; +import type { OpenAPIV3_1 as OpenAPIV31 } from 'openapi-types'; + +import fs from 'node:fs'; +import path from 'node:path'; + +import { Args, Flags } from '@oclif/core'; +import chalk from 'chalk'; +import ora from 'ora'; +import prompts from 'prompts'; + +import analyzeOas from '../../lib/analyzeOas.js'; +import BaseCommand from '../../lib/baseCommand.js'; +import { workingDirectoryFlag } from '../../lib/flags.js'; +import { info, warn, debug, oraOptions } from '../../lib/logger.js'; +import prepareOas from '../../lib/prepareOas.js'; +import promptTerminal from '../../lib/promptWrapper.js'; +import { validateFilePath } from '../../lib/validatePromptInput.js'; + +type Schema = OpenAPIV31.ReferenceObject | OpenAPIV31.SchemaObject; + +type SchemaCollection = Record; + +export default class OpenAPIRefsCommand extends BaseCommand { + static summary = 'Resolves circular and recursive references in OpenAPI by replacing them with object schemas.'; + + static description = + 'This command provides a workaround for circular or recursive references within OpenAPI definitions so they can render properly in ReadMe. It automatically identifies and replaces these references with simplified object schemas, ensuring compatibility for seamless display in the ReadMe API Reference. 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. Complex circular references may require manual inspection and may not be fully resolved.'; + + static args = { + spec: Args.string({ description: 'A file/URL to your API definition' }), + }; + + static examples = [ + { + description: + 'This will resolve circular and recursive references in the OpenAPI definition at the given file or URL:', + command: '<%= config.bin %> <%= command.id %> [url-or-local-path-to-file]', + }, + { + description: + 'You can omit the file name and `rdme` will scan your working directory (and any subdirectories) for OpenAPI files. This approach will provide you with CLI prompts, so we do not recommend this technique in CI environments.', + command: '<%= config.bin %> <%= command.id %>', + }, + { + description: 'If you wish to automate this command, you can pass in CLI arguments to bypass the prompts:', + command: '<%= config.bin %> <%= command.id %> petstore.json --out petstore.openapi.json', + }, + ]; + + static flags = { + out: Flags.string({ description: 'Output file path to write processed file to' }), + workingDirectory: workingDirectoryFlag, + }; + + /** + * Identifies circular references in the OpenAPI document. + * @returns A list of circular reference paths. + */ + static async getCircularRefsFromOas( + /** The OpenAPI document to analyze. */ + document: OASDocument, + ): Promise { + 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. + * @returns The modified schema or the original. + */ + static replaceRefWithObjectProxySchemes( + /** The schema to process. */ + schema: Schema, + /** List of circular reference paths. */ + circularRefs: string[], + /** The name of the schema being processed. */ + schemaName: string, + ) { + if ('$ref' in schema) { + const refSchemaName = schema.$ref.split('/').pop(); + if (!refSchemaName) { + throw new Error('Invalid $ref: unable to extract schema name.'); + } + const isCircular = circularRefs.some(refPath => refPath.includes(refSchemaName)); + const isRecursive = schemaName === refSchemaName; + + if (schemaName.includes('Ref') && (isCircular || isRecursive)) { + return { type: 'object' }; + } + } + + return schema; + } + + /** + * Recursively replaces references in schemas, transforming circular references to objects. + */ + static replaceRefsInSchema( + /** The schema to process. */ + schema: Schema, + /** List of circular reference paths. */ + circularRefs: string[], + /** The name of the schema being processed. */ + schemaName: string, + ) { + if ('$ref' in schema) { + return; + } + + if (schema.type === 'object' && schema.properties) { + for (const prop of Object.keys(schema.properties)) { + let property = JSON.parse(JSON.stringify(schema.properties[prop])); + property = OpenAPIRefsCommand.replaceRefWithObjectProxySchemes(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 = OpenAPIRefsCommand.replaceRefWithObjectProxySchemes( + property.items, + circularRefs, + schemaName, + ); + OpenAPIRefsCommand.replaceRefsInSchema(property.items, circularRefs, schemaName); + } + } + } + } + + /** + * Replaces circular references within a collection of schemas. + */ + static replaceCircularRefs( + /** Collection of schemas to modify. */ + schemas: SchemaCollection, + /** List of circular reference paths. */ + circularRefs: string[], + ): void { + const createdRefs = new Set(); + + function replaceRef(schemaName: string, propertyName: string, refSchemaName: string) { + const schema = schemas[schemaName]; + if ('properties' in schema) { + schema.properties![propertyName] = { $ref: `#/components/schemas/${refSchemaName}` }; + } else if ('$ref' in schema) { + schema.$ref = `#/components/schemas/${refSchemaName}`; + } + } + + function createRefSchema(originalSchemaName: string, refSchemaName: string) { + if (!createdRefs.has(refSchemaName) && schemas[originalSchemaName]) { + const schema = schemas[originalSchemaName]; + + if ('properties' in schema) { + schemas[refSchemaName] = { + type: 'object', + properties: { ...schema.properties }, + }; + } else if ('$ref' in schema) { + schemas[refSchemaName] = { + $ref: schema.$ref, + }; + } else { + throw new Error(`Unsupported schema type for ${originalSchemaName}. Please contact support@readme.io.`); + } + + OpenAPIRefsCommand.replaceRefsInSchema(schemas[refSchemaName], circularRefs, refSchemaName); + createdRefs.add(refSchemaName); + } + } + + circularRefs.forEach(refPath => { + const refParts = refPath.split('/'); + if (refParts.length < 4) { + throw new Error(`Invalid reference path: ${refPath}. Please contact support@readme.io.`); + } + + const schemaName = refParts[3]; + const propertyName = refParts[5]; + const schema = schemas[schemaName]; + + let property: Schema; + + if ('properties' in schema && schema.properties?.[propertyName]) { + property = schema.properties[propertyName]; + } else if ('$ref' in schema && schema.$ref) { + property = { $ref: schema.$ref }; + } else { + throw new Error( + `Property "${propertyName}" is not found or schema is invalid. Please contact support@readme.io.`, + ); + } + + if (!schema || !property) { + throw new Error(`Schema or property not found for path: ${refPath}. Please contact support@readme.io.`); + } + + if ('$ref' in property) { + const refSchemaName = property.$ref?.split('/')[3]; + if (refSchemaName) { + const newRefSchemaName = `${refSchemaName}Ref`; + if (refSchemaName.includes('Ref')) { + debug(`Skipping proxy schema for ${refSchemaName}.`); + return; + } + replaceRef(schemaName, propertyName, newRefSchemaName); + createRefSchema(refSchemaName, newRefSchemaName); + return; + } + throw new Error(`Invalid $ref in property: ${JSON.stringify(property)}. Please contact support@readme.io.`); + } + + // Handle references within items in an array + let refSchemaName: string; + if ( + refParts.length > 6 && + refParts[6] === 'items' && + property.type === 'array' && + property.items && + typeof property.items === 'object' + ) { + if ('$ref' in property.items) { + const itemsRefSchemaName = property.items.$ref.split('/')[3]; + + if (itemsRefSchemaName) { + refSchemaName = `${itemsRefSchemaName}Ref`; + if (itemsRefSchemaName.includes('Ref')) { + debug(`Skipping proxy schema for ${itemsRefSchemaName} in array items.`); + return; + } + property.items = { $ref: `#/components/schemas/${refSchemaName}` }; + createRefSchema(itemsRefSchemaName, refSchemaName); + } + } + } + }); + } + + /** + * Replaces all remaining circular references ($ref) in the schema with { type: 'object' }. + */ + static replaceAllRefsWithObject( + /** Collection of schemas to modify. */ + schemas: SchemaCollection, + /** List of circular reference paths. */ + circularRefs: string[], + ): void { + circularRefs.forEach(refPath => { + const refParts = refPath.split('/'); + if (refParts.length < 4) { + throw new Error(`Invalid reference path: ${refPath}. Please contact support@readme.io.`); + } + + const schemaName = refParts[3]; + const propertyName = refParts[5]; + + let schema: Schema = schemas?.[schemaName]; + if (!schema) { + warn(`Schema not found for: ${schemaName}`); + return; + } + + if ('properties' in schema && schema.properties && schema.properties[propertyName]) { + schema.properties[propertyName] = { type: 'object' }; + } else if ('type' in schema && schema.type === 'array' && 'items' in schema && schema.items) { + schema.items = { type: 'object' }; + } else if ('$ref' in schema && typeof schema.$ref === 'string') { + schema = { type: 'object' }; + } else { + throw new Error(`Invalid schema format: ${JSON.stringify(schema)}. Please contact support@readme.io.`); + } + }); + } + + /** + * Resolves circular references in the provided OpenAPI document. + */ + static async resolveCircularRefs( + /** The OpenAPI document to analyze. */ + openApiData: OASDocument, + /** Collection of schemas to modify. */ + schemas: SchemaCollection, + ) { + const initialCircularRefs = await OpenAPIRefsCommand.getCircularRefsFromOas(openApiData); + + if (initialCircularRefs.length === 0) { + throw new Error('The file does not contain circular or recursive references.'); + } + + debug(`Found ${initialCircularRefs.length} circular references. Attempting resolution.`); + + OpenAPIRefsCommand.replaceCircularRefs(schemas, initialCircularRefs); + + let remainingCircularRefs = await OpenAPIRefsCommand.getCircularRefsFromOas(openApiData); + let iterationCount = 0; + const maxIterations = 10; + + while (remainingCircularRefs.length > 0 && iterationCount < maxIterations) { + debug( + `Iteration ${iterationCount + 1}: Resolving ${remainingCircularRefs.length} remaining circular references.`, + ); + OpenAPIRefsCommand.replaceCircularRefs(schemas, remainingCircularRefs); + + // eslint-disable-next-line no-await-in-loop + remainingCircularRefs = await OpenAPIRefsCommand.getCircularRefsFromOas(openApiData); + iterationCount += 1; + } + + if (remainingCircularRefs.length > 0) { + info( + 'Unresolved circular references remain. These references will be replaced with empty objects for schema display purposes.', + { includeEmojiPrefix: true }, + ); + debug(`Remaining circular references: ${JSON.stringify(remainingCircularRefs, null, 2)}`); + + const maxObjectReplacementIterations = 5; + let objectReplacementIterationCount = 0; + + while (remainingCircularRefs.length > 0 && objectReplacementIterationCount < maxObjectReplacementIterations) { + debug( + `Object replacement iteration ${objectReplacementIterationCount + 1}: replacing remaining circular references.`, + ); + OpenAPIRefsCommand.replaceAllRefsWithObject(schemas, remainingCircularRefs); + + // eslint-disable-next-line no-await-in-loop + remainingCircularRefs = await OpenAPIRefsCommand.getCircularRefsFromOas(openApiData); + debug( + `After iteration ${objectReplacementIterationCount + 1}, remaining circular references: ${remainingCircularRefs.length}`, + ); + objectReplacementIterationCount += 1; + } + + if (remainingCircularRefs.length > 0) { + debug(`Final unresolved circular references: ${JSON.stringify(remainingCircularRefs, null, 2)}`); + throw new Error( + 'Unable to resolve all circular references, even with fallback replacements. Please contact support@readme.io.', + ); + } else { + debug('All remaining circular references successfully replaced with empty objects.'); + } + } else { + debug('All circular references successfully resolved.'); + } + } + + async run() { + const { spec } = this.args; + const { out, workingDirectory } = this.flags; + + if (workingDirectory) { + const previousWorkingDirectory = process.cwd(); + process.chdir(workingDirectory); + this.debug(`Switching working directory from ${previousWorkingDirectory} to ${process.cwd()}`); + } + + const { preparedSpec, specPath, specType } = await prepareOas(spec, 'openapi refs', { convertToLatest: true }); + if (specType !== 'OpenAPI') { + throw new Error('Sorry, this ref resolver feature in rdme only supports OpenAPI 3.0+ definitions.'); + } + + const openApiData: OASDocument = JSON.parse(preparedSpec); + + if (!openApiData.components?.schemas) { + throw new Error('Schemas not found in OpenAPI data'); + } + + const schemas: SchemaCollection = openApiData.components?.schemas; + + const spinner = ora({ ...oraOptions() }); + spinner.start('Identifying and resolving circular/recursive references in your API definition...'); + + try { + await OpenAPIRefsCommand.resolveCircularRefs(openApiData, schemas); + spinner.succeed(`${spinner.text} done! ✅`); + } catch (err) { + this.debug(`${err.message}`); + spinner.fail(); + throw err; + } + + prompts.override({ outputPath: out }); + const promptResults = await promptTerminal([ + { + type: 'text', + name: 'outputPath', + message: 'Enter the path to save your processed API definition to:', + initial: () => `${path.basename(specPath).split(path.extname(specPath))[0]}.openapi.json`, + validate: value => validateFilePath(value), + }, + ]); + + const outputPath = promptResults.outputPath; + this.debug(`Saving processed spec to ${outputPath}...`); + fs.writeFileSync(outputPath, JSON.stringify(openApiData, null, 2)); + + return Promise.resolve(chalk.green(`Your API definition has been processed and saved to ${outputPath}!`)); + } +} diff --git a/src/index.ts b/src/index.ts index 6039677ad..916a4b16a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import OpenAPIConvertCommand from './commands/openapi/convert.js'; import OpenAPICommand from './commands/openapi/index.js'; import OpenAPIInspectCommand from './commands/openapi/inspect.js'; import OpenAPIReduceCommand from './commands/openapi/reduce.js'; +import OpenAPIRefsCommand from './commands/openapi/refs.js'; import OpenAPIValidateCommand from './commands/openapi/validate.js'; import CreateVersionCommand from './commands/versions/create.js'; import DeleteVersionCommand from './commands/versions/delete.js'; @@ -54,6 +55,7 @@ export const COMMANDS = { 'openapi:convert': OpenAPIConvertCommand, 'openapi:inspect': OpenAPIInspectCommand, 'openapi:reduce': OpenAPIReduceCommand, + 'openapi:refs': OpenAPIRefsCommand, 'openapi:validate': OpenAPIValidateCommand, whoami: WhoAmICommand, diff --git a/src/lib/prepareOas.ts b/src/lib/prepareOas.ts index 555bc0a88..7c786a708 100644 --- a/src/lib/prepareOas.ts +++ b/src/lib/prepareOas.ts @@ -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. @@ -84,7 +84,7 @@ 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';