@absolunet/ioc2.1.0

View on GitHub

container/Container.js

//--------------------------------------------------------
//-- Node IoC - Container - Container
//--------------------------------------------------------

import to             from 'to-case';
import __             from '@absolunet/private-registry';
import checksTypes    from '../support/mixins/checksTypes';
import ContainerProxy from './Proxy';


/**
 * Inversion of Control container that allows bindings and factory with dependency injection, exposes tags, aliases and other IoC features.
 *
 * @memberof container
 * @augments support.mixins.CheckTypes
 * @hideconstructor
 */
class Container extends checksTypes() {

	/**
	 * Make a new Container instance.
	 *
	 * @returns {container.Container} A container instance.
	 */
	static make() {
		const instance = new this();
		if (!this.instance) {
			this.setDefaultInstance(instance);
		}

		return instance;
	}

	/**
	 * Get the current Container instance or create a new one.
	 *
	 * @returns {container.Container} The current instance or a newly created instance if no instance exists.
	 */
	static getInstance() {
		return this.instance || this.make();
	}

	/**
	 * Set the current Container instance.
	 *
	 * @param {container.Container} instance - A Container instance.
	 * @throws {TypeError} Indicates that the default instance was not a container instance.
	 */
	static setDefaultInstance(instance) {
		if (!(instance instanceof this)) {
			throw new TypeError('Default container instance must be instance of Container or subclass.');
		}

		this.instance = instance;
	}

	/**
	 * Container constructor.
	 */
	constructor() {
		super();
		this.setContext(module);
		__(this).set('pushInto', (property, key, value) => {
			const collection = __(this).get(property);
			if (!collection[key]) {
				collection[key] = [];
			}
			collection[key].push(value);
		});
		this.flush();

		const proxy = new Proxy(this, new ContainerProxy());
		__(this).set('proxy', proxy);

		return proxy;
	}

	/**
	 * Set JavaScript module context.
	 *
	 * @param {module} context - The Node.js module that represents the current context.
	 * @returns {container.Container} The current container instance.
	 */
	setContext(context) {
		if (!this.isFunction(context.require)) {
			throw new TypeError('The given context is not a valid module.');
		}

		__(this).set('context', context);

		return __(this).get('proxy');
	}

	/**
	 * Get current JavaScript module context.
	 *
	 * @returns {module} The Node.js module that represents the current context.
	 */
	getContext() {
		return __(this).get('context');
	}

	/**
	 * Bind abstract to the container.
	 *
	 * @param {string} abstract - Abstract representation of a concrete. Should be treated as an interface.
	 * @param {Function|*} concrete - Concrete reflecting the abstract. Can be either a class, a factory closure, an instance or even a primitive value, such as a string or a boolean.
	 * @param {boolean} shared - Indicates that the concrete should be treated as a singleton and be shared through all the other instances when requested.
	 * @returns {container.Container} The current container instance.
	 */
	bind(abstract, concrete, shared = false) {
		__(this).get('bindings')[abstract] = { concrete, shared };
		delete __(this).get('singletons')[abstract];

		return __(this).get('proxy');
	}

	/**
	 * Bind abstract as a singleton to the container.
	 *
	 * @param {string} abstract - Abstract representation of a concrete. Should be treated as an interface.
	 * @param {Function|*} concrete - Concrete reflecting the abstract. Can be either a class, a factory closure, an instance or even a primitive value, such as a string or a boolean.
	 * @returns {container.Container} The current container instance.
	 */
	singleton(abstract, concrete) {
		return this.bind(abstract, concrete, true);
	}

	/**
	 * Resolve a given argument with either its singleton,
	 * a new instance based on bindings or a new instance
	 * with resolved dependencies.
	 *
	 * @param {*} abstract - An abstract that was bound to the container, or a class, closure or instance that can be built by the container.
	 * @param {object<string, *>} [parameters={}] - Additional arguments to inject into the concrete when instantiating.
	 * @returns {*} The instantiated or the singleton concrete.
	 */
	make(abstract, parameters = {}) {
		if (this.isSingleton(abstract) && Object.keys(parameters).length === 0) {
			return this.getSingleton(abstract);
		}

		return this.resolve(abstract, parameters);
	}

