Projekt Końcowy w NestJS: Tworzenie Mini Aplikacji do Zarządzania Zadaniami

W ramach tej lekcji zajmiemy się stworzeniem kompletnego projektu, który pozwoli zastosować wszystkie poznane koncepcje NestJS w praktyce. Naszym celem będzie zbudowanie mini aplikacji API do zarządzania zadaniami. Projekt ten pozwoli lepiej zrozumieć, jak integrować różne elementy NestJS oraz jak zbudować funkcjonalną i wydajną aplikację serwerową.

Cele Projektu

  • Zastosować wszystkie poznane wcześniej koncepcje NestJS, takie jak moduły, kontrolery, serwisy, autoryzacja, middleware i inne.
  • Stworzyć mini aplikację – API do zarządzania zadaniami.
  • Zrozumieć, jak wszystkie elementy współpracują ze sobą w pełnej aplikacji.

Krok 1: Przygotowanie Projektu

Na początek stworzymy nowy projekt NestJS, używając CLI:

				
					npx @nestjs/cli new task-manager

				
			

Podczas tworzenia projektu NestJS zapyta o wybranie managera pakietów – wybieramy ten, który preferujemy, np. npm lub yarn.

Krok 2: Struktura Aplikacji

Nasza aplikacja do zarządzania zadaniami będzie mieć następującą strukturę:

  • Moduły: oddzielają różne funkcjonalności (np. TaskModule).
  • Kontrolery: zajmują się przetwarzaniem zapytań HTTP.
  • Serwisy: logika biznesowa aplikacji.
  • Baza danych: korzystamy z TypeORM i PostgreSQL do przechowywania zadań.

Utworzymy moduł zarządzania zadaniami:

				
					nest generate module tasks

				
			

Następnie generujemy kontroler i serwis dla tego modułu:

				
					nest generate controller tasks
nest generate service tasks

				
			

Krok 3: Tworzenie Modelu Task

Zacznijmy od stworzenia podstawowej struktury, która będzie reprezentować zadanie. Stworzymy klasę Task oraz połączymy ją z TypeORM, aby przechowywać zadania w bazie danych.

tasks/task.entity.ts

				
					import {Column, Entity, PrimaryGeneratedColumn} from "typeorm";

@Entity()
export class Task {
    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    title: string;

    @Column()
    description: string;

    @Column({ default: 'OPEN'})
    status: string;
}
				
			

W tym kodzie:

  • @Entity() – dekorator, który oznacza, że klasa jest encją przechowywaną w bazie danych.
  • @PrimaryGeneratedColumn() – automatycznie generowane ID zadania.
  • @Column() – kolumna, która będzie przechowywać dane (tytuł, opis, status).

Krok 4: Konfiguracja Bazy Danych

Kolejnym krokiem jest skonfigurowanie połączenia z bazą danych. Otwórz app.module.ts i dodaj konfigurację TypeORM:

app.module.ts

				
					import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TasksModule } from './tasks/tasks.module';
import { Task } from './tasks/task.entity';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: 'localhost',
      port: 5432,
      username: 'postgres',
      password: 'password',
      database: 'taskmanagement',
      entities: [Task],
      synchronize: true,
    }),
    TasksModule,
  ],
})
export class AppModule {}

				
			

Opis:

  • TypeOrmModule.forRoot() – konfigurujemy połączenie z bazą danych.
  • entities: [Task] – podajemy wszystkie encje, które będą używane.
  • synchronize: true – automatyczna synchronizacja struktury bazy danych z kodem (używane w środowisku deweloperskim).

Krok 5: Tworzenie Serwisu TaskService

Przechodzimy do serwisu, który będzie zajmował się logiką zarządzania zadaniami, taką jak tworzenie, aktualizowanie, usuwanie.

tasks/task.service.ts

				
					import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Task } from './task.entity';

@Injectable()
export class TasksService {
  constructor(
    @InjectRepository(Task)
    private taskRepository: Repository<Task>,
  ) {}

  findAll(): Promise<Task[]> {
    return this.taskRepository.find();
  }

  findOne(id: number): Promise<Task> {
    return this.taskRepository.findOneBy({ id });
  }

  async createTask(title: string, description: string): Promise<Task> {
    const task = this.taskRepository.create({ title, description });
    return await this.taskRepository.save(task);
  }

