di/src/Container.js

/**
 * @module flitter-di/src/Container
 */

const MissingContainerDefinitionError = require('./MissingContainerDefinitionError')

/** Manages service definitions, instances, and deferred injection. */
class Container {
    static TYPE_INJECTABLE = Symbol('injectable')
    static TYPE_SINGLETON = Symbol('singleton')

    /**
     * Instantiates the container.
     * @param {object} definitions - mapping of service name to static service CLASS definition
     */
    constructor(definitions = {}) {
        const def_map = {}
        for ( const def_name in definitions ) {
            if ( !definitions.hasOwnProperty(def_name) ) continue
            def_map[def_name] = {
                type: this.constructor.TYPE_INJECTABLE,
                ref: definitions[def_name]
            }
        }

        /**
         * Static IoC item definitions from which instances are created or
         * singleton values are returned when the items are requested.
         * Should be mapping of item_name -> {type: Symbol, ref: *}.
         * definition pairs.
         * @type {object}
         */
        this.definitions = def_map

        /**
         * Instantiated services. If a service has already been requested, it is
         * stored here so that the single instance can be reused.
         * @type {object}
         */
        this.instances = {}

        /**
         * Already injected static service definitions. These are used to resolve
         * circular dependencies.
         * @type {object}
         */
        this.statics = {}

        /**
         * Instance of the dependency injector this container is associated with.
         * If this is specified, services will be injected with other services when
         * they are instantiated.
         * @type {boolean|module:flitter-di/src/DependencyInjector~DependencyInjector}
         */
        this.di = false

        /**
         * Array of static class definitions with deferred services. These static
         * definitions are waiting for a service to be registered with this container
         * so it can be injected into the prototype and instances.
         * @type {Array<*>}
         */
        this.deferred_classes = []
    }

    /**
     * Check if a service definition exists in this container.
     * @param {string} service - the name of the service
     * @returns {boolean} - true if the service definition exists in this container
     */
    has(service) {
        return !!this.definitions[service]
    }

    /**
     * Get the container proxy. Allows accessing IoC items by name.
     * @returns {{}}
     */
    proxy() {
        return new Proxy({}, {
            get: (what, name) => {
                return this.get(name)
            }
        })
    }

    /**
     * Register a service class with the container. Allows the
     * service to be requested and it will be instantiated and
     * injected by the container.
     * @param {string} service_name
     * @param {typeof module:flitter-di/src/Service~Service} service_class - the uninstantiated Service class
     */
    register_service(service_name, service_class) {
        this.definitions[service_name] = {
            type: this.constructor.TYPE_INJECTABLE,
            ref: service_class
        }

        // check and process deferrals
        if ( this.deferred_classes.length > 0 ) {
            const deferred_requests = this.deferred_classes.map(x => x._di_deferred_services).flat()
            if ( deferred_requests.includes(service_name) ) {
                this._process_deferral(service_name, this.get(service_name))
            }
        }
    }

    /**
     * Register an item as a singleton with the container.
     * @param {string} singleton_name
     * @param {*} value - the value tobe returned by the container
     */
    register_singleton(singleton_name, value) {
        this.definitions[singleton_name] = {
            type: this.constructor.TYPE_SINGLETON,
            ref: value,
        }

        // check and process deferrals
        if ( this.deferred_classes.length > 0 ) {
            const deferred_requests = this.deferred_classes.map(x => x._di_deferred_services).flat()
            if ( deferred_requests.includes(singleton_name) ) {
                this._process_deferral(singleton_name, this.get(singleton_name))
            }
        }
    }

    /**
     * Fetch a container item by name. It it is an injectable item,
     * it will be injected and instantiated before return.
     * @param {string} name - the name of the IoC item
     * @returns {module:flitter-di/src/Service~Service|*} - the service instance or singleton item
     */
    get(name) {
        const def = this.definitions[name]
        if ( !def ) throw new MissingContainerDefinitionError(name)

        // Return the singleton value, if applicable
        if ( def.type === this.constructor.TYPE_SINGLETON ) {
            return def.ref
        } else if ( def.type === this.constructor.TYPE_INJECTABLE ) {
            // Store the static reference first.
            // This allows us to resolve circular dependencies.
            if ( !this.statics[name] ) {
                this.statics[name] = def.ref
                if ( this.di ) {
                    this.di.make(this.statics[name])
                }
            }

            if ( !this.instances[name] ) {
                const ServiceClass = this.statics[name]
                this.instances[name] = new ServiceClass()
            }

            return this.instances[name]
        }
    }

    /**
     * Fetch a container item by name.
     * @deprecated Please use Container.get from now on. This will be removed in the future.
     * @param {string} name
     * @returns {module:flitter-di/src/Service~Service|*}
     */
    service(name) {
        return this.get(name)
    }

    /**
     * Process deferred classes that need the provided service name and instance.
     * @param {string} item_name - the referential name of the IoC item
     * @param {module:flitter-di/src/Service~Service|*} item - the Service or item to be injected
     * @private
     */
    _process_deferral(item_name, item) {
        const new_deferrals = []
        for ( const Class of this.deferred_classes ) {
            if ( Class._di_deferred_services.includes(item_name) ) {
                Class.__deferral_callback(item_name, item)
            }

            if ( Class.__has_deferred_services ) {
                new_deferrals.push(Class)
            }
        }

        this.deferred_classes = new_deferrals
    }

    /**
     * Defer a static class to have its missing IoC items filled in as they
     * become available in the service container. The class should extend
     * from Injectable.
     * @param {*} Class - the static class to be deferred
     */
    defer(Class) {
        if ( !this.__is_deferrable(Class) ) {
            throw new TypeError('Cannot defer non-deferrable class: '+Class.name)
        }

        this.deferred_classes.push(Class)
    }

    /**
     * Checks if a class is deferrable. That is, does it have the requirements
     * for functioning with the defer logic. In almost all cases, these should be
     * satisfied by having the Class extend from Injectable.
     * @param {*} Class - the static class to check
     * @returns {boolean} - true if the class is deferrable
     * @private
     */
    __is_deferrable(Class) {
        return (
            Array.isArray(Class._di_deferred_services)
            && Array.isArray(Class._di_deferred_instances)
            && '_di_allow_defer' in Class
            && typeof Class.__deferral_callback === 'function'
            && '__has_deferred_services' in Class
        )
    }
}

module.exports = exports = Container