El conocimiento es el nuevo dinero.
Aprender es la nueva manera en la que inviertes
Acceso Cursos

NestJS: Microservicios con gRPC, API Gateway y autenticación - Parte 2/2

Segunda parte de la aplicación NestJS con gRPC, API Gateway, autenticación y validación

· 18 min de lectura
NestJS: Microservicios con gRPC, API Gateway y autenticación - Parte 2/2

⚠️ Atención: Es importante que tengas presente que debido a la amplitud del tema este post suele ser más largo de los que comúnmente estás acostumbrado a leer en este espacio. Sin embargo; te recomiendo que te pongas cómodo  y disfrutes paso a paso de este tutorial que está bastante completo.

Servicio de autenticación (grpc-nest-auth-svc)


Después de mucha preparación codificando el API Gateway, configurando las bases de datos, y proporcionando nuestros archivos proto como un repositorio compartido, finalmente comenzamos con nuestro primer microservicio.

En primer lugar, tenemos que abrir el proyecto en nuestro editor de código, así que vuelve a entrar en tu terminal al directorio, donde almacenamos nuestros proyectos.

$ cd grpc-nest-auth-svc
$ code .

🕵🏽 Ya viste el nuevo curso de NestJS

via GIPHY

Instalación de dependencias


Vamos a instalar algunas dependencias que vamos a necesitar.

$ npm i @nestjs/microservices @nestjs/typeorm @nestjs/jwt @nestjs/passport passport passport-jwt typeorm pg class-transformer class-validator bcryptjs
$ npm i -D @types/node @types/passport-jwt ts-proto

Estructura del proyecto


Vamos a continuar con la creación de la estructura final de carpetas y archivos. Para simplificar, sólo vamos para un solo módulo aquí, que va a ser llamado auth .

$ nest g mo auth && nest g co auth --no-spec
$ mkdir src/auth/filter && mkdir src/auth/service && mkdir src/auth/strategy
$ touch src/auth/filter/http-exception.filter.ts
$ touch src/auth/service/auth.service.ts
$ touch src/auth/service/jwt.service.ts
$ touch src/auth/strategy/jwt.strategy.ts
$ touch src/auth/auth.dto.ts
$ touch src/auth/auth.entity.ts

Añadir scripts


Además, necesitamos añadir algunos scripts a nuestro package.json para generar nuestros archivos protobuf basados en el proyecto proto compartido que acabamos de completar. Similar a lo que se hizo en el API Gateway.

Simplemente agreguemos estas 5 líneas de código dentro del scripts propiedad de nuestro archivo package.json

Sustituye YOUR_USERNAME por tu nombre de usuario en Github.

"proto:install": "npm i git+https://github.com/YOUR_USERNAME/grpc-nest-proto.git",
"proto:auth": "protoc --plugin=node_modules/.bin/protoc-gen-ts_proto -I=./node_modules/grpc-nest-proto/proto --ts_proto_out=src/auth/ node_modules/grpc-nest-proto/proto/auth.proto --ts_proto_opt=nestJs=true --ts_proto_opt=fileSuffix=.pb"

¡Vamos a ejecutar estas secuencias de comandos!

$ npm run proto:install && npm run proto:auth

Así que después de estos pasos, nuestro proyecto debería tener este aspecto:

Ahora vamos a empezar a codificar.

Filtro de excepciones HTTP


Dado que vamos a utilizar archivos Data-Transfer-Object para nuestra validación de la carga útil, necesitamos atrapar excepciones HTTP porque el paquete class-validator, que vamos a utilizar, lanza excepciones HTTP en caso de que la carga útil de una petición no sea válida.

Como no queremos lanzar excepciones HTTP desde nuestro servidor gRPC, capturamos estas excepciones y las convertimos en una respuesta gRPC normal.

Añadamos algo de código a src/auth/filter/http-exception.filter.ts

import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import { HttpArgumentsHost } from '@nestjs/common/interfaces';
import { Request, Response } from 'express';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx: HttpArgumentsHost = host.switchToHttp();
    const res: Response = ctx.getResponse<Response>();
    const req: Request = ctx.getRequest<Request>();
    const status: HttpStatus = exception.getStatus();

    if (status === HttpStatus.BAD_REQUEST) {
      const res: any = exception.getResponse();

      return { status, error: res.message };
    }

    res.status(status).json({
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: req.url,
    });
  }
}

Auth DTO / Validation

