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:

ConceptWhat You Used
Routing@Controller, @Get, @Post, @Put, @Delete
Parameters@Param, @Query, @Body
ValidationDTOs 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)
ErrorsNotFoundException for 404 responses

What's Next?