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.

5 min read

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é.

src/file-utils.ts
1
import { z } from 'zod'
2
3
export const fileSchema = z.object({
4
originalname: z.string(), // nom d’origine
5
mimetype: z.string(), // type MIME
6
buffer: z.instanceof(Buffer),// contenu en mémoire
7
})

2. contrôleur user et upload interceptor

Dans UserController, on va utiliser FileInterceptor de NestJS pour recevoir le fichier en multipart/form-data.

src/user/user.controller.ts
1
import {
2
Controller, Post, UseInterceptors, UploadedFile, Param,
3
} from '@nestjs/common'
4
import { FileInterceptor } from '@nestjs/platform-express'
5
import { diskStorage } from 'multer'
6
import { UserService } from './user.service'
7
import { fileSchema } from 'src/file-utils'
8
9
@Controller('users')
10
export class UserController {
11
constructor(private readonly userService: UserService) {}
12
13
@Post(':id/avatar')
14
@UseInterceptors(
15
FileInterceptor('avatar', {
16
storage: diskStorage({
17
destination: './tmp', // stockage temporaire
18
filename: (_, file, cb) => cb(null, Date.now() + file.originalname),
19
}),
20
limits: { fileSize: 2_000_000 }, // 2 Mo max
21
}),
22
)
23
async updateAvatar(
24
@Param('id') userId: string,
25
@UploadedFile() file: Express.Multer.File,
26
) {
27
// @callout: On valide et on convertit notre fichier
28
const parsed = fileSchema.parse({
29
originalname: file.originalname,
30
mimetype: file.mimetype,
31
buffer: file.buffer,
32
})
33
return this.userService.updateUser({
34
userId,
35
submittedFile: parsed,
36
})
37
}
38
}

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.

src/aws/aws-s3.service.ts
1
import {
2
PutObjectCommand, DeleteObjectCommand, GetObjectCommand, S3Client,
3
} from '@aws-sdk/client-s3'
4
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
5
import { createId } from '@paralleldrive/cuid2'
6
import { fileSchema } from 'src/file-utils'
7
import { z } from 'zod'
8
9
export class AwsS3Service {
10
private client: S3Client
11
12
constructor() {
13
this.client = new S3Client({
14
credentials: {
15
accessKeyId: process.env.AWS_ACCESS_KEY!,
16
secretAccessKey: process.env.AWS_SECRET!,
17
},
18
region: process.env.AWS_REGION!,
19
})
20
}
21
22
async uploadFile({ file }: { file: z.infer<typeof fileSchema> }) {
23
const key = createId() + file.originalname // ^? clé unique S3
24
const cmd = new PutObjectCommand({
25
Bucket: process.env.AWS_BUCKET_NAME,
26
Key: key,
27
ContentType: file.mimetype,
28
Body: file.buffer,
29
CacheControl: 'max-age=31536000',
30
})
31
const res = await this.client.send(cmd)
32
if (res.$metadata.httpStatusCode !== 200) {
33
console.error(res) // @callout: log en cas d’erreur
34
}
35
return { fileKey: key }
36
}
37
38
async getFileUrl({ fileKey }: { fileKey: string }) {
39
const cmd = new GetObjectCommand({
40
Bucket: process.env.AWS_BUCKET_NAME,
41
Key: fileKey,
42
})
43
return getSignedUrl(this.client, cmd)
44
}
45
46
async deleteFile({ fileKey }: { fileKey: string }) {
47
const cmd = new DeleteObjectCommand({
48
Bucket: process.env.AWS_BUCKET_NAME,
49
Key: fileKey,
50
})
51
await this.client.send(cmd)
52
}
53
}

4. mise à jour de l’utilisateur

Dans UserService, l’opération updateUser :

  1. Récupère l’avatar précédent
  2. Upload le nouveau fichier sur S3
  3. Met à jour la base Prisma
  4. Supprime l’ancien avatar
src/user/user.service.ts
1
import { Injectable } from '@nestjs/common'
2
import { PrismaService } from 'src/prisma.service'
3
import { AwsS3Service } from 'src/aws/aws-s3.service'
4
import { z } from 'zod'
5
import { fileSchema } from 'src/file-utils'
6
7
@Injectable()
8
export class UserService {
9
constructor(
10
private prisma: PrismaService,
11
private aws: AwsS3Service,
12
) {}
13
14
async updateUser({
15
userId,
16
submittedFile,
17
}: {
18
userId: string
19
submittedFile: z.infer<typeof fileSchema>
20
}) {
21
try {
22
const existing = await this.prisma.user.findUnique({
23
where: { id: userId },
24
select: { avatarFileKey: true },
25
})
26
if (!existing) throw new Error("L'utilisateur n'existe pas")
27
28
const { fileKey } = await this.aws.uploadFile({ file: submittedFile })
29
// @callout: mise à jour Prisma
30
await this.prisma.user.update({
31
where: { id: userId },
32
data: { avatarFileKey: fileKey },
33
})
34
35
if (existing.avatarFileKey) {
36
await this.aws.deleteFile({ fileKey: existing.avatarFileKey })
37
}
38
39
return { error: false, message: "Avatar mis à jour avec succès" }
40
} catch (err) {
41
const message = err instanceof Error
42
? err.message
43
: 'Erreur inattendue'
44
return { error: true, message }
45
}
46
}
47
}

5. tester l’endpoint

  1. Lance ton backend NestJS avec npm run start:dev.
  2. Utilise Postman ou curl pour envoyer un POST /users/<id>/avatar
Terminal
1
curl -X POST http://localhost:3000/users/123/avatar \
2
-F "avatar=@./path/to/avatar.png"
  1. Vérifie que la réponse contient error: false et que le lien S3 fonctionne.

Exercices

  1. Limiter les types MIME
    Modifie l’interceptor pour n’accepter que image/png et image/jpeg.
  2. Supprimer l’avatar
    Ajoute une route DELETE /users/:id/avatar qui supprime l’avatar dans S3
    et remet avatarFileKey à null.
  3. Cache et CDN
    Configure un paramètre CacheControl différent pour les images en miniatures.