/** * Base type for a canonical definition. */ import {Canon} from './Canon' import {universalPath, UniversalPath, ErrorWithContext, Safe} from '../util' import {Logging} from './Logging' import {Inject} from '../di' import * as nodePath from 'path' import {Unit} from '../lifecycle/Unit' import {isCanonicalReceiver} from '../support/CanonicalReceiver' /** * Interface describing a definition of a single canonical item loaded from the app. */ export interface CanonicalDefinition { canonicalName: string, originalName: string, imported: any, } /** * Type alias for a function that resolves a canonical name to a canonical item, if one exists. */ export type CanonicalResolverFunction = (key: string) => T | undefined /** * Interface for a canonical resolver that provides additional information. */ export interface ComplexCanonicalResolver { get: CanonicalResolverFunction, all: () => string[], } export type CanonicalResolver = CanonicalResolverFunction | ComplexCanonicalResolver /** * Base type for a canonical name reference. */ export interface CanonicalReference { resource?: string, item: string, particular?: string, } /** * Abstract unit type that loads items recursively from a directory structure, assigning * them normalized names ("canonical names"), and providing a way to fetch the resources * by name. * * @example * The Config service is a Canonical derivative that loads files ending with `.config.js` * from the `app/config` directory. * * If, for example, there is a config file `app/config/auth/Forms.config.js` (in the * generated code), it can be loaded by the canonical name `auth:Forms`. * */ export abstract class Canonical extends Unit { @Inject() protected readonly logging!: Logging @Inject() protected readonly canon!: Canon /** * The base path directory where the canonical definitions reside. * @type string */ protected appPath: string[] = [] /** * The file suffix of files in the base path that should be loaded. * This should EXCLUDE `.js` or `.ts`. These file extensions are determined * by the framework depending on the Node runtime. * @example `.service` * @example `.middleware` * @type string */ protected suffix = '' /** * The singular, programmatic name of one of these canonical items. * @example middleware * @type string */ protected canonicalItem = '' /** * Object mapping canonical names to loaded file references. * @type object */ protected loadedItems: { [key: string]: T } = {} /** * Object mapping canonical namespaces to resolver functions. * @protected */ protected loadedNamespaces: { [key: string]: CanonicalResolver } = {} /** * Resolve a canonical reference from its string form to a CanonicalReference. * @param {string} reference * @return CanonicalReference */ public static resolve(reference: string): CanonicalReference { const rscParts = reference.split('::') const resource = rscParts.length > 1 ? rscParts[0] + 's' : undefined const rscLess = rscParts.length > 1 ? rscParts[1] : rscParts[0] const prtParts = rscLess.split('.') const item = prtParts[0] const particular = prtParts.length > 1 ? prtParts.slice(1).join('.') : undefined return { resource, item, particular, } } /** * Returns true if the given file path has the correct suffix to be loaded by this unit. */ public isValidSuffix(path: string): boolean { const jsSuffix = `${this.suffix}.js` const tsSuffix = `${this.suffix}.ts` return path.endsWith(jsSuffix) || path.endsWith(tsSuffix) } /** * Return an array of all loaded canonical names. */ public all(namespace?: string): string[] { if ( namespace ) { const resolver = this.loadedNamespaces[namespace] if ( !resolver ) { throw new ErrorWithContext(`Unable to find namespace for ${this.canonicalItem}: ${namespace}`, { canonicalItem: this.canonicalItem, namespace, }) } if ( typeof resolver === 'function' ) { return [] } else { return resolver.all() } } return Object.keys(this.loadedItems) } /** * Return an array of all loaded canonical namespaces. */ public namespaces(): string[] { return Object.keys(this.loadedNamespaces) } /** * Get a Universal path to the base directory where this unit loads its canonical files from. */ public get path(): UniversalPath { return this.app().appPath(...this.appPath) } /** Get the plural name of the canonical items provided by this unit. */ public get canonicalItems(): string { return `${this.canonicalItem}s` } /** Get a canonical item by key, throwing an error if it could not be found. */ public getOrFail(key: string): T { const result = this.get(key) if ( !result ) { throw new ErrorWithContext(`Unable to resolve Canonical key: ${key}`, { key, }) } return result } /** Get a canonical item by key. */ public get(key: string): T | undefined { if ( key.startsWith('@') ) { const [namespace, ...rest] = key.split(':') key = rest.join(':') const resolver = this.loadedNamespaces[namespace] if ( !resolver ) { throw new ErrorWithContext(`Unable to find namespace for ${this.canonicalItem}: ${namespace}`, { canonicalItem: this.canonicalItem, namespace, key, }) } if ( typeof resolver === 'function' ) { return resolver(key) } else { return resolver.get(key) } } return this.loadedItems[key] } /** Get a canonical item by key as a Safe value. */ public safe(key: string): Safe { return (new Safe(this.get(key))).onError((message, value) => { throw new ErrorWithContext(`Invalid canonical value: ${message}`, { canonicalKey: key, canonicalItems: this.canonicalItems, value, message, }) }) } /** * Register a namespace resolver with the canonical unit. * * Namespaces are canonical names that start with a particular key, beginning with the `@` character, * which resolve their resources using a resolver function. * * @example * ```typescript * const items = { * 'foo:bar': 123, * 'bob': 456, * } * * const resolver = (key: string) => items[key] * * canonical.registerNamespace('@mynamespace', resolver) * ``` * * Now, the items in the `@mynamespace` namespace can be accessed like so: * * ```typescript * canonical.get('@mynamespace:foo:bar') // => 123 * canonical.get('@mynamespace:bob') // => 456 * ``` * * @param name * @param resolver */ public registerNamespace(name: string, resolver: CanonicalResolver): void { if ( !name.startsWith('@') ) { throw new ErrorWithContext(`Canonical namespaces must start with @.`, { name }) } if ( this.loadedNamespaces[name] ) { this.logging.warn(`Replacing canonical namespace resolver for: ${name}`) } this.loadedNamespaces[name] = resolver } public async up(): Promise { if ( await this.path.exists() ) { for await ( const entry of this.path.walk() ) { if ( !this.isValidSuffix(entry) ) { this.logging.debug(`Skipping file with invalid suffix: ${entry}`) continue } const definition = await this.buildCanonicalDefinition(entry) this.logging.verbose(`Registering canonical ${this.canonicalItem} "${definition.canonicalName}" from ${entry}`) const resolvedItem = await this.initCanonicalItem(definition) if ( isCanonicalReceiver(resolvedItem) ) { resolvedItem.setCanonicalResolver( `${this.canonicalItems}::${definition.canonicalName}`, definition.canonicalName, ) } this.loadedItems[definition.canonicalName] = resolvedItem } } this.canon.registerCanonical(this) } /** * Called for each canonical item loaded from a file. This function should do any setup necessary and return the item * that should be associated with the canonical name. * @param definition */ public async initCanonicalItem(definition: CanonicalDefinition): Promise { return definition.imported.default ?? definition.imported[definition.canonicalName.split(':').reverse()[0]] } /** * Given the path to a file in the canonical items directory, create a CanonicalDefinition record from that file. * @param filePath * @param basePath * @protected */ protected async buildCanonicalDefinition(filePath: string, basePath?: UniversalPath): Promise { const originalName = filePath.replace((basePath || this.path).toLocal, '').substr(1) const pathRegex = new RegExp(nodePath.sep, 'g') const canonicalName = originalName.replace(pathRegex, ':') .split('') .reverse() .join('') .substr(this.suffix.length + 3) // +3 to account for `.js` or `.ts` ending .split('') .reverse() .join('') const fullUniversalPath = universalPath(filePath) this.logging.verbose(`Importing from: ${fullUniversalPath}`) const imported = await import(fullUniversalPath.toLocal) return { canonicalName, originalName, imported } } }