ssube/noicejs

View on GitHub
src/Container.ts

Summary

Maintainability
A
25 mins
Test Coverage
A
100%
import { Dependency } from './Dependency.js';
import { ContainerBoundError } from './error/ContainerBoundError.js';
import { ContainerNotBoundError } from './error/ContainerNotBoundError.js';
import { InvalidProviderError } from './error/InvalidProviderError.js';
import { LoggerNotFoundError } from './error/LoggerNotFoundError.js';
import { MissingValueError } from './error/MissingValueError.js';
import { getInject } from './Inject.js';
import { Logger } from './logger/Logger.js';
import { Module, ProviderType } from './Module.js';
import { isNil } from './utils/index.js';
import { VERSION_INFO } from './version.js';

/* eslint-disable @typescript-eslint/no-explicit-any */

/**
 * Extra arguments of unknown types.
 *
 * @public
 */
export type ExtraArgs = ReadonlyArray<any>;

/**
 * Some constructor taking options as the first parameter.
 *
 * @public
 */
export interface Constructor<TReturn, TOptions extends BaseOptions> {
  /* eslint-disable-next-line @typescript-eslint/prefer-function-type */
  new(options: TOptions, ...extra: ExtraArgs): TReturn;
}

/**
 * The identifier for a contract of some type.
 *
 * @public
 */
export type ContractName = string | symbol;

/**
 * A contract identifier or concrete constructor.
 *
 * @public
 */
export type Contract<TReturn, TOptions extends BaseOptions> = ContractName | Constructor<TReturn, TOptions>;

export type AnyContract = Contract<any, AnyOptions>;

/**
 * Options after being wrapped with a permanent container.
 *
 * @public
 */
export type WrappedOptions<T extends BaseOptions> = Omit<T, 'container'>;

/**
 * A partial set of wrapped options.
 *
 * @public
 */
export type PartialOptions<T extends BaseOptions> = Partial<WrappedOptions<T>>;

export type AnyOptions = any;

/**
 * Get the standard name for a contract (usually a constructor).
 *
 * This accepts strings and symbols, so if a function is not provably a constructor, simply pass the name.
 *
 * @public
 */
export function contractName(c: Contract<unknown, AnyOptions>): ContractName {
  if (typeof c === 'function') {
    return c.name;
  } else {
    return c;
  }
}

/**
 * Typeguard for constructors. Only really validates that `it` is a function.
 *
 * @public
 */
export function isConstructor(it: unknown): it is Constructor<unknown, AnyOptions> {
  return typeof it === 'function';
}

/**
 * Base interface for all constructor options.
 *
 * @public
 */
export interface BaseOptions {
  container: Container;
}

export interface ContainerOptions {
  logger?: Logger;
}

/**
 * This is an exceptionally minimal DI container.
 *
 * @public
 */
export class Container implements ContainerOptions {
  public static from(...modules: Array<Module>) {
    return new Container(modules);
  }

  public logger?: Logger;
  protected modules: Array<Module>;
  protected ready: boolean;

  constructor(modules: Iterable<Module>) {
    this.modules = Array.from(modules);
    this.ready = false;
  }

  /**
   * Configure each module, linking dependencies to their contracts and preparing factories.
   *
   * This must be done sequentially, since some modules may use classes from other modules.
   */
  public async configure(options: ContainerOptions = { logger: undefined }) {
    if (this.ready) {
      throw new ContainerBoundError('container already bound');
    }

    this.logger = options.logger;
    this.ready = true;

    for (const module of this.modules) {
      await module.configure({
        container: this,
        logger: this.logger,
      });
    }

    return this;
  }

  /**
   * Returns the best provided value for the request contract.
   */
  public async create<TReturn, TOptions extends BaseOptions>(
    contract: Contract<TReturn, TOptions>,
    options: PartialOptions<TOptions> = {},
    ...args: ExtraArgs
  ): Promise<TReturn> {
    if (!this.ready) {
      throw new ContainerNotBoundError('container has not been configured yet');
    }
    if (isNil(contract)) {
      throw new MissingValueError('missing contract');
    }

    if (this.logger !== undefined) {
      this.logger.debug({ contract }, 'container create contract');
    }

    const module = this.modules.find((item) => item.has(contract));
    if (isNil(module)) {
      if (isConstructor(contract)) {
        return this.construct(contract, options, args);
      }

      throw new MissingValueError(`container has no provider for contract: ${contractName(contract).toString()}`);
    }

    if (this.logger !== undefined) {
      this.logger.debug({ module }, 'contract provided by module');
    }

    return this.provide(module, contract, options, args);
  }

  public debug() {
    if (isNil(this.logger)) {
      throw new LoggerNotFoundError('container has no logger');
    }

    this.logger.debug({ version: VERSION_INFO }, 'container debug');
    for (const m of this.modules) {
      m.debug();
    }
  }

