UNPKG

bump-cli

Version:

The Bump CLI is used to interact with your API documentation hosted on Bump.sh by using the API of developers.bump.sh

346 lines (345 loc) 14.2 kB
import { default as $RefParser, getJsonSchemaRefParserDefaultOptions } from '@apidevtools/json-schema-ref-parser'; import asyncapi from '@asyncapi/specs'; import { CLIError } from '@oclif/core/errors'; import { parseWithPointers, safeStringify } from '@stoplight/yaml'; import debug from 'debug'; import { default as nodePath } from 'node:path'; import { Overlay } from './core/overlay.js'; import arazzoSchemas from './core/schemas/arazzo-schemas/index.js'; import flowerSchemas from './core/schemas/flower-schemas/index.js'; import openapiSchemas from './core/schemas/oas-schemas/index.js'; class SupportedFormat { static arazzo = arazzoSchemas.schemas; static asyncapi = { '2.0': asyncapi.schemas['2.0.0'], '2.1': asyncapi.schemas['2.1.0'], '2.2': asyncapi.schemas['2.2.0'], '2.3': asyncapi.schemas['2.3.0'], '2.4': asyncapi.schemas['2.4.0'], '2.5': asyncapi.schemas['2.5.0'], '2.6': asyncapi.schemas['2.6.0'], }; static flower = flowerSchemas.schemas; static openapi = openapiSchemas.schemas; } class UnsupportedFormat extends CLIError { constructor(message = '') { const compatOpenAPI = Object.keys(SupportedFormat.openapi).join(', '); const compatAsyncAPI = Object.keys(SupportedFormat.asyncapi).join(', '); const errorMsgs = [ `Unsupported API specification (${message})`, `Please try again with an OpenAPI ${compatOpenAPI} or AsyncAPI ${compatAsyncAPI} file.`, ]; super(errorMsgs.join('\n')); } } class API { definition; location; overlayedDefinition; rawDefinition; references; specName; version; constructor(location, data) { this.location = location; const [raw, parsed, references] = this._resolveContentFrom(data); this.references = references || []; this.rawDefinition = raw; this.definition = parsed; this.specName = this.getSpecName(parsed); this.version = this.getVersion(parsed); if (!this.getSpec(parsed)) { throw new UnsupportedFormat(`${this.specName} ${this.version}`); } } static isArazzo(definition) { return 'arazzo' in definition; } static isAsyncAPI(definition) { return 'asyncapi' in definition; } static isFlower(definition) { return 'flower' in definition; } static isOpenAPI(definition) { return typeof definition.openapi === 'string' || typeof definition.swagger === 'string'; } static isOpenAPIOverlay(definition) { return 'overlay' in definition; } static isSupportedFormat(definition) { return (API.isArazzo(definition) || API.isAsyncAPI(definition) || API.isFlower(definition) || API.isOpenAPI(definition) || API.isOpenAPIOverlay(definition)); } static async load(path) { const { json, text, yaml } = getJsonSchemaRefParserDefaultOptions().parse; // Not sure why the lib types the parser as potentially // “undefined”, hence the forced typing in the following consts. const TextParser = text; const JSONParser = json; const YAMLParser = yaml; // We override the default parsers from $RefParser to be able // to keep the raw content of the files parsed const withRawTextParser = (parser) => ({ ...parser, async parse(file) { if (typeof parser.parse === 'function' && typeof TextParser.parse === 'function') { const parsed = (await parser.parse(file)); return { parsed, raw: TextParser.parse(file) }; } // Not sure why the lib states that Plugin.parse can be a // scalar number | string (on not only a callable function) return {}; }, }); return $RefParser .resolve(path, { dereference: { circular: false }, parse: { json: withRawTextParser(JSONParser), text: { ...TextParser, canParse: ['.md', '.markdown'], encoding: 'utf8', async parse(file) { if (typeof TextParser.parse === 'function') { const parsed = (await TextParser.parse(file)); return { parsed, raw: parsed }; } // Not sure why the lib states that Plugin.parse can be a // scalar number | string (on not only a callable function) return {}; }, }, yaml: withRawTextParser(YAMLParser), }, }) .then(($refs) => { // JSON schema refs parser lib doesn't type the output of this // method well (it types it as a generic JSON schema) where as // it builds a Map of string (the path/URLs of each reference) // to JSONSchema (the reference value) // // We also change the reference values in our custom parsers // defined above to include the raw values which gets “widen” // by the lib. We thus need to force the type output to a more // precise type. const data = $refs.values(); return new API(path, data); }) .catch((error) => { throw new CLIError(error); }); } async applyOverlay(overlayPath) { const overlay = await API.load(overlayPath); const overlayDefinition = overlay.definition; const currentDefinition = this.overlayedDefinition || this.definition; if (!API.isOpenAPIOverlay(overlayDefinition)) { throw new Error(`${overlayPath} does not look like an OpenAPI overlay`); } for (const reference of overlay.references) { // Keep overlay reference data only if there's no existing refs with the same location if (this.references.every((existing) => existing.location !== reference.location)) { this.references.push(reference); } } this.overlayedDefinition = await new Overlay().run(currentDefinition, overlayDefinition); } async extractDefinition(outputPath, overlays) { if (overlays) { /* eslint-disable no-await-in-loop */ // Alternatively we can apply all overlays in parallel // https://stackoverflow.com/questions/48957022/unexpected-await-inside-a-loop-no-await-in-loop for (const overlayFile of overlays) { debug('bump-cli:definition')(`Applying overlay (${overlayFile}) to definition (location: ${this.location})`); await this.applyOverlay(overlayFile); } /* eslint-enable no-await-in-loop */ } if (API.isArazzo(this.definition)) { await this._resolveArazzoSourceDescriptions(); } const references = []; for (let i = 0; i < this.references.length; i++) { const { content, location, name } = this.references[i]; references.push({ content, location, name }); } return [this.serializeDefinition(outputPath), references]; } getSpec(definition) { if (API.isArazzo(definition)) { return SupportedFormat.arazzo[this.versionWithoutPatch()]; } if (API.isAsyncAPI(definition)) { return SupportedFormat.asyncapi[this.versionWithoutPatch()]; } if (API.isFlower(definition)) { return SupportedFormat.flower[this.versionWithoutPatch()]; } if (API.isOpenAPI(definition)) { return SupportedFormat.openapi[this.versionWithoutPatch()]; } if (API.isOpenAPIOverlay(definition)) { return { overlay: { type: 'string' } }; } return undefined; } getSpecName(definition) { if (API.isArazzo(definition)) { return 'Arazzo'; } if (API.isAsyncAPI(definition)) { return 'AsyncAPI'; } if (API.isFlower(definition)) { return 'Flower'; } if (API.isOpenAPI(definition)) { return 'OpenAPI'; } if (API.isOpenAPIOverlay(definition)) { return 'OpenAPIOverlay'; } return undefined; } getVersion(definition) { if (API.isArazzo(definition)) { return definition.arazzo; } if (API.isAsyncAPI(definition)) { return definition.asyncapi; } if (API.isFlower(definition)) { return definition.flower; } if (API.isOpenAPI(definition)) { return (definition.openapi || definition.swagger); } if (API.isOpenAPIOverlay(definition)) { return definition.overlay; } return undefined; } guessFormat(output) { return (output || this.location).endsWith('.json') ? 'json' : 'yaml'; } isMainRefPath(path) { // $refs from json-schema-ref-parser lib returns posix style // paths. We need to make sure we compare all paths in posix style // independently of the platform runtime. const resolvedAbsLocation = nodePath .resolve(this.location) .split(nodePath?.win32?.sep) .join(nodePath?.posix?.sep ?? '/'); return path === this.location || path === resolvedAbsLocation; } serializeDefinition(outputPath) { if (this.overlayedDefinition) { const { comments } = parseWithPointers(this.rawDefinition, { attachComments: true }); const dumpOptions = { comments, lineWidth: Number.POSITIVE_INFINITY, noRefs: true }; return this.guessFormat(outputPath) === 'json' ? JSON.stringify(this.overlayedDefinition) : safeStringify(this.overlayedDefinition, dumpOptions); } return this.rawDefinition; } versionWithoutPatch() { if (!this.version) { return ''; } const [major, minor] = this.version.split('.', 3); return `${major}.${minor}`; } async _resolveArazzoSourceDescriptions() { if (!API.isArazzo(this.definition)) { debug('bump-cli:definition')('This is not an Arazzo definition, no source descriptions to resolve'); } else if (this.definition.sourceDescriptions) { const sources = this.definition.sourceDescriptions; for (const { name, type: sourceType, url: location } of sources) { if (sourceType === 'openapi') { const relativeLocation = this._resolveRelativeLocation(location); /* eslint-disable no-await-in-loop */ const api = await API.load(relativeLocation); const [content] = await api.extractDefinition(); /* eslint-enable no-await-in-loop */ this.references.push({ content, location: relativeLocation, name }); } else { debug('bump-cli:definition')(`Arazzo source description of type ${sourceType} is not yet supported.`); } } } else { debug('bump-cli:definition')("Arazzo definition doesn't have any sourceDescriptions"); } } _resolveContentFrom(data) { let definition; let rawDefinition; const references = []; // data contains all refs as a map of paths/URLs and their // correspond values for (const [absPath, reference] of Object.entries(data)) { if (this.isMainRefPath(absPath)) { ; ({ parsed: definition, raw: rawDefinition } = reference); } else { if (!reference.raw) { throw new UnsupportedFormat(`Reference ${absPath} is empty`); } references.push({ content: reference.raw, location: this._resolveRelativeLocation(absPath), }); } } if (!definition || !rawDefinition || !(definition instanceof Object) || !('info' in definition || 'flower' in definition)) { debug('bump-cli:definition')(`Main location (${this.location}) not found or empty (within ${JSON.stringify(Object.keys(data))})`); throw new UnsupportedFormat('Definition needs to be a valid Object'); } if (!API.isSupportedFormat(definition)) { throw new UnsupportedFormat(); } return [rawDefinition, definition, references]; } /* Resolve reference paths to the main api location when possible */ _resolveRelativeLocation(path) { const definitionUrl = this.url(); const refUrl = this.url(path); const unixStyle = /^\//.test(path); const windowsStyle = /^[A-Za-z]+:[/\\]/.test(path); const isUrl = /^https?:\/\//.test(path); // Guard: Absolute URL on different domain we return an untouched // path if (isUrl && definitionUrl.hostname !== refUrl.hostname) { return path; } const isAbsolutePath = refUrl.hostname === '' && (unixStyle || windowsStyle); // Absolute path or URL on **same domain** const isAbsolute = isAbsolutePath || isUrl; const relativeLocation = isAbsolute ? nodePath.relative(nodePath.dirname(this.location), path) : nodePath.join(nodePath.dirname(this.location), path); debug('bump-cli:definition')(`Resolved relative $ref location: ${relativeLocation}`); return relativeLocation; } url(location = this.location) { try { return new URL(location); } catch { return { hostname: '' }; } } } export { API, SupportedFormat };