	/**
	 * Resolve a given abstract to build a new instance.
	 *
	 * @param {*} abstract - An abstract that was bound to the container, or a class, closure or instance that can be built by the container.
	 * @param {object<string, *>} [parameters={}] - Additional arguments to inject into the concrete when instantiating.
	 * @returns {*} The instantiated concrete.
	 */
	resolve(abstract, parameters = {}) {
		const bindings   = __(this).get('bindings');
		const decorators = __(this).get('decorators')[abstract] || [];

		let concrete = abstract;
		let shared   = false;

		if (this.isBound(abstract)) {
			({ concrete, shared } = bindings[abstract]);
		} else {
			if (this.isTag(abstract)) {
				return this.getTagged(abstract, parameters);
			}

			if (!this.isInstantiable(abstract) && !this.isFunction(abstract) && !this.isObject(abstract)) {
				const module = this.getModule(abstract);

				if (typeof module === 'undefined') {
					return this.bindingNotFound(abstract);
				}

				return this.make(module, parameters);
			}
		}

		const build = decorators.reduce((current, decorator) => {
			return decorator(current);
		}, this.build(concrete, parameters));

		if (shared && Object.keys(parameters).length === 0 && !__(this).get('singletons')[abstract]) {
			__(this).get('singletons')[abstract] = build;
		}

		return build;
	}

	/**
	 * Check if the given abstract is bound to the container.
	 *
	 * @param {string} abstract - The abstract to check.
	 * @returns {boolean} Indicates if the abstract is bound in the container.
	 */
	isBound(abstract) {
		return this.getBounds().includes(abstract);
	}

	/**
	 * Get all bindings.
	 *
	 * @returns {Array<string>} List of abstracts bound into the container.
	 */
	getBounds() {
		return Object.keys(__(this).get('bindings') || {});
	}

	/**
	 * Get singleton from its abstract.
	 *
	 * @param {string} abstract - The abstract name that reflects a singleton already instantiated.
	 * @returns {*} The singleton instance.
	 */
	getSingleton(abstract) {
		return __(this).get('singletons')[abstract];
	}

	/**
	 * Check if a given abstract has a resolved singleton.
	 *
	 * @param {string} abstract - The abstract name that may reflect an instantiated singleton.
	 * @returns {boolean} Indicates that the singleton exists and was already instantiated.
	 */
	isSingleton(abstract) {
		return Object.prototype.hasOwnProperty.call(__(this).get('singletons'), abstract);
	}

	/**
	 * Instantiate a given class and resolve its dependencies.
	 *
	 * @param {Function} Concrete - A class that can be instantiated.
	 * @param {object<string, *>} [parameters={}] - Additional arguments to inject into the class instance when instantiating.
	 * @returns {*} The newly created instance.
	 */
	instantiate(Concrete, parameters = {}) {
		const dependencies = [
			...new Set([
				...Concrete.dependencies || [],
				...Object.keys(parameters)
			])
		];
		const resolvedDependencies = [];
		dependencies.forEach((name) => {
			const dependency = parameters[name] || this.make(name);

			resolvedDependencies.push(dependency);
		});

		const instance = new Concrete(...resolvedDependencies);
		const realInstance = instance.__instance || instance;
		dependencies.forEach((dependency, index) => {
			__(realInstance).set(dependency, resolvedDependencies[index]);
			const formattedName = to.camel(dependency.split('.').map((dependencyPart) => {
				return to.dot(dependencyPart);
			}).join('.'));
			if (!Object.prototype.hasOwnProperty.call(realInstance, formattedName)) {
				Object.defineProperty(realInstance, formattedName, {
					value:    resolvedDependencies[index],
					writable: false
				});
			}
		});

		if (this.isFunction(instance.init)) {
			instance.init();
		}

		return instance;
	}

	/**
	 * Call a given function with the given arguments.
	 *
	 * @param {Function} factory - A closure that factories a concrete.
	 * @param {object<string, *>} [parameters={}] - Additional arguments to inject into the factory when calling it.
	 * @returns {*} The factoried concrete.
	 */
	call(factory, parameters = {}) {
		return factory(this, parameters);
	}

