How to use RedisOM with NestJS

tugascript

Afonso Barracha

Posted on August 24, 2022

How to use RedisOM with NestJS

With the Redis Hackathon, a lot of people will want to use the RedisOM module as their main Object Mapper, but how do you connect it with NestJS, my favourite NodeJS framework.

On this article I'll explain how to connect it by creating a basic Todo app. In the end of the article I'll also explain how to create a Dynamic Module for the RedisOM for the Microservice Mavens out-there.

Set Up

Since this article is about NestJS I'll assume you have the cli installed, if not click here. On your projects folder do the following commands set up a new NestJS project and open it in VSC (or any other code editor).

~ nest n redis-todo -s
~ code ./redis-todo
Enter fullscreen mode Exit fullscreen mode

So we're all use the same node and package manager I recommend using Node 16 and Yarn. To set up yarn 3 with the node-modules folder create a file called .yarnrc.yml and inside write.

nodeLinker: node-modules

After to set up yarn version 3 and interactive tools:

~ yarn set version stable
~ yarn plugin import interactive-tools
Enter fullscreen mode Exit fullscreen mode

For this app after installing and updating all instances we'll need to install RedisOM node and the config module.

~ yarn install
~ yarn upgrade-interactive
~ yarn add redis-om
Enter fullscreen mode Exit fullscreen mode

On the tsconfig.json, at the end add esModuleInterop:

{
  "compilerOptions": {
    // ...
    "esModuleInterop": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Configuration

For configuration we'll use .env and NestJS Config Module, for this we'll install its dependency:

yarn add @nestjs/config joi

Create a folder called config, and a sub-folder inside called interfaces.

On the interfaces folder create a file with the configuration object called config.interface.ts:

export interface IConfig {
  redisUrl: string;
  port: number;
}
Enter fullscreen mode Exit fullscreen mode

On the config folder we'll create a validation schema for our .env file and call it 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(),
});
Enter fullscreen mode Exit fullscreen mode

On the config folder create an index.ts file and add the config function in it.

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';
Enter fullscreen mode Exit fullscreen mode

Optionally as seen above you can export the validation schema from there as well.

Finally just import your config module on the 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 {}

Enter fullscreen mode Exit fullscreen mode

Creating the RedisOM Client

Now generate both the module and service for the redis-om:

~ nest g mo redis-client
~ nest g s redis-client --no-spec
Enter fullscreen mode Exit fullscreen mode

On the redis-client folder open the redis-client service and import the Client class from the redis-om package:

import { Injectable } from '@nestjs/common';
import { Client } from 'redis-om';
Enter fullscreen mode Exit fullscreen mode

To be able to use the redis-om will extend the service with the Client Class:

// ...

@Injectable()
export class RedisClientService extends Client {}
Enter fullscreen mode Exit fullscreen mode

To be able to connect to the client we need to open it when the module initializes, and close the client when you destroy it.

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

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

  public async onModuleDestroy() {
    //...
  }
}
Enter fullscreen mode Exit fullscreen mode

To be able to connect to the server we need to use the open method on the constructor with the redis url which we can get from the config service, and for closing we use the close method the onModuleDestroy method. So putting it all together:

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

Finally on the redis-client.module export the service and make the module global:

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

@Global()
@Module({
  providers: [RedisClientService],
  exports: [RedisClientService],
})
export class RedisClientModule {}
Enter fullscreen mode Exit fullscreen mode

This is all the set-up necessery to use the redis-om node with NestJS, the rest of the article is a tutorial on how to use them within a REST API. If you skip to the end I added the code for the dynamic module library for the ones using NestJS micro-services.

TODO APP EXAMPLE

A todo app with a basic JWT authentication system using redis as its main database.

Basic Authentication System

Configuration Update:

Start by adding creating a jwt.interface.ts file on your config interfaces folder:

export interface IJwt {
  time: number;
  secret: string;
}
Enter fullscreen mode Exit fullscreen mode

On you config interface add the jwt:

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

export interface IConfig {
  redisUrl: string;
  port: number;
  jwt: IJwt;
}
Enter fullscreen mode Exit fullscreen mode