Vamos a darle código a src/auth/auth.dto.ts

import { IsEmail, IsString, MinLength } from 'class-validator';
import { LoginRequest, RegisterRequest, ValidateRequest } from './auth.pb';

export class LoginRequestDto implements LoginRequest {
  @IsEmail()
  public readonly email: string;

  @IsString()
  public readonly password: string;
}

export class RegisterRequestDto implements RegisterRequest {
  @IsEmail()
  public readonly email: string;

  @IsString()
  @MinLength(8)
  public readonly password: string;
}

export class ValidateRequestDto implements ValidateRequest {
  @IsString()
  public readonly token: string;
}

Entidad de autorización (Auth Entity)

Metamosle código a src/auth/auth.entity.ts

import { Exclude } from 'class-transformer';
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

@Entity()
export class Auth extends BaseEntity {
  @PrimaryGeneratedColumn()
  public id!: number;

  @Column({ type: 'varchar' })
  public email!: string;

  @Exclude()
  @Column({ type: 'varchar' })
  public password!: string;
}

Servicio JWT

Vamos añadir código a src/auth/service/jwt.service.ts

import { Injectable } from '@nestjs/common';
import { JwtService as Jwt } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Auth } from '../auth.entity';
import * as bcrypt from 'bcryptjs';

@Injectable()
export class JwtService {
  @InjectRepository(Auth)
  private readonly repository: Repository<Auth>;

  private readonly jwt: Jwt;

  constructor(jwt: Jwt) {
    this.jwt = jwt;
  }

  // Decoding the JWT Token
  public async decode(token: string): Promise<unknown> {
    return this.jwt.decode(token, null);
  }

  // Get User by User ID we get from decode()
  public async validateUser(decoded: any): Promise<Auth> {
    return this.repository.findOne(decoded.id);
  }

  // Generate JWT Token
  public generateToken(auth: Auth): string {
    return this.jwt.sign({ id: auth.id, email: auth.email });
  }

  // Validate User's password
  public isPasswordValid(password: string, userPassword: string): boolean {
    return bcrypt.compareSync(password, userPassword);
  }

  // Encode User's password
  public encodePassword(password: string): string {
    const salt: string = bcrypt.genSaltSync(10);

    return bcrypt.hashSync(password, salt);
  }

  // Validate JWT Token, throw forbidden error if JWT Token is invalid
  public async verify(token: string): Promise<any> {
    try {
      return this.jwt.verify(token);
    } catch (err) {}
  }
}

Estrategia JWT

Vamos al código src/auth/strategy/jwt.strategy.ts

import { Injectable, Inject } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Auth } from '../auth.entity';
import { JwtService } from '../service/jwt.service';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  @Inject(JwtService)
  private readonly jwtService: JwtService;

  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: 'dev',
      ignoreExpiration: true,
    });
  }

  private validate(token: string): Promise<Auth | never> {
    return this.jwtService.validateUser(token);
  }
}

Servicio de autorización

Vamos agregar código en src/auth/service/auth.service.ts

