Kurs NestJS · Moduł 1: Podstawy NestJS

PROJEKT: Rejestr Legionów Imperium

5 min czytania
W tej lekcji8

Ave, legacie! Przez cały moduł budowałeś pojedyncze elementy: kontroler tu, serwis tam, DTO i konfigurację osobno. Prawdziwa aplikacja nie jest zbiorem luźnych klocków - to jeden organizm, w którym żądanie przechodzi przez kilka rąk, zanim wróci odpowiedź. Czas złożyć wszystko w całość: Rejestr Legionów, API, w którym kancelaria Imperium przyjmuje, sprawdza i przechowuje dane legionistów.

Co zbudujesz

REST API z pięcioma endpointami pod adresem /legionaries:

MetodaAdresCo robiKod odpowiedzi
GET/legionarieslista wszystkich legionistów200
GET/legionaries/search?province=Gallialegioniści z jednej prowincji200
GET/legionaries/:idjeden legionista200 albo 404
POST/legionariesrekrutacja nowego legionisty201 albo 400
DELETE/legionaries/:idwykreślenie z rejestru204 albo 404

Każdy element tej tabeli poznałeś w tym module. Projekt sprawdza, czy umiesz je połączyć w jedną aplikację.

Etap 1: projekt i pakiety

Zacznij od nowego projektu (generator nest new w NestJS 12 potrzebuje Node.js 22.22.3+ albo 24.15+) i doinstaluj pakiety, z których korzystaliśmy w lekcjach o DTO i konfiguracji:

1npm install -g @nestjs/cli
2nest new legion-register
3cd legion-register
4npm i @nestjs/config joi class-validator class-transformer @nestjs/mapped-types

Potem utwórz w katalogu projektu plik .env. Pamiętaj, że nie trafia on do repozytorium - dopisz go do .gitignore, a obok zapisz .env.example z tymi samymi kluczami:

1PORT=3000
2LEGION_NAME=Legio X Equestris

Etap 2: DTO - formularz rekrutacyjny

Rejestr przyjmuje tylko kompletne zgłoszenia. Opisz je klasą DTO z dekoratorami z class-validator:

1// legion/dto/create-legionary.dto.ts
2import { IsEmail, IsIn, IsNotEmpty, IsString } from 'class-validator';
3
4export class CreateLegionaryDto {
5  @IsString()
6  @IsNotEmpty()
7  name: string;
8
9  @IsIn(['Miles', 'Optio', 'Centurio'])
10  rank: string;
11
12  @IsString()
13  @IsNotEmpty()
14  province: string;
15
16  @IsEmail()
17  email: string;
18}

Same dekoratory jeszcze niczego nie blokują. Zadziałają dopiero razem z ValidationPipe, który włączysz w etapie 5.

Etap 3: serwis - kancelaria rejestru

Cała logika mieszka w serwisie. Trzyma legionistów w tablicy, nadaje im kolejne numery i zgłasza brak legionisty wyjątkiem NotFoundException. Nazwę legionu czyta z konfiguracji przez ConfigService:

1// legion/legion.service.ts
2import { Injectable, NotFoundException } from '@nestjs/common';
3import { ConfigService } from '@nestjs/config';
4import { CreateLegionaryDto } from './dto/create-legionary.dto';
5
6export interface Legionary extends CreateLegionaryDto {
7  id: number;
8}
9
10@Injectable()
11export class LegionService {
12  private legionaries: Legionary[] = [];
13  private nextId = 1;
14
15  constructor(private readonly configService: ConfigService) {}
16
17  findAll(): Legionary[] {
18    return this.legionaries;
19  }
20
21  findByProvince(province: string): Legionary[] {
22    return this.legionaries.filter((l) => l.province === province);
23  }
24
25  findOne(id: number): Legionary {
26    const legionary = this.legionaries.find((l) => l.id === id);
27    if (!legionary) {
28      throw new NotFoundException('Nie ma legionisty o numerze ' + id);
29    }
30    return legionary;
31  }
32
33  recruit(data: CreateLegionaryDto): Legionary {
34    const legionary = { id: this.nextId++, ...data };
35    this.legionaries.push(legionary);
36    return legionary;
37  }
38
39  dismiss(id: number): void {
40    this.findOne(id);
41    this.legionaries = this.legionaries.filter((l) => l.id !== id);
42  }
43
44  getLegionName(): string {
45    return this.configService.get('LEGION_NAME');
46  }
47}

