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
JavaScript
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 };