Как использовать RedisOM с NestJS

После Redis Hackathon многие захотят использовать модуль RedisOM в качестве основного Object Mapper, но как связать его с NestJS, моим любимым NodeJS фреймворком.

В этой статье я объясню, как это сделать, создав базовое приложение Todo. В конце статьи я также объясню, как создать динамический модуль для RedisOM для тех, кто занимается микросервисами.

Настройка

Поскольку эта статья посвящена NestJS, я буду считать, что у вас установлен cli, если нет, нажмите здесь. В папке с проектами выполните следующие команды, создайте новый проект NestJS и откройте его в VSC (или любом другом редакторе кода).

~ nest n redis-todo -s
~ code ./redis-todo
Войти в полноэкранный режим Выйти из полноэкранного режима

Чтобы мы все использовали один и тот же узел и менеджер пакетов, я рекомендую использовать Node 16 и Yarn. Для установки yarn 3 в папке node-modules создайте файл .yarnrc.yml и внутри напишите.

nodeLinker: node-modules

После установите yarn версии 3 и интерактивные инструменты:

~ yarn set version stable
~ yarn plugin import interactive-tools
Войдите в полноэкранный режим Выйти из полноэкранного режима

Для этого приложения после установки и обновления всех экземпляров нам нужно будет установить узел RedisOM и модуль config.

~ yarn install
~ yarn upgrade-interactive
~ yarn add redis-om
Войти в полноэкранный режим Выход из полноэкранного режима

В файле tsconfig.json в конце добавьте esModuleInterop:

{
  "compilerOptions": {
    // ...
    "esModuleInterop": true
  }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Конфигурация

Для конфигурации мы будем использовать .env и модуль NestJS Config Module, для этого мы установим его зависимость:

yarn add @nestjs/config joi

Создайте папку config, а внутри нее вложенную папку interfaces.

В папке interfaces создайте файл с объектом конфигурации под названием config.interface.ts:

export interface IConfig {
  redisUrl: string;
  port: number;
}
Вход в полноэкранный режим Выход из полноэкранного режима

В папке config создадим схему валидации для нашего файла .env и назовем его validation.schema.ts:

import Joi from 'joi';

export const validationSchema = Joi.object({
  NODE_ENV: Joi.string().required(),
  PORT: Joi.number().required(),
  REDIS_URL: Joi.string().required(),
});
Войти в полноэкранный режим Выход из полноэкранного режима

В папке config создадим файл index.ts и добавим в него функцию config.

import { IConfig } from './interfaces/config.interface';

export function config(): IConfig {
  return {
    port: parseInt(process.env.PORT, 10),
    redisUrl: process.env.REDIS_URL,
  };
}

export { validationSchema } from './validation.schema';
Вход в полноэкранный режим Выход из полноэкранного режима

По желанию, как показано выше, вы можете экспортировать схему валидации оттуда же.

Наконец, просто импортируйте ваш модуль config в AppModule:

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { config, validationSchema } from './config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      validationSchema,
      load: [config],
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

Войти в полноэкранный режим Выйти из полноэкранного режима

Создание клиента RedisOM

Теперь сгенерируйте модуль и сервис для redis-om:

~ nest g mo redis-client
~ nest g s redis-client --no-spec
Войти в полноэкранный режим Выйти из полноэкранного режима

В папке redis-client откройте сервис redis-client и импортируйте класс Client из пакета redis-om:

import { Injectable } from '@nestjs/common';
import { Client } from 'redis-om';
Войти в полноэкранный режим Выйти из полноэкранного режима

Чтобы иметь возможность использовать redis-om, расширьте сервис классом Client:

// ...

@Injectable()
export class RedisClientService extends Client {}
Войти в полноэкранный режим Выйти из полноэкранного режима

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

import { Injectable, OnModuleDestroy } from '@nestjs/common';
// ...

@Injectable()
export class RedisClientService
  extends Client
  implements OnModuleDestroy
{
  constructor() {
    super();
  }

  public async onModuleDestroy() {
    //...
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Для подключения к серверу нам нужно использовать метод open в конструкторе с url redis, который мы можем получить из службы config, а для закрытия мы используем метод close в методе onModuleDestroy. Итак, собираем все вместе:

import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Client } from 'redis-om';

@Injectable()
export class RedisClientService extends Client implements OnModuleDestroy {
  constructor(private readonly configService: ConfigService) {
    super();
    (async () => {
      await this.open(configService.get<string>('redisUrl'));
    })();
  }


  public async onModuleDestroy() {
    await this.close();
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Наконец, в модуле redis-client.module экспортируем сервис и делаем модуль глобальным:

import { Global, Module } from '@nestjs/common';
import { RedisClientService } from './redis-client.service';

@Global()
@Module({
  providers: [RedisClientService],
  exports: [RedisClientService],
})
export class RedisClientModule {}
Войти в полноэкранный режим Выйти из полноэкранного режима

Это все настройки, необходимые для использования узла redis-om с NestJS, остальная часть статьи — это учебник по использованию их в REST API. Если вы пропустите конец статьи, я добавил код библиотеки динамических модулей для тех, кто использует микросервисы NestJS.

ПРИМЕР ПРИЛОЖЕНИЯ TODO

Приложение todo с базовой системой аутентификации JWT, использующей redis в качестве основной базы данных.

Базовая система аутентификации

Обновление конфигурации:

Начните с создания файла jwt.interface.ts в папке config interfaces:

export interface IJwt {
  time: number;
  secret: string;
}
Вход в полноэкранный режим Выход из полноэкранного режима

На вашем интерфейсе config добавьте файл jwt:

import { IJwt } from './jwt.interface';

export interface IConfig {
  redisUrl: string;
  port: number;
  jwt: IJwt;
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Наконец, измените схему валидации и функцию config, чтобы получить jwt:

Validation schema:

// ...
export const validationSchema = Joi.object({
  // ...
  ACCESS_SECRET: Joi.string().required(),
  ACCESS_TIME: Joi.number().required(),
});
Войти в полноэкранный режим Выход из полноэкранного режима

Функция конфигурации:

// ...
export function config(): IConfig {
  return {
    // ...
    jwt: {
      secret: process.env.ACCESS_SECRET,
      time: parseInt(process.env.ACCESS_TIME, 10),
    },
  };
}
Войти в полноэкранный режим Выход из полноэкранного режима

Служба авторизации:

Начните с использования cli для генерации REST Api ресурса для аутентификации:

~ nest g res auth
Войти в полноэкранный режим Выйти из полноэкранного режима

При использовании этой команды не создавайте конечные точки CRUD.

В папке auth создайте подпапку entities, в которой будет находиться файл user.entity.ts. Сущность User будет базовой сущностью redis со схемой, как показано в readme redis-om-node:

import { Entity, Schema } from 'redis-om';

export class User extends Entity {
  name: string;
  email: string;
  password: string;
  createdAt: Date;
}

export const userSchema = new Schema(User, {
  name: { type: 'string' },
  email: { type: 'string' },
  password: { type: 'string' },
  createdAt: { type: 'date' },
});
Войдите в полноэкранный режим Выход из полноэкранного режима

На сервисе auth нужно внедрить redis-client и config-server в качестве зависимостей, и поскольку мы сделали redis-client и config глобальными, нам пока не нужно трогать этот модуль:

import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { RedisClientService } from '../redis-client/redis-client.service';

@Injectable()
export class AuthService {
  constructor(
    private readonly redisClient: RedisClientService,
    private readonly configService: ConfigService,
  ) {}
}
Вход в полноэкранный режим Выход из полноэкранного режима

После этого нам нужно настроить хранилище пользователей и запустить его индекс, чтобы мы могли использовать поиск redis (ПРИМЕЧАНИЕ: Это можно сделать в конструкторе внутри функции arrow, но я предпочитаю добавить OnModuleInit), а также время и секрет JWT:

import {
  BadRequestException,
  Injectable,
  OnModuleInit,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Repository } from 'redis-om';
import { IJwt } from '../config/interfaces/jwt.interface';
import { RedisClientService } from '../redis-client/redis-client.service';
import { User, userSchema } from './entities/user.entity';

@Injectable()
export class AuthService implements OnModuleInit {
  private readonly usersRepository: Repository<User>;
  private readonly jwt: IJwt;

  constructor(
    private readonly redisClient: RedisClientService,
    private readonly configService: ConfigService,
  ) {
    this.usersRepository = redisClient.fetchRepository(userSchema);
    this.jwt = configService.get<IJwt>('jwt');
  }

  public async onModuleInit() {
    await this.usersRepository.createIndex();
  }
}

Вход в полноэкранный режим Выход из полноэкранного режима

Мы могли бы использовать модуль Nestjs Jwt, но я считаю, что создание методов async jwt на службе аутентификации проще, поэтому начните с установки пакетов jsonwebtoken и bcrypt:

~ yarn add jsonwebtoken bcrypt
~ yarn add -D @types/jsonwebtoken @types/bcrypt
Войдите в полноэкранный режим Выйдите из полноэкранного режима

Создайте подкаталог interfaces и добавьте access-id.interface.ts:

export interface IAccessId {
  id: string;
}

export interface IAccessIdResponse extends IAccessId {
  iat: number;
  exp: number;
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Затем просто добавьте приватный метод для генерации:

import {
  //...
  BadRequestException,
  UnauthorizedException,
} from '@nestjs/common';
// ...
import { sign } from 'jsonwebtoken';

@Injectable()
export class AuthService implements OnModuleInit {
  // ...
  private async generateToken(user: User): Promise<string> {
    return new Promise((resolve) => {
      sign(
        { id: user.entityId },
        this.jwt.secret,
        { expiresIn: this.jwt.time },
        (error, token) => {
          if (error)
            throw new InternalServerErrorException('Something went wrong');

          resolve(token);
        },
      );
    });
  }
}

Enter fullscreen mode Выйти из полноэкранного режима

Теперь мы можем приступить к созданию dtos для маршрутов регистрации, входа и удаления, поэтому нам нужно установить class-validator:

~ yarn add class-validator class-transformer
Войти в полноэкранный режим Выйти из полноэкранного режима

Создайте следующие файлы в подпапке dto:

Register dto (register.dto.ts):

import { IsEmail, IsString, Length, MinLength } from 'class-validator';

export abstract class RegisterDto {
  @IsString()
  @IsEmail()
  @Length(7, 255)
  public email: string;

  @IsString()
  @Length(3, 100)
  public name: string;

  @IsString()
  @Length(8, 40)
  public password1: string;

  @IsString()
  @MinLength(1)
  public password2: string;
}
Войти в полноэкранный режим Выход из полноэкранного режима

Пароль dto (password.dto.ts):

import { IsString, Length } from 'class-validator';

export abstract class PasswordDto {
  @IsString()
  @Length(1, 40)
  public password: string;
}
Войти в полноэкранный режим Выход из полноэкранного режима

Вход в систему (login.dto.ts):

import { IsEmail, IsString, Length } from 'class-validator';

export abstract class LoginDto {
  @IsString()
  @IsEmail()
  @Length(7, 255)
  public email: string;

  @IsString()
  @Length(1, 40)
  public password: string;
}
Войти в полноэкранный режим Выход из полноэкранного режима

В сервисе нам нужно создать публичный метод для каждого маршрута.

Метод Register (Регистрация):

// ...
import { hash } from 'bcrypt';

@Injectable()
export class AuthService implements OnModuleInit {
  // ...

  public async registerUser({
    email,
    name,
    password1,
    password2,
  }: RegisterDto): Promise<string> {
    // Check if passwords match
    if (password1 !== password2)
      throw new BadRequestException('Passwords do not match');

    email = email.toLowerCase(); // so its always consistent and lowercase.
    const count = await this.usersRepository
      .search()
      .where('email')
      .equals(email)
      .count();

    // We use the count to check if the email is already in use.
    if (count > 0) throw new BadRequestException('Email already in use');

    // Create the user with a hashed password
    const user = await this.usersRepository.createAndSave({
      email,
      name: name // Capitalize and trim the name
        .trim()
        .replace(/n/g, ' ')
        .replace(/ss+/g, ' ')
        .replace(/wS*/g, (w) => w.replace(/^w/, (l) => l.toUpperCase())),
      password: await hash(password1, 10),
      createdAd: new Date(),
    });
    return this.generateToken(user); // Generate an access token for the user
  }

  // ...
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Метод входа в систему:

// ...
import { hash, compare } from 'bcrypt';

@Injectable()
export class AuthService implements OnModuleInit {
  // ...

  public async login({ email, password }: LoginDto): Promise<string> {
    // Find the first user with a given email
    const user = await this.usersRepository
      .search()
      .where('email')
      .equals(email.toLowerCase())
      .first();

    // Check if the user exists and the password is valid
    if (!user || (await compare(password, user.password)))
      throw new UnauthorizedException('Invalid credentials');

    return this.generateToken(user); // Generate an access token for the user
  }

  // ...
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Метод поиска по идентификатору:

// ...
@Injectable()
export class AuthService implements OnModuleInit {
  // ...

  public async userById(id: string): Promise<User> {
    const user = await this.usersRepository.fetch(id);
    if (!user || !user.email) throw new NotFoundException('User not found');
    return user;
  }

  // ...
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Метод удаления:

// ...
@Injectable()
export class AuthService implements OnModuleInit {
  // ...

  public async remove(id: string, password: string): Promise<string> {
    const user = await this.userById(id);
    if (!(await compare(password, user.password)))
      throw new BadRequestException('Invalid password');
    await this.usersRepository.remove(id);
    return 'User deleted successfully';
  }

  // ...
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Логика авторизации:

Чтобы иметь возможность работать с авторизацией, нам нужно создать стратегию аутентификации, для этого мы будем использовать passport и passport-jwt, поэтому нам нужно установить их:

~ yarn add @nestjs/passport passport passport-jwt
~ yarn add -D @types/passport-jwt
Войти в полноэкранный режим Выйти из полноэкранного режима

Затем создайте стратегию, в папке auth создайте файл jwt.strategy.ts, основываясь на документации:

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy, VerifiedCallback } from 'passport-jwt';
import { AuthService } from './auth.service';
import { IAccessIdResponse } from './interfaces/access-id.interface';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    private readonly configService: ConfigService,
    private readonly authService: AuthService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: configService.get<string>('jwt.secret'),
      ignoreExpiration: false,
      passReqToCallback: false,
    });
  }

  public async validate(
    { id, iat }: IAccessIdResponse,
    done: VerifiedCallback,
  ) {
    const user = await this.authService.userById(id);
    return done(null, user.entityId, iat);
  }
}
Войти в полноэкранный режим Выход из полноэкранного режима

Для защиты наших маршрутов нам нужен guard, а также декоратор текущего пользователя для получения ID пользователя, поэтому нам нужно создать их оба.

Чтобы создать охранника, выполните следующие команды:

~ cd src/auth
~ nest g gu jwt-auth --no-spec
~ cd ../..
Войти в полноэкранный режим Выйти из полноэкранного режима

На JwtAuthGuard расширьте его с помощью Passport AuthGuard:

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Observable } from 'rxjs';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    return true;
  }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Чтобы гвард был глобальным и получал ID пользователя, нам нужно создать декораторы, поэтому начните с создания подпапки decorators с двумя файлами: public.decorator.ts и current-user.decorator.ts.

Public Decorator (устанавливает метаданные для публичных маршрутов):

import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
Войти в полноэкранный режим Выход из полноэкранного режима

Текущий пользователь (получает идентификатор текущего пользователя):

import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const CurrentUser = createParamDecorator(
  (_, context: ExecutionContext): string | undefined => {
    return context.switchToHttp().getRequest().user;
  },
);
Войти в полноэкранный режим Выход из полноэкранного режима

Мы обновим модуль auth и модуль app для новых изменений в нашем приложении.

В модуле auth нам нужно импортировать PassportModule и добавить нашу стратегию jwt в качестве провайдера:

// ...
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from './jwt.strategy';

@Module({
  imports: [PassportModule.register({ defaultStrategy: 'jwt' })],
  controllers: [AuthController],
  providers: [AuthService, JwtStrategy],
})
export class AuthModule {}
Вход в полноэкранный режим Выход из полноэкранного режима

В модуле приложения нам нужно добавить auth guard:

//...
import { APP_GUARD } from '@nestjs/core';
import { JwtAuthGuard } from './auth/jwt-auth.guard';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      validationSchema,
      load: [config],
    }),
    RedisClientModule,
    AuthModule,
  ],
  providers: [AppService, { provide: APP_GUARD, useClass: JwtAuthGuard }],
  controllers: [AppController],
})
export class AppModule {}
Войти в полноэкранный режим Выйти из полноэкранного режима

