Dependency injection (DI) is widely used mechanism to autowire controller/service dependency. In fastify-decorators DI only available for controllers.
There's few simple steps to enable this library:
- Install
@fastify-decorators/simple-di
- Enable
"experimentalDecorators"
as"emitDecoratorMetadata"
intsconfig.json
Note: auto-generated type metadata may have issues with circular or forward references for types.
Service
decorator used to make class injectable
my-service.ts:
import { Service } from '@fastify-decorators/simple-di';
@Service()
export class MyService {
calculate() {
doSomething();
}
}
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();
}
}
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();
}
}
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();
}
}
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');
Token | Provides | Description |
---|---|---|
FastifyInstanceToken |
FastifyInstance |
Token used to provide FastifyInstance |
-
It's not possible to use
getInstanceByToken
for gettingFastifyInstance
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); }
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, {
/* ... */
});
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' });
});
});
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' });
});
});
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.
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);
});
});
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);
});
});