Skip to content

Latest commit

 

History

History

simple-di

@fastify-decorators/simple-di

npm version npm License: MIT

Dependency injection

Dependency injection (DI) is widely used mechanism to autowire controller/service dependency. In fastify-decorators DI only available for controllers.

Table of Content

Getting started

There's few simple steps to enable this library:

  1. Install @fastify-decorators/simple-di
  2. Enable "experimentalDecorators" as "emitDecoratorMetadata" in tsconfig.json

Note: auto-generated type metadata may have issues with circular or forward references for types.

Writing services

Service decorator used to make class injectable

my-service.ts:

import { Service } from '@fastify-decorators/simple-di';

@Service()
export class MyService {
  calculate() {
    doSomething();
  }
}

Async service initialization

It's possible that some services may require async initialization, for example to setup database connection. For such reasons library provides the special decorator called @Initializer.

Usage is quite simple, just annotate your async method with it:

database.service.ts:

import { Initializer, Service } from '@fastify-decorators/simple-di';
import { join } from 'node:path';
import { DataSource } from 'typeorm';
import { Message } from '../entity/message';

@Service()
export class ConnectionService {
  dataSource = new DataSource({
    type: 'sqljs',
    autoSave: true,
    location: join(process.cwd(), 'db', 'database.db'),
    entities: [Message],
    logging: ['query', 'schema'],
    synchronize: true,
  });

  @Initializer()
  async init(): Promise<void> {
    await this.dataSource.init();
  }
}

Services may depend on other async services for their init, for such reasons @Initializer accepts array of such services:

import { Initializer, Service } from '@fastify-decorators/simple-di';
import { Message } from '../entity/message';
import { ConnectionService } from '../services/connection.service';
import type { Repository } from 'typeorm';

@Service()
export class MessageFacade {
  private repository!: Repository<Message>;
  constructor(private connectionService: ConnectionService) {}

  @Initializer([ConnectionService])
  async init(): Promise<void> {
    // because we added DataSourceProvider as a dependency, we are sure it was properly initialized if it reaches
    // this point
    this.repository = this.connectionService.dataSource.getRepository(Message);
  }

  async getMessages(): Promise<Message[]> {
    return this.repository.find();
  }
}

Graceful services destroy

If you need to have stuff executed before service destroyed (e.g. close database connection) you can use @Destructor decorator:

import { Initializer, Destructor, Service } from '@fastify-decorators/simple-di';
import { Message } from '../entity/message';
import { DataSource } from 'typeorm';

@Service()
export class ConnectionService {
  dataSource = new DataSource();

  @Initializer()
  async init(): Promise<void> {
    await this.dataSource.initialize();
  }

  @Destructor()
  async destroy(): Promise<void> {
    await this.dataSource.destroy();
  }
}

Injecting into Controllers

The easiest way to inject dependencies to controllers is using constructors:

sample.controller.ts:

import { Controller, GET } from 'fastify-decorators';
import { MyService } from './my-service';

@Controller()
export class SampleController {
  constructor(private service: MyService) {}

  @GET()
  async index() {
    return this.service.doSomething();
  }
}

Another option to inject dependencies is @Inject decorator:

sample.controller.ts:

import { Controller, GET } from 'fastify-decorators';
import { Inject } from '@fastify-decorators/simple-di';
import { MyService } from './my-service';

@Controller()
export class SampleController {
  @Inject(MyService)
  private service!: MyService;

  @GET()
  async index() {
    return this.service.doSomething();
  }
}

Inject and available tokens

When you use @Inject you need to specify token, so what is token? Token is kind of identifier of instance to inject.

By default, when you use @Service decorator it uses class object as token, and it can be changed by specifying token explicitly:

my-service.ts:

import { Service } from '@fastify-decorators/simple-di';

@Service('MyServiceToken')
class MyService {}

this way MyService injection token will be MyServiceToken string and this token can be used in both methods:

import { getInstanceByToken } from '@fastify-decorators/simple-di';
import { MyService } from './my-service.ts';

const service = getInstanceByToken<MyService>('MyServiceToken');

Built-in tokens

Token Provides Description
FastifyInstanceToken FastifyInstance Token used to provide FastifyInstance

Limitations:

  • It's not possible to use getInstanceByToken for getting FastifyInstance in static fields or decorators options:

    import { Controller, FastifyInstanceToken, getInstanceByToken } from 'fastify-decorators';
    
    @Controller()
    class InstanceController {
      // Will throw an error when bootstrap via controllers list
      // This happens because "FastifyInstance" not available before "bootstrap" call but required when controller imported
      static instance = getInstanceByToken(FastifyInstanceToken);
    }

Dependency inversion

Library as well provides option to set token at fastify initialization in order to have top-down DI initialization:

blog-service.ts:

export abstract class BlogService {
  abstract getBlogPosts(): Promise<Array<BlogPost>>;
}

sqlite-blog-service.ts:

import { BlogService } from './blog-service.js';
import { BlogPost } from '../models/blog-post.js';

@Service()
export class SqliteBlogService extends BlogService {
  async getBlogPosts(): Promise<Array<BlogPost>> {
    /* ... */
  }
}

sqlite-blog-service.ts:

import { BlogService } from './blog-service.js';
import { BlogPost } from '../models/blog-post.js';

export class MySQLBlogService extends BlogService {
  async getBlogPosts(): Promise<Array<BlogPost>> {
    /* ... */
  }
}