import { HttpStatus, Inject, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { JwtService } from './jwt.service';
import { RegisterRequestDto, LoginRequestDto, ValidateRequestDto } from '../auth.dto';
import { Auth } from '../auth.entity';
import { LoginResponse, RegisterResponse, ValidateResponse } from '../auth.pb';

@Injectable()
export class AuthService {
  @InjectRepository(Auth)
  private readonly repository: Repository<Auth>;

  @Inject(JwtService)
  private readonly jwtService: JwtService;

  public async register({ email, password }: RegisterRequestDto): Promise<RegisterResponse> {
    let auth: Auth = await this.repository.findOne({ where: { email } });

    if (auth) {
      return { status: HttpStatus.CONFLICT, error: ['E-Mail already exists'] };
    }

    auth = new Auth();

    auth.email = email;
    auth.password = this.jwtService.encodePassword(password);

    await this.repository.save(auth);

    return { status: HttpStatus.CREATED, error: null };
  }

  public async login({ email, password }: LoginRequestDto): Promise<LoginResponse> {
    const auth: Auth = await this.repository.findOne({ where: { email } });

    if (!auth) {
      return { status: HttpStatus.NOT_FOUND, error: ['E-Mail not found'], token: null };
    }

    const isPasswordValid: boolean = this.jwtService.isPasswordValid(password, auth.password);

    if (!isPasswordValid) {
      return { status: HttpStatus.NOT_FOUND, error: ['Password wrong'], token: null };
    }

    const token: string = this.jwtService.generateToken(auth);

    return { token, status: HttpStatus.OK, error: null };
  }

  public async validate({ token }: ValidateRequestDto): Promise<ValidateResponse> {
    const decoded: Auth = await this.jwtService.verify(token);

    if (!decoded) {
      return { status: HttpStatus.FORBIDDEN, error: ['Token is invalid'], userId: null };
    }

    const auth: Auth = await this.jwtService.validateUser(decoded);

    if (!auth) {
      return { status: HttpStatus.CONFLICT, error: ['User not found'], userId: null };
    }

    return { status: HttpStatus.OK, error: null, userId: decoded.id };
  }
}

Controlador de autenticación

Cambiemos src/auth/auth.controller.ts de

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

@Controller('auth')
export class AuthController {}

a

import { Controller, Inject } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';
import { LoginRequestDto, RegisterRequestDto, ValidateRequestDto } from './auth.dto';
import { AUTH_SERVICE_NAME, RegisterResponse, LoginResponse, ValidateResponse } from './auth.pb';
import { AuthService } from './service/auth.service';

@Controller()
export class AuthController {
  @Inject(AuthService)
  private readonly service: AuthService;

  @GrpcMethod(AUTH_SERVICE_NAME, 'Register')
  private register(payload: RegisterRequestDto): Promise<RegisterResponse> {
    return this.service.register(payload);
  }

  @GrpcMethod(AUTH_SERVICE_NAME, 'Login')
  private login(payload: LoginRequestDto): Promise<LoginResponse> {
    return this.service.login(payload);
  }

  @GrpcMethod(AUTH_SERVICE_NAME, 'Validate')
  private validate(payload: ValidateRequestDto): Promise<ValidateResponse> {
    return this.service.validate(payload);
  }
}

Módulo Auth

Cambiemos src/auth/auth.module.ts de

import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';

@Module({
  controllers: [AuthController]
})
export class AuthModule {}
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller';
import { Auth } from './auth.entity';
import { AuthService } from './service/auth.service';
import { JwtService } from './service/jwt.service';
import { JwtStrategy } from './strategy/jwt.strategy';

@Module({
  imports: [
    JwtModule.register({
      secret: 'dev',
      signOptions: { expiresIn: '365d' },
    }),
    TypeOrmModule.forFeature([Auth]),
  ],
  controllers: [AuthController],
  providers: [AuthService, JwtService, JwtStrategy],
})
export class AuthModule {}

Módulo de la aplicación

Cambiemos src/app.module.ts de

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module';

@Module({
  imports: [AuthModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: 'localhost',
      port: 5432,
      database: 'micro_auth',
      username: 'kevin',
      password: null,
      entities: ['dist/**/*.entity.{ts,js}'],
      synchronize: true, // never true in production!
    }),
    AuthModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

Bootstrap

Cambiemos main.ts de

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();
import { INestMicroservice, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { Transport } from '@nestjs/microservices';
import { join } from 'path';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './auth/filter/http-exception.filter';
import { protobufPackage } from './auth/auth.pb';

async function bootstrap() {
  const app: INestMicroservice = await NestFactory.createMicroservice(AppModule, {
    transport: Transport.GRPC,
    options: {
      url: '0.0.0.0:50051',
      package: protobufPackage,
      protoPath: join('node_modules/grpc-nest-proto/proto/auth.proto'),
    },
  });

  app.useGlobalFilters(new HttpExceptionFilter());
  app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));

  await app.listen();
}

bootstrap();

Ejecutar nuestro microservicio

$ npm run start:dev

Muy bien. Todo funciona como se esperaba. Ahora hemos terminado con nuestro Microservicio de Autenticación, sólo quedan 2 microservicios. Como el Microservicio de Pedidos depende del Microservicio de Productos, vamos a hacer el Microservicio de Productos a continuación.

Ambos microservicios serán mucho más fáciles de desarrollar, al igual que sus puntos finales en el API Gateway, ya que sólo necesitan manejar los datos entrantes.

Una vez realizado este comando, abrimos el proyecto grp-nest-product-svc en nuestro editor de código.

$ cd grpc-nest-product-svc
$ code .

Instalación de dependencias


Vamos a instalar algunas dependencias que vamos a necesitar.

$ npm i @nestjs/microservices @grpc/grpc-js @grpc/proto-loader @nestjs/typeorm typeorm pg class-transformer class-validator
$ npm i -D @types/node ts-proto

Estructura del proyecto


Como es habitual en mis guías, vamos a continuar con la creación de la estructura final de carpetas y archivos. Para simplificar, sólo vamos para un solo módulo aquí, que va a ser llamado product.

$ nest g mo product && nest g co product --no-spec && nest g s product --no-spec
$ mkdir src/product/entity
$ touch src/product/product.dto.ts
$ touch src/product/entity/product.entity.ts
$ touch src/product/entity/stock-decrease-log.entity.ts

Añadir scripts


Además, necesitamos añadir algunos scripts a nuestro package.json para generar nuestros archivos protobuf basados en el proyecto proto compartido que acabamos de completar. Similar a lo que hicimos en el API Gateway.

Simplemente añadamos estas 2 líneas de código dentro del scripts propiedad de nuestro archivo package.json

Sustituye YOUR_USERNAME por tu nombre de usuario en Github.

¡Vamos a ejecutar estas secuencias de comandos!

$ npm run proto:install && npm run proto:product

Así que después de estos pasos, nuestro proyecto debería tener este aspecto:

Ahora, es el momento de codificar.

Entidad de producto

En primer lugar, creamos nuestra entidad Producto.

Añadamos algo de código a src/product/entity/product.entity.ts

import { BaseEntity, Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { StockDecreaseLog } from './stock-decrease-log.entity';

@Entity()
export class Product extends BaseEntity {
  @PrimaryGeneratedColumn()
  public id!: number;

  @Column({ type: 'varchar' })
  public name!: string;

  @Column({ type: 'varchar' })
  public sku!: string;

  @Column({ type: 'integer' })
  public stock!: number;

  @Column({ type: 'decimal', precision: 12, scale: 2 })
  public price!: number;

  /*
   * One-To-Many Relationships
   */

  @OneToMany(() => StockDecreaseLog, (stockDecreaseLog) => stockDecreaseLog.product)
  public stockDecreaseLogs: StockDecreaseLog[];
}

Entidad StockDecreaseLog


Además, tenemos que crear nuestra entidad StockDecreaseLog. Aquí, guardamos cada acción de disminución vinculada a un ID de pedido y un ID de producto. Esto es porque queremos prevenir cualquier disminución múltiple por una sola creación de orden causada por errores impredecibles.

Añadamos algo de código a src/product/entity/stock-decrease-log.entity.ts

import { BaseEntity, Column, Entity, PrimaryGeneratedColumn, ManyToOne } from 'typeorm';
import { Product } from './product.entity';

@Entity()
export class StockDecreaseLog extends BaseEntity {
  @PrimaryGeneratedColumn()
  public id!: number;

  /*
   * Relation IDs
   */

  @Column({ type: 'integer' })
  public orderId!: number;

  /*
   * Many-To-One Relationships
   */

  @ManyToOne(() => Product, (product) => product.stockDecreaseLogs)
  public product: Product;
}

Producto DTO / Validación

Para la validación, creamos 3 DTOs, implementando la interfaz de cada endpoint.

Añadamos algo de código a src/product/product.dto.ts

import { IsNotEmpty, IsNumber, IsString } from 'class-validator';
import { CreateProductRequest, DecreaseStockRequest, FindOneRequest } from './product.pb';

export class FindOneRequestDto implements FindOneRequest {
  @IsNumber({ allowInfinity: false, allowNaN: false })
  public readonly id: number;
}

export class CreateProductRequestDto implements CreateProductRequest {
  @IsString()
  @IsNotEmpty()
  public readonly name: string;

  @IsString()
  @IsNotEmpty()
  public readonly sku: string;

  @IsNumber({ allowInfinity: false, allowNaN: false })
  public readonly stock: number;

  @IsNumber({ allowInfinity: false, allowNaN: false })
  public readonly price: number;
}

export class DecreaseStockRequestDto implements DecreaseStockRequest {
  @IsNumber({ allowInfinity: false, allowNaN: false })
  public readonly id: number;

  @IsNumber({ allowInfinity: false, allowNaN: false })
  public readonly orderId: number;
}

Servicio del producto


Como tenemos 3 puntos finales, necesitamos servir 3 servicios para cada punto final. Entenderás rápidamente lo que ocurre ya que sus nombres son bastante autodescriptivos.

Cambiemos src/product/product.service.ts de

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

@Injectable()
export class ProductService {}
import { HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Product } from './entity/product.entity';
import { CreateProductRequestDto, DecreaseStockRequestDto, FindOneRequestDto } from './product.dto';
import { CreateProductResponse, DecreaseStockResponse, FindOneResponse } from './product.pb';
import { StockDecreaseLog } from './entity/stock-decrease-log.entity';

@Injectable()
export class ProductService {
  @InjectRepository(Product)
  private readonly repository: Repository<Product>;

  @InjectRepository(StockDecreaseLog)
  private readonly decreaseLogRepository: Repository<StockDecreaseLog>;

  public async findOne({ id }: FindOneRequestDto): Promise<FindOneResponse> {
    const product: Product = await this.repository.findOne({ where: { id } });

    if (!product) {
      return { data: null, error: ['Product not found'], status: HttpStatus.NOT_FOUND };
    }

    return { data: product, error: null, status: HttpStatus.OK };
  }

  public async createProduct(payload: CreateProductRequestDto): Promise<CreateProductResponse> {
    const product: Product = new Product();

    product.name = payload.name;
    product.sku = payload.sku;
    product.stock = payload.stock;
    product.price = payload.price;

    await this.repository.save(product);

    return { id: product.id, error: null, status: HttpStatus.OK };
  }

  public async decreaseStock({ id, orderId }: DecreaseStockRequestDto): Promise<DecreaseStockResponse> {
    const product: Product = await this.repository.findOne({ select: ['id', 'stock'], where: { id } });

    if (!product) {
      return { error: ['Product not found'], status: HttpStatus.NOT_FOUND };
    } else if (product.stock <= 0) {
      return { error: ['Stock too low'], status: HttpStatus.CONFLICT };
    }

    const isAlreadyDecreased: number = await this.decreaseLogRepository.count({ where: { orderId } });

    if (isAlreadyDecreased) {
      // Idempotence
      return { error: ['Stock already decreased'], status: HttpStatus.CONFLICT };
    }

    await this.repository.update(product.id, { stock: product.stock - 1 });
    await this.decreaseLogRepository.insert({ product, orderId });

    return { error: null, status: HttpStatus.OK };
  }
}

En la línea 49 vamos a comprobar si el producto ya fue disminuido una vez por una orden específica. Esto es por razones de idempotencia.

Imagínate que llamamos a decreaseStock dos veces para el mismo pedido debido a algún error, entonces tenemos el stock del producto equivocado. Vamos a prevenir esto, vinculando el ID de la orden a este método. Así que incluso si se llama a este método por accidente dos veces, el stock será correcto.

¿Qué es la idempotencia?

La idempotencia es una propiedad que garantiza que las llamadas repetidas a la misma operación no causarán ningún cambio en el estado del servicio ni provocarán efectos secundarios adicionales.

Controlador del producto


Además, cada punto final necesita un método dentro de nuestro controlador.

Cambiemos src/product/product.controller.ts de

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

@Controller('product')
export class ProductController {}
import { HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Product } from './product.entity';
import { CreateProductRequestDto, DecreaseStockRequestDto, FindOneRequestDto } from './product.dto';
import { CreateProductResponse, DecreaseStockResponse, FindOneResponse } from './product.pb';

@Injectable()
export class ProductService {
  @InjectRepository(Product)
  private readonly repository: Repository<Product>;

  public async findOne({ id }: FindOneRequestDto): Promise<FindOneResponse> {
    const product: Product = await this.repository.findOne({ where: { id } });

    if (!product) {
      return { data: null, error: ['Product not found'], status: HttpStatus.NOT_FOUND };
    }
    return { data: product, error: null, status: HttpStatus.OK };
  }

  public async createProduct(payload: CreateProductRequestDto): Promise<CreateProductResponse> {
    const product: Product = new Product();

    product.name = payload.name;
    product.sku = payload.sku;
    product.stock = payload.stock;
    product.price = payload.price;

    await this.repository.save(product);

    return { id: product.id, error: null, status: HttpStatus.OK };
  }

  public async decreaseStock({ id }: DecreaseStockRequestDto): Promise<DecreaseStockResponse> {
    const product: Product = await this.repository.findOne({ where: { id } });

    if (!product) {
      return { error: ['Product not found'], status: HttpStatus.NOT_FOUND };
    } else if (product.stock <= 0) {
      return { error: ['Stock too low'], status: HttpStatus.CONFLICT };
    }

    this.repository.update(product.id, { stock: product.stock - 1 });

    return { error: null, status: HttpStatus.OK };
  }
}

Módulo de Producto


Ahora, simplemente tenemos que añadir la Entidad Producto a nuestro módulo ya que vamos a utilizarla dentro del Servicio Producto.

Cambiemos src/product/product.module.tsde

import { Module } from '@nestjs/common';
import { ProductController } from './product.controller';
import { ProductService } from './product.service';

@Module({
  controllers: [ProductController],
  providers: [ProductService],
})
export class ProductModule {}
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ProductController } from './product.controller';
import { ProductService } from './product.service';
import { Product } from './entity/product.entity';
import { StockDecreaseLog } from './entity/stock-decrease-log.entity';

@Module({
  imports: [TypeOrmModule.forFeature([Product, StockDecreaseLog])],
  controllers: [ProductController],
  providers: [ProductService],
})
export class ProductModule {}

Módulo de la aplicación


En este microservicio, también necesitamos una conexión a la base de datos.

Cambiemos src/app.module.ts de

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

@Module({
  controllers: [AppController],
  providers: [AppService],
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ProductModule } from './product/product.module';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: 'localhost',
      port: 5432,
      database: 'micro_product',
      username: 'kevin',
      password: null,
      entities: ['dist/**/*.entity.{ts,js}'],
      synchronize: true, // never true in production!
    }),
    ProductModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