Finally change both the validation schema and the config function to have the jwt:

Validation schema:

// ...
export const validationSchema = Joi.object({
  // ...
  ACCESS_SECRET: Joi.string().required(),
  ACCESS_TIME: Joi.number().required(),
});
Enter fullscreen mode Exit fullscreen mode

Config function:

// ...
export function config(): IConfig {
  return {
    // ...
    jwt: {
      secret: process.env.ACCESS_SECRET,
      time: parseInt(process.env.ACCESS_TIME, 10),
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

The Auth Service:

Start by using the cli to generate a REST Api resource for auth:

~ nest g res auth
Enter fullscreen mode Exit fullscreen mode

When using this command don't create the CRUD end-points.

On the auth folder create a sub-folder called entities which will have the user.entity.ts. The User entity will be a basic redis entity with a schema as seen in the redis-om-node readme:

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' },
});
Enter fullscreen mode Exit fullscreen mode

On the auth service need to inject the redis-client and the config-server as dependencies, and since we made redis-client and config global we don't need to touch the module for now:

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,
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

After that we need to set up the user repository and start its index so we can use redis search (NOTE: This could go to the constructor inside an arrow function but I prefer adding the OnModuleInit), as well as the JWT time and secret:

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();
  }
}

Enter fullscreen mode Exit fullscreen mode

We could use the Nestjs Jwt module but I find that creating the async jwt methods on the auth service is easier, so start by installing the jsonwebtoken and bcrypt packages:

~ yarn add jsonwebtoken bcrypt
~ yarn add -D @types/jsonwebtoken @types/bcrypt
Enter fullscreen mode Exit fullscreen mode

Create a sub-directory called interfaces and add the access-id.interface.ts:

export interface IAccessId {
  id: string;
}

export interface IAccessIdResponse extends IAccessId {
  iat: number;
  exp: number;
}
Enter fullscreen mode Exit fullscreen mode

Then just add a private method for generation:

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 Exit fullscreen mode

Now we can start creating the dtos for the register, login and delete routes, so we need to install the class-validator:

~ yarn add class-validator class-transformer
Enter fullscreen mode Exit fullscreen mode

Create the following files on a dto sub-folder:

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;
}
Enter fullscreen mode Exit fullscreen mode

Password dto (password.dto.ts):

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

export abstract class PasswordDto {
  @IsString()
  @Length(1, 40)
  public password: string;
}
Enter fullscreen mode Exit fullscreen mode

Login dto (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;
}
Enter fullscreen mode Exit fullscreen mode

On the service we need to create a public method for each route.

Register method:

// ...
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(/\s\s+/g, ' ')
        .replace(/\w\S*/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
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Login method:

// ...
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
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Find by ID method:

// ...
@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;
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Remove method:

// ...
@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';
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Authorization Logic:

To be able to deal with authorization we need to create an authentication strategy, for that we're going to use passport and passport-jwt so we need to install them:

~ yarn add @nestjs/passport passport passport-jwt
~ yarn add -D @types/passport-jwt
Enter fullscreen mode Exit fullscreen mode

And then create the strategy, on the auth folder create a file called jwt.strategy.ts based on the docs:

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

To protect our routes we need a guard, as well as a current user decorator to get the user ID, so we need to create both of them.

To generate a guard use the following commands:

~ cd src/auth
~ nest g gu jwt-auth --no-spec
~ cd ../..
Enter fullscreen mode Exit fullscreen mode

On the JwtAuthGuard extend it with the 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

For the guard to be global and to get the user ID, we need to create the decorators, so start by creating a decorators sub-folder with two files: public.decorator.ts and current-user.decorator.ts.

Public Decorator (sets meta data for public routes):

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

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
Enter fullscreen mode Exit fullscreen mode

Current user (gets the current user id):

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

export const CurrentUser = createParamDecorator(
  (_, context: ExecutionContext): string | undefined => {
    return context.switchToHttp().getRequest().user;
  },
);
Enter fullscreen mode Exit fullscreen mode

We update both the auth module and app module for the new changes in our application.

In the auth module we need to import the PassportModule and add our jwt strategy as a provider:

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

@Module({
  imports: [PassportModule.register({ defaultStrategy: 'jwt' })],
  controllers: [AuthController],
  providers: [AuthService, JwtStrategy],
})
export class AuthModule {}
Enter fullscreen mode Exit fullscreen mode

While in the app module we need to add the 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 {}
Enter fullscreen mode Exit fullscreen mode

Finally to be able to use the public decorator properly we need to change the guard a little as seen in the docs:

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

The Controller:

For the controller we just need to add the routes, but before that its good practice to create interfaces with the return value, so create the following interfaces on the interfaces sub-folder:

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

export interface IAccessToken {
  token: string;
}

export interface IAccessIdResponse extends IAccessId {
  iat: number;
  exp: number;
}
Enter fullscreen mode Exit fullscreen mode

Message (message.interface.ts):

export interface IMessage {
  message: string;
}
Enter fullscreen mode Exit fullscreen mode

User Response (user-response.interface.ts):

export interface IUserResponse {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}
Enter fullscreen mode Exit fullscreen mode

Now you can add all routes:

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,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Todos CRUD

The Todos Service

Again, start by using the cli to generate a REST Api resource for todos:

~ nest g res todos
Enter fullscreen mode Exit fullscreen mode

But, when using this command create the CRUD enpoints.

This command will create a folder for dtos and entities, so on the on entity folder build the 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' },
});
Enter fullscreen mode Exit fullscreen mode

