Skip to content

Latest commit

 

History

History
453 lines (368 loc) · 12.4 KB

File metadata and controls

453 lines (368 loc) · 12.4 KB

Backend Module Recipe

🌐 Language: English · Tiếng Việt · 中文 · 한국어

This guide walks through creating a complete NestJS module with database integration, authentication, and API documentation.

Prerequisites

  • Boilerplate set up locally (getting-started.md)
  • NestJS CLI available: pnpm --filter @mobile-boilerplate/api exec nest

Step 1: Generate the Module

cd /repo/root
pnpm --filter @mobile-boilerplate/api exec nest generate module modules/users

Output:

CREATE apps/api/src/modules/users/users.module.ts

Step 2: Create DTOs

DTOs define the API contract. Create apps/api/src/modules/users/dto/ folder:

create-user.dto.ts:

import { IsEmail, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class CreateUserDto {
  @ApiProperty({ example: 'alice@example.com' })
  @IsEmail()
  email: string;

  @ApiProperty({ example: 'Alice' })
  @IsString()
  @MinLength(3)
  name: string;
}

user-response.dto.ts:

import { ApiProperty } from '@nestjs/swagger';

export class UserResponseDto {
  @ApiProperty({ example: 1 })
  id: number;

  @ApiProperty({ example: 'alice@example.com' })
  email: string;

  @ApiProperty({ example: 'Alice' })
  name: string;
}

Step 3: Create Service

users.service.ts:

import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../infra/prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UserResponseDto } from './dto/user-response.dto';

@Injectable()
export class UsersService {
  constructor(private prisma: PrismaService) {}

  async create(dto: CreateUserDto): Promise<UserResponseDto> {
    const user = await this.prisma.user.create({
      data: dto,
    });
    return user;
  }

  async findById(id: number): Promise<UserResponseDto> {
    const user = await this.prisma.user.findUnique({
      where: { id },
    });
    if (!user) {
      throw new NotFoundException(`User ${id} not found`);
    }
    return user;
  }

  async findAll(): Promise<UserResponseDto[]> {
    return this.prisma.user.findMany();
  }
}

Step 4: Create Controller

users.controller.ts:

import { Controller, Get, Post, Body, Param, NotFoundException } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UserResponseDto } from './dto/user-response.dto';
import { Public } from '../../common/auth/decorators/public.decorator';
import { RequireAuth } from '../../common/auth/decorators/require-auth.decorator';
import { ApiStandardResponse } from '../../common/decorators/api-standard-response.decorator';

/**
 * User management endpoints.
 *
 * POST /users (public) — create new user
 * GET /users/:id (private) — fetch user by id
 */
@ApiTags('users')
@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  @Public()  // Registration is public; no auth required
  @ApiOperation({ operationId: 'createUser', summary: 'Create a new user' })
  @ApiStandardResponse(UserResponseDto)
  async create(@Body() dto: CreateUserDto): Promise<UserResponseDto> {
    return this.usersService.create(dto);
  }

  @Get(':id')
  @RequireAuth()  // Must be authenticated
  @ApiOperation({ operationId: 'getUser', summary: 'Get user by ID' })
  @ApiStandardResponse(UserResponseDto)
  async findById(@Param('id') id: string): Promise<UserResponseDto> {
    return this.usersService.findById(parseInt(id, 10));
  }

  @Get()
  @RequireAuth()
  @ApiOperation({ operationId: 'listUsers', summary: 'List all users' })
  @ApiStandardResponse(UserResponseDto)
  async findAll(): Promise<UserResponseDto[]> {
    return this.usersService.findAll();
  }
}

Auth Rules (CRITICAL)

Decorator Meaning Usage
@Public() Route is publicly accessible (no auth required) /login, /register, /health, /hello
@RequireAuth() marker decorator (sets metadata + adds Bearer auth in Swagger). The global guard reads the marker; you don't bind a guard at route level. /users/:id, /profile, admin endpoints
None Default: FORBIDDEN (app.module sets NotImplementedAuthGuard) Error 501 if not decorated

Choose ONE:

// ✓ GOOD: Public registration
@Post('register')
@Public()
async register(@Body() dto: RegisterDto) { }

// ✓ GOOD: Private user fetch
@Get(':id')
@RequireAuth()
async getUser(@Param('id') id: string) { }

// ✗ BAD: No decorator (will be denied by default guard)
@Get('me')
async getCurrentUser() { }  // Throws 501 Not Implemented

Step 5: Update Module

users.module.ts:

import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

@Module({
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],  // Allow other modules to import UsersService
})
export class UsersModule {}

Step 6: Register Module in Root App

app.module.ts: Add to imports array:

import { UsersModule } from './modules/users/users.module';

@Module({
  imports: [
    AppConfigModule,
    LoggerModule,
    PrismaModule,
    ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),
    UsersModule,           // ← ADD HERE
    HelloModule,
    HealthModule,
  ],
  // ... providers omitted
})
export class AppModule {}

Step 7: Add to Database Schema

apps/api/prisma/schema.prisma:

model User {
  id    Int     @id @default(autoincrement())
  email String  @unique
  name  String
}

Run migration:

cd /repo/root
pnpm --filter @mobile-boilerplate/api prisma:migrate dev --name add_users

Generate Prisma client:

pnpm --filter @mobile-boilerplate/api prisma:generate

Step 8: Add Unit Tests

