Exercices — Module 02
Controllers, Routes & Providers · 5 exercices pratiques
Controller REST complet
Créez un BooksController avec les 5 méthodes CRUD : GET /books, GET /books/:id, POST /books, PATCH /books/:id, DELETE /books/:id. Utilisez ParseIntPipe et les codes HTTP appropriés.
Voir la solution
@Controller('books')
export class BooksController {
constructor(private readonly booksService: BooksService) {}
@Get()
findAll(@Query('genre') genre?: string, @Query('author') author?: string) {
return this.booksService.findAll({ genre, author });
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.booksService.findOne(id);
}
@Post()
@HttpCode(HttpStatus.CREATED)
create(@Body() dto: CreateBookDto) {
return this.booksService.create(dto);
}
@Patch(':id')
update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateBookDto,
) {
return this.booksService.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id', ParseIntPipe) id: number) {
return this.booksService.remove(id);
}
}
Paramètres avancés
Créez une route de recherche avec pagination : GET /books/search?q=string&page=1&limit=10&sortBy=title&order=asc. Gérez les valeurs par défaut avec DefaultValuePipe.
Voir la solution
@Get('search')
search(
@Query('q') q = '',
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
@Query('sortBy', new DefaultValuePipe('title')) sortBy: string,
@Query('order', new DefaultValuePipe('asc')) order: 'asc' | 'desc',
) {
return this.booksService.search({ q, page, limit, sortBy, order });
}
Service avec logique métier
Implémentez un BooksService avec un tableau en mémoire (pas de DB). Gérez les cas d'erreur avec les exceptions NestJS appropriées (NotFoundException, ConflictException).
Voir la solution
@Injectable()
export class BooksService {
private books: Book[] = [];
private nextId = 1;
findAll(filters?: Partial<Book>): Book[] {
return this.books.filter(b =>
(!filters?.genre || b.genre === filters.genre) &&
(!filters?.author || b.author.includes(filters.author))
);
}
findOne(id: number): Book {
const book = this.books.find(b => b.id === id);
if (!book) throw new NotFoundException(`Book #${id} not found`);
return book;
}
create(dto: CreateBookDto): Book {
const existing = this.books.find(b => b.isbn === dto.isbn);
if (existing) throw new ConflictException(`ISBN ${dto.isbn} already exists`);
const book: Book = { id: this.nextId++, ...dto };
this.books.push(book);
return book;
}
update(id: number, dto: UpdateBookDto): Book {
const index = this.books.findIndex(b => b.id === id);
if (index === -1) throw new NotFoundException(`Book #${id} not found`);
this.books[index] = { ...this.books[index], ...dto };
return this.books[index];
}
remove(id: number): void {
const index = this.books.findIndex(b => b.id === id);
if (index === -1) throw new NotFoundException(`Book #${id} not found`);
this.books.splice(index, 1);
}
}
Value Provider et injection
Créez un provider de configuration 'APP_CONFIG' avec useValue. Injectez-le dans le service via @Inject() et utilisez-le pour configurer les limites de pagination.
Voir la solution
// books.module.ts
@Module({
controllers: [BooksController],
providers: [
BooksService,
{
provide: 'APP_CONFIG',
useValue: {
maxPageSize: 50,
defaultPageSize: 10,
apiVersion: 'v1',
},
},
],
})
export class BooksModule {}
// books.service.ts
@Injectable()
export class BooksService {
constructor(
@Inject('APP_CONFIG')
private readonly config: { maxPageSize: number; defaultPageSize: number },
) {}
paginate(items: Book[], page: number, rawLimit: number): Book[] {
const limit = Math.min(rawLimit, this.config.maxPageSize);
const start = (page - 1) * limit;
return items.slice(start, start + limit);
}
}
Controller avec upload de fichier
Ajoutez une route POST /books/:id/cover pour uploader une image de couverture. Utilisez @UseInterceptors(FileInterceptor) de multer, validez le type MIME (image seulement) et la taille max (2MB).
Voir la solution
npm install @nestjs/platform-express multer @types/multer
import { FileInterceptor } from '@nestjs/platform-express';
import { UploadedFile } from '@nestjs/common';
import { diskStorage } from 'multer';
import { extname } from 'path';
@Post(':id/cover')
@UseInterceptors(FileInterceptor('cover', {
storage: diskStorage({
destination: './uploads/covers',
filename: (req, file, cb) => {
const uniqueName = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
cb(null, uniqueName + extname(file.originalname));
},
}),
limits: { fileSize: 2 * 1024 * 1024 }, // 2 MB
fileFilter: (req, file, cb) => {
if (!file.mimetype.startsWith('image/')) {
return cb(new BadRequestException('Only image files allowed'), false);
}
cb(null, true);
},
}))
async uploadCover(
@Param('id', ParseIntPipe) id: number,
@UploadedFile() file: Express.Multer.File,
) {
const url = `/uploads/covers/${file.filename}`;
return this.booksService.updateCoverUrl(id, url);
}