  async removeTask(id: number): Promise<void> {
    await this.taskRepository.delete(id);
  }
}

				
			

Opis:

  • @InjectRepository(Task) – używamy repozytorium do zarządzania danymi w bazie.
  • findAll(), findOne(), createTask(), removeTask() – metody do wyszukiwania, tworzenia i usuwania zadań.

Krok 6: Tworzenie Kontrolera TaskController

Teraz utworzymy kontroler, który będzie przetwarzał żądania HTTP:

tasks/task.controller.ts

				
					import { Controller, Get, Post, Delete, Param, Body } from '@nestjs/common';
import { TasksService } from './tasks.service';
import { Task } from './task.entity';

@Controller('tasks')
export class TasksController {
  constructor(private readonly tasksService: TasksService) {}

  @Get()
  getAllTasks(): Promise<Task[]> {
    return this.tasksService.findAll();
  }

  @Get(':id')
  getTask(@Param('id') id: number): Promise<Task> {
    return this.tasksService.findOne(id);
  }

  @Post()
  createTask(
    @Body('title') title: string,
    @Body('description') description: string,
  ): Promise<Task> {
    return this.tasksService.createTask(title, description);
  }

  @Delete(':id')
  deleteTask(@Param('id') id: number): Promise<void> {
    return this.tasksService.removeTask(id);
  }
}

				
			

Opis:

  • @Controller(’tasks’) – kontroler zarządza ścieżką /tasks.
  • @Get(), @Post(), @Delete() – dekoratory wskazujące, jaki rodzaj żądania HTTP jest przetwarzany.
  • @Param(’id’) id: number – pobieramy parametry ścieżki, np. ID zadania.
  • @Body() – służy do pobierania danych z ciała żądania.

Krok 7: Middleware i Guards

Dodajemy middleware do logowania wszystkich przychodzących żądań:

tasks/logging.middleware.ts

				
					import { Injectable, NestMiddleware } from '@nestjs/common';

@Injectable()
export class LoggingMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: () => void) {
    console.log(`Request...`);
    next();
  }
}

				
			

Dodajemy middleware do aplikacji w tasks.module.ts:

				
					import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Task } from './task.entity';
import { LoggingMiddleware } from './logging.middleware';

@Module({
  imports: [TypeOrmModule.forFeature([Task])],
  controllers: [TasksController],
  providers: [TasksService],
})
export class TasksModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(LoggingMiddleware).forRoutes(TasksController);
  }
}

				
			

Krok 8: Dodanie Modułu User

Dodajmy odpowiednie pliki i zaktualizujmy konfigurację. Poniżej znajdziesz dokładną instrukcję.

1. Tworzenie Modułu User

Na początek generujemy moduł dla użytkownika (user). Możemy to zrobić poleceniem:

				
					nest generate module user

				
			

Następnie generujemy serwis:

				
					nest generate service user

				
			

Teraz zaimplementujemy pliki tak, jak opisałeś.

2. Implementacja User w plikach

user.entity.ts

				
					import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";

@Entity()
export class User {
    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    username: string;

    @Column()
    password: string;
}

				
			

Opis:

  • @Entity() – klasa User jest encją zarządzaną przez TypeORM.
  • @PrimaryGeneratedColumn() – automatycznie generowane ID.
  • @Column() – kolumny username i password.

user.service.ts

				
					import { Injectable } from '@nestjs/common';
import { InjectRepository } from "@nestjs/typeorm";
import { User } from "./user.entity";
import { Repository } from "typeorm";

@Injectable()
export class UserService {
    constructor(
        @InjectRepository(User)
        private userRepository: Repository<User>,
    ) {}

    async findOne(username: string): Promise<User | undefined> {
        return this.userRepository.findOneBy({ username });
    }

    async createUser(username: string, password: string): Promise<User> {
        const user = this.userRepository.create({ username, password });
        return await this.userRepository.save(user);
    }

    async removeUser(id: number): Promise<void> {
        await this.userRepository.delete(id);
    }
}

				
			

Opis:

  • findOne(username: string) – metoda znajduje użytkownika na podstawie nazwy użytkownika.
  • createUser(username: string, password: string) – tworzenie nowego użytkownika.
  • removeUser(id: number) – usuwanie użytkownika na podstawie ID.

user.module.ts

				
					import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from "./user.entity";
import { UserService } from './user.service';

