UNPKG

openapi-format

Version:

Format an OpenAPI document by ordering, formatting and filtering fields.

435 lines (392 loc) 13.4 kB
const fs = require('fs'); const bundler = require('api-ref-bundler'); const yaml = require('@stoplight/yaml'); const http = require('http'); const https = require('https'); const {dirname} = require('path'); /** * Converts a string object to a JSON/YAML object. * @param {string} str - The input string to be parsed (either JSON or YAML). * @param {object} options - Options to define the parsing behavior (e.g., keeping comments). * @returns {Promise<object>} Parsed data object. */ async function parseString(str, options = {}) { // Exit early if (str.length === 0) { return str; } // Convert large number values safely before parsing let encodedContent = encodeLargeNumbers(str); encodedContent = addQuotesToRefInString(encodedContent); // Default to YAML format unless specified as JSON const toYaml = options.format !== 'json' && (!options.hasOwnProperty('json') || options.json !== true); if (toYaml) { try { const result = yaml.parseWithPointers(encodedContent, {attachComments: options?.keepComments || false}); options.yamlComments = result.comments; const obj = result.data; if (typeof obj === 'object') { return obj; } else { throw new SyntaxError('Invalid YAML'); } } catch (yamlError) { return yamlError; } } else { try { // Try parsing as JSON return JSON.parse(encodedContent); } catch (jsonError) { return jsonError; } } } /** * Checks if a given string is valid JSON. * @param {string} str - The input string to check. * @returns {Promise<boolean>} True if the string is valid JSON, false otherwise. */ async function isJSON(str) { try { JSON.parse(str); return true; } catch (e) { return false; } } /** * Checks if a given string is valid YAML. * @param {string} str - The input string to check. * @returns {Promise<boolean>} True if the string is valid YAML, false otherwise. */ async function isYaml(str) { try { const rest = yaml.parse(str); return typeof rest === 'object'; } catch (e) { return false; } } /** * Detects the format of a given string (either JSON or YAML). * @param {string} str - The input string to check. * @returns {Promise<string>} "json", "yaml", or "unknown" based on the detected format. */ async function detectFormat(str) { if ((await isJSON(str)) !== false) { return 'json'; } else if ((await isYaml(str)) !== false) { return 'yaml'; } else { return 'unknown'; } } /** * Reads a file (local or remote) and returns its content as a string. * @param {string} filePath - The path to the file (local or URL). * @param {object} options - Parse file options (e.g., format detection). * @returns {Promise<string>} The content of the file as a string. */ async function readFile(filePath, options) { try { const isRemoteFile = filePath.startsWith('http://') || filePath.startsWith('https://'); let fileContent; if (isRemoteFile) { fileContent = await getRemoteFile(filePath); } else { const isYamlFile = filePath.endsWith('.yaml') || filePath.endsWith('.yml'); isYamlFile ? (options.format = 'yaml') : (options.format = 'json'); fileContent = await getLocalFile(filePath); } // Check JSON or YAML (await isJSON(fileContent)) ? (options.format = 'json') : (options.format = 'yaml'); return fileContent; } catch (err) { throw err; } } /** * Parses a JSON/YAML file and returns the parsed object * @param {string} filePath - The path to the JSON/YAML file. * @param {object} options - Parse file options (e.g., reference resolution hooks). * @returns {Promise<object>} Parsed data object with references resolved, if any. */ async function parseFile(filePath, options = {}) { try { // Read local or remote file content and get format JSON or YAML let rawContent = await readFile(filePath, options); if (rawContent.includes('$ref') && options.bundle === true) { // Handler to Resolve references const resolver = async sourcePath => { let refContent = await readFile(sourcePath, options); return await parseString(refContent, options); }; const onErrorHook = msg => { throw new Error(msg); }; // Use the bundler to resolve external refs and bundle the document return bundler.bundle(filePath, resolver, {ignoreSibling: false, hooks: {onError: onErrorHook}}); } // Parse file content as JSON/YAML return await parseString(rawContent, options); } catch (err) { throw err; } } /** * Converts a data object to a JSON/YAML string representation. * @param {object} obj - The data object to stringify. * @param {object} options - Stringify options (e.g., line width, format). * @returns {Promise<string>} The object as a string in JSON/YAML format. */ async function stringify(obj, options = {}) { try { let output; // Default to YAML format const toYaml = options.format !== 'json' && (!options.hasOwnProperty('json') || options.json !== true); if (toYaml) { // Set YAML options const yamlOptions = {}; yamlOptions.lineWidth = (options.lineWidth && options.lineWidth === -1 ? Infinity : options.lineWidth) || Infinity; if (options?.yamlComments && options?.keepComments === true) { yamlOptions.comments = options.yamlComments; } // Convert object to YAML string output = yaml.safeStringify(obj, yamlOptions); output = addQuotesToRefInString(output); // Decode large number YAML values safely before writing output output = decodeLargeNumbers(output); } else { // Convert object to JSON string output = JSON.stringify(obj, null, 2); // Decode large number JSON values safely before writing output output = decodeLargeNumbers(output, true); } // Return the stringify output return output; } catch (err) { // Handle errors or rethrow throw err; } } /** * Writes an object to a JSON/YAML file. * @param {string} filePath - The path to the output file. * @param {object} data - The data object to write. * @param {object} options - Write options (e.g., format). * @returns {Promise<void>} Resolves when the file is written successfully. */ async function writeFile(filePath, data, options = {}) { try { let output; const isYamlFile = filePath.endsWith('.yaml') || filePath.endsWith('.yml'); if (isYamlFile) { // Convert Object to YAML string options.format = 'yaml'; output = await stringify(data, options); } else { // Convert Object to JSON string options.format = 'json'; output = await stringify(data, options); } const dir = dirname(filePath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, {recursive: true}); } // Write the output to the file fs.writeFileSync(filePath, output, 'utf8'); } catch (err) { console.error('\x1b[31m', `Error writing file "${filePath}": ${err.message}`); throw err; } } /** * Reads a local file and returns the content. * @param {string} filePath - The path to the local file. * @returns {Promise<string>} The content of the file as a string. */ async function getLocalFile(filePath) { try { const inputContent = fs.readFileSync(filePath, 'utf8'); return inputContent; } catch (err) { throw err; // throw new Error(`Input file error - Failed to read file: ${filePath}`); } } /** * Reads a remote file and returns the content. * @param {string} filePath - The URL to the remote file. * @returns {Promise<string>} The content of the remote file as a string. */ async function getRemoteFile(filePath) { const protocol = filePath.startsWith('https://') ? https : http; const inputContent = await new Promise((resolve, reject) => { protocol.get(filePath, res => { if (res.statusCode < 200 || res.statusCode >= 300) { reject(new Error(`${res.statusCode} ${res.statusMessage}`)); } const chunks = []; res.on('data', chunk => { chunks.push(chunk); }); res.on('end', () => { resolve(Buffer.concat(chunks).toString()); }); res.on('error', err => { reject(new Error(`${err.message}`)); }); }); }); return inputContent; } /** * Convert large number value safely before parsing * @param inputContent Input content. * @returns {*} Encoded content. */ function encodeLargeNumbers(inputContent) { // Convert large number value safely before parsing const regexEncodeLargeNumber = /: ([0-9]+(\.[0-9]+)?)\b(?!\.[0-9])(,|\n)/g; // match > : 123456789.123456789 return inputContent.replace(regexEncodeLargeNumber, rawInput => { const endChar = rawInput.endsWith(',') ? ',' : '\n'; const rgx = new RegExp(endChar, 'g'); const number = rawInput.replace(/: /g, '').replace(rgx, ''); // Handle large numbers safely in javascript if (Number(number).toString().includes('e') || number.replace('.', '').length > 15) { return `: "${number}==="${endChar}`; } else { return `: ${number}${endChar}`; } }); } /** * Decode large number YAML/JSON values safely before writing output * @param output YAML/JSON Output content. * @param isJson Indicate if the output is JSON. * @returns {*} Decoded content. */ function decodeLargeNumbers(output, isJson = false) { if (isJson) { // Decode large number JSON values safely before writing output const regexDecodeJsonLargeNumber = /: "([0-9]+(\.[0-9]+)?)\b(?!\.[0-9])==="/g; // match > : "123456789.123456789===" return output.replace(regexDecodeJsonLargeNumber, strNumber => { const number = strNumber.replace(/: "|"/g, ''); // Decode large numbers safely in javascript if (number.endsWith('===') || number.replace('.', '').length > 15) { return strNumber.replace('===', '').replace(/"/g, ''); } else { // Keep original number return strNumber; } }); } else { // Decode large number YAML values safely before writing output const regexDecodeYamlLargeNumber = /: ([0-9]+(\.[0-9]+)?)\b(?!\.[0-9])===/g; // match > : 123456789.123456789=== return output.replace(regexDecodeYamlLargeNumber, strNumber => { const number = strNumber.replace(/: '|'/g, ''); // Decode large numbers safely in javascript if (number.endsWith('===') || number.replace('.', '').length > 15) { return strNumber.replace('===', '').replace(/'/g, ''); } else { // Keep original number return strNumber; } }); } } /** * Add quotes to $ref in string * @param {string} yamlString - The input YAML string. * @returns {string} YAML string with quotes. */ function addQuotesToRefInString(yamlString) { return yamlString.replace(/(\$ref:\s*)([^"'\s>]+)/g, '$1"$2"'); } /** * Analyze the OpenAPI document. * @param {object} oaObj - The OpenAPI document as a JSON object. * @returns {{operations: *[], methods: any[], paths: *[], flags: any[], operationIds: *[], flagValues: any[], responseContent: any[], tags: any[]}} */ function analyzeOpenApi(oaObj) { const flags = new Set(); const tags = new Set(); const operationIds = []; const paths = []; const methods = new Set(); const operations = []; const responseContent = new Set(); const requestContent = new Set(); const flagValues = new Set(); if (oaObj && oaObj.paths) { Object.keys(oaObj.paths).forEach(path => { paths.push(path); const pathItem = oaObj.paths[path]; Object.keys(pathItem).forEach(method => { methods.add(method.toUpperCase()); const operation = pathItem[method]; operations.push(`${method.toUpperCase()}::${path}`); if (operation?.tags && Array.isArray(operation.tags)) { operation.tags.forEach(tag => { if (tag.startsWith('x-')) { flags.add(tag); } else { tags.add(tag); } }); } if (operation?.operationId) { operationIds.push(operation.operationId); } if (operation?.requestBody?.content) { Object.keys(operation.requestBody.content).forEach(contentType => { requestContent.add(contentType); }); } if (operation?.responses) { Object.values(operation.responses).forEach(response => { if (response?.content) { Object.keys(response.content).forEach(contentType => { responseContent.add(contentType); }); } }); } Object.keys(operation).forEach(key => { if (key.startsWith('x-')) { flagValues.add(`${key}: ${operation[key]}`); } }); }); }); } return { methods: Array.from(methods), tags: Array.from(tags), operationIds, flags: Array.from(flags), flagValues: Array.from(flagValues), paths, operations, responseContent: Array.from(responseContent), requestContent: Array.from(requestContent) }; } module.exports = { readFile, parseString, parseFile, isJSON, isYaml, detectFormat, stringify, writeFile, encodeLargeNumbers, decodeLargeNumbers, getLocalFile, getRemoteFile, analyzeOpenApi, addQuotesToRefInString };