Наконец, чтобы иметь возможность правильно использовать публичный декоратор, нам нужно немного изменить гвардию, как показано в документации:

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Observable } from 'rxjs';
import { IS_PUBLIC_KEY } from './decorators/public.decorator';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') implements CanActivate {
  constructor(private readonly reflector: Reflector) {
    super();
  }

  public canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    return isPublic || super.canActivate(context);
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Контроллер:

Для контроллера нам просто нужно добавить маршруты, но перед этим хорошая практика заключается в создании интерфейсов с возвращаемым значением, поэтому создайте следующие интерфейсы в подпапке interfaces:

Access Token (access-token.interface.ts):

export interface IAccessToken {
  token: string;
}

export interface IAccessIdResponse extends IAccessId {
  iat: number;
  exp: number;
}
Вход в полноэкранный режим Выход из полноэкранного режима

Сообщение (message.interface.ts):

export interface IMessage {
  message: string;
}
Вход в полноэкранный режим Выход из полноэкранного режима

Ответ пользователя (user-response.interface.ts):

export interface IUserResponse {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь вы можете добавить все маршруты:

import { Body, Controller, Delete, Get, Post } from '@nestjs/common';
import { AuthService } from './auth.service';
import { CurrentUser } from './decorators/current-user.decorator';
import { Public } from './decorators/public.decorator';
import { LoginDto } from './dtos/login.dto';
import { PasswordDto } from './dtos/password.dto';
import { RegisterDto } from './dtos/register.dto';
import { IAccessToken } from './interfaces/access-token.interface';
import { IMessage } from './interfaces/message.interface';
import { IUserResponse } from './interfaces/user-response.interface';

@Controller('api/auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Public()
  @Post('register')
  public async register(@Body() dto: RegisterDto): Promise<IAccessToken> {
    return {
      token: await this.authService.register(dto),
    };
  }

  @Public()
  @Post('login')
  public async login(@Body() dto: LoginDto): Promise<IAccessToken> {
    return {
      token: await this.authService.login(dto),
    };
  }

  @Delete('account')
  public async deleteAccount(
    @CurrentUser() userId: string,
    @Body() dto: PasswordDto,
  ): Promise<IMessage> {
    return {
      message: await this.authService.remove(userId, dto.password),
    };
  }

  @Get('account')
  public async findAccount(
    @CurrentUser() userId: string,
  ): Promise<IUserResponse> {
    const { name, email, entityId, createdAt } =
      await this.authService.userById(userId);

    return {
      name,
      email,
      createdAt,
      id: entityId,
    };
  }
}
Войти в полноэкранный режим Выход из полноэкранного режима

Todos CRUD

Служба Todos

Снова начните с использования cli для создания REST Api ресурса для Todos:

~ nest g res todos
Войти в полноэкранный режим Выйти из полноэкранного режима

Но при использовании этой команды создайте CRUD enpoints.

Эта команда создаст папку для dtos и entities, поэтому в папке entity создайте Todo Entity:

import { Entity, Schema } from 'redis-om';

export class Todo extends Entity {
  body: string;
  completed: boolean;
  createdAt: Date;
  author: string;
}

export const todoSchema = new Schema(Todo, {
  body: { type: 'string' },
  completed: { type: 'boolean' },
  createdAt: { type: 'date' },
  author: { type: 'string' },
});
Войти в полноэкранный режим Выйти из полноэкранного режима

На папке dtos измените оба dtos следующим образом:

Создайте Todo Dto (create-todo.dto.ts):

import { IsString, Length } from 'class-validator';

export class CreateTodoDto {
  @IsString()
  @Length(1, 300)
  public body: string;
}
Войти в полноэкранный режим Выход из полноэкранного режима

Обновить Todo Dto (update-todo.dto.ts):

import { IsIn, IsOptional, IsString, Length } from 'class-validator';

export class UpdateTodoDto {
  @IsString()
  @Length(1, 300)
  @IsOptional()
  public body?: string;

