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

259 lines (258 loc) 10.8 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 openapiSchemas from './core/schemas/oas-schemas/index.js'; class SupportedFormat { 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 openapi = { '2.0': openapiSchemas.schemas['2.0'], '3.0': openapiSchemas.schemas['3.0'], '3.1': openapiSchemas.schemas['3.1'], '3.2': openapiSchemas.schemas['3.2'], }; } 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; spec; specName; version; constructor(location, values) { this.location = location; this.references = []; const [raw, parsed] = this.resolveContent(values); this.rawDefinition = raw; this.definition = parsed; this.specName = this.getSpecName(parsed); this.version = this.getVersion(parsed); this.spec = this.getSpec(parsed); if (this.spec === undefined) { throw new UnsupportedFormat(`${this.specName} ${this.version}`); } } static isAsyncAPI(definition) { return 'asyncapi' in definition; } static isOpenAPI(definition) { return typeof definition.openapi === 'string' || typeof definition.swagger === 'string'; } static isOpenAPIOverlay(definition) { return 'overlay' in 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) => { const values = $refs.values(); return new API(path, values); }) .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 */ } const references = []; for (let i = 0; i < this.references.length; i++) { const reference = this.references[i]; references.push({ content: reference.content, location: reference.location, }); } return [this.serializeDefinition(outputPath), references]; } getSpec(definition) { if (API.isAsyncAPI(definition)) { return SupportedFormat.asyncapi[this.versionWithoutPatch()]; } if (API.isOpenAPIOverlay(definition)) { return { overlay: { type: 'string' } }; } return SupportedFormat.openapi[this.versionWithoutPatch()]; } getSpecName(definition) { if (API.isAsyncAPI(definition)) { return 'AsyncAPI'; } return 'OpenAPI'; } getVersion(definition) { if (API.isAsyncAPI(definition)) { return definition.asyncapi; } return (definition.openapi || definition.swagger); } 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; } resolveContent(values) { let mainReference = { parsed: {}, raw: '' }; for (const [absPath, reference] of Object.entries(values)) { if (this.isMainRefPath(absPath)) { // $refs.values is not properly typed so we need to force it // with the resulting type of our custom defined parser mainReference = reference; } else { // $refs.values is not properly typed so we need to force it // with the resulting type of our custom defined parser const { raw } = reference; if (!raw) { throw new UnsupportedFormat(`Reference ${absPath} is empty`); } this.references.push({ content: raw, location: this.resolveRelativeLocation(absPath), }); } } const { parsed, raw } = mainReference; if (!parsed || !raw || !(parsed instanceof Object) || !('info' in parsed)) { debug('bump-cli:definition')(`Main location (${this.location}) not found or empty (within ${JSON.stringify(Object.keys(values))})`); throw new UnsupportedFormat("Definition needs to be an object with at least an 'info' key"); } if (!API.isOpenAPI(parsed) && !API.isAsyncAPI(parsed) && !API.isOpenAPIOverlay(parsed)) { throw new UnsupportedFormat(); } return [raw, parsed]; } /* Resolve reference absolute paths to the main api location when possible */ resolveRelativeLocation(absPath) { const definitionUrl = this.url(); const refUrl = this.url(absPath); if ((refUrl.hostname === '' && // filesystem path (/^\//.test(absPath) || // Unix style /^[A-Za-z]+:[/\\]/.test(absPath))) || // Windows style (/^https?:\/\//.test(absPath) && definitionUrl.hostname === refUrl.hostname) // Same domain URLs ) { const relativeLocation = nodePath.relative(nodePath.dirname(this.location), absPath); debug('bump-cli:definition')(`Resolved relative $ref location: ${relativeLocation}`); return relativeLocation; } return absPath; } 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() { const [major, minor] = this.version.split('.', 3); return `${major}.${minor}`; } url(location = this.location) { try { return new URL(location); } catch { return { hostname: '' }; } } } export { API, SupportedFormat };