users.service.spec.ts:

import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { UsersService } from './users.service';
import { PrismaService } from '../../infra/prisma/prisma.service';

describe('UsersService', () => {
  let service: UsersService;
  let prisma: PrismaService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UsersService,
        {
          provide: PrismaService,
          useValue: {
            user: {
              create: jest.fn(),
              findUnique: jest.fn(),
              findMany: jest.fn(),
            },
          },
        },
      ],
    }).compile();

    service = module.get<UsersService>(UsersService);
    prisma = module.get<PrismaService>(PrismaService);
  });

  describe('create', () => {
    it('should create a user', async () => {
      const dto = { email: 'test@ex.com', name: 'Test' };
      const mockUser = { id: 1, ...dto };

      jest.spyOn(prisma.user, 'create').mockResolvedValue(mockUser);

      const result = await service.create(dto);
      expect(result).toEqual(mockUser);
    });
  });

  describe('findById', () => {
    it('should return user if found', async () => {
      const mockUser = { id: 1, email: 'test@ex.com', name: 'Test' };
      jest.spyOn(prisma.user, 'findUnique').mockResolvedValue(mockUser);

      const result = await service.findById(1);
      expect(result).toEqual(mockUser);
    });

    it('should throw NotFoundException if not found', async () => {
      jest.spyOn(prisma.user, 'findUnique').mockResolvedValue(null);

      await expect(service.findById(999)).rejects.toThrow(
        NotFoundException,
      );
    });
  });
});

Step 9: Add E2E Tests

apps/api/test/users.e2e-spec.ts:

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';

describe('Users (e2e)', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    app.useGlobalPipes(
      new ValidationPipe({
        whitelist: true,
        forbidNonWhitelisted: true,
      }),
    );
    await app.init();
  });

  afterAll(async () => {
    await app.close();
  });

  describe('POST /users (create)', () => {
    it('should create a user', () => {
      return request(app.getHttpServer())
        .post('/users')
        .send({ email: 'newuser@ex.com', name: 'New User' })
        .expect(201)
        .expect((res) => {
          expect(res.body.data).toHaveProperty('id');
          expect(res.body.data.email).toBe('newuser@ex.com');
        });
    });

    it('should reject invalid email', () => {
      return request(app.getHttpServer())
        .post('/users')
        .send({ email: 'not-an-email', name: 'Test' })
        .expect(400);
    });
  });

  describe('GET /users/:id (fetch)', () => {
    it('should require authentication', () => {
      return request(app.getHttpServer())
        .get('/users/1')
        .expect(401);  // Or 501 if NotImplementedAuthGuard still active
    });
  });
});

Step 10: Regenerate API Client

pnpm codegen:api

This generates Dart methods in packages/api_client/:

  • createUser(body: CreateUserDto) → POST /users
  • getUser(id: int) → GET /users/{id}
  • listUsers() → GET /users

operationId is critical:

  • operationId: 'createUser' → method name createUser()
  • Without operationId, generated name is ugly (e.g., postuserscreateUserDtoPostResponse)

Step 11: Verify

Start backend:

pnpm --filter @mobile-boilerplate/api dev

Test endpoint:

# Public: create user (no auth header needed)
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@ex.com","name":"Alice"}'

# Response:
# {
#   "data": { "id": 1, "email": "alice@ex.com", "name": "Alice" },
#   "meta": { "requestId": "...", "timestamp": "...", "version": "1.0" },
#   "error": null
# }

# Private: fetch user (requires auth once NotImplementedAuthGuard is replaced)
curl -X GET http://localhost:3000/users/1 \
  -H "Authorization: Bearer <token>"

Check Swagger:

http://localhost:3000/api-docs (development mode)

Step 12: Add Real Authentication (Later)

When you're ready to implement real auth:

  1. Implement JwtAuthGuard honoring IS_PUBLIC_KEY + (optionally) REQUIRES_AUTH_KEY metadata.

    // apps/api/src/common/auth/jwt-auth.guard.ts
    import { ExecutionContext, Injectable } from '@nestjs/common';
    import { Reflector } from '@nestjs/core';
    import { AuthGuard } from '@nestjs/passport';
    import { IS_PUBLIC_KEY } from './decorators/public.decorator';
    
    @Injectable()
    export class JwtAuthGuard extends AuthGuard('jwt') {
      constructor(private readonly reflector: Reflector) {
        super();
      }
    
      canActivate(context: ExecutionContext) {
        const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
          context.getHandler(),
          context.getClass(),
        ]);
        if (isPublic) return true;
        return super.canActivate(context); // passport JWT
      }
    }
  2. In apps/api/src/app.module.ts, swap useClass: NotImplementedAuthGuarduseClass: JwtAuthGuard.

  3. All existing @Public() and @RequireAuth() decorations work unchanged. Routes without a decorator now require a valid JWT (passport rejects with 401).

Troubleshooting

Issue Solution
"No controller found" Ensure module is imported in app.module.ts
"Prisma model not found" Run prisma:migrate dev + prisma:generate
"Swagger endpoint missing" Verify @ApiStandardResponse(Dto) on controller method (not raw @ApiOkResponse)
"Generated client has ugly method names" Add @ApiOperation({ operationId: 'verbNoun' }) to controller method
"Validation fails unexpectedly" Check forbidNonWhitelisted: true in ValidationPipe — extra fields rejected

Next Steps: