openapi-explorer
Version:
OpenAPI Explorer - API viewer with dynamically generated components, documentation, and interaction console
295 lines (285 loc) • 13.4 kB
JavaScript
;
exports.__esModule = true;
exports.default = ProcessSpec;
var _openapiResolverBrowser = _interopRequireDefault(require("openapi-resolver/dist/openapi-resolver.browser.js"));
var _marked = require("marked");
var _commonUtils = require("./common-utils.js");
var _lodash = _interopRequireDefault(require("lodash.clonedeep"));
var _toposort = _interopRequireDefault(require("toposort"));
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
// To inline test changes to the OpenAPiResolver, it must be copied into the node_modules directory. The builder for this package will not work across repositories.
async function ProcessSpec(specUrlOrObject, serverUrl = '') {
var _jsonParsedSpec$info, _jsonParsedSpec$compo;
const inputSpecIsAUrl = typeof specUrlOrObject === 'string' && specUrlOrObject.match(/^http/) || typeof specUrlOrObject === 'object' && typeof specUrlOrObject.href === 'string';
let jsonParsedSpec;
let errorToDisplay;
for (let iteration = 0; iteration < 7; iteration++) {
try {
jsonParsedSpec = await (0, _openapiResolverBrowser.default)(specUrlOrObject);
break;
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error parsing specification', error);
errorToDisplay = error.message;
await new Promise(resolve => setTimeout(resolve, 100 * 2 ** iteration));
}
}
if (!jsonParsedSpec) {
if (errorToDisplay) {
if (inputSpecIsAUrl && specUrlOrObject.toString().match('localhost')) {
throw Error(`Cannot connect to your localhost running spec because your webserver is blocking requests. To the load the spec from ${specUrlOrObject.toString()}, return the following CORS header \`"Access-Control-Allow-Private-Network": "true"\`.`);
}
const message = `Failed to resolve the spec: ${errorToDisplay}`;
throw Error(message);
}
throw Error('SpecificationNotFound');
}
// Tags with Paths and WebHooks
const tags = groupByTags(jsonParsedSpec);
// Components
const components = getComponents(jsonParsedSpec);
// Info Description Headers
const infoDescriptionHeaders = (_jsonParsedSpec$info = jsonParsedSpec.info) !== null && _jsonParsedSpec$info !== void 0 && _jsonParsedSpec$info.description ? getHeadersFromMarkdown(jsonParsedSpec.info.description) : [];
// Security Scheme
const securitySchemes = [];
if ((_jsonParsedSpec$compo = jsonParsedSpec.components) !== null && _jsonParsedSpec$compo !== void 0 && _jsonParsedSpec$compo.securitySchemes) {
Object.entries(jsonParsedSpec.components.securitySchemes).forEach(kv => {
const securityObj = {
apiKeyId: kv[0],
...kv[1]
};
securityObj.value = '';
securityObj.finalKeyValue = '';
if (kv[1].type === 'apiKey' || kv[1].type === 'http') {
securityObj.name = kv[1].name || 'Authorization';
securityObj.user = '';
securityObj.password = '';
} else if (kv[1].type === 'oauth2') {
securityObj.name = 'Authorization';
securityObj.clientId = '';
securityObj.clientSecret = '';
}
securitySchemes.push(securityObj);
});
}
// Servers
let servers = [];
if (Array.isArray(jsonParsedSpec.servers) && jsonParsedSpec.servers.length) {
jsonParsedSpec.servers.filter(s => s).forEach(v => {
let computedUrl = v.url.trim();
if (!(computedUrl.startsWith('http') || computedUrl.startsWith('//') || computedUrl.startsWith('{'))) {
if (window.location.origin.startsWith('http')) {
v.url = window.location.origin + v.url;
computedUrl = v.url;
}
}
// Apply server-variables to generate final computed-url
if (v.variables) {
Object.entries(v.variables).forEach(kv => {
const regex = new RegExp(`{${kv[0]}}`, 'g');
computedUrl = computedUrl.replace(regex, kv[1].default || '');
kv[1].value = kv[1].default || '';
});
}
v.computedUrl = computedUrl;
});
const explicitServers = serverUrl && !jsonParsedSpec.servers.some(s => s.url === serverUrl || s.computedUrl === serverUrl) ? [{
url: serverUrl,
computedUrl: serverUrl
}] : [];
servers = explicitServers.concat(jsonParsedSpec.servers);
} else if (serverUrl) {
servers = [{
url: serverUrl,
computedUrl: serverUrl
}];
} else if (inputSpecIsAUrl) {
servers = [{
url: new URL(specUrlOrObject).origin,
computedUrl: new URL(specUrlOrObject).origin
}];
} else if (window.location.origin.startsWith('http')) {
servers = [{
url: window.location.origin,
computedUrl: window.location.origin
}];
} else {
servers = [{
url: 'http://localhost',
computedUrl: 'http://localhost'
}];
}
const parsedSpec = {
info: {
...jsonParsedSpec.info,
expanded: true,
headers: infoDescriptionHeaders
},
tags,
components,
// pathGroups,
externalDocs: jsonParsedSpec.externalDocs,
securitySchemes,
servers
};
return parsedSpec;
}
function getHeadersFromMarkdown(markdownContent) {
const tokens = _marked.marked.lexer(markdownContent);
const headers = tokens.filter(v => v.type === 'heading' && v.depth <= 2);
return headers || [];
}
function getComponents(openApiSpec) {
if (!openApiSpec.components) {
return [];
}
const components = [];
for (const componentKeyId in openApiSpec.components) {
const subComponents = Object.keys(openApiSpec.components[componentKeyId]).map(sComponentId => ({
expanded: true,
id: `${componentKeyId.toLowerCase()}-${sComponentId.toLowerCase()}`.replace(_commonUtils.invalidCharsRegEx, '-'),
name: openApiSpec.components[componentKeyId][sComponentId].title || sComponentId,
component: openApiSpec.components[componentKeyId][sComponentId]
})).sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
if (componentKeyId === 'requestBodies' || componentKeyId === 'securitySchemes' || componentKeyId === 'securitySchemas') {
continue;
}
components.push({
expanded: true,
componentKeyId,
subComponents
});
}
return components;
}
function groupByTags(openApiSpec) {
const supportedMethods = ['get', 'query', 'put', 'post', 'patch', 'delete', 'head', 'options']; // this is also used for ordering endpoints by methods
const rawTags = openApiSpec.tags && Array.isArray(openApiSpec.tags) ? openApiSpec.tags : [];
const unsortedTags = rawTags
// Only support nav tags in grouping, because these tags will be used for navigation.
.filter(t => !t.kind || t.kind === 'nav').map(t => {
const name = typeof t === 'string' ? t : t.name;
return {
elementId: `tag--${name.replace(_commonUtils.invalidCharsRegEx, '-')}`,
name: name,
summary: t.summary,
description: t.description || '',
headers: t.description ? getHeadersFromMarkdown(t.description) : [],
paths: [],
expanded: true
};
});
const tagMap = unsortedTags.reduce((acc, t) => ({
...acc,
[t.name]: t
}), {});
const tagDependencies = rawTags.map(t => t.parent && [t.parent, t.name] || [t.name, 'null']);
const tags = (0, _toposort.default)(tagDependencies).map(tagName => tagMap[tagName]).filter(t => t);
const pathsAndWebhooks = openApiSpec.paths || {};
if (openApiSpec.webhooks) {
for (const [key, value] of Object.entries(openApiSpec.webhooks)) {
value._type = 'webhook'; // eslint-disable-line no-underscore-dangle
pathsAndWebhooks[key] = value;
}
}
// For each path find the tag and push it into the corresponding tag
for (const pathOrHookName in pathsAndWebhooks) {
const commonPathPropServers = pathsAndWebhooks[pathOrHookName].servers || [];
const isWebhook = pathsAndWebhooks[pathOrHookName]._type === 'webhook'; // eslint-disable-line no-underscore-dangle
supportedMethods.forEach(methodName => {
const commonParams = (0, _lodash.default)(pathsAndWebhooks[pathOrHookName].parameters || []);
if (pathsAndWebhooks[pathOrHookName][methodName]) {
const pathOrHookObj = openApiSpec.paths[pathOrHookName][methodName];
// If path.methods are tagged, else generate it from path
const pathTags = Array.isArray(pathOrHookObj.tags) ? pathOrHookObj.tags : pathOrHookObj.tags && [pathOrHookObj.tags] || [];
if (pathTags.length === 0) {
pathTags.push('General ⦂');
}
pathTags.forEach(tag => {
var _pathOrHookObj$parame;
let tagObj;
let specTagsItem;
if (openApiSpec.tags) {
specTagsItem = tags.find(v => v.name.toLowerCase() === tag.toLowerCase());
}
tagObj = tags.find(v => v.name === tag);
if (!tagObj) {
tagObj = {
elementId: `tag--${tag.replace(_commonUtils.invalidCharsRegEx, '-')}`,
name: tag,
description: specTagsItem && specTagsItem.description || '',
headers: specTagsItem && specTagsItem.description ? getHeadersFromMarkdown(specTagsItem.description) : [],
paths: [],
expanded: true
};
tags.push(tagObj);
}
// Generate a short summary which is broken
let shortSummary = (pathOrHookObj.summary || pathOrHookObj.description || `${methodName.toUpperCase()} ${pathOrHookName}`).trim();
if (shortSummary.length > 100) {
shortSummary = shortSummary.split(/[.|!|?]\s|[\r?\n]/)[0]; // take the first line (period or carriage return)
}
// Merge Common Parameters with This methods parameters
const finalParameters = ((_pathOrHookObj$parame = pathOrHookObj.parameters) === null || _pathOrHookObj$parame === void 0 ? void 0 : _pathOrHookObj$parame.slice(0)) || [];
finalParameters.push(...commonParams.filter(commonParam => !finalParameters.some(param => commonParam.name === param.name && commonParam.in === param.in)));
const successResponseKeys = Object.keys(pathOrHookObj.responses || {}).filter(r => !r.match(/^\d{3}$/i) || r.match(/^[23]\d{2}$/i));
const responseContentTypesMap = successResponseKeys.map(key => pathOrHookObj.responses[key]).reduce((acc, response) => Object.assign({}, acc, response.content || {}), {});
const responseContentTypes = Object.keys(responseContentTypesMap).sort((a, b) => a.localeCompare(b));
if (!finalParameters.some(p => p.in === 'header' && p.name.match(/^accept$/i)) && Object.keys(responseContentTypesMap).length > 1) {
finalParameters.push({
in: 'header',
name: 'Accept',
description: 'Select the response body Content-Type. By default, the service will return a Content-Type that best matches the requested type.',
schema: {
type: 'string',
enum: responseContentTypes
},
default: responseContentTypes[0],
example: responseContentTypes[0]
});
}
// Remove bad callbacks
if (pathOrHookObj.callbacks) {
for (const [callbackName, callbackConfig] of Object.entries(pathOrHookObj.callbacks)) {
const originalCallbackEntries = Object.entries(callbackConfig);
const filteredCallbacks = originalCallbackEntries.filter(entry => typeof entry[1] === 'object') || [];
pathOrHookObj.callbacks[callbackName] = Object.fromEntries(filteredCallbacks);
if (filteredCallbacks.length !== originalCallbackEntries.length) {
console.warn(`OpenAPI Explorer: Invalid Callback found in ${callbackName}`); // eslint-disable-line no-console
}
}
}
// Update Responses
const pathObject = {
expanded: false,
isWebhook,
summary: pathOrHookObj.summary || '',
description: pathOrHookObj.description || '',
shortSummary,
method: methodName,
path: pathOrHookName,
operationId: pathOrHookObj.operationId,
elementId: `${methodName}-${pathOrHookName.replace(_commonUtils.invalidCharsRegEx, '-')}`,
servers: pathOrHookObj.servers ? commonPathPropServers.concat(pathOrHookObj.servers) : commonPathPropServers,
parameters: finalParameters,
requestBody: pathOrHookObj.requestBody,
responses: pathOrHookObj.responses,
callbacks: pathOrHookObj.callbacks,
deprecated: pathOrHookObj.deprecated,
security: pathOrHookObj.security || openApiSpec.security,
externalDocs: pathOrHookObj.externalDocs,
// commonSummary: commonPathProp.summary,
// commonDescription: commonPathProp.description,
xCodeSamples: pathOrHookObj['x-code-samples'] || '',
extensions: Object.keys(pathOrHookObj).filter(k => k.startsWith('x-') && k !== 'x-code-samples').reduce((acc, extensionKey) => {
acc[extensionKey] = pathOrHookObj[extensionKey];
return acc;
}, {})
};
tagObj.paths.push(pathObject);
}); // End of tag path create
}
}); // End of Methods
}
return tags;
}