  @IsString()
  @IsIn(['true', 'false', 'True', 'False'])
  @IsOptional()
  public completed?: string;
}
Войти в полноэкранный режим Выход из полноэкранного режима

На сервисе у нас будет начальная настройка, и теперь нам нужно только импортировать клиент redis и обновить все функции.

Сначала импортируйте клиент redis:

import { Injectable, NotFoundException, OnModuleInit } from '@nestjs/common';
import { Repository } from 'redis-om';
import { RedisClientService } from '../redis-client/redis-client.service';
import { Todo, todoSchema } from './entities/todo.entity';

@Injectable()
export class TodosService implements OnModuleInit {
  private readonly todosRepository: Repository<Todo>;

  constructor(private readonly redisClient: RedisClientService) {
    this.todosRepository = redisClient.fetchRepository(todoSchema);
    // (async () => {await this.todosRepository.createIndex()})()
  }

  // ...

  public async onModuleInit() {
    // This could go to the constructor but I prefer it this way
    await this.todosRepository.createIndex();
  }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Затем начнем модифицировать созданные методы.

Создайте метод:

// ...
import { CreateTodoDto } from './dto/create-todo.dto';

@Injectable()
export class TodosService implements OnModuleInit {
  // ...

  public async create(userId: string, { body }: CreateTodoDto): Promise<Todo> {
    const todo = await this.todosRepository.createAndSave({
      body,
      completed: false,
      createdAt: new Date(),
      author: userId,
    });
    return todo;
  }

