REST APIs with Controllers - Node.js Microservice with NestJS: Part 4

Building REST APIs with Controllers in NestJS

NestJS is a progressive Node.js framework that builds on top of Express and uses TypeScript, making it a fantastic choice for developers seeking a structured and scalable approach to building backend applications. In this fourth installment of the series of posts on building a Node.js microservice with NestJS, we'll explore how NestJS helps developers create declarative REST APIs using controllers, along with powerful features like decorators, Data Transfer Objects (DTOs), validation, and type transformation. If you're familiar with JAX-RS in Java, you'll notice a similar approach to structuring APIs.

For more details about the service we are building and what we have done so far, check out some of the prior posts on the topic:

Declarative REST APIs with Controllers

Controllers in NestJS are the primary way of creating REST APIs. They enable you to define routes and HTTP methods declaratively, eliminating much of the boilerplate code commonly found in Express applications. By defining controllers, routes, and handlers through decorators, NestJS makes it straightforward to define RESTful services.

Below is a simplified example of our LandmarkController in NestJS which we will evolve during this article.  It provides the primary REST API used by our mobile app:

import { Controller, Get, Query } from '@nestjs/common';
import {
DistanceUnit,
LocalLandmarkCriteria,
LocalLandmarkDetails,
} from '../common';
import { LandmarkService } from './landmark.service';

@Controller('landmark/v1')
export class LandmarkController {
private readonly landmarkService: LandmarkService;

// Injection via constructor
constructor(landmarkService: LandmarkService) {
this.landmarkService = landmarkService;
}

@Get('/landmark/local')
public async getLocalLandmarks(
@Query('latitude') latitude: number,
@Query('longitude') longitude: number,
@Query('distanceUnit') distanceUnit: DistanceUnit,
@Query('maxCount') maxCount: number,
): Promise<LocalLandmarkDetails[]> {
const criteria: LocalLandmarkCriteria = {
coordinates: {
latitude,
longitude,
},
maxCount,
distanceUnit,
};
console.log(
'Request made to LandmarkController.getLocalLandmarks with criteria',
criteria,
);
return this.landmarkService.getLocalLandmarks(criteria);
}
}

In the above example:

  • The @Controller decorator lets the NestJS application know to register this class as a controller, and all routes in this controller will be prefixed with /landmark/v1.
  • @Get and other HTTP method decorators define the HTTP methods for each route as well as the more specific paths. In the case above /landmark/local
  • @Query decorators provide a clear way to access query parameters without having to manually parse them.

This approach is reminiscent of the JAX-RS framework in Java, where decorators are used to declaratively define route handlers and parameters.


Eliminating Boilerplate with Express

NestJS is built on top of Express (or optionally Fastify), but it eliminates the boilerplate code that often accompanies Express applications. Normally, with Express, you'd need to manually set up routes, parse parameters, validate inputs, and handle responses. NestJS’s built-in decorators and dependency injection reduce this complexity by handling much of the setup automatically, so you can focus on the application logic.

For example, setting up a simple GET endpoint in Express might look like this:

app.get('/landmark/v1/landmark/local', async (req, res) => {
// get query params from request object
const { latitude, longitude, maxCount, distanceUnit } = req.query;
const criteria: LocalLandmarkCriteria = {
coordinates: {
latitude,
longitude,
},
maxCount,
distanceUnit,
};
console.log(
'Request made to /landmark/v1/landmark/local with criteria',
criteria,
);
const localLandmarks = await landmarkService.getLocalLandmarks(criteria);
// Serialize response
res.json(localLandmarks);
});

By contrast, NestJS’s use of decorators allows us to eliminate all the manual routing setup, making code simpler, more readable, and maintainable.  We have not even touched on validation yet, which would add more code to the express route.  Additionally to test this, you either need to mock out the express app or run express.


Decorators vs. Function Calls

NestJS heavily leverages decorators to make code more declarative. Rather than using function calls, decorators allow you to define routing, validation, and parameter handling directly in your classes. Decorators provide metadata and enable NestJS to handle HTTP requests without requiring you to write extensive boilerplate code.

For example, the @Get, @Post, @Param, and @Body decorators let you specify how routes should function without needing function calls to retrieve these values from the request object.

Advantages of using decorators over function calls:

  1. Readability: Code structure is more organized, and it’s easier to see what each function is responsible for.
  2. Modularity: Each decorator focuses on a single task (e.g., defining the HTTP method, accessing route parameters).
  3. Scalability: Controllers remain cleaner as the complexity of your application increases.


DTOs and Class-Validator: Easy Validation

Validation is crucial for building reliable APIs. NestJS uses Data Transfer Objects (DTOs) alongside the class-validator package to simplify input validation. DTOs serve as schema definitions for request data, allowing you to easily define required fields and validate them with decorators. This provides a powerful and declarative way to enforce data integrity in your APIs.

Add class-validator and class-transformer to the Project

Before we can get started using class-validator, lets add it to our project.  Since we are using pnpm for our package manager we run the following command from tour-service directory to add it.  We will also add class-transformer:
pnpm add class-validator class-transformer

Enabling Validation and Transformation in the NestJS App

To enable validation for incoming requests in NestJS, you can use a ValidationPipe. This pipe leverages class-validator and class-transformer to validate and transform DTOs automatically based on decorators you apply to DTO properties. Using a ValidationPipe ensures that any request data is automatically checked against the rules you’ve defined, providing a simple, centralized approach to input validation across your application.

NestJS's ValidationPipe can be globally applied, allowing you to enforce validation rules for every incoming request that has a DTO attached. The ValidationPipe can be configured with various options to customize the validation behavior according to your needs.  We do so by updating our main.ts as follows:

apps/tour-service/src/main.ts

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

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

// Add validation and class transformation pipes
app.useGlobalPipes(
new ValidationPipe({
transform: true,
transformOptions: { enableImplicitConversion: true },
whitelist: true,
}),
);

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

In the above example, we configure the ValidationPipe with the following options:

  • whitelist: true: Strips any properties not defined in the DTO, ensuring that the request body contains only the fields you expect.
  • transform: true: Automatically transforms input data to the specified types in your DTO (e.g., converting strings to numbers, dates, etc., using class-transformer).
  • transformOptions: { enableImplicitConversion: true }: Allows automatic type conversion (or "implicit conversion") of incoming request data based on the types defined in the DTO (Data Transfer Object) class.

    By default, without this option, values coming from HTTP requests are treated as strings, as that’s how HTTP data is parsed. However, with enableImplicitConversion set to true, the class-transformer library will attempt to convert these string values into the types specified in the DTO class. This feature is particularly useful for converting basic types, such as:

    • Numbers: Converts numeric strings to number type.
    • Booleans: Converts 'true' or 'false' strings to boolean type.
    • Dates: Converts ISO date strings to Date objects.

Defining a DTO and Adding Validation

We started off with a separate method parameter for each query string value.  Lets move them into DTOs and leverage class-validator decorators to validate the input values. We will add the following CoordinatesDto and LocalLandmarkSearchCriteriaDto DTOs:

apps/tour-service/src/landmark/dto/coordinates.dto.ts

import { IsNumber, Max, Min } from 'class-validator';
import { Coordinates } from '../../common';

export class CoordinatesDto implements Coordinates {
@IsNumber()
@Max(90, { message: 'latitude may not be greater than 90' })
@Min(-90, { message: 'latitude may not be less than -90' })
latitude: number;

@IsNumber()
@Max(180, { message: 'longitude may not be greater than 180' })
@Min(-180, { message: 'longitude may not be less than -180' })
longitude: number;
}
The CoordinatesDto implements the coordinates interface seen below:
export interface Coordinates {
latitude: number;
longitude: number;
}
Coordinates represents a set of geographic coordinates. 
  • @Max() @Min() - Since the latitude ranges from -90 to 90 and longitude ranges from -180 to 180, we leverage the class-validator @Max and @Min decorators to validate that those values are within the appropriate range and return a meaningful error message if they are not. 
  • @IsNumber() ensures that latitude and longitude are numbers.
apps/tour-service/src/landmark/dto/local-landmark-search-criteria.dto.ts
import { IsEnum, IsInt, IsOptional } from 'class-validator';
import { DistanceUnit } from '../../common';
import { LocalLandmarkSearchCriteria } from '../landmark';

import { CoordinatesDto } from './coordinates.dto';

export class LocalLandmarkSearchCriteriaDto
extends CoordinatesDto
implements LocalLandmarkSearchCriteria
{
@IsEnum(DistanceUnit)
@IsOptional()
distanceUnit: DistanceUnit = DistanceUnit.MILE;

@IsInt()
@IsOptional()
maxCount: number = 10;
}

In the above example LocalLandmarkSearchCriteriaDto extends the CoordinatesDto and adds distanceUnit and maxCount
  • @IsEnum(DistanceUnit) ensures that distanceUnit is a value in the enum DistanceUnit.
  • @IsOptional() ensures that validation will not fail if either distanceUnit or maxCount are not specified.  We default distanceUnit to DistanceUnit.MILE in and maxCount to 10
  • @IsInt() ensures that maxCount is an integer.

Update the Controller to use a DTO

Now that we have created the above DTOs. Lets update the LandmarkController to use it instead of individual method params for each query parameter as follows:

apps/tour-service/src/landmark/landmark.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { LocalLandmarkDetails } from '../common';
import { LocalLandmarkSearchCriteriaDto } from './dto/local-landmark-search-criteria.dto';
import { LandmarkService } from './landmark.service';

@Controller('landmark/v1')
export class LandmarkController {
private readonly landmarkService: LandmarkService;

// Injection via constructor
constructor(landmarkService: LandmarkService) {
this.landmarkService = landmarkService;
}

@Get('/landmark/local')
public async getLocalLandmarks(
@Query() criteria: LocalLandmarkSearchCriteriaDto,
): Promise<LocalLandmarkDetails[]> {
console.log(
'Request made to LandmarkController.getLocalLandmarks with criteria',
criteria,
);
return this.landmarkService.getLocalLandmarks({
...criteria,
// our internal LocalLandmarkCriteria interface has a coordinates as a property
coordinates: { ...criteria },
});
}
}

Example Validation Errors

If validation fails, NestJS automatically returns an error response detailing the validation issues. This makes it easy to identify and address issues with incoming data. 

For example if we start up our service

  tour-service git:(nestjs-service-2-cli)  pnpm run start

> tour-service@0.0.1 start /Users/alexlevine/dev/autotoor-app/apps/tour-service
> nest start

[Nest] 15217  - 06/30/2024, 4:07:02 PM     LOG [NestFactory] Starting Nest application...
[Nest] 15217  - 06/30/2024, 4:07:02 PM     LOG [InstanceLoader] AppModule dependencies initialized +8ms
[Nest] 15217  - 06/30/2024, 4:07:02 PM     LOG [InstanceLoader] LandmarkModule dependencies initialized +0ms
[Nest] 15217  - 06/30/2024, 4:07:02 PM     LOG [RoutesResolver] LandmarkController {/landmark/v1}: +8ms
[Nest] 15217  - 06/30/2024, 4:07:02 PM     LOG [RouterExplorer] Mapped {/landmark/v1/landmark/local, GET} route +2ms
[Nest] 15217  - 06/30/2024, 4:07:02 PM     LOG [NestApplication] Nest application successfully started +2ms

Then hit the url: 

http://localhost:3000/landmark/v1/landmark/local?longitude=200&latitude=-300

We should get an error response like the following:

{

  • "message":
    [
    • "latitude may not be less than -90",
    • "longitude may not be greater than 180"
    ],
  • "error": "Bad Request",
  • "statusCode": 400

}


Now if we make a valid request like the following: 

http://localhost:3000/landmark/v1/landmark/local?latitude=70&longitude=123&distanceUnit=FOOT&maxCount=10

We get our canned response back

[

]

Also if we take a look at the logs, we will see that the values for latitudelongitude, and maxCount are output as numbers and not strings:

Request made to LandmarkController.getLocalLandmarks with criteria LocalLandmarkSearchCriteriaDto {
  distanceUnit: 'FOOT',
  maxCount: 10,
  latitude: 70,
  longitude: 123
}

This is due to the transformOptions we specified when configuring the ValidationPipe earlier.  

If we comment out transformationOptions in the ValidationPipe configuration in main.ts and make the same request again, we get the following error response:

{

  • "message":
    [
    • "maxCount must be an integer number",
    • "latitude may not be less than -90",
    • "latitude may not be greater than 90",
    • "latitude must be a number conforming to the specified constraints",
    • "longitude may not be less than -180",
    • "longitude may not be greater than 180",
    • "longitude must be a number conforming to the specified constraints"
    ],
  • "error": "Bad Request",
  • "statusCode": 400

}

This is because the numbers in latitudelongitude, and maxCount are passed as strings which fails validation.


Get the Code

All of the code from this post can be found in the apps/tour-service directory of the nestjs-service-4-controller git branch here: 


Final Thoughts

NestJS offers a modern and declarative approach to building REST APIs using controllers, decorators, DTOs, and validation. This structure not only improves code readability but also enhances maintainability. With features like validation, error handling, and automatic type transformation, NestJS provides a clean, powerful, and Java-inspired framework that makes backend development efficient and enjoyable.

NestJS takes care of the heavy lifting so you can focus on crafting great applications. Give it a try, and experience how much simpler backend development can be!

Stay tuned for future posts where we continue building out our NestJS microservice.

Comments

Popular posts from this blog

Node.js Microservice with NestJS: Part 3 - NestJS Dependency Injection

Constructor-Based vs Property-Based Dependency Injection in NestJS - Which is Better?