DEV Community

Iva-rgb
Iva-rgb

Posted on • Edited on

Hybrid NestJs Microservice Responding to Both HTTP and gRPC Requests

Image description

This post is not intended to compare the pros and cons of gRPC versus REST. Instead, the focus is on how to combine both.

In some cases, you may need your microservice to communicate with a browser via REST, while also allowing internal microservices to communicate with it. For internal communication, gRPC is often the better choice due to its speed and language-agnostic capabilities.

File Structure and Overview

To handle both REST and gRPC, we’ll need two controllers—one for each protocol—both communicating with a shared service. The REST setup is straightforward, but gRPC requires a few additional files in the libs folder, which stores shared resources across the monorepo. The libs folder is located at the root of the project, while the microservice itself is placed in the apps folder.

Setting up the REST Controller

To generate the microservice, use the following Nx command: nx g @nx/nest:application hybrid-app. Afterward, rename the generated controller to http-hybrid-app.controller.ts. Below is an example of the file’s contents:


import { Body, Controller, Post, UseFilters } from '@nestjs/common';
import { HybridAppService } from './hybrid-app.service';
import { CustomExceptionFilter } from '@monorepo/utils';

@Controller()
@UseFilters(CustomExceptionFilter)
export class HttpHybridAppController {
  constructor(private readonly hybridAppService: HybridAppService) {}

  @Post('greet')
  public async greet(@Body() dto: { **some type** }) {
    return this.hybridAppService.greetTheUser({ ...dto });
  }

  @Post('meet')
  public async meet(@Body() dto: { **some type** }) {
    return this.hybridAppService.meetTheUser({ ...dto });
  }
}

Enter fullscreen mode Exit fullscreen mode

This controller handles REST requests and communicates with a shared service to process the logic. The @UseFilters decorator applies a custom exception filter to ensure consistent error handling. This setup is intentional, as it later allows us to demonstrate how error handling differs when using gRPC.

Setting up the gRPC Controller

Before setting up your gRPC controller, you first need to create a .proto file, which defines the structure of the gRPC service.

I’ve placed my .proto files in the libs/proto folder. This organization keeps the files accessible as shared resources across the monorepo. If you decide to extend this example by creating a client gRPC microservice to communicate with the hybrid service, both services will need to use the same .proto file definition, making it convenient to store it in a shared location.

It is expected that you have at least basic knowledge in protobuf before diving in the next step, which is the content of the hybrid.proto file:

syntax = "proto3";

package hybrid;

message GreetDto {
  string greeting = 1;
  string full_name = 2;
}

message GreetResponse {
  string greet = 1;
}

message MeetDto {
  string name = 1;
  string surname = 2;
  int32 age = 3;
}

message MeetResponse {
  string meet = 1;
}

service HybridAppService {
  rpc Greet (GreetDto) returns (GreetResponse);
  rpc Meet (MeetDto) returns (MeetResponse);
}
Enter fullscreen mode Exit fullscreen mode

While this format may seem unfamiliar, it can be converted into readable TypeScript code for use in your microservice. To do this, you need to install the Google protobuf compiler. This tool provides the protoc command, which you can run to generate the TypeScript file:

protoc --plugin=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=./ --ts_proto_opt=nestJs=true ./libs/proto/hybrid.proto
Enter fullscreen mode Exit fullscreen mode

This command will generate a .ts file in the same directory as hybrid.proto (my practice is to move the file under libs/types). The resulting file looks like this:

// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
//   protoc-gen-ts_proto  v2.0.4
//   protoc               v5.27.3
// source: shared-resources/proto/hybrid.proto

/* eslint-disable */
import { GrpcMethod, GrpcStreamMethod } from "@nestjs/microservices";
import { Observable } from "rxjs";

export const protobufPackage = "hybrid";

export interface GreetDto {
  greeting: string;
  fullName: string;
}

export interface GreetResponse {
  greet: string;
}

export interface MeetDto {
  name: string;
  surname: string;
  age: number;
}

export interface MeetResponse {
  meet: string;
}

export const HYBRID_PACKAGE_NAME = "hybrid";

export interface HybridAppServiceClient {
  greet(request: GreetDto): Observable<GreetResponse>;

  meet(request: MeetDto): Observable<MeetResponse>;
}

export interface HybridAppServiceController {
  greet(request: GreetDto): Promise<GreetResponse> | Observable<GreetResponse> | GreetResponse;

  meet(request: MeetDto): Promise<MeetResponse> | Observable<MeetResponse> | MeetResponse;
}