Bootstrap


Por último, pero no menos importante, necesitamos registrar nuestro microservicio en el puerto 50053.

Cambiemos main.tsde

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();
import { INestMicroservice, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { Transport } from '@nestjs/microservices';
import { join } from 'path';
import { AppModule } from './app.module';
import { protobufPackage } from './product/product.pb';

async function bootstrap() {
  const app: INestMicroservice = await NestFactory.createMicroservice(AppModule, {
    transport: Transport.GRPC,
    options: {
      url: '0.0.0.0:50053',
      package: protobufPackage,
      protoPath: join('node_modules/grpc-nest-proto/proto/product.proto'),
    },
  });

  app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));

  await app.listen();
}

bootstrap();

Ahora podemos ejecutar nuestro Microservicio de Producto

$ npm run start:dev

¡Enhorabuena! Nuestro Microservicio de Producto ha sido completado.

Servicio de pedidos (grpc-nest-order-svc)

Por fin hemos llegado al último microservicio. Vamos a mantenerlo lo más simple posible, así que quédate aquí para la última ronda. De nuevo, en tu terminal, vuelve al directorio donde almacenamos todos nuestros proyectos para esta guía, entonces:

$ cd grpc-nest-order-svc
$ code .

Instalación de dependencias


Vamos a instalar algunas dependencias que vamos a necesitar.

