You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
lib/src/service/Canonical.ts

322 lines
10 KiB

3 years ago
/**
* 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'
3 years ago
import * as nodePath from 'path'
import {Unit} from '../lifecycle/Unit'
import {isCanonicalReceiver} from '../support/CanonicalReceiver'
3 years ago
/**
* Interface describing a definition of a single canonical item loaded from the app.
*/
3 years ago
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>
3 years ago
/**
* 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`.
*
*/
3 years ago
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`
3 years ago
* @type string
*/
protected suffix = ''
3 years ago
/**
* The singular, programmatic name of one of these canonical items.
* @example middleware
* @type string
*/
protected canonicalItem = ''
3 years ago
/**
* 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> } = {}
3 years ago
/**
* 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('.')
3 years ago
const item = prtParts[0]
const particular = prtParts.length > 1 ? prtParts.slice(1).join('.') : undefined
return {
resource,
item,
particular,
3 years ago
}
}
/**
* 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()
}
}
3 years ago
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.
*/
3 years ago
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 {
3 years ago
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. */
3 years ago
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)
}
}
3 years ago
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
3 years ago
}
}
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
*/
3 years ago
public async initCanonicalItem(definition: CanonicalDefinition): Promise<T> {
return definition.imported.default ?? definition.imported[definition.canonicalName.split(':').reverse()[0]]
3 years ago
}
/**
* 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)
3 years ago
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('')
3 years ago
const fullUniversalPath = universalPath(filePath)
this.logging.verbose(`Importing from: ${fullUniversalPath}`)
const imported = await import(fullUniversalPath.toLocal)
return { canonicalName,
originalName,
imported }
3 years ago
}
}