export function HybridAppServiceControllerMethods() {
  return function (constructor: Function) {
    const grpcMethods: string[] = ["greet", "meet"];
    for (const method of grpcMethods) {
      const descriptor: any = Reflect.getOwnPropertyDescriptor(constructor.prototype, method);
      GrpcMethod("HybridAppService", method)(constructor.prototype[method], method, descriptor);
    }
    const grpcStreamMethods: string[] = [];
    for (const method of grpcStreamMethods) {
      const descriptor: any = Reflect.getOwnPropertyDescriptor(constructor.prototype, method);
      GrpcStreamMethod("HybridAppService", method)(constructor.prototype[method], method, descriptor);
    }
  };
}

export const HYBRID_APP_SERVICE_NAME = "HybridAppService";
Enter fullscreen mode Exit fullscreen mode

Below is an example of how to structure your gRPC controller to utilize the TypeScript definitions.

import { Controller, UseFilters } from '@nestjs/common';
import { HybridAppService } from './hybrid-app.service';
import { RpcCustomExceptionFilter } from '@monorepo/utils';
import { GrpcMethod } from '@nestjs/microservices';
import { HybridAppServiceController, HybridAppServiceControllerMethods, HYBRID_APP_SERVICE_NAME } from '@monorepo/types';

@Controller()
@UseFilters(RpcCustomExceptionFilter)
@HybridAppServiceControllerMethods()
export class GrpcHybridAppController implements HybridAppServiceController {
  constructor(private readonly hybridAppService: HybridAppService) {}

  public async greet(dto: { greeting: string; fullName: string }) {
    return this.hybridAppService.greetTheUser(dto);
  }

  public async meet(dto: { name: string; surname: string; age: number }) {
    return this.hybridAppService.meetTheUser(dto);
  }
}

Enter fullscreen mode Exit fullscreen mode
  • HybridAppServiceController is an interface that enforces structure on your gRPC controller, ensuring it implements the necessary methods (Greet and Meet).

  • HybridAppServiceControllerMethods is a decorator that auto-implements boilerplate methods or configurations for the controller, reducing manual setup.

  • GrpcMethod binds a method in your NestJS controller to a specific gRPC method defined in the .proto file.

The final step is connecting your gRPC microservice during application bootstrapping. This is straightforward and can be done using NestJS’s hybrid application support:

import { NestFactory } from '@nestjs/core';
import { Transport } from '@nestjs/microservices';
import { AppModule } from './app/app.module';
import { HYBRID_PACKAGE_NAME } from '@monorepo/types';
import { join } from 'path';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Connect the gRPC microservice
  await app.connectMicroservice({
    transport: Transport.GRPC,
    options: {
      port: '5000',
      protoPath: join(__dirname, '../../libs/proto/hybrid.proto'),
      package: HYBRID_PACKAGE_NAME, // Package name generated from the proto file
      loader: {
        keepCase: true,
      },
    },
  });

  await app.startAllMicroservices();
  await app.listen(3000);
}

bootstrap();
Enter fullscreen mode Exit fullscreen mode

Lets not forget the custom error handling of these requests. gRPC relies on status codes and metadata to convey details about errors. Let’s look at an example:

import { ArgumentsHost, Catch } from '@nestjs/common';
import { BaseRpcExceptionFilter } from '@nestjs/microservices';
import { Metadata, StatusBuilder, StatusObject } from '@grpc/grpc-js';
import { Status } from '@grpc/grpc-js/build/src/constants';
import { Observable, throwError } from 'rxjs';

// Custom validation exception class
class ValidationException extends Error {
  constructor(public errors: Record<string, string[]>) {
    super('Validation Error');
  }
}

@Catch(ValidationException)
export class RpcValidationExceptionFilter extends BaseRpcExceptionFilter {
  catch(exception: ValidationException, host: ArgumentsHost): Observable<StatusObject> {
    const metadata = new Metadata();
    metadata.add('errors', JSON.stringify(exception.errors));

    const statusBuilder = new StatusBuilder();
    const statusObject = statusBuilder
      .withCode(Status.INVALID_ARGUMENT) 
      .withDetails('Validation failed') 
      .withMetadata(metadata) 
      .build();

    return throwError(() => statusObject);
  }
}
Enter fullscreen mode Exit fullscreen mode

And there you have it — your hybrid application is now capable of handling both HTTP and gRPC requests, but also managing errors effectively.

Testing gRPC Endpoints

To test the gRPC endpoints, you can use Postman's gRPC client interface. It provides an easy way to interact with gRPC services.

Accessing hybrid.ts via @monorepo/types

If you’re wondering how I do this, it’s thanks to configuring the paths option in the root tsconfig.base.json file:

"compilerOptions": {
   "paths": {
         "@monorepo/libs": ["libs/types/index.ts"],
   }
}
Enter fullscreen mode Exit fullscreen mode

This allows TypeScript to resolve the path for shared code across the monorepo.

Top comments (0)