$ npm i @nestjs/microservices @grpc/grpc-js @grpc/proto-loader @nestjs/typeorm typeorm pg class-transformer class-validator
$ npm i -D @types/node ts-proto

Estructura del proyecto


Vamos a crear una estructura de proyecto ajustada.

$ nest g mo order && nest g co order --no-spec && nest g s order --no-spec
$ mkdir src/order/proto
$ touch src/order/order.dto.ts
$ touch src/order/order.entity.ts

Añadir scripts


Además, necesitamos añadir algunos scripts a nuestro package.json para generar nuestros archivos protobuf basados en el proyecto proto compartido que acabamos de completar. Similar a lo que hicimos en el API Gateway.

Simplemente agreguemos estas 4 líneas de código dentro del scripts propiedad de nuestro archivo package.jsonç

Sustituye YOUR_USERNAME por tu nombre de usuario en Github.

"proto:install": "npm i git+https://github.com/YOUR_USERNAME/grpc-nest-proto.git",
"proto:order": "protoc --plugin=node_modules/.bin/protoc-gen-ts_proto -I=./node_modules/grpc-nest-proto/proto --ts_proto_out=src/order/proto/ node_modules/grpc-nest-proto/proto/order.proto --ts_proto_opt=nestJs=true --ts_proto_opt=fileSuffix=.pb",
"proto:product": "protoc --plugin=node_modules/.bin/protoc-gen-ts_proto -I=./node_modules/grpc-nest-proto/proto --ts_proto_out=src/order/proto/ node_modules/grpc-nest-proto/proto/product.proto --ts_proto_opt=nestJs=true --ts_proto_opt=fileSuffix=.pb",
"proto:all": "npm run proto:order && npm run proto:product"

