inversify / InversifyJS

A powerful and lightweight inversion of control container for JavaScript & Node.js apps powered by TypeScript.
http://inversify.io/
MIT License
11.22k stars 715 forks source link

How to manage the injection of interface type arguments in third party libraries with inversify? #517

Closed viktor1190 closed 7 years ago

viktor1190 commented 7 years ago

Is there a way to manage the optional injections, of Interface types arguments, in the constructor of third Party libraries like Hapi.Server?

I want to extends a Hapi.Server and I like to use principle patterns, so I decide to use Inversify to inject ALL my code dependencies. Then I create the next Classes:

file server.ts:

import { injectable, inject, unmanaged, optional } from "inversify";
import "reflect-metadata";
import TYPES from "./types";
import * as Hapi from "hapi";
import { IServerConfiguration } from "./interfaces";

@injectable()
export class HapiServer extends Hapi.Server {

  constructor(@inject(TYPES.ServerConfiguration) configs: IServerConfiguration) {
    super();
    this.connection({
      port: configs.port,
      routes: {
        cors: false
      }
    });
}

file interfaces.ts:

export interface IServerConfiguration {
  port: number;
}

let TYPES = {
    Server: Symbol("Server"),
    ServerConfiguration: Symbol("ServerConfiguration")
};

file inversify.config.ts

import { Configuration } from "./config";
import { Container } from 'inversify';
import { HapiServer } from "./server";
import { IServerConfiguration } from "./interfaces";
import { Server } from 'hapi';
import TYPES from "./ioc/types";

/**
* Inversion Of Control class container
*/
export class Kernel extends Container {
  constructor() {
    super();
    this.declareDependencies();
  }

  declareDependencies() {

    const configs = new Configuration();

    this.bind<Server>(TYPES.Server).to(HapiServer);
    this.bind<IServerConfiguration>(TYPES.ServerConfiguration).toConstantValue(configs.getServerConfig());
  }
}

and the entry point file index.ts:

import { Kernel } from './inversify.config';
import { Server } from "hapi";
import TYPES from './types';

const iocKernel = new Kernel();

var server = iocKernel.get<Server>(TYPES.Server);

server.start((err) => {
    if (err) {
      console.error(err);
    }
    console.log('Server running at:', server.info.uri);
});

when I compile with tsc, there is no errors, all is well. But when I start the server with npm start command, the output console says

Error: Missing required @injectable annotation in: HapiServer.

With a little more of patience and thoroughness I revise the constructor declaration of the Hapi.Server interface, in the third party Hapi library:

export class Server extends Events.EventEmitter {
  constructor(options?: IServerOptions);
  ...
}}

There is an OPTIONAL argument of IServerOptions type, a single INTERFACE. So I go to the Inversify documentation and found that I can use @decorated(), ok I try but this annotation only accept class types, no an Interface like in this case... The inversify docs also said something about unmanaged(), and optional() annotations... the same error about missing required injection.

I got a solution; in the inversify.config.ts file I replace the line for binding the Server type to Hapi.Server with:

this.bind<Server>(TYPES.Server).toConstantValue(new HapiServer(configs.getServerConfig()));

it works, but this feel like a bad solution, like a very forced and no natural way, also make the next line of code about binding the ServerConfigurations useless. For me this behavior make to lose the essence of implementing code with a Dependecy injection tool, so maybe someone could help me to find another better way.

add: my package.json dependencies are:

"dependencies": {
  "hapi": "^16.1.0",
  "inversify": "^3.3.0",
  "nconf": "^0.8.4",
  "reflect-metadata": "^0.1.10"
},
"devDependencies": {
  "@types/hapi": "^16.0.0",
  "@types/nconf": "^0.0.34",
  "gulp": "^3.9.1",
  "gulp-sourcemaps": "^2.4.1",
  "gulp-strip-comments": "^2.4.5",
  "gulp-tslint": "^7.1.0",
  "gulp-typescript": "^3.1.6"
}

PD 1: Sorry for the so long post, but I tried to get very detailed to get the right solution for us.

PD 2: I'm working with typescript 2.2.1

remojansen commented 7 years ago

Thanks for the long post, it was required to explain your concerns :+1: I think your main concern is:

There is an OPTIONAL argument of IServerOptions type, a single INTERFACE. So I go to the Inversify documentation and found that I can use @decorated(), ok I try but this annotation only accept class types, no an Interface like in this case...

The problem is that you have annotated your HapiServer class:

@injectable()
export class HapiServer extends Hapi.Server {
// ...

But you are not the owner of the source code of the Hapi.Server class and you cannot decorate it as follows:

@injectable()
class Server extends Events.EventEmitter {
  constructor(@unmanaged() options?: IServerOptions);
  ...
}

But you should be able to use the decorate helper in this case:

import { decorate, injectable, unmanaged } from "inversify";
import * as Hapi from "hapi";

decorate(injectable(), Hapi.Server);
decorate(unmanaged(), Hapi.Server, 1);

I haven't tried this but it should work. Please let me know how do you get on.

ps: more info about decorate().

viktor1190 commented 7 years ago

Sorry for my bad english redaction, I need to practice it, hehe, Yes, that is my question, I will try it and give you my feedback.

viktor1190 commented 7 years ago

I only added those lines to my Server implementation:

decorate(injectable(), Hapi.Server);
decorate(unmanaged(), Hapi.Server, 1);

the output is:

/Users/****/git/nodejs/myproject/node_modules/inversify/lib/planning/planner.js:107 throw new Error(error.message); ^

Error: Missing required @injectable annotation in: Anonymous function: function (options) {

Hoek.assert(this instanceof internals.Server, 'Server must be instantiated using new');

options = Schema.apply('server', options || {});

this._settings = Hoek.applyToDefaultsWithShallow(Defaults.server, options, ['connections.routes.bind']);
this._settings.connections = Hoek.applyToDefaultsWithShallow(Defaults.connection, this._settings.connections || {}, ['routes.bind']);
this._settings.connections.routes.cors = Hoek.applyToDefaults(Defaults.cors, this._settings.connections.routes.cors);
this._settings.connections.routes.security = Hoek.applyToDefaults(Defaults.security, this._settings.connections.routes.security);

this._caches = {};                                                              // Cache clients
this._handlers = {};                                                            // Registered handlers
this._methods = new Methods(this);
his._events = new Podium([{ name: 'log', tags: true }, 'start', 'stop']);      // Server-only events
this._dependencies = [];                                                        // Plugin dependencies
this._registrations = {};                                                       // Tracks plugins registered before connection added
this._heavy = new Heavy(this._settings.load);
this._mime = new Mimos(this._settings.mime);
this._replier = new Reply();
this._requestor = new Request();
this._decorations = {};
this._plugins = {};                                                             // Exposed plugin properties by name
this._app = {};
this._registring = false;                                                       // true while register() is waiting for plugin callbacks
this._state = 'stopped';                                                        // 'stopped', 'initializing', 'initialized', 'starting', 'started', 'stopping', 'invalid'

this._extensionsSeq = 0;                                                        // Used to keep absolute order of extensions based on the order added across locations
this._extensions = {
    onPreStart: new Ext('onPreStart', this),
    onPostStart: new Ext('onPostStart', this),
    onPreStop: new Ext('onPreStop', this),
    onPostStop: new Ext('onPostStop', this)
};

if (options.cache) {
    this._createCache(options.cache);
}
if (!this._caches._default) {
    this._createCache([{ engine: CatboxMemory }]);                              // Defaults to memory-based
}

Plugin.call(this, this, [], '', null);

// Subscribe to server log events

if (this._settings.debug) {
    const debug = (request, event) => {

        const data = event.data;
        console.error('Debug:', event.tags.join(', '), (data ? '\n    ' + (data.stack || (typeof data === 'object' ? Hoek.stringify(data) : data)) : ''));
    };

    if (this._settings.debug.log) {
        this._events.on({ name: 'log', filter: this._settings.debug.log }, (event) => debug(null, event));
    }

    if (this._settings.debug.request) {
        this.on({ name: 'request', filter: this._settings.debug.request }, debug);
        this.on({ name: 'request-internal', filter: this._settings.debug.request }, debug);
    }
}

}. at _createSubRequests (/Users//git/nodejs/myproject/node_modules/inversify/lib/planning/planner.js:107:19) at Object.plan (/Users//git/nodejs/myproject/node_modules/inversify/lib/planning/planner.js:126:5) at /Users//git/nodejs/myproject/node_modules/inversify/lib/container/container.js:228:37 at Kernel.Container._get (/Users//git/nodejs/myproject/node_modules/inversify/lib/container/container.js:221:44) at Kernel.Container.get (/Users//git/nodejs/myproject/node_modules/inversify/lib/container/container.js:180:21) at Object. (/Users//git/nodejs/myproject/build/app/index.js:21:24) at Module._compile (module.js:570:32) at Object.Module._extensions..js (module.js:579:10) at Module.load (module.js:487:32) at tryModuleLoad (module.js:446:12)

at ChildProcess.exithandler (child_process.js:206:12)
at emitTwo (events.js:106:13)
at ChildProcess.emit (events.js:191:7)
at maybeClose (internal/child_process.js:877:16)
at Socket.<anonymous> (internal/child_process.js:334:11)
at emitOne (events.js:96:13)
at Socket.emit (events.js:188:7)
at Pipe._handle.close [as _onclose] (net.js:498:12)

.....

19 verbose node v6.9.5
20 verbose npm  v4.4.4
remojansen commented 7 years ago

Sorry about this issue. I will investigate ASAP will try to build something with Hapi to see if I can reproduce the issue.

remojansen commented 7 years ago

Ok, so I've been researching this and I found an alternative way. It looks like there is something inside Hapi.Server which makes it complicated to create instances of it using inversify. After trying a few different ways I found one that I feel is not too bad using a factory. I will copy all the source code files here:

interfaces.ts

import { Server } from 'hapi';

export interface IServerConfiguration {
  port: number;
}

export interface IConfigurationManager {
  getServerConfig(): IServerConfiguration;
}

export interface IServerFactory {
  create(): Server;
}

types.ts

export const TYPES = {
    IServerFactory: "IServerFactory",
    IConfigurationManager: "IConfigurationManager"
};

config.ts

import { IServerConfiguration } from "./interfaces";
import { injectable } from "inversify";

@injectable()
export class Configuration {
    getServerConfig(): IServerConfiguration {
        return {
            port: 8080
        };
    }
}

server_factory.ts

import { interfaces, injectable, inject } from "inversify";
import { Server } from 'hapi';
import { TYPES } from "./types";
import { IServerConfiguration, IConfigurationManager, IServerFactory } from "./interfaces";

@injectable()
export class ServerFactory implements IServerFactory {

  private _serverConfig: IServerConfiguration;

  public constructor(
    @inject(TYPES.IConfigurationManager) config: IConfigurationManager
  ) {
    this._serverConfig = config.getServerConfig();
  }

  public create() {
      const instance = new Server();
      instance.connection({
        port: this._serverConfig.port,
        routes: {
          cors: false
        }
      });
      return instance;
  }

}

inversify.config.ts

import { Server } from 'hapi';
import { Container } from 'inversify';
import { Configuration } from "./config";
import { IConfigurationManager, IServerFactory } from "./interfaces";
import { TYPES } from "./types";
import { ServerFactory } from "./server_factory";

export class Kernel extends Container {
  constructor() {
    super();
    this.declareDependencies();
  }

  declareDependencies() {
    this.bind<IConfigurationManager>(TYPES.IConfigurationManager).to(Configuration);
    this.bind<IServerFactory>(TYPES.IServerFactory).to(ServerFactory);
  }

}

index.ts

import "reflect-metadata";
import { interfaces } from "inversify";
import { Server } from "hapi";
import { Kernel } from './inversify.config';
import { TYPES } from "./types";
import { IServerFactory } from "./interfaces";

const iocKernel = new Kernel();
const serverFactory = iocKernel.get<IServerFactory>(TYPES.IServerFactory);
const server = serverFactory.create();

server.start((err) => {
    if (err) {
      console.error(err);
    }
    console.log('Server running at:', server.info.uri);
});

I hope you find this solution better than the original one.

viktor1190 commented 7 years ago

Thank you very much, it has served me well.