322 lines
10 KiB
TypeScript
322 lines
10 KiB
TypeScript
/**
|
|
* 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<T> = (key: string) => T | undefined
|
|
|
|
/**
|
|
* Interface for a canonical resolver that provides additional information.
|
|
*/
|
|
export interface ComplexCanonicalResolver<T> {
|
|
get: CanonicalResolverFunction<T>,
|
|
all: () => string[],
|
|
}
|
|
|
|
export type CanonicalResolver<T> = CanonicalResolverFunction<T> | ComplexCanonicalResolver<T>
|
|
|
|
/**
|
|
* 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<T> 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<T> } = {}
|
|
|
|
/**
|
|
* 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<T>): 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<void> {
|
|
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<T> {
|
|
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<CanonicalDefinition> {
|
|
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 }
|
|
}
|
|
}
|