¡Vamos a ejecutar estas secuencias de comandos!

$ npm run proto:install && npm run proto:all

Así que después de estos pasos, nuestro proyecto debería tener este aspecto:

Ordenar la entidad


Como siempre, empezamos con nuestra entidad. Recuerda que, por razones de simplicidad, cada pedido sólo puede almacenar 1 producto. El precio será el precio del producto en el momento del pedido.

Añadamos algo de código a rc/order/order.entity.ts

import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

@Entity()
export class Order extends BaseEntity {
  @PrimaryGeneratedColumn()
  public id!: number;

  @Column({ type: 'decimal', precision: 12, scale: 2 })
  public price!: number;

  /*
   * Relation IDs
   */

  @Column({ type: 'integer' })
  public productId!: number;

  @Column({ type: 'integer' })
  public userId!: number;
}

Orden DTO / Validación


Para la validación, creamos una clase que implementa la interfaz de nuestro archivo protobuf generado. La cantidad debe ser un mínimo de 1, supongo que es bastante auto-explicado.

Añadamos algo de código a src/order/order.dto.ts

import { IsNumber, Min } from 'class-validator';
import { CreateOrderRequest } from './proto/order.pb';

export class CreateOrderRequestDto implements CreateOrderRequest {
  @IsNumber()
  public productId: number;