  public getModules(): ReadonlyArray<Module> {
    return this.modules;
  }

  /**
   * Create a child container with additional modules.
   */
  public with(...modules: ReadonlyArray<Module>): Container {
    const merged = this.modules.concat(modules);
    return new Container(merged);
  }

  public async provide<TReturn, TOptions extends BaseOptions>(
    module: Module,
    contract: Contract<TReturn, TOptions>,
    options: PartialOptions<TOptions>,
    args: ExtraArgs
  ): Promise<TReturn> {
    const provider = module.get<TReturn, TOptions>(contract);
    if (isNil(provider)) {
      this.fail(`module has no provider for contract: ${contractName(contract).toString()}`);
    }

    switch (provider.type) {
      case ProviderType.Constructor:
        return this.construct(provider.value, options, args);
      case ProviderType.Factory:
        return this.apply(provider.value, module, options, args);
      case ProviderType.Instance:
        return provider.value;
      default:
        throw new InvalidProviderError('invalid provider type');
    }
  }

  public async construct<TReturn, TOptions extends BaseOptions>(
    ctor: Constructor<TReturn, TOptions>,
    options: PartialOptions<TOptions>,
    args: ExtraArgs
  ) {
    const deps: ExtraArgs = [await this.dependencies(getInject(ctor), options)];
    return Reflect.construct(ctor, deps.concat(args));
  }

  public async apply<TReturn, TOptions extends BaseOptions>(
    // eslint-disable-next-line @typescript-eslint/ban-types
    impl: Function,
    thisArg: unknown,
    options: PartialOptions<TOptions>,
    args: ExtraArgs
  ): Promise<TReturn> {
    const deps: ExtraArgs = [await this.dependencies(getInject(impl), options)];
    return Reflect.apply(impl, thisArg, deps.concat(args)) as TReturn;
  }

  protected fail(msg: string): never {
    const error = new MissingValueError(msg);

    if (this.logger !== undefined) {
      this.logger.error(error, msg);
    }

    throw error;
  }

  /**
   * Prepare a map with the dependencies for a descriptor.
   *
   * This will always inject the container itself to configure children.
   */
  protected async dependencies<TOptions extends BaseOptions>(deps: Array<Dependency>, passed: PartialOptions<TOptions>): Promise<TOptions> {
    const options: Partial<TOptions> = {};
    for (const dependency of deps) {
      const { contract, name } = dependency;
      if (!Reflect.has(passed, name)) {
        // tslint:disable-next-line:no-any
        const dep = await this.create<any, AnyOptions>(contract);
        options[name as keyof TOptions] = dep;
      }
    }
    Object.assign(options, passed, {
      container: this,
    });
    return options as TOptions;
  }
}

export interface WrappedConstructor<TInner, TOptions extends BaseOptions> extends Constructor<TInner, TOptions> {
  new(options: WrappedOptions<TOptions>): TInner;
}

/**
 * Permanently attach a container to all instances of this class.
 *
 * @public
 */
export function constructWithContainer(container: Container) {
  return <TInner, TOptions extends BaseOptions>(target: Constructor<TInner, TOptions>): WrappedConstructor<TInner, TOptions> => {
    // TODO: this shouldn't need any, but TInner is not sufficiently provable and causes a TS error
    class WrappedTarget extends (target as Constructor<any, TOptions>) {
      constructor(options: TOptions, ...others: ExtraArgs) {
        /* eslint-disable-next-line constructor-super */
        super({
          ...options,
          container,
        }, ...others);
      }
    }
    return WrappedTarget as WrappedConstructor<TInner, TOptions>;
  };
}

/**
 * The required signature for a function to be invoked by the wrapper.
 *
 * @public
 */
export type InvokableFunction<TOptions, TReturn> = (options: TOptions, ...others: ExtraArgs) => TReturn;

// TODO: do these options need to extend BaseOptions?
export type WrappedFunction<TOptions extends BaseOptions, TReturn> = InvokableFunction<WrappedOptions<TOptions>, TReturn>;

/**
 * Permanently attach a container to all invocations of this function.
 *
 * @public
 */
export function invokeWithContainer<TReturn, TOptions extends BaseOptions>(
  container: Container,
  target: InvokableFunction<TOptions, TReturn>
): InvokableFunction<WrappedOptions<TOptions>, TReturn> {
  /**
   * @this unknown
   */
  return function wrapper(this: unknown, options: WrappedOptions<TOptions>, ...others: ExtraArgs): TReturn {
    const completeOptions: BaseOptions = {
      ...options,
      container,
    };
    return target.apply(this, [completeOptions as TOptions, ...others]);
  };
}