Hybrid NestJs Microservice Responding to Both HTTP and gRPC Requests
Iva-rgb
Posted on September 8, 2024
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 });
}
}
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);
}
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
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";
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);
}
}
HybridAppServiceController
is an interface that enforces structure on your gRPC controller, ensuring it implements the necessary methods (Greet
andMeet
).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();
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);
}
}
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"],
}
}
This allows TypeScript to resolve the path for shared code across the monorepo.
Posted on September 8, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.