  @IsNumber()
  @Min(1)
  public quantity: number;

  @IsNumber()
  public userId: number;
}

Servicio de pedidos


El Servicio de Pedidos es un poco diferente porque, por primera vez, llamamos a un microservicio desde otro microservicio. Vamos a llamar al Microservicio de Producto dos veces. Primero, vamos a comprobar si el producto existe, y luego, vamos a disminuir el stock de este producto para crear un pedido.

Cambiemos src/order/order.service.ts de

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

@Injectable()
export class OrderService {}
import { HttpStatus, Inject, Injectable, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ClientGrpc } from '@nestjs/microservices';
import { Repository } from 'typeorm';
import { firstValueFrom } from 'rxjs';
import { Order } from './order.entity';
import { FindOneResponse, DecreaseStockResponse, ProductServiceClient, PRODUCT_SERVICE_NAME } from './proto/product.pb';
import { CreateOrderRequest, CreateOrderResponse } from './proto/order.pb';

@Injectable()
export class OrderService implements OnModuleInit {
  private productSvc: ProductServiceClient;

  @Inject(PRODUCT_SERVICE_NAME)
  private readonly client: ClientGrpc;

  @InjectRepository(Order)
  private readonly repository: Repository<Order>;

  public onModuleInit(): void {
    this.productSvc = this.client.getService<ProductServiceClient>(PRODUCT_SERVICE_NAME);
  }

  public async createOrder(data: CreateOrderRequest): Promise<CreateOrderResponse> {
    const product: FindOneResponse = await firstValueFrom(this.productSvc.findOne({ id: data.productId }));

    if (product.status >= HttpStatus.NOT_FOUND) {
      return { id: null, error: ['Product not found'], status: product.status };
    } else if (product.data.stock < data.quantity) {
      return { id: null, error: ['Stock too less'], status: HttpStatus.CONFLICT };
    }

    const order: Order = new Order();

    order.price = product.data.price;
    order.productId = product.data.id;
    order.userId = data.userId;

    await this.repository.save(order);

    const decreasedStockData: DecreaseStockResponse = await firstValueFrom(
      this.productSvc.decreaseStock({ id: data.productId, orderId: order.id }),
    );

    if (decreasedStockData.status === HttpStatus.CONFLICT) {
      // deleting order if decreaseStock fails
      await this.repository.delete(order);

      return { id: null, error: decreasedStockData.error, status: HttpStatus.CONFLICT };
    }

    return { id: order.id, error: null, status: HttpStatus.OK };
  }
}

Controlador de pedidos


En nuestro último controlador, necesitamos crear un método para recibir la solicitud entrante desde el API Gateway.

Cambiemos src/order/order.controller.ts de

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

@Controller('order')
export class OrderController {}
import { Controller, Inject } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';
import { OrderService } from './order.service';
import { ORDER_SERVICE_NAME, CreateOrderResponse } from './proto/order.pb';
import { CreateOrderRequestDto } from './order.dto';

