UNPKG

@nyteshade/lattice-legacy

Version:

OO Underpinnings for ease of GraphQL Implementation

530 lines (468 loc) 16 kB
// @flow import fs, { readdirSync, statSync } from 'fs' import path from 'path' import * as types from 'ne-types' import { GQLBase } from './GQLBase' import { GQLJSON } from './types/GQLJSON' import { merge } from 'lodash' import { promisify, Deferred, getLatticePrefs, LatticeLogs as ll } from './utils' // Promisify some bits const readdirAsync = promisify(fs.readdir) const statAsync = promisify(fs.stat) // Fetch some type checking bits from 'types' const { typeOf, isString, isOfType, isPrimitive, isArray, isObject, extendsFrom } = types; /** * The ModuleParser is a utility class designed to loop through and iterate * on a directory and pull out of each .js file found, any classes or exports * that extend from GQLBase or a child of GQLBase. * * @class ModuleParser * @since 2.7.0 */ export class ModuleParser { /** * An internal array of `GQLBase` extended classes found during either a * `parse()` or `parseSync()` call. * * @memberof ModuleParser * @type {Array<GQLBase>} */ classes: Array<GQLBase>; /** * An array of strings holding loose GraphQL schema documents. * * @memberof ModuleParser * @type {Array<string>} */ looseGraphQL: Array<string> = []; /** * A map of skipped items on the last pass and the associated error that * accompanies it. */ skipped: Map<string, Error>; /** * A string denoting the directory on disk where `ModuleParser` should be * searching for its classes. * * @memberof ModuleParser * @type {string} */ directory: string; /** * A boolean value denoting whether or not the `ModuleParser` instance is * valid; i.e. the directory it points to actually exists and is a directory * * @type {boolean} */ valid: boolean; /** * An object, optionally added during construction, that specifies some * configuration about the ModuleParser and how it should do its job. * * Initially, the * * @type {Object} */ options: Object = {}; /** * The constructor * * @constructor * @method ⎆⠀constructor * @memberof ModuleParser * @inner * * @param {string} directory a string path to a directory containing the * various GQLBase extended classes that should be gathered. */ constructor(directory: string, options: Object = {addLatticeTypes: true}) { this.directory = path.resolve(directory); this.classes = []; this.skipped = new Map(); merge(this.options, options); try { this.valid = fs.statSync(directory).isDirectory(); } catch (error) { this.valid = false; } } /** * Given a file path, this method will attempt to import/require the * file in question and return the object it exported; whatever that * may be. * * @method ModuleParser#⌾⠀importClass * @since 2.7.0 * * @param {string} filePath a path to pass to `require()` * * @return {Object} the object, or undefined, that was returned when * it was `require()`'ed. */ importClass(filePath: string): Object { let moduleContents: Object = {}; let yellow: string = '\x1b[33m' let clear: string = '\x1b[0m' try { moduleContents = require(filePath) } catch(ignore) { if (/\.graphql/i.test(path.extname(filePath))) { ll.log(`Ingesting .graphql file ${filePath}`) let buffer = fs.readFileSync(filePath) this.looseGraphQL.push(fs.readFileSync(filePath).toString()) } else { ll.log(`${yellow}Skipping${clear} ${filePath}`) ll.trace(ignore) this.skipped.set(filePath, ignore) } } return moduleContents; } /** * Given an object, typically the result of a `require()` or `import` * command, iterate over its contents and find any `GQLBase` derived * exports. Continually, and recursively, build this list of classes out * so that we can add them to a `GQLExpressMiddleware`. * * @method ModuleParser#⌾⠀findGQLBaseClasses * @since 2.7.0 * * @param {Object} contents the object to parse for properties extending * from `GQLBase` * @param {Array<GQLBase>} gqlDefinitions the results, allowed as a second * parameter during recursion as a means to save state between calls * @return {Set<mixed>} a unique set of values that are currently being * iterated over. Passed in as a third parameter to save state between calls * during recursion. */ findGQLBaseClasses( contents: Object, gqlDefinitions?: Array<GQLBase> = [], stack?: Set<GQLBase> = new Set() ): Array<GQLBase> { // In order to prevent infinite object recursion, we should add the // object being iterated over to our Set. At each new recursive level // add the item being iterated over to the set and only recurse into // if the item does not already exist in the stack itself. stack.add(contents) for (let key in contents) { let value = contents[key]; if (isPrimitive(value)) { continue } if (extendsFrom(value, GQLBase)) { gqlDefinitions.push(value) } if ((isObject(value) || isArray(value)) && !stack.has(value)) { gqlDefinitions = this.findGQLBaseClasses(value, gqlDefinitions, stack); } } // We remove the current iterable from our set as we leave this current // recursive iteration. stack.delete(contents) return gqlDefinitions } /** * This method takes a instance of ModuleParser, initialized with a directory, * and walks its contents, importing files as they are found, and sorting * any exports that extend from GQLBase into an array of such classes * in a resolved promise. * * @method ModuleParser#⌾⠀parse * @async * @since 2.7.0 * * @return {Promise<Array<GQLBase>>} an array GQLBase classes, or an empty * array if none could be identified. */ async parse(): Promise<Array<GQLBase>> { let modules let files let set = new Set(); let opts = getLatticePrefs() if (!this.valid) { throw new Error(` ModuleParser instance is invalid for use with ${this.directory}. The path is either a non-existent path or it does not represent a directory. `) } this.skipped.clear() // @ComputedType files = await this.constructor.walk(this.directory) modules = files.map(file => this.importClass(file)) // @ComputedType (modules .map(mod => this.findGQLBaseClasses(mod)) .reduce((last, cur) => (last || []).concat(cur || []), []) .forEach(Class => set.add(Class))) // Convert the set back into an array this.classes = Array.from(set); // We can ignore equality since we came from a set; @ComputedType this.classes.sort((l,r) => l.name < r.name ? -1 : 1) // Add in any GraphQL Lattice types requested if (this.options.addLatticeTypes) { this.classes.push(GQLJSON) } // Stop flow and throw an error if some files failed to load and settings // declare we should do so. After Lattice 3.x we should expect this to be // the new default if (opts.ModuleParser.failOnError && this.skipped.size) { this.printSkipped() throw new Error('Some files skipped due to errors') } return this.classes; } /** * This method takes a instance of ModuleParser, initialized with a directory, * and walks its contents, importing files as they are found, and sorting * any exports that extend from GQLBase into an array of such classes * * @method ModuleParser#⌾⠀parseSync * @async * @since 2.7.0 * * @return {Array<GQLBase>} an array GQLBase classes, or an empty * array if none could be identified. */ parseSync(): Array<GQLBase> { let modules: Array<Object>; let files: Array<string>; let set = new Set(); let opts = getLatticePrefs() if (!this.valid) { throw new Error(` ModuleParser instance is invalid for use with ${this.directory}. The path is either a non-existent path or it does not represent a directory. `) } this.skipped.clear() files = this.constructor.walkSync(this.directory) modules = files.map(file => { return this.importClass(file) }) modules .map(mod => this.findGQLBaseClasses(mod)) .reduce((last, cur) => (last || []).concat(cur || []), []) .forEach(Class => set.add(Class)) // Convert the set back into an array this.classes = Array.from(set); // We can ignore equality since we came from a set; @ComputedType this.classes.sort((l,r) => l.name < r.name ? -1 : 1) // Add in any GraphQL Lattice types requested if (this.options.addLatticeTypes) { this.classes.push(GQLJSON) } // Stop flow and throw an error if some files failed to load and settings // declare we should do so. After Lattice 3.x we should expect this to be // the new default if (opts.ModuleParser.failOnError && this.skipped.size) { this.printSkipped() throw new Error('Some files skipped due to errors') } return this.classes; } /** * Prints the list of skipped files, their stack traces, and the errors * denoting the reasons the files were skipped. */ printSkipped() { if (this.skipped.size) { ll.outWrite('\x1b[1;91m') ll.outWrite('Skipped\x1b[0;31m the following files\n') for (let [key, value] of this.skipped) { ll.log(`${path.basename(key)}: ${value.message}`) if (value.stack) ll.log(value.stack.replace(/(^)/m, '$1 ')) } ll.outWrite('\x1b[0m') } else { ll.log('\x1b[1;32mNo files skipped\x1b[0m') } } /** * Returns the `constructor` name. If invoked as the context, or `this`, * object of the `toString` method of `Object`'s `prototype`, the resulting * value will be `[object MyClass]`, given an instance of `MyClass` * * @method ⌾⠀[Symbol.toStringTag] * @memberof ModuleParser * * @return {string} the name of the class this is an instance of * @ComputedType */ get [Symbol.toStringTag]() { return this.constructor.name } /** * Applies the same logic as {@link #[Symbol.toStringTag]} but on a static * scale. So, if you perform `Object.prototype.toString.call(MyClass)` * the result would be `[object MyClass]`. * * @method ⌾⠀[Symbol.toStringTag] * @memberof ModuleParser * @static * * @return {string} the name of this class * @ComputedType */ static get [Symbol.toStringTag]() { return this.name } /** * Recursively walks a directory and returns an array of asbolute file paths * to the files under the specified directory. * * @method ModuleParser~⌾⠀walk * @async * @since 2.7.0 * * @param {string} dir string path to the top level directory to parse * @param {Array<string>} filelist an array of existing absolute file paths, * or if not parameter is supplied a default empty array will be used. * @return {Promise<Array<string>>} an array of existing absolute file paths * found under the supplied `dir` directory. */ static async walk( dir: string, filelist: Array<string> = [], extensions: Array<string> = ['.js', '.jsx', '.ts', '.tsx'] ): Promise<Array<string>> { let files = await readdirAsync(dir); let exts = ModuleParser.checkForPackageExtensions() || extensions let pattern = ModuleParser.arrayToPattern(exts) let stats files = files.map(file => path.resolve(path.join(dir, file))) for (let file of files) { stats = await statAsync(file) if (stats.isDirectory()) { filelist = await this.walk(file, filelist) } else { if (pattern.test(path.extname(file))) filelist = filelist.concat(file); } } return filelist; } /** * Recursively walks a directory and returns an array of asbolute file paths * to the files under the specified directory. This version does this in a * synchronous fashion. * * @method ModuleParser~⌾⠀walkSync * @async * @since 2.7.0 * * @param {string} dir string path to the top level directory to parse * @param {Array<string>} filelist an array of existing absolute file paths, * or if not parameter is supplied a default empty array will be used. * @return {Array<string>} an array of existing absolute file paths found * under the supplied `dir` directory. */ static walkSync( dir: string, filelist: Array<string> = [], extensions: Array<string> = ['.js', '.jsx', '.ts', '.tsx'] ): Array<string> { let files = readdirSync(dir) let exts = ModuleParser.checkForPackageExtensions() || extensions let pattern = ModuleParser.arrayToPattern(exts) let stats files = files.map(file => path.resolve(path.join(dir, file))) for (let file of files) { stats = statSync(file) if (stats.isDirectory()) { filelist = this.walkSync(file, filelist) } else { if (pattern.test(path.extname(file))) filelist = filelist.concat(file); } } return filelist; } /** * The ModuleParser should only parse files that match the default or * supplied file extensions. The default list contains .js, .jsx, .ts * and .tsx; so JavaScript or TypeScript files and their JSX React * counterparts * * Since the list is customizable for a usage, however, it makes sense * to have a function that will match what is supplied rather than * creating a constant expression to use instead. * * @static * @memberof ModuleParser * @function ⌾⠀arrayToPattern * @since 2.13.0 * * @param {Array<string>} extensions an array of extensions to * convert to a regular expression that would pass for each * @param {string} flags the value passed to a new RegExp denoting the * flags used in the pattern; defaults to 'i' for case insensitivity * @return {RegExp} a regular expression object matching the contents * of the array of extensions or the default extensions and that will * also match those values in a case insensitive manner */ static arrayToPattern( extensions: Array<string> = ['.js', '.jsx', '.ts', '.tsx'], flags: string = 'i' ) { return new RegExp( extensions .join('|') .replace(/\./g, '\\.') .replace(/([\|$])/g, '\\b$1'), flags ) } /** * Using the module `read-pkg-up`, finds the nearest package.json file * and checks to see if it has a `.lattice.moduleParser.extensions' * preference. If so, if the value is an array, that value is used, * otherwise the value is wrapped in an array. If the optional parameter * `toString` is `true` then `.toString()` will be invoked on any non * Array values found; this behavior is the default * * @static * @memberof ModuleParser * @method ⌾⠀checkForPackageExtensions * @since 2.13.0 * * @param {boolean} toString true if any non-array values should have * their `.toString()` method invoked before being wrapped in an Array; * defaults to true * @return {?Array<string>} null if no value is set for the property * `lattice.ModuleParser.extensions` in `package.json` or the value * of the setting if it is an array. Finally if the value is set but is * not an array, the specified value wrapped in an array is returned */ static checkForPackageExtensions(toString: boolean = true): ?Array<string> { let pkg = getLatticePrefs() let extensions = null if (pkg.ModuleParser && pkg.ModuleParser.extensions) { let packageExts = pkg.ModuleParser.extensions if (Array.isArray(packageExts)) { extensions = packageExts } else { extensions = [toString ? packageExts.toString() : packageExts] } } return extensions } } export default ModuleParser;