blog-controller.ts:

import { BlogService } from '../services/blog-service.js';

@Controller({
  route: '/api/blogposts',
})
export class BlogController {
  constructor(private blogService: BlogService) {}

  @GET()
  public async getBlogPosts(req, res): Promise<Array<BlogPosts>> {
    return this.blogService.getBlogPosts();
  }
}

and finally set BlogService token in index.ts:

if (environment === 'development') {
  injectables.injectService(BlogService, SqliteBlogService);
} else if (environment === 'production') {
  injectables.injectSingleton(BlogService, new MySQLBlogService());
}

fastify.register(bootstrap, {
  /* ... */
});

Testing

Using configureControllerTest

The configureControllerTest(options) function registers a Controller and allow you to mock out the Services for testing functionality. You can write tests validating behaviors corresponding to the specific result of Controller interacting with mocked services.

Note: if mock was not provided for one or more dependencies than originals will be used.

Usage:

import { FastifyInstance } from 'fastify';
import { configureControllerTest } from '@fastify-decorators/simple-di/testing';
import { AuthController } from '../src/auth.controller';
import { AuthService } from '../src/auth.service';

describe('Controller: AuthController', () => {
  let instance: FastifyInstance;
  const authService = { authorize: jest.fn() };

  beforeEach(async () => {
    instance = await configureControllerTest({
      controller: AuthController,
      mocks: [
        {
          provide: AuthService,
          useValue: authService,
        },
      ],
    });
  });
  afterEach(() => jest.restoreAllMocks());

  it(`should reply with 'ok' if authorization success`, async () => {
    authService.authorize.and.returnValue(Promise.resolve(true));

    const result = await instance.inject({
      url: '/authorize',
      method: 'POST',
      payload: { login: 'test', password: 'test' },
    });

    expect(result.json()).toEqual({ message: 'ok' });
  });
});

Accessing controller instance

The configureControllerTest decorate Fastify instance with controller property which may be used to access controller instance.

Note: controller will be undefined in case "per request" type is used.

Example:

import { FastifyInstance } from 'fastify';
import { configureControllerTest, FastifyInstanceWithController } from '@fastify-decorators/simple-di/testing';
import { AuthController } from '../src/auth.controller';

describe('Controller: AuthController', () => {
  let instance: FastifyInstanceWithController<AuthController>;

  beforeEach(async () => {
    instance = await configureControllerTest({
      controller: AuthController,
    });
  });
  afterEach(() => jest.restoreAllMocks());

  it(`should reply with 'ok' if authorization success`, async () => {
    const controllerInstance = instance.controller;
    jest.spyOn(controllerInstance, 'authorize').mockReturnValue(Promise.resolve({ message: 'ok' }));

    const result = await instance.inject({
      url: '/authorize',
      method: 'POST',
      payload: { login: 'test', password: 'test' },
    });

    expect(result.json()).toEqual({ message: 'ok' });
  });
});

Using configureServiceTest

The configureControllerTest(options) is pretty close to configureControllerTest the difference is that this method returns service with mocked dependencies.

Note: if mock was not provided for one or more dependencies then originals will be used.

Sync service testing

For those services which has no method with @Initializer decorator, then configureServiceTest will return an instance of it.

Usage:

import { configureServiceTest } from '@fastify-decorators/simple-di/testing';
import { RolesService } from '../src/roles.service';
import { AuthService } from '../src/auth.service';

describe('Service: AuthService', () => {
  let service: AuthService;
  const rolesService = { isTechnical: jest.fn(), isAdmin: jest.fn() };

  beforeEach(() => {
    service = configureServiceTest({
      service: AuthService,
      mocks: [
        {
          provide: RolesService,
          useValue: rolesService,
        },
      ],
    });
  });
  afterEach(() => jest.restoreAllMocks());

  it(`should reply with 'ok' if authorization success`, async () => {
    rolesService.isTechnical.and.returnValue(true);
    rolesService.isAdmin.and.returnValue(false);
    const bearer = 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6W119.0Dd6yUeJ4UbCr8WyXOiK3BhqVVwJFk5c53ipJBWenmc';

    const result = service.hasSufficientRole(bearer);

    expect(result).toBe(true);
  });
});

Async service testing

If service has method with @Initializer decorator, then configureServiceTest will return intersection of an instance and Promise. You can work with service like it has no @Initializer unless you await it.

import { configureServiceTest } from '@fastify-decorators/simple-di/testing';
import { RolesService } from '../src/roles.service';
import { AuthService } from '../src/auth.service';

describe('Service: AuthService', () => {
  let service: AuthService;
  const rolesService = { isTechnical: jest.fn(), isAdmin: jest.fn() };

  beforeEach(async () => {
    service = await configureServiceTest({
      service: AuthService,
      mocks: [
        {
          provide: RolesService,
          useValue: rolesService,
        },
      ],
    });
  });
  afterEach(() => jest.restoreAllMocks());

  it(`should reply with 'ok' if authorization success`, async () => {
    rolesService.isTechnical.and.returnValue(true);
    rolesService.isAdmin.and.returnValue(false);
    const bearer = 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6W119.0Dd6yUeJ4UbCr8WyXOiK3BhqVVwJFk5c53ipJBWenmc';

    const result = service.hasSufficientRole(bearer);

    expect(result).toBe(true);
  });
});