@Controller('order')
export class OrderController {
  @Inject(OrderService)
  private readonly service: OrderService;

  @GrpcMethod(ORDER_SERVICE_NAME, 'CreateOrder')
  private async createOrder(data: CreateOrderRequestDto): Promise<CreateOrderResponse> {
    return this.service.createOrder(data);
  }
}

Módulo de pedidos


Como vamos a conectarnos al Microservicio de Producto, necesitamos registrar un cliente.

Ahora, vamos a cambiar el src/order/order.module.ts de

import { Module } from '@nestjs/common';
import { OrderController } from './order.controller';
import { OrderService } from './order.service';

@Module({
  controllers: [OrderController],
  providers: [OrderService],
})
export class OrderModule {}
import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OrderController } from './order.controller';
import { Order } from './order.entity';
import { OrderService } from './order.service';
import { PRODUCT_SERVICE_NAME, PRODUCT_PACKAGE_NAME } from './proto/product.pb';

@Module({
  imports: [
    ClientsModule.register([
      {
        name: PRODUCT_SERVICE_NAME,
        transport: Transport.GRPC,
        options: {
          url: '0.0.0.0:50053',
          package: PRODUCT_PACKAGE_NAME,
          protoPath: 'node_modules/grpc-nest-proto/proto/product.proto',
        },
      },
    ]),
    TypeOrmModule.forFeature([Order]),
  ],
  controllers: [OrderController],
  providers: [OrderService],
})
export class OrderModule {}

Módulo de la aplicación


Y una vez más, necesitamos conectarnos a la base de datos que hemos creado en la parte 1 de este artículo.

Ahora, vamos a cambiar src/app.module.ts de

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

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { OrderModule } from './order/order.module';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: 'localhost',
      port: 5432,
      database: 'micro_order',
      username: 'kevin',
      password: null,
      entities: ['dist/**/*.entity.{ts,js}'],
      synchronize: true, // never true in production!
    }),
    OrderModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

Bootstrap


Y por última vez, necesitamos inicializar nuestro microservicio en NestJS.

Cambiemos main.ts de

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();
import { INestMicroservice, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { Transport } from '@nestjs/microservices';
import { join } from 'path';
import { AppModule } from './app.module';
import { protobufPackage } from './order/proto/order.pb';

async function bootstrap() {
  const app: INestMicroservice = await NestFactory.createMicroservice(AppModule, {
    transport: Transport.GRPC,
    options: {
      url: '0.0.0.0:50052',
      package: protobufPackage,
      protoPath: join('node_modules/grpc-nest-proto/proto/order.proto'),
    },
  });

  app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));

  await app.listen();
}

bootstrap();

Ahora, podemos ejecutar este microservicio.

$ npm run start:dev

Testing


Ahora vamos a probar nuestros endpoints. Ten en cuenta que necesitas ejecutar los 3 microservicios y también tu API Gateway. Puedes utilizar software como Postman, Insomnia, o simplemente mediante comandos CURL en tu terminal.

Ha creado un documento Insomnia con todos los endpoints que puede importar. Aquí está el archivo JSON.

Registro de usuarios

$ curl -X POST http://localhost:3000/auth/register -H "Content-Type: application/json" -d '{"email": "elon@gmail.com", "password": "12345678"}'
Server Response:
{ "status": 201 }

Inicio de sesión del usuario

$ curl -X PUT http://localhost:3000/auth/login -H "Content-Type: application/json" -d '{"email": "elon@gmail.com", "password": "12345678"}'
Server Response:
{ "status": 200, "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwiZW1haWwiOiJoZWxsb2tldmludm9nZWxAZ21haWwuY29tIiwiaWF0IjoxNjQ3NTMzMjczLCJleHAiOjE2NzkwNjkyNzN9.w14jPT72_sfbdPIPXxEmSdopn8TXS-EDMJ3HalXT9Kw" }

Crear producto

$ curl -X POST http://localhost:3000/product -H "Content-Type: application/json" -d '{"name": "Test A", "sku": "A00001", "price": 100, "stock": 5}'
Server Response:
{ "status": 200, "id": 1 }

Crear orden

$ curl -X POST http://localhost:3000/order -H "Content-Type: application/json" -d '{"productId": 1, "quantity": 1}'
Server Response:
{ "status": 200, "id": 1 }

Github

Fuente

Plataforma de cursos gratis sobre programación