@Module({
    imports: [TypeOrmModule.forFeature([User])],
    providers: [UserService],
    exports: [UserService],
})
export class UserModule {}

				
			

Opis:

  • imports: [TypeOrmModule.forFeature([User])] – moduł TypeORM dostaje dostęp do repozytorium User.
  • exports: [UserService] – eksportowanie UserService, aby można było go używać w innych modułach.

3. Aktualizacja AppModule

Zaktualizujmy AppModule, aby zaimportować nowo utworzony moduł UserModule:

				
					import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TasksModule } from './tasks/tasks.module';
import { UserModule } from './user/user.module';
import { Task } from './tasks/task.entity';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: 'localhost',
      port: 5432,
      username: 'postgres',
      password: 'password',
      database: 'taskmanagement',
      entities: [Task],
      synchronize: true,
    }),
    TasksModule,
    UserModule,
  ],
})
export class AppModule {}

				
			

Krok 9: Dodanie JWT do Autoryzacji

JWT (JSON Web Token) jest standardem do wymiany informacji, który jest często używany do uwierzytelniania użytkowników i autoryzacji. Dzięki temu, tylko uwierzytelnieni użytkownicy będą mieli dostęp do określonych części API.

Aby zaimplementować JWT w NestJS, przejdziemy przez następujące kroki:

  1. Instalacja niezbędnych bibliotek
  2. Tworzenie modułu autoryzacji (AuthModule)
  3. Tworzenie serwisu do uwierzytelniania użytkowników
  4. Generowanie tokenów JWT dla zalogowanych użytkowników
  5. Zabezpieczanie endpointów za pomocą guardów

1. Instalacja Niezbędnych Bibliotek

Pierwszym krokiem jest instalacja paczek, które są potrzebne do obsługi JWT:

				
					npm install @nestjs/jwt @nestjs/passport passport passport-jwt bcryptjs
npm install -D @types/passport-jwt @types/bcryptjs

				
			

Opis:

  • @nestjs/jwt i @nestjs/passport – biblioteki do obsługi JWT w NestJS.
  • passport i passport-jwt – narzędzie do autoryzacji.
  • bcryptjs – do hashowania haseł.

2. Tworzenie Modułu Autoryzacji (AuthModule)

Stworzymy moduł autoryzacji, który będzie odpowiedzialny za logowanie użytkowników i zarządzanie autoryzacją. Najpierw generujemy nowy moduł:

				
					nest generate module auth

				
			

3. Tworzenie Serwisu do Uwierzytelniania Użytkowników

Następnie stworzymy serwis do zarządzania uwierzytelnianiem. Serwis ten będzie odpowiedzialny za sprawdzanie użytkowników, logowanie oraz generowanie tokenów JWT.

				
					nest generate service auth

				
			

auth.service.ts

				
					import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UsersService } from '../users/users.service';
import * as bcrypt from 'bcryptjs';

@Injectable()
export class AuthService {
  constructor(
    private usersService: UsersService,
    private jwtService: JwtService,
  ) {}

  async validateUser(username: string, password: string): Promise<any> {
    const user = await this.usersService.findOne(username);
    if (user && await bcrypt.compare(password, user.password)) {
      const { password, ...result } = user;
      return result;
    }
    return null;
  }

  async login(user: any) {
    const payload = { username: user.username, sub: user.userId };
    return {
      access_token: this.jwtService.sign(payload),
    };
  }
}

				
			

Opis:

  • validateUser(username: string, password: string) – metoda sprawdzająca, czy użytkownik istnieje oraz czy hasło jest poprawne (używając bcrypt).
  • login(user: any) – metoda generująca token JWT. Token zawiera informacje, takie jak nazwa użytkownika oraz identyfikator.

4. Tworzenie Kontrolera Autoryzacji

Stwórzmy kontroler do obsługi operacji związanych z uwierzytelnianiem:

auth.controller.ts

				
					import { Controller, Post, Request, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalAuthGuard } from './local-auth.guard';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @UseGuards(LocalAuthGuard)
  @Post('login')
  async login(@Request() req) {
    return this.authService.login(req.user);
  }
}

				
			

Opis:

  • @UseGuards(LocalAuthGuard) – używamy guarda do zabezpieczenia tego endpointu. Guard sprawdza, czy dane logowania są poprawne.
  • @Post(’login’) – punkt końcowy /auth/login przyjmujący dane logowania.

5. Tworzenie LocalAuthGuard

