UNPKG

@advanced-rest-client/electron-amf-service

Version:
482 lines (456 loc) 13.4 kB
import { file, dir } from 'tmp-promise'; import { Duplex } from 'stream'; import unzipper from 'unzipper'; import path from 'path'; import { fork } from 'child_process'; import fs from 'fs-extra'; import { ApiSearch } from './ApiSearch.js'; /** @typedef {import('../types').AmfServiceProcessingOptions} AmfServiceProcessingOptions */ /** @typedef {import('../types').ApiSearchTypeResult} ApiSearchTypeResult */ /** @typedef {import('../types').ApiParseResult} ApiParseResult */ /** @typedef {import('tmp-promise').FileResult} FileResult */ /** @typedef {import('tmp-promise').DirectoryResult} DirectoryResult */ /** @typedef {import('child_process').ChildProcess} ChildProcess */ /** * A class that handles parsing a file to AMF format. * * It unpacks zip files, searches for main entry point to the API and parses * the data to AMF json-ld format. * * The process can be split into 3 parts: * * - prepare - unzips the file to a temporary location, sets paths * - resolve - in case when deterministic method of finding API main file fails, the application should aks a user to choose the main API file. * - parse - parsing API data and returning AMF model. * * Example use: * * ```javascript * const service = new AmfService(filePathOrBuffer); * * service.prepare() * .then(() => service.resolve()) * .then((candidates) => { * // If "candidates" is set then the application * // should ask the user to select main file * if (candidates) { * return askUser(candidates); * } else { * return service.parse(); * } * }) * .then((mainFile) => service.parse(mainFile)) * .then((model) => console.log(model)) * .catch((cause) => console.error(cause)); * ``` */ export class AmfService { /** * True when tmp object represents a file and not a directory * @type {boolean} */ #tmpIsFile = false; /** * @return {boolean} True when tmp object represents a file and not a directory */ get tmpIsFile() { return this.#tmpIsFile; } /** * The same as with constructor but resets the sate. * @param {Buffer|string} source Location of the API file on the disk or * buffer of the file. If the source is a file and it's not a zip file then * it must be the API file. * @param {AmfServiceProcessingOptions=} [opts={}] Processing options */ setSource(source, opts={}) { this.source = source; this.isZip = opts.zip; this.validate = opts.validate; /** * Temp folder data object. * @type {FileResult|DirectoryResult} */ this.tmpObj = undefined; /** * A directory path where files are stored. * @type {string} */ this.workingDir = undefined; /** * API main file (entry point) in the working directory. * If this is set it means the files has been resolved. * @type {string} */ this.mainFile = undefined; } /** * Cleans up if the operation is canceled. * This must be called if `prepare()` was called or otherwise some temporary * files will be kept on the disk. * @return {Promise<void>} */ async cancel() { await this._cleanTempFiles(); this.tmpObj = undefined; this.workingDir = undefined; this.mainFile = undefined; } /** * Cleans up temporary directories and kills all child processes. * @return {Promise<void>} */ async cleanup() { this._cancelMonitorParser(); this._cancelParseProcTimeout(); const proc = this._parserProc; if (!proc) { return this.cancel(); } return new Promise((resolve) => { this._killParser(); proc.on('exit', () => { this.cancel().then(() => resolve()); }); }); } /** * Prepares the file to be processed. * @return {Promise<void>} */ async prepare() { if (this.isZip) { return this._prepareZip(); } if (this.source instanceof Buffer) { return this._prepareBuffer(); } const stat = await fs.stat(this.source); if (stat.isDirectory()) { this.workingDir = this.source; } else { this.workingDir = path.dirname(this.source); this.mainFile = path.basename(this.source); } } /** * Prepares zip files to be processed. Unzips un "unfolders" the * content of the zip. * @return {Promise<void>} */ async _prepareZip() { try { await this._unzipSource(); } catch (cause) { await this._cleanTempFiles(); throw cause; } } /** * Prepares buffer data to be processed. * @return {Promise<void>} */ async _prepareBuffer() { const location = await this._tmpBuffer(/** @type Buffer */ (this.source)); this.workingDir = path.dirname(location); this.mainFile = path.basename(location); } /** * Resolves the API structure and tries to find main API file. * * @param {string=} mainFile API main file if known. * @return {Promise<string[]>} If promise resolves to an array it means that API type could not be determined automatically. */ async resolve(mainFile) { if (this.#tmpIsFile) { return; } if (!this.workingDir) { await this._cleanTempFiles(); throw new Error(`prepare() function not called`); } if (this.mainFile) { return; } if (mainFile) { const file = path.join(this.workingDir, mainFile); const exists = fs.pathExists(file); if (exists) { this.mainFile = mainFile; return; } throw new Error('API main file does not exist.'); } const search = new ApiSearch(this.workingDir); try { const result = await search.findApiFile(); if (!result) { throw new Error('Unable to find API files in the source location'); } if (Array.isArray(result)) { return result; } this.mainFile = result; } catch (cause) { await this._cleanTempFiles(); throw cause; } } /** * Parses API data using AMF parser. * @param {string=} mainFile Main API file to use. * @return {Promise<ApiParseResult>} A promise resolved to AMF model. */ async parse(mainFile) { if (!this.workingDir) { await this._cleanTempFiles(); throw new Error(`prepare() function not called`); } if (mainFile && typeof mainFile === 'string') { this.mainFile = mainFile; } if (!this.mainFile) { await this._cleanTempFiles(); throw new Error(`resolve() function not called`); } const search = new ApiSearch(this.workingDir); const apiLocation = path.join(this.workingDir, this.mainFile); try { const type = await search._readApiType(apiLocation); const model = await this._runParser(apiLocation, type); await this._cleanTempFiles(); return { model, type, }; } catch (cause) { await this._cleanTempFiles(); throw cause; } } /** * Unzips the source to a tem folder. * @return {Promise<void>} */ async _unzipSource() { let buffer; if (this.source instanceof Buffer) { buffer = this.source; } else { buffer = await fs.readFile(this.source); } const location = await this._unzip(buffer); this.workingDir = location; await this._removeZipMainFolder(location); } /** * Creates a temporary file. * @param {Buffer} buffer A buffer to use to write the data to the temp file * @return {Promise<string>} */ async _tmpBuffer(buffer) { const tmp = await file(); this.tmpObj = tmp; this.#tmpIsFile = true; const fd = tmp.fd; await fs.write(fd, buffer); await fs.close(fd); return tmp.path; } /** * Unzips API folder and returns path to the folder in tmp location. * * @param {Buffer} buffer Zip file data * @return {Promise<string>} */ async _unzip(buffer) { this.tmpObj = await dir(); return new Promise((resolve, reject) => { const stream = new Duplex(); stream.push(buffer); stream.push(null); const extractor = unzipper.Extract({ path: this.tmpObj.path, }); extractor.on('close', () => { resolve(this.tmpObj.path); }); extractor.on('error', (err) => { reject(err); }); stream.pipe(extractor); }); } /** * The zip may have source files enclosed in a folder. * This will look for a folder in the root path and will copy sources from it. * * @param {string} destination A place where the zip sources has been extracted. * @return {Promise<void>} */ async _removeZipMainFolder(destination) { let files = await fs.readdir(destination); files = files.filter((item) => item !== '__MACOSX'); if (files.length > 1) { return; } const dirPath = path.join(destination, files[0]); const stats = await fs.stat(dirPath); if (stats.isDirectory()) { await fs.copy(dirPath, destination); } } /** * Removes created temporary directory. * @return {Promise<void>} */ async _cleanTempFiles() { if (!this.tmpObj) { return; } if (this.#tmpIsFile) { this.tmpObj.cleanup(); this.tmpObj = undefined; return; } await fs.emptyDir(this.tmpObj.path); this.tmpObj.cleanup(); this.tmpObj = undefined; } /** * Creates a child process for the parser. It ensures stability of * the application when the parser is having trouble parsing the API. * Otherwise it would make the application unstable. * * @return {ChildProcess} A reference to the child process */ _createParserProcess() { if (this._parserProc) { if (this._parserProc.connected) { return this._parserProc; } this._killParser(); } const options = { execArgv: [], }; this._parserProc = fork(`${__dirname}/amf-parser.js`, options); this._parserProc.on('exit', () => { this._cancelParseProcTimeout(); this._cancelMonitorParser(); this._parserProc = undefined; }); return this._parserProc; } /** * Sets process timeout. * @param {Function} cb Function to be called when timeout is triggered. * @param {Number} [time=180000] Process timeout. */ _setParserProcTimeout(cb, time = 180000) { this._parserProcCb = cb; this._parserProcessTimeout = setTimeout(() => { this._parserProcessTimeout = undefined; this._killParser(); const fn = this._parserProcCb; this._parserProcCb = undefined; fn(); }, time); } /** * Cancels process timeout */ _cancelParseProcTimeout() { if (this._parserProcessTimeout) { clearTimeout(this._parserProcessTimeout); this._parserProcessTimeout = undefined; this._parserProcCb = undefined; } } /** * Kills created child process, if any. */ _killParser() { this._cancelParseProcTimeout(); this._cancelMonitorParser(); if (this._parserProc) { this._parserProc.disconnect(); this._parserProc.removeAllListeners('message'); this._parserProc.removeAllListeners('error'); this._parserProc.removeAllListeners('exit'); this._parserProc.kill(); this._parserProc = undefined; } } /** * Sets a timeout to kill child process. */ _monitorParserProc() { this._parserMonitorTimeout = setTimeout(() => { this._parserMonitorTimeout = undefined; this._killParser(); }, 60000); } /** * Cancels child process kill timeout, if any. */ _cancelMonitorParser() { if (this._parserMonitorTimeout) { clearTimeout(this._parserMonitorTimeout); } } /** * Runs the parser. * * @param {string} apiLocation API file location * @param {ApiSearchTypeResult} type API type info object. * @return {Promise<string>} */ _runParser(apiLocation, type) { this._cancelMonitorParser(); return new Promise((resolve, reject) => { const callbacks = { onmessage: (result) => { if (result.validation) { // eslint-disable-next-line no-console console.log(result.validation); return; } this._cancelParseProcTimeout(); this._parserProc.removeAllListeners('message'); this._parserProc.removeAllListeners('error'); this._parserProcCb = undefined; this._monitorParserProc(); this._killParser(); if (result.error) { reject(new Error(result.error)); } else { resolve(result.api); } }, onerror: (err) => { this._cancelParseProcTimeout(); this._parserProc.removeAllListeners('message'); this._parserProc.removeAllListeners('error'); this._parserProcCb = undefined; this._monitorParserProc(); reject(new Error(err.message || 'Unknown error')); }, }; const proc = this._createParserProcess(); this._setParserProcTimeout(() => { reject(new Error('API parsing timeout')); this._parserProc.removeAllListeners('message'); this._parserProc.removeAllListeners('error'); this._monitorParserProc(); }); proc.on('message', callbacks.onmessage); proc.on('error', callbacks.onerror); proc.send({ source: apiLocation, from: type, validate: this.validate, }); }); } }