  // ...
}
Войдите в полноэкранный режим Выйти из полноэкранного режима

Метод обновления:

// ...
import { UpdateTodoDto } from './dto/update-todo.dto';

@Injectable()
export class TodosService implements OnModuleInit {
  // ...

  public async update(
    userId: string,
    todoId: string,
    { body, completed }: UpdateTodoDto,
  ): Promise<Todo> {
    const todo = await this.findOne(userId, todoId);

    if (body && todo.body !== body) todo.body = body;
    if (completed) {
      const boolComplete = completed.toLowerCase() === 'true';
      if (todo.completed !== boolComplete) todo.completed = boolComplete;
    }

    await this.todosRepository.save(todo);
    return todo;
  }

  // ...
}
Войти в полноэкранный режим Выход из полноэкранного режима

Метод «Найти все» (заполненным является параметр запроса):

@Injectable()
export class TodosService implements OnModuleInit {
  // ...

  public async findAll(userId: string, completed?: boolean): Promise<Todo[]> {
    const qb = this.todosRepository.search().where('author').equals(userId);

    if (completed !== null) {
      qb.where('completed').equals(completed);
    }

    return qb.all();
  }

  // ...
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Метод «Найти один»:

@Injectable()
export class TodosService implements OnModuleInit {
  // ...

  public async findOne(userId: string, todoId: string): Promise<Todo> {
    const todo = await this.todosRepository.fetch(todoId);

    if (!todo || todo.author !== userId)
      throw new NotFoundException('Todo not found');

    return todo;
  }

  // ...
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Удалить метод:

@Injectable()
export class TodosService implements OnModuleInit {
  // ...

  public async remove(userId: string, todoId: string): Promise<string> {
    const todo = await this.findOne(userId, todoId);
    await this.todosRepository.remove(todo.entityId);
    return 'Todo removed successfully';
  }

  // ...
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Контроллер Todos

Контроллер todos — это просто базовый контроллер со всеми методами, заданными сгенерированным контроллером, но с «api/todos» в качестве основного маршрута.

import {
  BadRequestException,
  Body,
  Controller,
  Delete,
  Get,
  Param,
  Patch,
  Post,
  Query,
} from '@nestjs/common';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { CreateTodoDto } from './dto/create-todo.dto';
import { UpdateTodoDto } from './dto/update-todo.dto';
import { TodosService } from './todos.service';

@Controller('api/todos')
export class TodosController {
  constructor(private readonly todosService: TodosService) {}

  @Post()
  public async create(
    @CurrentUser() userId: string,
    @Body() dto: CreateTodoDto,
  ) {
    return this.todosService.create(userId, dto);
  }