On the dtos modify both dtos as following:

Create Todo Dto (create-todo.dto.ts):

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

export class CreateTodoDto {
  @IsString()
  @Length(1, 300)
  public body: string;
}
Enter fullscreen mode Exit fullscreen mode

Update 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;
}
Enter fullscreen mode Exit fullscreen mode

On the service we'll have the initial set up, and now we just need to inject the redis client and update all function.

First import the redis client:

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

And then start modifying the generated methods.

Create method:

// ...
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;
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Update method:

// ...
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;
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Find All method (completed is a query paramenter):

@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();
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Find One method:

@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;
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Remove method:

@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';
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

The Todos Controller

The todos controller is just a basic controller with all the methods given by the generated controller but with "api/todos" as the main route.

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

Puting it all together

To be able to run the app in development we still need to update the main file, to add both the port from the configuration and set up a global validation pipe for 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();
Enter fullscreen mode Exit fullscreen mode

The full working project can be found here.

Dynamic Module

Now if you use a nestjs monorepo, you'll probably need a redis-client library, this is where dynamic modules are necessary, as you'll need a client library for all your apps.

After generating the redis-client library with the nestjs cli:

~ nest g lib redis-client --no-spec
Enter fullscreen mode Exit fullscreen mode

Start by creating a interfaces folder with three files (including the index.ts):

Redis Options (redis-options.interface.ts), the options for the register static method:

export interface IRedisOptions {
  url: string;
}
Enter fullscreen mode Exit fullscreen mode

Redis Async Options (redis-async-options.interface.ts), the options for the register async static method:

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[];
}
Enter fullscreen mode Exit fullscreen mode

On the index.ts just export the other types of the other files:

export type { IRedisOptions } from './redis-options.interface';
export type {
  IRedisOptionsFactory,
  IRedisAsyncOptions,
} from './redis-async-options.interface';
Enter fullscreen mode Exit fullscreen mode

The only difference between the past version of the redis-client module and this one is that now we have static function to register the client on.

So to inject options on dynamic modules we need a constants.ts that exports a string constant:

export const REDIS_OPTIONS = 'REDIS_OPTIONS';
Enter fullscreen mode Exit fullscreen mode

In the end the module will look something like this, I won't touch on the logic of each method that much as they're very basic and follow the community guidelines.

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] : [],
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

The service is pretty identical to the normal one but url comes from the injected options and not the config service:

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

Thanks for reading my article to the end, and good luck on the redis hackathon.

💖 💪 🙅 🚩
tugascript
Afonso Barracha

Posted on August 24, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

How to use RedisOM with NestJS
redis How to use RedisOM with NestJS

August 24, 2022