	/**
	 * Make a mass assignment to a given object.
	 *
	 * @param {*} object - An instance that can receive parameters by assignation.
	 * @param {object<string, *>} [parameters={}] - Additional arguments to associate to the instance.
	 * @returns {*} The instance.
	 */
	assign(object, parameters = {}) {
		Object.keys(parameters).forEach((name) => {
			object[name] = parameters[name];
		});

		return object;
	}

	/**
	 * Build a given concrete, either a factory, a class or an object.
	 *
	 * @param {Function|*} concrete - A concrete that can be either instantiated, called or assigned.
	 * @param {object<string, *>} [parameters={}] - Additional arguments to inject into the concrete when instantiating.
	 * @returns {*} The built concrete.
	 */
	build(concrete, parameters = {}) {
		if (this.isInstantiable(concrete)) {
			return this.instantiate(concrete, parameters);
		}

		if (this.isFunction(concrete)) {
			return this.call(concrete, parameters);
		}

		return this.assign(concrete, parameters);
	}

	/**
	 * Decorate a given abstract with a callback.
	 *
	 * @param {string} abstract - The abstract to decorate.
	 * @param {Function} decorator - The decorator function.
	 * @returns {container.Container} The current container instance.
	 */
	decorate(abstract, decorator) {
		__(this).get('pushInto')('decorators', abstract, decorator);

		return __(this).get('proxy');
	}

	/**
	 * Tag a given abstract.
	 *
	 * @param {string|Array<string>} abstract - The abstract(s) to tag.
	 * @param {string} tag - The tag to give to the abstract(s).
	 * @returns {container.Container} The current container instance.
	 */
	tag(abstract, tag) {
		const abstracts = Array.isArray(abstract) ? abstract : [abstract];
		abstracts.forEach((a) => {
			__(this).get('pushInto')('tags', tag, a);
		});

		return __(this).get('proxy');
	}

	/**
	 * Alias an abstract.
	 *
	 * @param {string} alias - The alias to given.
	 * @param {string} abstract - The abstract to associate to the alias.
	 * @returns {container.Container} The current container instance.
	 */
	alias(alias, abstract) {
		return this.bind(alias, () => {
			return this.make(abstract);
		});
	}

	/**
	 * Check if the given string was used as a tag.
	 *
	 * @param {string} tag - The tag name.
	 * @returns {boolean} Indicates if the tag exists.
	 */
	isTag(tag) {
		return Object.prototype.hasOwnProperty.call(__(this).get('tags'), tag);
	}

	/**
	 * Get tagged dependencies from the tag name.
	 *
	 * @param {string} tag - The tag name.
	 * @returns {object<string, *>} The instances associated to the given tag, in a dictionary, mapping the abstract name to the concrete.
	 */
	getTagged(tag) {
		const tagged   = __(this).get('tags')[tag] || [];
		const bindings = {};
		tagged.forEach((binding) => {
			bindings[binding] = this.make(binding);
		});

		return bindings;
	}

	/**
	 * Flush container from attached abstracts and concretes.
	 *
	 * @returns {container.Container} The current container instance.
	 */
	flush() {
		__(this).set('bindings', {});
		__(this).set('singletons', {});
		__(this).set('decorators', {});
		__(this).set('tags', {});

		return this.bind('app', () => {
			return this.constructor.getInstance();
		});
	}

	/**
	 * Throw an error announcing that the given abstract was not found.
	 *
	 * @param {string} abstract - The abstract that was not found.
	 * @throws {TypeError} Indicates that the given abstract was not found in the container.
	 */
	bindingNotFound(abstract) {
		throw new TypeError(`Binding [${abstract}] was not found in the container.`);
	}

	/**
	 * Check if the given file is a valid and existing JavaScript file with a valid extension.
	 *
	 * @param {string} filePath - The file path to load.
	 * @returns {*} The file parsed value, or null if not found.
	 * @throws {Error} Indicates that an error occurred during file loading or parsing.
	 */
	getModule(filePath) {
		try {
			if (typeof filePath === 'string') {
				const value = this.getContext().require(filePath);

				return value && value.__esModule ? value.default : value;
			}

			return null;
		} catch (error) {
			if (error && error.message.startsWith(`Cannot find module "${filePath.toString()}"`)) {
				return null;
			}

			throw error;
		}
	}

}


export default Container;