LocalAuthGuard jest guardem, który obsługuje uwierzytelnianie przy użyciu danych użytkownika. Korzysta z mechanizmu Passport do sprawdzania danych:

local-auth.guard.ts

				
					import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

				
			

6. Konfiguracja Passport Strategy

Musimy zdefiniować strategię logowania lokalnego, używając Passport:

local.strategy.ts

				
					import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private authService: AuthService) {
    super();
  }

  async validate(username: string, password: string): Promise<any> {
    const user = await this.authService.validateUser(username, password);
    if (!user) {
      throw new UnauthorizedException();
    }
    return user;
  }
}

				
			

Opis:

  • LocalStrategy używa Passport do obsługi uwierzytelniania.
  • validate() sprawdza, czy podane dane użytkownika są poprawne, a jeśli nie, zwraca wyjątek UnauthorizedException.

7. Tworzenie JWT Strategy

JWT Strategy pozwala na zweryfikowanie poprawności tokenu JWT, kiedy użytkownik próbuje uzyskać dostęp do chronionych zasobów:

jwt.strategy.ts

				
					import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: 'secretKey',
    });
  }

  async validate(payload: any) {
    return { userId: payload.sub, username: payload.username };
  }
}

				
			

Opis:

  • jwtFromRequest – pobieramy token JWT z nagłówka Authorization (typ Bearer).
  • secretOrKey – klucz do weryfikacji tokenu (w środowisku produkcyjnym należy używać zmiennych środowiskowych do przechowywania kluczy).
  • validate(payload: any) – metoda służy do weryfikacji użytkownika na podstawie payloadu z tokenu.

8. Zabezpieczanie Endpointów

Ostatecznym krokiem jest zabezpieczenie endpointów za pomocą JWT Guards. Dodamy JwtAuthGuard do kontrolera TasksController, aby dostęp do operacji związanych z zadaniami był możliwy tylko dla zalogowanych użytkowników.

jwt-auth.guard.ts

				
					import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

				
			

tasks/task.controller.ts

				
					import { Controller, Get, Post, Delete, Param, Body, UseGuards } from '@nestjs/common';
import { TasksService } from './tasks.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';

@Controller('tasks')
@UseGuards(JwtAuthGuard) // Używamy JwtAuthGuard do zabezpieczenia
export class TasksController {
  constructor(private readonly tasksService: TasksService) {}

  @Get()
  getAllTasks() {
    return this.tasksService.findAll();
  }

  @Post()
  createTask(
    @Body('title') title: string,
    @Body('description') description: string,
  ) {
    return this.tasksService.createTask(title, description);
  }

  @Delete(':id')
  deleteTask(@Param('id') id: number) {
    return this.tasksService.removeTask(id);
  }
}

				
			

Opis:

  • @UseGuards(JwtAuthGuard) – zabezpieczamy wszystkie metody kontrolera TasksController przy użyciu JWT.
  • Tylko użytkownicy posiadający poprawny token będą mogli uzyskać dostęp do zasobów.

9. Dodanie JwtStrategy do AuthModule

Dodanie JwtStrategy do providers w AuthModule jest kluczowe, aby odpowiednio skonfigurować autoryzację JWT w aplikacji. Upewniamy się, że strategia JWT będzie zarejestrowana i gotowa do użycia w celu weryfikacji tokenów.

Oto jak powinien wyglądać zaktualizowany AuthModule:

				
					import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { UserModule } from '../user/user.module';
import { LocalStrategy } from './local.strategy';
import { JwtStrategy } from './jwt.strategy';

@Module({
  imports: [
    PassportModule,
    JwtModule.register({
      secret: 'secretKey', // Powinno być przechowywane jako zmienna środowiskowa w środowisku produkcyjnym
      signOptions: { expiresIn: '60m' },
    }),
    UserModule,
  ],
  providers: [AuthService, LocalStrategy, JwtStrategy],
  exports: [AuthService],
})
export class AuthModule {}

				
			

Kluczowe Zmiany

  1. Dodanie JwtStrategy do providers:

    • JwtStrategy dodajemy do providers, aby NestJS mógł używać tej strategii w mechanizmach Passport.
    • Jest to kluczowe, ponieważ JwtStrategy obsługuje weryfikację tokenów JWT podczas uwierzytelniania i autoryzacji użytkowników.
  2. Konfiguracja JwtModule:

    • JwtModule z @nestjs/jwt jest zarejestrowany z secret oraz signOptions, aby możliwe było generowanie i weryfikowanie tokenów.
    • Wartość secret powinna być przechowywana jako zmienna środowiskowa, aby uniknąć wycieku klucza prywatnego.

