UNPKG

@ima/core

Version:

IMA.js framework for isomorphic javascript application

554 lines (553 loc) 26.1 kB
import { BindingState } from './BindingState'; import { Entry } from './Entry'; import { GenericError } from '../error/GenericError'; import { ns } from '../Namespace'; const SPREAD_RE = /^\.../; const OPTIONAL_RE = /^(...)?\?/; /** * The Object Container is an enhanced dependency injector with support for * aliases and constants, and allowing to reference classes in the application * namespace by specifying their fully qualified names. */ export class ObjectContainer { /** * The current binding state. * * The {@link setBindingState()} method may be called for changing * object container binding state only by the bootstrap script. */ _bindingState; /** * The current plugin binding to OC. * * The {@link setBindingState()} method may be called for changing * object container binding state only by the bootstrap script. */ _bindingPlugin; _entries = new Map(); /** * The namespace container, used to access classes and values using * their fully qualified names. */ _namespace; /** * Initializes the object container. * * @param namespace The namespace container, used to * access classes and values using their fully qualified names. */ constructor(namespace){ this._namespace = namespace; } /** * Binds the specified class or factory function and dependencies to the * specified alias. Binding a class or factory function to an alias allows * the class or function to be specified as a dependency by specifying the * alias and creating new instances by referring to the class or function * by the alias. * * Also note that the same class or function may be bound to several * aliases and each may use different dependencies. * * The alias will use the default dependencies bound for the class if no * dependencies are provided. * * @param name Alias name. * @param classConstructor The * class constructor or a factory function. * @param dependencies The dependencies to pass into the * constructor or factory function. * @return This object container. */ bind(name, classConstructor, dependencies) { if ($Debug) { if (this._bindingState === BindingState.Plugin && typeof name === 'string' && name[0] !== '$') { throw new GenericError(`ima.core.ObjectContainer:bind Object container ` + `is locked. You do not have the permission to ` + `create a new alias named ${name}.`, { name, classConstructor: classConstructor?.toString(), dependencies: dependencies?.toString() }); } if (typeof classConstructor !== 'function') { throw new GenericError(`ima.core.ObjectContainer:bind The second ` + `argument has to be a class constructor function, ` + `but ${this.#getDebugName(classConstructor)} was provided. Fix alias ` + `${this.#getDebugName(name)} for your bind.js file.`, { name, classConstructor: classConstructor, dependencies: dependencies?.toString() }); } } const classConstructorEntry = this._entries.get(classConstructor); const nameEntry = this._entries.get(name); const entry = classConstructorEntry || nameEntry; /** * Create instance using class constructor and dependencies and bind * it given name. */ if (classConstructorEntry && !nameEntry && dependencies) { const entry = this._createEntry(classConstructor, dependencies); this._entries.set(name, entry); return this; } if (entry) { // Update/set existing instance to new entry name this._entries.set(name, entry); // Update dependencies of existing oc entry if (dependencies) { this._updateEntryValues(entry, classConstructor, dependencies); } } else { /** * If neither name entry or class constructor entry exist, * we'll create both of them and add them to oc. */ const entry = this._createEntry(classConstructor, dependencies); this._entries.set(classConstructor, entry); this._entries.set(name, entry); } return this; } /** * Defines a new constant registered with this object container. Note that * this is the only way of passing `string` values to constructors * because the object container treats strings as class, interface, alias * or constant names. * * @param name The constant name. * @param value The constant value. * @return This object container. */ constant(name, value) { if ($Debug) { if (this._entries.has(name) || !!this._getEntryFromConstant(name)) { throw new GenericError(`ima.core.ObjectContainer:constant The ${this.#getDebugName(name)} ` + `constant has already been declared and cannot be ` + `redefined.`, { name, value: value?.toString() }); } if (this._bindingState === BindingState.Plugin) { throw new GenericError(`ima.core.ObjectContainer:constant The ${this.#getDebugName(name)} ` + `constant can't be declared in plugin. ` + `The constant must be define in app/config/bind.js file.`, { name, value: value?.toString() }); } } const constantEntry = this._createEntry(()=>value, [], { writeable: false }); constantEntry.sharedInstance = value; this._entries.set(name, constantEntry); return this; } /** * Configures the object loader with the specified default dependencies for * the specified class. * * New instances of the class created by this object container will receive * the provided dependencies into constructor unless custom dependencies * are provided. * * @param classConstructor The class constructor. * @param dependencies The dependencies to pass into the * constructor function. * @return This object container. */ inject(classConstructor, dependencies) { if ($Debug) { if (typeof classConstructor !== 'function') { throw new GenericError(`ima.core.ObjectContainer:inject The first ` + `argument has to be a class constructor function, ` + `but ${this.#getDebugName(classConstructor)} was provided. Fix your ` + `bind.js file.`, { classConstructor: classConstructor, dependencies: dependencies?.toString() }); } if (this._entries.has(classConstructor) && this._bindingState === BindingState.Plugin) { throw new GenericError(`ima.core.ObjectContainer:inject The ` + `${this.#getDebugName(classConstructor.name)} has already had its ` + `default dependencies configured, and the object ` + `container is currently locked, therefore the ` + `dependency configuration cannot be override. The ` + `dependencies of the provided class must be ` + `overridden from the application's bind.js ` + `configuration file.`, { classConstructor: classConstructor?.toString(), dependencies: dependencies?.toString() }); } } let classConstructorEntry = this._entries.get(classConstructor); if (classConstructorEntry) { if (dependencies) { this._updateEntryValues(classConstructorEntry, classConstructor, dependencies); } } else { classConstructorEntry = this._createEntry(classConstructor, dependencies); this._entries.set(classConstructor, classConstructorEntry); } return this; } /** * Configures the default implementation of the specified interface to use * when an implementation provider of the specified interface is requested * from this object container. * * The implementation constructor will obtain the provided default * dependencies or the dependencies provided to the {@link create()} * method. * * @param interfaceConstructor The constructor * of the interface representing the service. * @param implementationConstructor * The constructor of the class implementing the service interface. * @param dependencies The dependencies to pass into the * constructor function. * @return This object container. */ provide(interfaceConstructor, implementationConstructor, dependencies) { if ($Debug) { if (this._entries.has(interfaceConstructor) && this._bindingState === BindingState.Plugin) { throw new GenericError('ima.core.ObjectContainer:provide The ' + 'implementation of the provided interface ' + `(${this.#getDebugName(interfaceConstructor.name)}) has already been ` + `configured and cannot be overridden.`, { interfaceConstructor: interfaceConstructor?.toString(), implementationConstructor: implementationConstructor?.toString(), dependencies: dependencies?.toString() }); } // check that implementation really extends interface const prototype = implementationConstructor.prototype; if (!(prototype instanceof interfaceConstructor)) { throw new GenericError('ima.core.ObjectContainer:provide The specified ' + `class (${this.#getDebugName(implementationConstructor.name)}) does not ` + `implement the ${this.#getDebugName(interfaceConstructor.name)} ` + `interface.`, { interfaceConstructor: interfaceConstructor?.toString(), implementationConstructor: implementationConstructor?.toString(), dependencies: dependencies?.toString() }); } } let classConstructorEntry = this._entries.get(implementationConstructor); if (classConstructorEntry) { this._entries.set(interfaceConstructor, classConstructorEntry); if (dependencies) { this._updateEntryValues(classConstructorEntry, implementationConstructor, dependencies); } } else { classConstructorEntry = this._createEntry(implementationConstructor, dependencies); this._entries.set(implementationConstructor, classConstructorEntry); this._entries.set(interfaceConstructor, classConstructorEntry); } return this; } /** * Retrieves the shared instance or value of the specified constant, alias, * class or factory function, interface, or fully qualified namespace path * (the method checks these in this order in case of a name clash). * * The instance or value is created lazily the first time it is requested. * * @param name The name * of the alias, class, interface, or the class, interface or a * factory function. * @return The shared instance or value. */ get(name) { const entry = this._getEntry(name); if (entry?.sharedInstance === null) { entry.sharedInstance = this._createInstanceFromEntry(entry); } // Optional entries can be null if they are not found in the OC return entry?.sharedInstance; } /** * Returns the class constructor function of the specified class. * * @param name The name by which the class * is registered with this object container. * @return The constructor function. */ getConstructorOf(name) { const entry = this._getEntry(name); if (!entry) { return null; } return entry.classConstructor; } /** * Returns `true` if the specified object, class or resource is * registered with this object container. * * @param name The resource name. * @return `true` if the specified object, class or * resource is registered with this object container. */ has(name) { return this._entries.has(name) || !!this._getEntryFromConstant(name) || !!this._getEntryFromNamespace(name) || !!this._getEntryFromClassConstructor(name); } /** * Creates a new instance of the class or retrieves the value generated by * the factory function identified by the provided name, class, interface, * or factory function, passing in the provided dependencies. * * The method uses the dependencies specified when the class, interface or * factory function has been registered with the object container if no * custom dependencies are provided. * * @param name The name * of the alias, class, interface, or the class, interface or a * factory function to use. * @param dependencies The dependencies to pass into the * constructor or factory function. * @return Created instance or generated value. */ create(name, dependencies = []) { const entry = this._getEntry(name); if (!entry) { throw new Error('ima.core.ObjectContainer:create unable to create ' + `entry with ${name}, as it is null`); } return this._createInstanceFromEntry(entry, dependencies); } /** * Clears all entries from this object container and resets the locking * mechanism of this object container. * * @return This object container. */ clear() { this._entries.clear(); this._bindingState = undefined; this._bindingPlugin = undefined; return this; } setBindingState(bindingState, bindingPluginName) { if (this._bindingState === BindingState.App && bindingState !== BindingState.Plugin) { throw new GenericError(`ima.core.ObjectContainer:setBindingState The setBindingState() ` + `method has to be called only by the bootstrap script. Other ` + `calls are not allowed.`, { bindingState, bindingPluginName }); } this._bindingState = bindingState; this._bindingPlugin = bindingState === BindingState.Plugin ? bindingPluginName : undefined; } /** * Retrieves the entry for the specified constant, alias, class or factory * function, interface, or fully qualified namespace path (the method * checks these in this order in case of a name clash). * * The method retrieves an existing entry even if a qualified namespace * path is provided (if the target class or interface has been configured * in this object container). * * The method throws an {@link Error} if no such constant, alias, * registry, interface implementation is known to this object container and * the provided identifier is not a valid namespace path specifying an * existing class, interface or value. * * @param name Name of a constant or alias, * factory function, class or interface constructor, or a fully * qualified namespace path. * @return The retrieved entry. * @throws If no such constant, alias, registry, interface * implementation is known to this object container. */ _getEntry(name) { let entryName = Array.isArray(name) ? name[0] : name; // Remove all meta symbols from the start of the alias if (typeof entryName === 'string') { entryName = entryName.replace(SPREAD_RE, ''); entryName = entryName.replace(OPTIONAL_RE, ''); } const entry = this._entries.get(entryName) || this._getEntryFromConstant(entryName) || this._getEntryFromNamespace(entryName) || this._getEntryFromClassConstructor(entryName); if ($Debug && !entry && !this._isOptional(name)) { throw new Error(`ima.core.ObjectContainer:_getEntry There is no constant, ` + `alias, registered class, registered interface with ` + `configured implementation or namespace entry ` + `identified as: \n\n${this.#getDebugName(name)}\n\n Check your bind.js file for ` + `typos or register given entry with the object container.`); } if (this._isSpread(name)) { if (entry && Array.isArray(entry.sharedInstance)) { const spreadEntry = Entry.from(entry); spreadEntry.sharedInstance = entry.sharedInstance.map((sharedInstance)=>this.get(sharedInstance)); return spreadEntry; } if ($Debug && !this._isOptional(name)) { throw new Error(`ima.core.ObjectContainer:_getEntry Invalid use of spread entry identified as: <strong>${this.#getDebugName(name)}</strong> Check your bind.js file for ` + `typos or register given entry with the object container.`); } } return entry; } /** * Checks whether the name is marked as optional. * * @param name Name of a constant or alias, * factory function, class or interface constructor, or a fully * qualified namespace path. */ _isOptional(name) { return Array.isArray(name) && name[1]?.optional || typeof name === 'string' && OPTIONAL_RE.test(name); } /** * Checks whether the name is marked as spread. * * @param name Name of a constant or alias, * factory function, class or interface constructor, or a fully * qualified namespace path. */ _isSpread(name) { const normalizedName = Array.isArray(name) ? name[0] : name; return typeof normalizedName === 'string' && SPREAD_RE.test(normalizedName); } /** * The method update classConstructor and dependencies for defined entry. * The entry throw Error for constants and if you try override dependencies * more than once. * * @param classConstructor The * class constructor or factory function. * @param entry The entry representing the class that should * have its instance created or factory faction to use to create a * value. * @param dependencies The dependencies to pass into the * constructor or factory function. */ _updateEntryValues(entry, classConstructor, dependencies) { entry.classConstructor = classConstructor; entry.dependencies = dependencies; } /** * Creates a new entry for the provided class or factory function, the * provided dependencies and entry options. * * @template T * @param classConstructor The * class constructor or factory function. * @param dependencies The dependencies to pass into the * constructor or factory function. * @param options * @return Created instance or generated value. */ _createEntry(classConstructor, dependencies, options) { if ((!dependencies || dependencies.length === 0) && // @ts-expect-error fixme, () => T fails Array.isArray(classConstructor.$dependencies)) { // @ts-expect-error fixme, () => T fails dependencies = classConstructor.$dependencies; } return new Entry(classConstructor, dependencies, this._bindingState === BindingState.Plugin ? this._bindingPlugin : this._bindingState?.toString(), options); } /** * Creates a new instance of the class or retrieves the value generated by * the factory function represented by the provided entry, passing in the * provided dependencies. * * The method uses the dependencies specified by the entry if no custom * dependencies are provided. * * @param entry The entry representing the class that should * have its instance created or factory faction to use to create a * value. * @param dependencies The dependencies to pass into the * constructor or factory function. * @return Created instance or generated value. */ _createInstanceFromEntry(entry, dependencies = []) { if (dependencies.length === 0) { dependencies = []; for (const dependency of entry.dependencies){ if ($Debug && dependency === undefined) { throw new GenericError(`ima.core.ObjectContainer:_createInstanceFromEntry The dependency ` + `of class constructor function ${this.#getDebugName(entry.classConstructor)} is undefined. Fix class constructor $dependencies.`, { classConstructor: entry.classConstructor, referrer: entry.referrer, dependencies: entry.dependencies?.toString() }); } // Optional and spread dependency handling if ([ 'function', 'string' ].indexOf(typeof dependency) !== -1 || Array.isArray(dependency)) { const retrievedDependency = this.get(dependency); if (Array.isArray(retrievedDependency) && this._isSpread(dependency)) { dependencies.push(...retrievedDependency); } else { dependencies.push(retrievedDependency); } } else { dependencies.push(dependency); } } } return new entry.classConstructor(...dependencies); } /** * Retrieves the constant value denoted by the provided fully qualified * composition name. * * The method returns the entry for the constant if the constant is registered * with this object container, otherwise return `null`. * * Finally, if the constant composition name does not resolve to value, * the method return `null`. * * @param compositionName * @return An entry representing the value at the specified * composition name in the constants. The method returns `null` * if the specified composition name does not exist in the constants. */ _getEntryFromConstant(compositionName) { if (typeof compositionName !== 'string') { return null; } const objectProperties = compositionName.split('.'); let constantValue = this._entries.has(objectProperties[0]) ? this._entries.get(objectProperties[0]).sharedInstance : null; for(let i = 1; i < objectProperties.length && constantValue; i++){ constantValue = constantValue[objectProperties[i]]; } if (constantValue !== undefined && constantValue !== null) { const entry = this._createEntry(()=>constantValue, [], { writeable: false }); entry.sharedInstance = constantValue; return entry; } return null; } /** * Retrieves the class denoted by the provided fully qualified name within * the application namespace. * * The method then checks whether there are dependencies configured for the * class, no matter whether the class is an implementation class or an * "interface" class. * * The method returns the entry for the class if the class is registered * with this object container, otherwise an unregistered entry is created * and returned. * * Finally, if the namespace path does not resolve to a class, the method * return an unregistered entry resolved to the value denoted by the * namespace path. * * Alternatively, if a constructor function is passed in instead of a * namespace path, the method returns `null`. * * @param path Namespace path pointing to * a class or a value in the application namespace, or a constructor * function. * @return An entry representing the value or class at the * specified path in the namespace. The method returns `null` * if the specified path does not exist in the namespace. */ _getEntryFromNamespace(path) { if (typeof path !== 'string' || !this._namespace.has(path)) { return null; } const namespaceValue = this._namespace.get(path); if (typeof namespaceValue === 'function') { if (this._entries.has(namespaceValue)) { return this._entries.get(namespaceValue); } return this._createEntry(namespaceValue); } const entry = this._createEntry(()=>namespaceValue); entry.sharedInstance = namespaceValue; return entry; } /** * Retrieves the class denoted by the provided class constructor. * * The method then checks whether there are defined `$dependencies` * property for class. Then the class is registered to this object * container. * * The method returns the entry for the class if the specified class * does not have defined `$dependencies` property return * `null`. * * @param classConstructor * @return An entry representing the value at the specified * classConstructor. The method returns `null` * if the specified classConstructor does not have defined * `$dependencies`. */ _getEntryFromClassConstructor(classConstructor) { if (typeof classConstructor !== 'function') { return null; } if (!Array.isArray(classConstructor.$dependencies)) { if ($Debug) { throw new Error(`The class constructor identified as: ${this.#getDebugName(classConstructor)} is missing <b>static get $dependencies() {}</b> definition.`); } return null; } const entry = this._createEntry(classConstructor, classConstructor.$dependencies); this._entries.set(classConstructor, entry); return entry; } /** * Formats name, function, class constructor to more compact * name/message to allow for cleaner debug Error messages. */ #getDebugName(name) { return `<strong>${name?.toString().split('\n').slice(0, 5).join('\n') ?? name}</strong>`; } } ns.set('ns.ima.core.ObjectContainer', ObjectContainer); //# sourceMappingURL=ObjectContainer.js.map