NestJS — фреймворк, вобравший в себя преимущества TypeScript, IoC/DI и структуру Angular, стремительно развивается, набирая популярность.

Множество методик и практик описано в официальной документации. Рассмотрим написание собственного Dynamic module и его публикацию в npm. В качестве библиотеки используем Mailchimp Transaction API.

Некоторые концепции опущены. Подробнее можно прочитать в публикации John Biundo — Build a NestJS Module for Knex.js.

Dynamic module

Создание полноценного Dynamic module обрекает нас на выполнение некоторых рутинных приготовлений. Хорошо, что вышеупомянутый John Biundo подготовил для нас небольшую утилиту, которая позволяет автоматизировать рутину.

Установим утилиту глобально dyn-schematics.

npm install @nestjsplus/dyn-schematics -g

Запустим следующую команду, заменив на имя модуля

nest g -c @nestjsplus/dyn-schematics dynpkg <NAME>

Для быстрого тестирования, предпочтительнее создать тестовый клиент

? Generate a testing client? Yes

Будет создана следующая структура проекта:

|   .npmignore
|   .prettierrc
|   nest-cli.json
|   package.json
|   README.md
|   tsconfig.build.json
|   tsconfig.json
|   tslint.json
|
\---src
    |   constants.ts
    |   index.ts
    |   mailchimp-habr.module.ts
    |   mailchimp-habr.providers.ts
    |   mailchimp-habr.service.ts
    |   main.ts
    |
    +---interfaces
    |       index.ts
    |       mailchimp-habr-module-async-options.interface.ts
    |       mailchimp-habr-options-factory.interface.ts
    |       mailchimp-habr-options.interface.ts
    |
    \---mailchimp-habr-client
            mailchimp-habr-client.controller.ts
            mailchimp-habr-client.module.ts

Обновите зависимости в package.json. Отлично подойдет npm-check-updates.  При возникновении конфликтов версий, например, RxJS, установите совместимые версии, основываясь на зависимостях NestJS.

Установим библиотеку  Mailchimp Transaction API.

npm i --save @mailchimp/mailchimp_transactional

Библиотека mailchimp предлагает передать ей в качестве аргумента строковый литерал. Чтобы сильно не отходить от шаблона, созданного dyn-schematics, изменим тип основных параметров

//mailchimp-habr-options.interface.ts
export type MailchimpHabrOptions = string;
//mailchimp-habr-options-factory.interface.ts
import { MailchimpHabrOptions } from './mailchimp-habr-options.interface';

export interface MailchimpHabrOptionsFactory {
 createMailchimpHabrOptions(): | Promise<MailchimpHabrOptions> | MailchimpHabrOptions;
}
//mailchimp-habr-module-async-options.interface.ts
import { ModuleMetadata, Type } from '@nestjs/common/interfaces';
import { MailchimpHabrOptionsFactory } from './mailchimp-habr-options-factory.interface';

import { MailchimpHabrOptions } from './mailchimp-habr-options.interface';

export interface MailchimpHabrAsyncOptions
 extends Pick<ModuleMetadata, 'imports'> {
 inject?: any[];
 useExisting?: Type<MailchimpHabrOptionsFactory>;
 useClass?: Type<MailchimpHabrOptionsFactory>;
 useFactory?: (...args: any[]) => Promise<MailchimpHabrOptions> | MailchimpHabrOptions;
}

Изменим строковые константы на Symbol и добавим MAILCHIMP_HABR_TOKEN

// constants.ts
export const MAILCHIMP_HABR_OPTIONS = Symbol('MAILCHIMP_HABR_OPTIONS');
export const MAILCHIMP_HABR_TOKEN = Symbol('MAILCHIMP_HABR_TOKEN');

Внесем некоторые изменения в mailchimp-habr.service.ts

//mailchimp-habr.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { MAILCHIMP_HABR_OPTIONS} from './constants';
import { MailchimpHabrOptions } from './interfaces';

import * as Mailchimp from '@mailchimp/mailchimp_transactional/src';

interface IMailchimpHabrService {
 getInstance(): Mailchimp;
}

@Injectable()

export class MailchimpHabrService implements IMailchimpHabrService {
 private serviceInstance: Mailchimp;

 constructor(
   @Inject(MAILCHIMP_HABR_OPTIONS) private mailchimpHabrOptions: MailchimpHabrOptions,
 ) {}

 async getInstance(): Mailchimp {
   return this.serviceInstance ? this.serviceInstance : Mailchimp(this.mailchimpHabrOptions);
 }
}

Изменения совсем небольшие, но они являются ключевыми для этого сервиса.

Во-первых, сервис имеет декоратор @Injectable(), который говорит NestJS, что этот сервис может быть использован для DI. В свою очередь constructor сервиса, сам принимает параметры для инъекции, используя декоратор @Inject(MAILCHIMP_HABR_OPTIONS) с аргументом типа Symbol, который был создан автоматически, а потом нами изменён.

Единственный метод нашего сервиса, отвечает за создание и возврат экземпляра. Его работу мы увидим при реализации самого Dynamic Module.

Команда dyn-schematics заботливо создала для нас вспомогательную функцию для создания провайдера

//mailchimp-habr.providers.ts
import { MAILCHIMP_HABR_OPTIONS } from './constants';
import { MailchimpHabrOptions } from './interfaces';

export function createMailchimpHabrProviders(options: MailchimpHabrOptions) {
 return [
   {
     provide: MAILCHIMP_HABR_OPTIONS,
     useValue: options,
   },
 ];
}

Она максимально тривиальна, поэтому, думаю, что не нуждается в пояснении. Подробнее вы можете узнать в документации NestJS.

В NestJS Dynamic Modules должны реализовывать интерфейс статических модулей. За одним исключением, возвращаемый объект должен иметь дополнительно название модуля module: MailchimpHabrModule.

Открыв файл mailchimp-habr.module.ts, рассмотрим статическую регистрацию

export class MailchimpHabrModule {
  public static register(options: MailchimpHabrOptions): DynamicModule {
   return {
     module: MailchimpHabrModule,
     providers: createMailchimpHabrProviders(options),
   };
  }
}

Имя метода можно изменить на forRoot, аналогично другим модулям NestJS.

Метод достаточно прост, за исключением того, что в нем не хватает двух вещей, а именно нескольких провайдеров и массива exports. Без этих моментов, к сожалению, ничего работать не будет!

Добавить недостающие элементы можно прямо в возвращаемый объект, или, если посмотреть на декоратор @Module класса MailchimpHabrModule , то можно заметить, что в нём указаны провайдеры и экспорты, хотя в них только один элемент! Элементы, указанные в декораторе @Module будут добавлены к возвращаемым из статических методов регистрации (forRoot/register и forRootAsync/registerAsync). Поэтому остаётся лишь добавить немного “магии”, а именно custom provider.

Добавим следующие строки в файл mailchimp-habr.providers.ts

// mailchimp-habr.providers.ts
export const MailchimpProvider: Provider = {
 provide: MAILCHIMP_HABR_TOKEN,
 useFactory: async (mailchimpService) => mailchimpService.getInstance(),
 inject: [MailchimpHabrService],
};

Добавим этот провайдер в декоратор @Module в файле mailchimp-habr.module.ts

@Global()
@Module({
 providers: [MailchimpProvider, MailchimpHabrService],
 exports: [MailchimpProvider, MailchimpHabrService],
})
export class MailchimpHabrModule {
	/* */
}

Почти готово! Добавим немного “сахарку”, точнее декоратор для инъекций. Создайте файл (или прямо в модуле) с именем mailchimp-habr.decorator.ts

//mailchimp-habr.decorator.ts
import { Inject } from '@nestjs/common';
import { MAILCHIMP_HABR_TOKEN } from './constants';

export const InjectMailchimp = () => Inject(MAILCHIMP_HABR_TOKEN);

Теперь точно готово! Тестируем! Как? Для этого у нас есть минифицированный клиент, созданный при создании проекта.

import { Module } from '@nestjs/common';
import { MailchimpHabrClientController } from './mailchimp-habr-client.controller';
import { MailchimpHabrModule } from '../mailchimp-habr.module';

@Module({
 controllers: [MailchimpHabrClientController],
 imports: [MailchimpHabrModule.forRoot('YOUR_API_KEY')],
})
export class MailchimpHabrClientModule {}
import { Controller, Get } from '@nestjs/common';
import { InjectMailchimp } from '../mailchimp-habr.decorator';

@Controller()
export class MailchimpHabrClientController {
 constructor(@InjectMailchimp() private readonly mailchimpHabrService) {}

 @Get()
 index() {
   return this.mailchimpHabrService.users.ping();
 }
}

Можно создать отдельный сервис и инъекцию ожидать в нём.

npm run start:dev

Откройте страницу в браузере http://localhost:3000/ (или воспользуйтесь curl, postman, insomnia...).

Вы должны увидеть слово «PONG!», но это только при наличии действующего api key. Если у вас его нет, то достаточно проверить, что наш сервис имеет метод this.mailchimpHabrService.users.ping();

А как насчет асинхронной регистрации? Да всё с ней отлично! Она уже будет работать! Попробуйте зарегистрировать модуль не через forRoot/register а через forRootAsync/registerAsync.

import { Module } from '@nestjs/common';
import { MailchimpHabrClientController } from './mailchimp-habr-client.controller';
import { MailchimpHabrModule } from '../mailchimp-habr.module';

@Module({
 controllers: [MailchimpHabrClientController],
 imports: [MailchimpHabrModule.forRootAsync({ useFactory: () => 'API_KEY' })],
})
export class MailchimpHabrClientModule {}

Проверяем... Работает!

Но что, если мы не хотим указывать ключ на прямую в useFactory? Стоит попробовать воспользоваться другим модулей от NestJS — ConfigModule.

Для этого нам нужно добавить его в imports и в inject при регистрации нашего модуля

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MailchimpHabrModule } from '../mailchimp-habr.module';
import { MailchimpHabrClientController } from './mailchimp-habr-client.controller';

@Module({
 controllers: [MailchimpHabrClientController],
 imports: [
   ConfigModule.forRoot(),
   MailchimpHabrModule.forRootAsync({
   imports: [ConfigModule],
   useFactory: async (config: ConfigService) => {
     return config.get('API_KEY');
   },
   inject: [ConfigService],
 })],
})
export class MailchimpHabrClientModule {}

Получилось? Думаю, что нет. Скорее всего вы получили ошибку:

Что-то здесь не так. Следуя документации ищем ошибку... Внимательно посмотрев на forRootAsync, обнаружим, что мы никак не обрабатываем imports в options. Добавим.

export class MailchimpHabrModule {
  public static forRootAsync(options: MailchimpHabrAsyncOptions,): DynamicModule {
   return {
     module: MailchimpHabrModule,
     imports: options.imports ?? [], // Добавим imports
     providers: [
       ...this.createProviders(options),
     ],
   };
  }
}

Пробуем ещё раз. Всё работает!

Unit тестирование

NestJS поддерживает Jest «из коробки». Воспользуемся такой возможностью. Создадим файл mailchimp-habr.spec.ts

import * as Mailchimp from '@mailchimp/mailchimp_transactional/src';
import { Test } from '@nestjs/testing';
import { MAILCHIMP_HABR_TOKEN, MailchimpHabrModule } from './';

describe('Mailchimp forRoot', () => {
 let mailchimpService: Mailchimp;

 beforeEach(async () => {
   const moduleRef = await Test.createTestingModule({
     imports: [MailchimpHabrModule.forRoot('MAILCHIPM_API_KEY')],
   }).compile();

   mailchimpService = moduleRef.get<Mailchimp>(MAILCHIMP_HABR_TOKEN);
 });

 describe('mailchimpHabrService', () => {
   it('method ping exists', async () => {
     expect(mailchimpService.users.ping).toBeDefined();
   });

   it('method is a function', async () => {
     expect(mailchimpService.users.ping).toBeInstanceOf(Function);
   });

   it('users.ping() should return "PONG"', async () => {
     const result = 'PONG';
     jest
       .spyOn(mailchimpService.users, 'ping')
       .mockImplementation(() => result);

     expect(await mailchimpService.users.ping()).toBe(result);
   });
 });
});

Все тесты проходят. Аналогично тестируем регистрацию через forRootAsync().

Итог

Dynamic modules NestJS позволяют «оборачивать» множество библиотек! Написание собственных модулей позволяет сократить код в проектах. А публикация их в npm, облегчает жизнь не только вам.

Модуль доступен в npm и GitHub.

Комментарии (0)