Krok 10: Testowanie Aplikacji z JWT

Testowanie aplikacji z JWT wymaga nie tylko testów jednostkowych, ale także integracyjnych, aby sprawdzić, czy użytkownicy mogą się zalogować i uzyskać dostęp do chronionych zasobów za pomocą tokenu JWT.

a) Testy Jednostkowe dla Serwisu AuthService

Rozpoczniemy od testowania serwisu AuthService, który jest odpowiedzialny za walidację użytkownika oraz generowanie tokenów.

auth/auth.service.spec.ts

				
					import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { UsersService } from '../users/users.service';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';

describe('AuthService', () => {
  let authService: AuthService;
  let usersService: UsersService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        AuthService,
        {
          provide: UsersService,
          useValue: {
            findOne: jest.fn(),
          },
        },
        {
          provide: JwtService,
          useValue: {
            sign: jest.fn().mockReturnValue('fake-jwt-token'),
          },
        },
      ],
    }).compile();

    authService = module.get<AuthService>(AuthService);
    usersService = module.get<UsersService>(UsersService);
  });

  it('should validate user successfully', async () => {
    const user = { userId: 1, username: 'john', password: await bcrypt.hash('changeme', 10) };
    jest.spyOn(usersService, 'findOne').mockResolvedValue(user);

    const result = await authService.validateUser('john', 'changeme');
    expect(result).toEqual({ userId: 1, username: 'john' });
  });

  it('should return null if user validation fails', async () => {
    jest.spyOn(usersService, 'findOne').mockResolvedValue(null);

    const result = await authService.validateUser('john', 'changeme');
    expect(result).toBeNull();
  });

  it('should generate a JWT token for the user', async () => {
    const user = { userId: 1, username: 'john' };
    const result = await authService.login(user);
    expect(result.access_token).toEqual('fake-jwt-token');
  });
});

				
			

Opis:

  • validateUser() – sprawdzamy, czy użytkownik z odpowiednim hasłem może być poprawnie zwalidowany.
  • login() – sprawdzamy, czy poprawnie generujemy token JWT dla zalogowanego użytkownika.

b) Testy Integracyjne Dla AuthController

Testowanie kontrolera AuthController jest ważne, aby upewnić się, że proces logowania działa poprawnie i generuje poprawny token JWT.

auth/auth.controller.spec.ts

				
					import { Test, TestingModule } from '@nestjs/testing';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';

describe('AuthController', () => {
  let authController: AuthController;
  let authService: AuthService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [AuthController],
      providers: [
        {
          provide: AuthService,
          useValue: {
            login: jest.fn().mockResolvedValue({ access_token: 'fake-jwt-token' }),
          },
        },
      ],
    }).compile();

    authController = module.get<AuthController>(AuthController);
    authService = module.get<AuthService>(AuthService);
  });

  it('should login and return JWT token', async () => {
    const req = {
      user: { userId: 1, username: 'john' },
    };
    const result = await authController.login(req);
    expect(result.access_token).toEqual('fake-jwt-token');
  });
});

				
			

Opis:

  • login() – sprawdzamy, czy metoda zwraca poprawnie wygenerowany token dla użytkownika, symulując logowanie.

c) Testy Integracyjne Endpointów TasksController

Kolejnym krokiem jest sprawdzenie, czy endpointy w TasksController są zabezpieczone i można je wywołać tylko wtedy, gdy użytkownik posiada odpowiedni token JWT.

tasks/tasks.controller.spec.ts

				
					import { Test, TestingModule } from '@nestjs/testing';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { PassportModule } from '@nestjs/passport';
import { APP_GUARD } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';