Zwróć uwagę na licznik nextId. Numer z długości tablicy powtórzyłby się po wykreśleniu kogoś ze środka rejestru, a licznik zawsze rośnie. Metoda dismiss najpierw woła findOne, więc usunięcie nieistniejącego legionisty też skończy się odpowiedzią 404.

Etap 4: kontroler - centurion przy bramie

Kontroler tylko przyjmuje żądania i przekazuje je serwisowi. Pilnuj kolejności tras: search przed :id, inaczej adres /legionaries/search trafiłby do findOne:

1// legion/legion.controller.ts
2import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Query } from '@nestjs/common';
3import { CreateLegionaryDto } from './dto/create-legionary.dto';
4import { LegionService } from './legion.service';
5
6@Controller('legionaries')
7export class LegionController {
8  constructor(private readonly legionService: LegionService) {}
9
10  @Get()
11  findAll() {
12    return this.legionService.findAll();
13  }
14
15  @Get('search')
16  search(@Query('province') province: string) {
17    return this.legionService.findByProvince(province);
18  }
19
20  @Get(':id')
21  findOne(@Param('id') id: string) {
22    return this.legionService.findOne(Number(id));
23  }
24
25  @Post()
26  recruit(@Body() dto: CreateLegionaryDto) {
27    return this.legionService.recruit(dto);
28  }
29
30  @Delete(':id')
31  @HttpCode(HttpStatus.NO_CONTENT)
32  dismiss(@Param('id') id: string) {
33    this.legionService.dismiss(Number(id));
34  }
35}

Id z adresu jest tekstem, dlatego kontroler zamienia je na liczbę przez Number(id), zanim przekaże je serwisowi. Kodu 201 dla @Post() nie trzeba ustawiać - NestJS dobiera go sam.

Etap 5: moduły i start aplikacji

Moduł legionu zgłasza kontroler i serwis:

1// legion/legion.module.ts
2import { Module } from '@nestjs/common';
3import { LegionController } from './legion.controller';
4import { LegionService } from './legion.service';
5
6@Module({
7  controllers: [LegionController],
8  providers: [LegionService],
9})
10export class LegionModule {}

Moduł główny wczytuje konfigurację, sprawdza ją przy starcie schematem Joi i dołącza moduł legionu:

1// app.module.ts
2import { Module } from '@nestjs/common';
3import { ConfigModule } from '@nestjs/config';
4import * as Joi from 'joi';
5import { LegionModule } from './legion/legion.module';
6
7@Module({
8  imports: [
9    ConfigModule.forRoot({
10      isGlobal: true,
11      validationSchema: Joi.object({
12        PORT: Joi.number().default(3000),
13        LEGION_NAME: Joi.string().required(),
14      }),
15    }),
16    LegionModule,
17  ],
18})
19export class AppModule {}

Na koniec main.ts włącza walidację dla całej aplikacji i uruchamia serwer:

1// main.ts
2import { ValidationPipe } from '@nestjs/common';
3import { NestFactory } from '@nestjs/core';
4import { AppModule } from './app.module';
5
6async function bootstrap() {
7  const app = await NestFactory.create(AppModule);
8  app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
9  await app.listen(process.env.PORT ?? 3000);
10}
11bootstrap();

Sprawdź rejestr

Uruchom serwer poleceniem npm run start:dev i przetestuj endpointy w drugim terminalu:

1curl -X POST http://localhost:3000/legionaries \
2  -H "Content-Type: application/json" \
3  -d '{"name": "Titus Pullo", "rank": "Miles", "province": "Gallia", "email": "titus@legio.rome"}'
4
5curl "http://localhost:3000/legionaries/search?province=Gallia"
6curl http://localhost:3000/legionaries/1
7curl -X DELETE http://localhost:3000/legionaries/1
8curl http://localhost:3000/legionaries/1

Ostatnie polecenie powinno zwrócić 404, bo legionista został wykreślony. Wyślij też zgłoszenie z błędnym adresem email albo rangą spoza listy - ValidationPipe odpowie kodem 400 i listą błędów.

Co oddajesz

