Tutorial: Build a REST API
In this tutorial you will build a complete Task Management REST API from scratch using HazelJS. By the end you will have a working API with:
- CRUD endpoints for tasks and users
- Input validation with DTOs
- Dependency injection with services
- Module-based organization
- Error handling with exception filters
- Authentication with guards
Prerequisites
- Node.js 18+ installed
- Basic TypeScript knowledge
- A code editor (VS Code recommended)
Step 1: Project Setup
Create a new project and install dependencies:
mkdir task-api && cd task-api
npm init -y
npm install @hazeljs/core class-validator class-transformer
npm install -D typescript @types/node ts-node-dev
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"resolveJsonModule": true,
"moduleResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Add scripts to package.json:
{
"scripts": {
"dev": "ts-node-dev --respawn src/main.ts",
"build": "tsc",
"start": "node dist/main.js"
}
}
Create the folder structure:
src/
├── task/
│ ├── dto/
│ │ ├── create-task.dto.ts
│ │ └── update-task.dto.ts
│ ├── task.controller.ts
│ ├── task.service.ts
│ └── task.module.ts
├── user/
│ ├── dto/
│ │ └── create-user.dto.ts
│ ├── user.controller.ts
│ ├── user.service.ts
│ └── user.module.ts
├── auth/
│ ├── auth.guard.ts
│ └── auth.module.ts
├── app.module.ts
└── main.ts
Step 2: Define the Task DTOs
DTOs (Data Transfer Objects) define the shape of data for requests and enable validation.
// src/task/dto/create-task.dto.ts
import { IsString, IsNotEmpty, IsOptional, IsEnum } from 'class-validator';
export enum TaskStatus {
TODO = 'todo',
IN_PROGRESS = 'in_progress',
DONE = 'done',
}
export class CreateTaskDto {
@IsString()
@IsNotEmpty()
title: string;
@IsString()
@IsOptional()
description?: string;
@IsEnum(TaskStatus)
@IsOptional()
status?: TaskStatus;
}
// src/task/dto/update-task.dto.ts
import { IsString, IsOptional, IsEnum } from 'class-validator';
import { TaskStatus } from './create-task.dto';
export class UpdateTaskDto {
@IsString()
@IsOptional()
title?: string;
@IsString()
@IsOptional()
description?: string;
@IsEnum(TaskStatus)
@IsOptional()
status?: TaskStatus;
}
Step 3: Create the Task Service
The service contains all business logic. It is @Injectable() so the DI container can manage it.
// src/task/task.service.ts
import { Injectable, NotFoundException } from '@hazeljs/core';
import { CreateTaskDto, TaskStatus } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
export interface Task {
id: number;
title: string;
description: string;
status: TaskStatus;
userId: number;
createdAt: Date;
updatedAt: Date;
}
@Injectable()
export class TaskService {
private tasks: Task[] = [];
private nextId = 1;
findAll(userId?: number): Task[] {
if (userId) {
return this.tasks.filter(t => t.userId === userId);
}
return this.tasks;
}
findOne(id: number): Task {
const task = this.tasks.find(t => t.id === id);
if (!task) {
throw new NotFoundException(`Task #${id} not found`);
}
return task;
}
create(dto: CreateTaskDto, userId: number): Task {
const task: Task = {
id: this.nextId++,
title: dto.title,
description: dto.description || '',
status: dto.status || TaskStatus.TODO,
userId,
createdAt: new Date(),
updatedAt: new Date(),
};
this.tasks.push(task);
return task;
}
update(id: number, dto: UpdateTaskDto): Task {
const task = this.findOne(id);
if (dto.title !== undefined) task.title = dto.title;
if (dto.description !== undefined) task.description = dto.description;
if (dto.status !== undefined) task.status = dto.status;
task.updatedAt = new Date();
return task;
}
remove(id: number): void {
const index = this.tasks.findIndex(t => t.id === id);
if (index === -1) {
throw new NotFoundException(`Task #${id} not found`);
}
this.tasks.splice(index, 1);
}
}
Step 4: Create the Task Controller
The controller maps HTTP requests to service methods. Each decorator tells HazelJS how to route the request.
// src/task/task.controller.ts
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
} from '@hazeljs/core';
import { TaskService } from './task.service';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
@Controller('tasks')
export class TaskController {
// TaskService is automatically injected by the DI container
constructor(private readonly taskService: TaskService) {}
// GET /tasks?userId=1
@Get()
findAll(@Query('userId') userId?: string) {
return this.taskService.findAll(userId ? parseInt(userId) : undefined);
}
// GET /tasks/:id
@Get(':id')
findOne(@Param('id') id: string) {
return this.taskService.findOne(parseInt(id));
}
// POST /tasks
@Post()
@HttpCode(201)
create(@Body(CreateTaskDto) dto: CreateTaskDto) {
// In a real app, userId would come from the authenticated user
return this.taskService.create(dto, 1);
}
// PUT /tasks/:id
@Put(':id')
update(@Param('id') id: string, @Body(UpdateTaskDto) dto: UpdateTaskDto) {
return this.taskService.update(parseInt(id), dto);
}
// DELETE /tasks/:id
@Delete(':id')
@HttpCode(204)
remove(@Param('id') id: string) {
this.taskService.remove(parseInt(id));
}
}
Step 5: Create the Task Module
The module groups the controller and service together.
// src/task/task.module.ts
import { HazelModule } from '@hazeljs/core';
import { TaskController } from './task.controller';
import { TaskService } from './task.service';
@HazelModule({
controllers: [TaskController],
providers: [TaskService],
exports: [TaskService], // export so other modules can use TaskService
})
export class TaskModule {}
Step 6: Create the User Feature
Now add a second feature module for users, following the same pattern.
// src/user/dto/create-user.dto.ts
import { IsString, IsNotEmpty, IsEmail } from 'class-validator';
export class CreateUserDto {
@IsString()
@IsNotEmpty()
name: string;
@IsEmail()
email: string;
}
// src/user/user.service.ts
import { Injectable, NotFoundException } from '@hazeljs/core';
import { CreateUserDto } from './dto/create-user.dto';
export interface User {
id: number;
name: string;
email: string;
createdAt: Date;
}
@Injectable()
export class UserService {
private users: User[] = [];
private nextId = 1;
findAll(): User[] {
return this.users;
}
findOne(id: number): User {
const user = this.users.find(u => u.id === id);
if (!user) {
throw new NotFoundException(`User #${id} not found`);
}
return user;
}
create(dto: CreateUserDto): User {
const user: User = {
id: this.nextId++,
name: dto.name,
email: dto.email,
createdAt: new Date(),
};
this.users.push(user);
return user;
}
}
// src/user/user.controller.ts
import { Controller, Get, Post, Body, Param, HttpCode } from '@hazeljs/core';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create-user.dto';
@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) {}
@Get()
findAll() {
return this.userService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.userService.findOne(parseInt(id));
}
@Post()
@HttpCode(201)
create(@Body(CreateUserDto) dto: CreateUserDto) {
return this.userService.create(dto);
}
}
// src/user/user.module.ts
import { HazelModule } from '@hazeljs/core';
import { UserController } from './user.controller';
import { UserService } from './user.service';
@HazelModule({
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}
Step 7: Add Authentication with a Guard
Guards protect routes by running before the handler. Here we create a simple API key guard.
// src/auth/auth.guard.ts
import { Injectable } from '@hazeljs/core';
import type { CanActivate, ExecutionContext } from '@hazeljs/core';
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest() as {
headers?: Record<string, string>;
};
const apiKey = request.headers?.['x-api-key'];
if (!apiKey || apiKey !== 'my-secret-key') {
return false; // Request is rejected with 403
}
return true;
}
}
// src/auth/auth.module.ts
import { HazelModule } from '@hazeljs/core';
import { AuthGuard } from './auth.guard';
@HazelModule({
providers: [AuthGuard],
exports: [AuthGuard],
})
export class AuthModule {}
Now protect the task controller by applying the guard:
// Update src/task/task.controller.ts — add these imports and decorator
import { UseGuards } from '@hazeljs/core';
import { AuthGuard } from '../auth/auth.guard';
@Controller('tasks')
@UseGuards(AuthGuard) // All routes in this controller now require authentication
export class TaskController {
// ... same as before
}
Step 8: Wire Everything Together
Create the root module that imports all feature modules:
// src/app.module.ts
import { HazelModule } from '@hazeljs/core';
import { TaskModule } from './task/task.module';
import { UserModule } from './user/user.module';
import { AuthModule } from './auth/auth.module';
@HazelModule({
imports: [TaskModule, UserModule, AuthModule],
})
export class AppModule {}
Create the entry point:
// src/main.ts
import { HazelApp } from '@hazeljs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = new HazelApp(AppModule);
await app.listen(3000);
console.log('Task API is running on http://localhost:3000');
}
bootstrap();
Step 9: Run and Test
Start the development server:
npm run dev
Test with curl:
# Create a user
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name": "Alice", "email": "alice@example.com"}'
# List users
curl http://localhost:3000/users
# Create a task (requires API key)
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-H "x-api-key: my-secret-key" \
-d '{"title": "Learn HazelJS", "description": "Complete the tutorial"}'
# List tasks (requires API key)
curl http://localhost:3000/tasks \
-H "x-api-key: my-secret-key"
# Get a specific task
curl http://localhost:3000/tasks/1 \
-H "x-api-key: my-secret-key"
# Update a task
curl -X PUT http://localhost:3000/tasks/1 \
-H "Content-Type: application/json" \
-H "x-api-key: my-secret-key" \
-d '{"status": "in_progress"}'
# Delete a task
curl -X DELETE http://localhost:3000/tasks/1 \
-H "x-api-key: my-secret-key"
# Try without API key — should get 403
curl http://localhost:3000/tasks
Step 10: Add Error Handling
Create a custom exception filter for consistent error responses:
// src/filters/http-exception.filter.ts
import { Catch, HttpError } from '@hazeljs/core';
import type { ExceptionFilter, ArgumentsHost } from '@hazeljs/core';
@Catch(HttpError)
export class GlobalExceptionFilter implements ExceptionFilter<HttpError> {
catch(exception: HttpError, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse() as {
status(code: number): { json(body: unknown): void };
};
response.status(exception.statusCode).json({
statusCode: exception.statusCode,
message: exception.message,
timestamp: new Date().toISOString(),
});
}
}
Summary
You built a complete REST API with:
| Concept | What You Used |
|---|---|
| Routing | @Controller, @Get, @Post, @Put, @Delete |
| Parameters | @Param, @Query, @Body |
| Validation | DTOs with class-validator decorators |
| DI | @Injectable services injected via constructor |
| Modules | @HazelModule with imports, providers, exports |
| Guards | @UseGuards with CanActivate interface |
| Status Codes | @HttpCode(201), @HttpCode(204) |
| Errors | NotFoundException for 404 responses |
What's Next?
- Add Prisma for a real database
- Use @hazeljs/auth for JWT-based authentication
- Add Swagger for auto-generated API docs
- Deploy with Serverless to AWS Lambda
- Add Caching for performance
- Build AI features with the AI package