describe('TasksController', () => {
  let tasksController: TasksController;
  let tasksService: TasksService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      imports: [PassportModule],
      controllers: [TasksController],
      providers: [
        TasksService,
        JwtService,
        {
          provide: APP_GUARD,
          useClass: JwtAuthGuard,
        },
      ],
    }).compile();

    tasksController = module.get<TasksController>(TasksController);
    tasksService = module.get<TasksService>(TasksService);
  });

  it('should be defined', () => {
    expect(tasksController).toBeDefined();
  });

  it('should get all tasks', async () => {
    const result = [{ id: 1, title: 'Test Task', description: 'Test Desc', status: 'OPEN' }];
    jest.spyOn(tasksService, 'findAll').mockResolvedValue(result);

    expect(await tasksController.getAllTasks()).toBe(result);
  });

  it('should protect routes with JWT', async () => {
    // Testujemy, czy wymagane jest posiadanie tokenu JWT.
    // To jest raczej teoretyczny test, ponieważ rzeczywista ochrona jest realizowana przez NestJS i Passport.
    // Testujemy, czy JwtAuthGuard jest odpowiednio skonfigurowany.
    expect(JwtAuthGuard).toBeDefined();
  });
});

				
			

Opis:

  • getAllTasks() – sprawdzamy, czy metoda zwraca wszystkie zadania. Zakładamy, że użytkownik posiada odpowiedni token.
  • should protect routes with JWT – sprawdzamy, czy guard jest zainicjowany, aby chronić zasoby.

d) Testowanie JWT Guard

Na koniec przetestujemy JwtAuthGuard, aby upewnić się, że użytkownicy bez tokenu nie będą mogli uzyskać dostępu do chronionych zasobów:

auth/jwt-auth.guard.spec.ts

				
					import { JwtAuthGuard } from './jwt-auth.guard';
import { ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';

describe('JwtAuthGuard', () => {
  let jwtAuthGuard: JwtAuthGuard;

  beforeEach(() => {
    jwtAuthGuard = new JwtAuthGuard(new Reflector());
  });

  it('should be defined', () => {
    expect(jwtAuthGuard).toBeDefined();
  });

  it('should return false if no authorization header', () => {
    const mockContext = {
      switchToHttp: () => ({
        getRequest: () => ({
          headers: {},
        }),
      }),
    } as ExecutionContext;

    expect(jwtAuthGuard.canActivate(mockContext)).toBe(false);
  });
});

				
			

Opis:

  • should return false if no authorization header – ten test sprawdza, czy guard poprawnie blokuje dostęp, gdy nie jest dostarczony token JWT.

Podsumowanie Testów z JWT

Dodanie JWT wprowadza nowe wyzwania związane z testowaniem aplikacji. Aby zapewnić pełne pokrycie, przetestowaliśmy:

  • Serwis AuthService – testy jednostkowe, które sprawdzają poprawność walidacji użytkownika oraz generowania tokenów.
  • Kontroler AuthController – testowanie endpointu logowania.
  • Kontroler TasksController – testy zabezpieczenia chronionych zasobów oraz weryfikacja, czy dostęp jest ograniczony do użytkowników z tokenem JWT.
  • JwtAuthGuard – upewniliśmy się, że guard poprawnie blokuje nieautoryzowane żądania.

Dzięki temu podejściu możemy mieć pewność, że autoryzacja użytkowników jest realizowana w sposób bezpieczny, a tylko zalogowani użytkownicy mają dostęp do chronionych zasobów.

Krok 11: Obsługa Błędów

Każda aplikacja powinna posiadać zaimplementowaną solidną obsługę błędów, która umożliwi użytkownikowi otrzymywanie odpowiednich komunikatów. Dodanie Globalnego Filtra dla wyjątków (Exception Filter) pomoże w eleganckiej obsłudze błędów.

tasks/http-exception.filter.ts

				
					import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { Request, Response } from 'express';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = exception.getStatus();

    response
      .status(status)
      .json({
        statusCode: status,
        timestamp: new Date().toISOString(),
        path: request.url,
      });
  }
}

				
			

Dodanie filtra w app.module.ts:

				
					import { APP_FILTER } from '@nestjs/core';
import { HttpExceptionFilter } from './tasks/http-exception.filter';

@Module({
  providers: [
    {
      provide: APP_FILTER,
      useClass: HttpExceptionFilter,
    },
  ],
})
export class AppModule {}

				
			

Krok 12: Dokumentacja API za pomocą Swaggera

Dokumentacja API to kluczowy element każdej aplikacji. Możemy użyć Swaggera, aby wygenerować interaktywną dokumentację.

app.module.ts

				
					import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const config = new DocumentBuilder()
    .setTitle('Task Manager')
    .setDescription('API do zarządzania zadaniami')
    .setVersion('1.0')
    .build();
  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup('api', app, document);

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

				
			

