API NestJS : réception, stockage S3 et mise à jour utilisateur
Traitement du fichier, upload sur S3, suppression de l’ancien avatar, mise à jour du profil.
Dans cette leçon, tu vas apprendre comment :
- Recevoir un fichier multipart/form-data dans un contrôleur NestJS
- Valider et parser le fichier avec Zod
- Stocker l’avatar dans AWS S3 via
AwsS3Service - Mettre à jour la fiche utilisateur dans la base Prisma
- Supprimer l’ancien avatar pour éviter les fichiers orphelins
Pour aller plus loin, on réutilise la stack vue en formation (Prisma, NestJS, AWS S3). Si tu as manqué la configuration de ton bucket, reviens à la leçon « Configuration AWS S3 » 🔗AWS S3.
1. dto et validation du fichier
On commence par définir notre schéma Zod pour normaliser le fichier uploadé.
1import { z } from 'zod'23export const fileSchema = z.object({4originalname: z.string(), // nom d’origine5mimetype: z.string(), // type MIME6buffer: z.instanceof(Buffer),// contenu en mémoire7})
Pourquoi Zod ?
On s’assure que file.buffer soit bien un Buffer et pas un string vide.
2. contrôleur user et upload interceptor
Dans UserController, on va utiliser FileInterceptor de NestJS pour recevoir
le fichier en multipart/form-data.
1import {2Controller, Post, UseInterceptors, UploadedFile, Param,3} from '@nestjs/common'4import { FileInterceptor } from '@nestjs/platform-express'5import { diskStorage } from 'multer'6import { UserService } from './user.service'7import { fileSchema } from 'src/file-utils'89@Controller('users')10export class UserController {11constructor(private readonly userService: UserService) {}1213@Post(':id/avatar')14@UseInterceptors(15FileInterceptor('avatar', {16storage: diskStorage({17destination: './tmp', // stockage temporaire18filename: (_, file, cb) => cb(null, Date.now() + file.originalname),19}),20limits: { fileSize: 2_000_000 }, // 2 Mo max21}),22)23async updateAvatar(24@Param('id') userId: string,25@UploadedFile() file: Express.Multer.File,26) {27// @callout: On valide et on convertit notre fichier28const parsed = fileSchema.parse({29originalname: file.originalname,30mimetype: file.mimetype,31buffer: file.buffer,32})33return this.userService.updateUser({34userId,35submittedFile: parsed,36})37}38}
Tip
On utilise un stockage diskStorage temporaire pour la validation/mutation avant upload S3.
3. service AWS S3
Le service AwsS3Service encapsule toutes les opérations S3 : upload, getUrl,
delete. Il utilise @aws-sdk/client-s3 et @aws-sdk/s3-request-presigner.
1import {2PutObjectCommand, DeleteObjectCommand, GetObjectCommand, S3Client,3} from '@aws-sdk/client-s3'4import { getSignedUrl } from '@aws-sdk/s3-request-presigner'5import { createId } from '@paralleldrive/cuid2'6import { fileSchema } from 'src/file-utils'7import { z } from 'zod'89export class AwsS3Service {10private client: S3Client1112constructor() {13this.client = new S3Client({14credentials: {15accessKeyId: process.env.AWS_ACCESS_KEY!,16secretAccessKey: process.env.AWS_SECRET!,17},18region: process.env.AWS_REGION!,19})20}2122async uploadFile({ file }: { file: z.infer<typeof fileSchema> }) {23const key = createId() + file.originalname // ^? clé unique S324const cmd = new PutObjectCommand({25Bucket: process.env.AWS_BUCKET_NAME,26Key: key,27ContentType: file.mimetype,28Body: file.buffer,29CacheControl: 'max-age=31536000',30})31const res = await this.client.send(cmd)32if (res.$metadata.httpStatusCode !== 200) {33console.error(res) // @callout: log en cas d’erreur34}35return { fileKey: key }36}3738async getFileUrl({ fileKey }: { fileKey: string }) {39const cmd = new GetObjectCommand({40Bucket: process.env.AWS_BUCKET_NAME,41Key: fileKey,42})43return getSignedUrl(this.client, cmd)44}4546async deleteFile({ fileKey }: { fileKey: string }) {47const cmd = new DeleteObjectCommand({48Bucket: process.env.AWS_BUCKET_NAME,49Key: fileKey,50})51await this.client.send(cmd)52}53}
Sécurité des clés
Pense à stocker tes secrets AWS dans un vault ou variables d’environnement.
4. mise à jour de l’utilisateur
Dans UserService, l’opération updateUser :
- Récupère l’avatar précédent
- Upload le nouveau fichier sur S3
- Met à jour la base Prisma
- Supprime l’ancien avatar
1import { Injectable } from '@nestjs/common'2import { PrismaService } from 'src/prisma.service'3import { AwsS3Service } from 'src/aws/aws-s3.service'4import { z } from 'zod'5import { fileSchema } from 'src/file-utils'67@Injectable()8export class UserService {9constructor(10private prisma: PrismaService,11private aws: AwsS3Service,12) {}1314async updateUser({15userId,16submittedFile,17}: {18userId: string19submittedFile: z.infer<typeof fileSchema>20}) {21try {22const existing = await this.prisma.user.findUnique({23where: { id: userId },24select: { avatarFileKey: true },25})26if (!existing) throw new Error("L'utilisateur n'existe pas")2728const { fileKey } = await this.aws.uploadFile({ file: submittedFile })29// @callout: mise à jour Prisma30await this.prisma.user.update({31where: { id: userId },32data: { avatarFileKey: fileKey },33})3435if (existing.avatarFileKey) {36await this.aws.deleteFile({ fileKey: existing.avatarFileKey })37}3839return { error: false, message: "Avatar mis à jour avec succès" }40} catch (err) {41const message = err instanceof Error42? err.message43: 'Erreur inattendue'44return { error: true, message }45}46}47}
Gestion des erreurs
On renvoie toujours {error, message} pour un retour uniforme côté frontend.
5. tester l’endpoint
- Lance ton backend NestJS avec
npm run start:dev. - Utilise Postman ou
curlpour envoyer unPOST /users/<id>/avatar
1curl -X POST http://localhost:3000/users/123/avatar \2-F "avatar=@./path/to/avatar.png"
- Vérifie que la réponse contient
error: falseet que le lien S3 fonctionne.
Tip
Tu peux ajouter une route GET /users/:id pour vérifier l’URL pré-signée renvoyée.
Exercices
- Limiter les types MIME
Modifie l’interceptor pour n’accepter queimage/pngetimage/jpeg. - Supprimer l’avatar
Ajoute une routeDELETE /users/:id/avatarqui supprime l’avatar dans S3
et remetavatarFileKeyànull. - Cache et CDN
Configure un paramètreCacheControldifférent pour les images en miniatures.