  @Get()
  public async findAll(
    @CurrentUser() userId: string,
    @Query('completed') completed?: string,
  ) {
    if (completed) {
      completed = completed.toLowerCase();

      if (completed !== 'true' && completed !== 'false')
        throw new BadRequestException('Invalid completed query parameter');
    }

    return this.todosService.findAll(
      userId,
      completed ? completed === 'true' : null,
    );
  }

  @Get(':id')
  public async findOne(@CurrentUser() userId: string, @Param('id') id: string) {
    return this.todosService.findOne(userId, id);
  }

  @Patch(':id')
  public async update(
    @CurrentUser() userId: string,
    @Param('id') id: string,
    @Body() dto: UpdateTodoDto,
  ) {
    return this.todosService.update(userId, id, dto);
  }

  @Delete(':id')
  public async remove(@CurrentUser() userId: string, @Param('id') id: string) {
    return this.todosService.remove(userId, id);
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Собираем все вместе

Чтобы запустить приложение в разработке, нам все еще нужно обновить главный файл, добавить порт из конфигурации и настроить глобальную трубу валидации для class-validator:

import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const configService = app.get(ConfigService);
  app.useGlobalPipes(new ValidationPipe());
  await app.listen(configService.get<number>('port'));
}

bootstrap();
Вход в полноэкранный режим Выход из полноэкранного режима

Полный рабочий проект можно найти здесь.

Динамический модуль

Теперь, если вы используете монорепо nestjs, вам, вероятно, понадобится библиотека redis-client, именно здесь необходимы динамические модули, поскольку вам понадобится клиентская библиотека для всех ваших приложений.

После генерации библиотеки redis-client с помощью nestjs cli:

~ nest g lib redis-client --no-spec
Войдите в полноэкранный режим Выйдите из полноэкранного режима

Начните с создания папки interfaces с тремя файлами (включая index.ts):

Redis Options (redis-options.interface.ts), опции для статического метода register:

export interface IRedisOptions {
  url: string;
}
Вход в полноэкранный режим Выход из полноэкранного режима

Redis Async Options (redis-async-options.interface.ts), параметры для статического метода register async:

import { ModuleMetadata, Type } from '@nestjs/common';
import { IRedisOptions } from './redis-options.interface';

export interface IRedisOptionsFactory {
  createRedisOptions(): Promise<IRedisOptions> | IRedisOptions;
}

export interface IRedisAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
  useFactory?: (...args: any[]) => Promise<IRedisOptions> | IRedisOptions;
  useClass?: Type<IRedisOptionsFactory>;
  inject?: any[];
}
Войти в полноэкранный режим Выйти из полноэкранного режима

На index.ts просто экспортируйте другие типы других файлов:

export type { IRedisOptions } from './redis-options.interface';
export type {
  IRedisOptionsFactory,
  IRedisAsyncOptions,
} from './redis-async-options.interface';
Войти в полноэкранный режим Выйти из полноэкранного режима

Единственная разница между прошлой версией модуля redis-client и этой заключается в том, что теперь у нас есть статическая функция для регистрации клиента.

Поэтому для инъекции опций в динамические модули нам понадобится constants.ts, экспортирующий строковую константу:

export const REDIS_OPTIONS = 'REDIS_OPTIONS';
Войти в полноэкранный режим Выйти из полноэкранного режима

В итоге модуль будет выглядеть примерно так, я не буду подробно останавливаться на логике каждого метода, так как они очень простые и соответствуют рекомендациям сообщества.

import { DynamicModule, Global, Module, Provider } from '@nestjs/common';
import { RedisClientService } from './redis-client.service';
import {
  IRedisAsyncOptions,
  IRedisOptions,
  IRedisOptionsFactory,
} from './interfaces';
import { REDIS_OPTIONS } from './constants';

@Global()
@Module({
  providers: [RedisClientService],
  exports: [RedisClientService],
})
export class RedisClientModule {
  public static forRoot(options: IRedisOptions): DynamicModule {
    return {
      module: RedisOrmModule,
      global: true,
      providers: [
        {
          provide: REDIS_OPTIONS,
          useValue: options,
        },
      ],
    };
  }