Wypchnij projekt na GitHub i prześlij link w następnym zadaniu. W README opisz endpointy i sposób uruchomienia: npm install, plik .env utworzony na podstawie .env.example i npm run start:dev. Dla chętnych: dodaj PATCH /legionaries/:id z UpdateLegionaryDto opartym na PartialType, tak jak w lekcji o DTO.

Pamiętaj, legacie: w dobrze zbudowanym Imperium każdy zna swoje miejsce - kontroler przyjmuje, serwis wykonuje, DTO pilnuje bramy, a konfiguracja mieszka poza kodem.

Kod do tej lekcji: src/imperium-project.ts
1// PROJEKT: Kompleksowa Aplikacja Imperium Romanum
2import { Module, Controller, Injectable, Get, Post, Body, Param } from '@nestjs/common';
3
4console.log("=== PROJEKT: Imperium Management System ===");
5
6// ===========================================
7// 1. Moduł Legionów
8// ===========================================
9
10interface Legion {
11  id: number;
12  name: string;
13  commander: string;
14  soldiers: number;
15  province: string;
16}
17
18@Injectable()
19export class LegionService {
20  private legions: Legion[] = [
21    { id: 1, name: 'Legio X Gemina', commander: 'Caesar', soldiers: 5000, province: 'Gallia' },
22    { id: 2, name: 'Legio III Augusta', commander: 'Scipio', soldiers: 4500, province: 'Africa' },
23  ];
24
25  findAll(): Legion[] { return this.legions; }
26
27  findById(id: number): Legion | undefined {
28    return this.legions.find(l => l.id === id);
29  }
30
31  create(data: Omit<Legion, 'id'>): Legion {
32    const legion = { id: this.legions.length + 1, ...data };
33    this.legions.push(legion);
34    return legion;
35  }
36}
37
38@Controller('legiones')
39export class LegionController {
40  constructor(private legionService: LegionService) {}
41
42  @Get()
43  findAll() { return this.legionService.findAll(); }
44
45  @Get(':id')
46  findOne(@Param('id') id: string) { return this.legionService.findById(+id); }
47}
48
49@Module({
50  controllers: [LegionController],
51  providers: [LegionService],
52  exports: [LegionService],
53})
54export class LegionModule {}
55
56// ===========================================
57// 2. Moduł Tributów
58// ===========================================
59
60interface Tribute {
61  id: number;
62  province: string;
63  amount: number;
64  type: 'gold' | 'silver' | 'goods';
65  collectedAt: string;
66}
67
68@Injectable()
69export class TributeService {
70  private tributes: Tribute[] = [
71    { id: 1, province: 'Gallia', amount: 50000, type: 'gold', collectedAt: '44 BC' },
72    { id: 2, province: 'Aegyptus', amount: 80000, type: 'gold', collectedAt: '30 BC' },
73  ];
74
75  findAll() { return this.tributes; }
76  getTotal() { return this.tributes.reduce((sum, t) => sum + t.amount, 0); }
77}
78
79// ===========================================
80// 3. Moduł Prowincji
81// ===========================================
82
83@Injectable()
84export class ProvinciaService {
85  private provinces = [
86    { name: 'Gallia', governor: 'Gaius Julius Caesar', population: 120000 },
87    { name: 'Aegyptus', governor: 'Gaius Cornelius Gallus', population: 300000 },
88    { name: 'Britannia', governor: 'Aulus Plautius', population: 80000 },
89  ];
90
91  findAll() { return this.provinces; }
92}
93
94// ===========================================
95// Główny moduł aplikacji
96// ===========================================
97
98@Module({
99  imports: [LegionModule],
100  providers: [TributeService, ProvinciaService],
101})
102export class AppModule {}
103
104console.log("\n=== STRUKTURA PROJEKTU ===");
105console.log("LegionModule - zarządzanie legionami (Controller + Service)");
106console.log("TributeService - zarządzanie tributami");
107console.log("ProvinciaService - zarządzanie prowincjami");
108console.log("AppModule - główny moduł łączący wszystko");
109console.log("\nProjekt łączy moduły, wstrzykiwanie zależności,");
110console.log("kontrolery i serwisy w jedną aplikację.");
111

Widzisz błąd w tej lekcji?

Przydatne artykuły