Krok 13: Optymalizacja Wydajności

Możemy również zadbać o optymalizację wydajności aplikacji, stosując odpowiednie techniki, takie jak caching za pomocą Redis:

tasks/task.service.ts

Dodanie mechanizmu cachowania do metody wyszukiwania wszystkich zadań:

				
					import { CACHE_MANAGER, Inject } from '@nestjs/common';
import { Cache } from 'cache-manager';

@Injectable()
export class TasksService {
  constructor(
    @InjectRepository(Task)
    private taskRepository: Repository<Task>,
    @Inject(CACHE_MANAGER) private cacheManager: Cache,
  ) {}

  async findAll(): Promise<Task[]> {
    const cachedTasks = await this.cacheManager.get<Task[]>('tasks');
    if (cachedTasks) {
      return cachedTasks;
    }
    const tasks = await this.taskRepository.find();
    await this.cacheManager.set('tasks', tasks, { ttl: 1000 });
    return tasks;
  }
}

				
			

Krok 14: Bezpieczeństwo

Dodanie narzędzi zabezpieczających, takich jak helmet do ochrony przed atakami XSS, oraz rate limiter do zabezpieczenia przed zbyt wieloma zapytaniami:

main.ts

				
					import * as helmet from 'helmet';
import * as rateLimit from 'express-rate-limit';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  
  app.use(helmet()); // Zabezpieczenie nagłówków
  app.use(
    rateLimit({
      windowMs: 15 * 60 * 1000, // 15 minut
      max: 100, // limit na 100 zapytań na IP
    }),
  );

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

				
			

Krok 15: Zabezpieczenie Endpoints za pomocą Guards

Dodanie guardów, aby upewnić się, że tylko zalogowani użytkownicy mają dostęp do określonych endpointów:

auth/jwt-auth.guard.ts

				
					import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  canActivate(context: ExecutionContext) {
    // Dodatkowe logiki zabezpieczeń
    return super.canActivate(context);
  }
}

				
			

tasks/task.controller.ts

				
					import { UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';

@Controller('tasks')
@UseGuards(JwtAuthGuard)
export class TasksController {
  // wszystkie endpointy w tym kontrolerze będą zabezpieczone przez JwtAuthGuard
}

				
			

Krok 16: Integracja z Front-Endem

Jeśli tworzysz aplikację full-stack, warto zintegrować API z frontendem, np. React lub Angular, aby w pełni zademonstrować możliwości aplikacji.

  • Dodaj endpointy do uwierzytelniania użytkowników (np. rejestracja, logowanie).
  • Skonfiguruj CORS, aby umożliwić komunikację pomiędzy backendem a frontendem.

Krok 17: Deployment Aplikacji

Na zakończenie wdrażamy aplikację na chmurze – możemy skorzystać z Dockera oraz chmury, takiej jak AWS, DigitalOcean czy Heroku.

Dockerfile

				
					FROM node:14

WORKDIR /app

COPY package*.json ./

RUN npm install

COPY . .

RUN npm run build

EXPOSE 3000

CMD ["npm", "run", "start:prod"]

				
			

Krok 18: Monitorowanie i Utrzymanie

Wdrożenie aplikacji produkcyjnej wiąże się z koniecznością monitorowania jej działania i stabilności:

  • Sentry do śledzenia błędów.
  • Prometheus i Grafana do monitorowania wydajności.

Integracja Sentry:

				
					import * as Sentry from '@sentry/node';

Sentry.init({
  dsn: 'YOUR_SENTRY_DSN',
});

				
			

Podsumowanie

Projekt końcowy w NestJS nie musi ograniczać się tylko do kilku kluczowych punktów. Rozszerzenie o dodatkowe aspekty, takie jak testowanie, optymalizacja, dokumentacja, bezpieczeństwo oraz monitorowanie aplikacji, pozwala na pełniejsze zrozumienie, jak zbudować kompletną, profesjonalną aplikację serwerową.

Każdy z kroków opisanych w tym artykule pokazuje, jak praktycznie wdrożyć zdobyte wcześniej umiejętności, aby stworzyć aplikację, która jest wydajna, bezpieczna i łatwa do utrzymania. Dzięki zastosowaniu tych technik możemy przejść od prostego API do pełnoprawnej aplikacji, która spełnia wymogi produkcyjne.