  public static forRootAsync(options: IRedisAsyncOptions): DynamicModule {
    return {
      module: RedisOrmModule,
      imports: options.imports,
      providers: this.createAsyncProviders(options),
    };
  }

  private static createAsyncProviders(options: IRedisAsyncOptions): Provider[] {
    const providers: Provider[] = [this.createAsyncOptionsProvider(options)];

    if (options.useClass) {
      providers.push({
        provide: options.useClass,
        useClass: options.useClass,
      });
    }

    return providers;
  }

  private static createAsyncOptionsProvider(
    options: IRedisAsyncOptions,
  ): Provider {
    if (options.useFactory) {
      return {
        provide: REDIS_OPTIONS,
        useFactory: options.useFactory,
        inject: options.inject || [],
      };
    }

    return {
      provide: REDIS_OPTIONS,
      useFactory: async (optionsFactory: IRedisOptionsFactory) =>
        await optionsFactory.createRedisOptions(),
      inject: options.useClass ? [options.useClass] : [],
    };
  }
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Сервис практически идентичен обычному, но url берется из инжектированных опций, а не из сервиса config:

import { Inject, Injectable, OnModuleDestroy } from '@nestjs/common';
import { Client } from 'redis-om';
import { REDIS_OPTIONS } from './constants';
import { IRedisOptions } from './interfaces';

@Injectable()
export class RedisClientService extends Client implements OnModuleDestroy {
  constructor(@Inject(REDIS_OPTIONS) options: IRedisOptions) {
    super();
    (async () => {
      await this.open(options.url);
    })();
  }

  public async onModuleDestroy() {
    await this.close();
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Спасибо, что дочитали мою статью до конца, и удачи на хакатоне Redis.

Оцените статью
devanswers.ru
Добавить комментарий