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