Kurs NestJS · Moduł 1: Podstawy NestJS
PROJEKT: Rejestr Legionów Imperium
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:
| Metoda | Adres | Co robi | Kod odpowiedzi |
|---|---|---|---|
| GET | /legionaries | lista wszystkich legionistów | 200 |
| GET | /legionaries/search?province=Gallia | legioniści z jednej prowincji | 200 |
| GET | /legionaries/:id | jeden legionista | 200 albo 404 |
| POST | /legionaries | rekrutacja nowego legionisty | 201 albo 400 |
| DELETE | /legionaries/:id | wykreślenie z rejestru | 204 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-typesPotem 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 EquestrisEtap 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/1Ostatnie 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ę.");
111Widzisz błąd w tej lekcji?