From ef3884c68da8da44e1ac711395d02be9cb0d3f85 Mon Sep 17 00:00:00 2001 From: z3n Date: Wed, 10 Jun 2026 08:48:40 +0000 Subject: [PATCH] =?UTF-8?q?feat(upload):=20validation=20YAML=20c=C3=B4t?= =?UTF-8?q?=C3=A9=20serveur=20d=C3=A8s=20la=20s=C3=A9lection=20du=20fichie?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nouvel endpoint POST /api/uploads/validate : validation en 3 niveaux (syntaxe js-yaml → détection type → asyncapi validate / redocly lint) - Extraction automatique de info.title/version/description pour pré-remplir le formulaire - Validation déclenchée immédiatement à la sélection, résultat affiché inline - Bouton "Suivant" désactivé pendant la validation et si le fichier est invalide - Sans fichier sélectionné, on peut toujours passer à l'étape 2 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .../src/modules/uploads/uploads.controller.ts | 120 ++++++++++++++++++ front-public/src/app/core/api.service.ts | 20 +++ .../src/app/pages/upload/upload.component.ts | 96 ++++++++++++-- 3 files changed, 224 insertions(+), 12 deletions(-) diff --git a/back/src/modules/uploads/uploads.controller.ts b/back/src/modules/uploads/uploads.controller.ts index a283745..476d284 100644 --- a/back/src/modules/uploads/uploads.controller.ts +++ b/back/src/modules/uploads/uploads.controller.ts @@ -39,6 +39,126 @@ export class UploadsController { private readonly docsService: DocsGenerationService, ) {} + @Post('validate') + @UseInterceptors( + FilesInterceptor('files', 20, { + storage: diskStorage({ destination: path.join(os.tmpdir(), 'datacat-uploads') }), + fileFilter: (_req, file, cb) => { + if (!file.originalname.match(/\.(yaml|yml)$/i)) { + return cb(new BadRequestException('Only YAML files are allowed'), false); + } + cb(null, true); + }, + }), + ) + async validate( + @UploadedFiles() files: Express.Multer.File[], + ): Promise<{ + valid: boolean; + type: ApiType | null; + mainFile: string | null; + title: string | null; + version: string | null; + description: string | null; + errors: Array<{ file: string; message: string }>; + }> { + if (!files || files.length === 0) { + return { valid: false, type: null, mainFile: null, title: null, version: null, description: null, errors: [{ file: '', message: 'Aucun fichier fourni.' }] }; + } + + /* Lire tous les contenus en mémoire avant toute suppression */ + const fileContents = files.map((file) => ({ + name: file.originalname, + path: file.path, + content: fs.readFileSync(file.path, 'utf-8'), + })); + + /* Nettoyage immédiat des fichiers temporaires */ + for (const file of files) { + fsPromises.unlink(file.path).catch(() => undefined); + } + + const errors: Array<{ file: string; message: string }> = []; + + /* Validation syntaxique de chaque fichier */ + for (const { name, content } of fileContents) { + try { + const parsed = yaml.load(content); + if (parsed === null || typeof parsed !== 'object') { + errors.push({ file: name, message: 'le fichier YAML est vide ou invalide' }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + errors.push({ file: name, message: `erreur de syntaxe YAML — ${msg}` }); + } + } + + if (errors.length > 0) { + return { valid: false, type: null, mainFile: null, title: null, version: null, description: null, errors }; + } + + /* Détection du fichier principal */ + let mainFileName: string | null = null; + let detectedType: ApiType | null = null; + + for (const { name, content } of fileContents) { + const type = this.detectTypeFromContent(content); + if (type) { + mainFileName = name; + detectedType = type; + break; + } + } + + if (!mainFileName) { + return { + valid: false, type: null, mainFile: null, title: null, version: null, description: null, + errors: [{ file: '', message: 'Aucun fichier principal trouvé (clé openapi: ou asyncapi: manquante).' }], + }; + } + + /* Validation complète via CLI (asyncapi validate / redocly lint) */ + const tempDir = path.join(os.tmpdir(), 'datacat-validate', randomUUID()); + try { + await fsPromises.mkdir(tempDir, { recursive: true }); + + /* Écrire tous les fichiers dans le répertoire temp avec leurs noms d'origine (pour les $ref) */ + for (const { name, content } of fileContents) { + await fsPromises.writeFile(path.join(tempDir, name), content, 'utf-8'); + } + + const mainPath = path.join(tempDir, mainFileName); + + try { + if (detectedType === 'ASYNCAPI') { + await execFileAsync('asyncapi', ['validate', mainPath], { env: execEnv, timeout: 30000 }); + } else { + await execFileAsync('redocly', ['lint', mainPath], { shell: true, env: execEnv, timeout: 30000 }); + } + } catch (cliError) { + const err = cliError as { stdout?: string; stderr?: string }; + const output = (err.stdout ?? err.stderr ?? String(cliError)).trim(); + return { + valid: false, type: detectedType, mainFile: mainFileName, + title: null, version: null, description: null, + errors: [{ file: mainFileName, message: output }], + }; + } + } finally { + await fsPromises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); + } + + /* Extraction des métadonnées depuis le fichier principal */ + const mainContent = fileContents.find((f) => f.name === mainFileName)!.content; + const parsed = yaml.load(mainContent) as Record; + const info = parsed['info'] as Record | undefined; + const title = typeof info?.['title'] === 'string' ? info['title'] : null; + const version = typeof info?.['version'] === 'string' ? String(info['version']) : null; + const description = typeof info?.['description'] === 'string' ? info['description'] : null; + + return { valid: true, type: detectedType, mainFile: mainFileName, title, version, description, errors: [] }; + } + @Post() @UseInterceptors( FilesInterceptor('files', 20, { diff --git a/front-public/src/app/core/api.service.ts b/front-public/src/app/core/api.service.ts index 0c3e4c8..bd44268 100644 --- a/front-public/src/app/core/api.service.ts +++ b/front-public/src/app/core/api.service.ts @@ -48,4 +48,24 @@ export class ApiService { upload(formData: FormData): Observable { return this.http.post('/api/uploads', formData); } + + validate(formData: FormData): Observable<{ + valid: boolean; + type: 'ASYNCAPI' | 'OPENAPI' | null; + mainFile: string | null; + title: string | null; + version: string | null; + description: string | null; + errors: Array<{ file: string; message: string }>; + }> { + return this.http.post<{ + valid: boolean; + type: 'ASYNCAPI' | 'OPENAPI' | null; + mainFile: string | null; + title: string | null; + version: string | null; + description: string | null; + errors: Array<{ file: string; message: string }>; + }>('/api/uploads/validate', formData); + } } diff --git a/front-public/src/app/pages/upload/upload.component.ts b/front-public/src/app/pages/upload/upload.component.ts index 12ad728..e692648 100644 --- a/front-public/src/app/pages/upload/upload.component.ts +++ b/front-public/src/app/pages/upload/upload.component.ts @@ -85,6 +85,26 @@ import { Category } from '@datacat/shared'; } + + @if (validating()) { +
+

Validation en cours...

+
+ } @else if (validationDone()) { + @if (validationErrors().length > 0) { +
+

Fichier(s) invalide(s)

+ @for (err of validationErrors(); track err.file) { +

{{ err.file ? err.file + ' : ' : '' }}{{ err.message }}

+ } +
+ } @else { +
+

Fichier(s) valide(s)

+

Le fichier a été validé avec succès.

+
+ } + } }
@@ -136,14 +156,19 @@ import { Category } from '@datacat/shared'; }
-
- - +
+

Type *

+ @if (detectedType()) { + {{ detectedType() }} + Détecté automatiquement depuis le fichier YAML + } @else { +
+ +
+ }
@@ -339,6 +364,9 @@ export class UploadComponent implements OnInit { fileError = signal(null); uploading = signal(false); uploadError = signal(null); + validating = signal(false); + validationDone = signal(false); + validationErrors = signal>([]); categories = signal([]); readonly stepTitles = [ @@ -373,10 +401,11 @@ export class UploadComponent implements OnInit { return this.categories().find((c) => c.id === id)?.name ?? ''; }); canProceedFromStep1 = computed(() => { - if (this.selectedFiles().length === 0) return false; - if (this.selectedFiles().length === 1) return true; - /* Plusieurs fichiers : un fichier principal doit être détecté */ - return this.detectedMainFilename() !== null; + /* Sans fichier, on peut toujours passer à l'étape 2 */ + if (this.selectedFiles().length === 0) return true; + /* Avec fichier : validation serveur doit être terminée et sans erreur */ + if (this.validating()) return false; + return this.validationDone() && this.validationErrors().length === 0; }); ngOnInit() { @@ -395,6 +424,8 @@ export class UploadComponent implements OnInit { this.fileError.set(null); this.detectedType.set(null); this.detectedMainFilename.set(null); + this.validationErrors.set([]); + this.validationDone.set(false); const input = event.target as HTMLInputElement; const fileList = input.files; @@ -415,6 +446,9 @@ export class UploadComponent implements OnInit { /* Détection côté client du fichier principal (pré-remplissage du type) */ void this.detectMainFileClient(files); + + /* Validation serveur immédiate */ + this.runValidation(files); } goToStep2() { @@ -422,6 +456,44 @@ export class UploadComponent implements OnInit { this.step.set(2); } + private runValidation(files: File[]): void { + this.validating.set(true); + this.validationDone.set(false); + + const formData = new FormData(); + for (const f of files) { + formData.append('files', f); + } + + this.apiService.validate(formData).subscribe({ + next: (result) => { + this.validating.set(false); + this.validationDone.set(true); + if (!result.valid) { + this.validationErrors.set(result.errors); + } else { + this.validationErrors.set([]); + if (result.type) { + this.detectedType.set(result.type); + this.metaForm.patchValue({ type: result.type }); + } + if (result.mainFile) { + this.detectedMainFilename.set(result.mainFile); + } + /* Pré-remplissage du formulaire depuis les métadonnées du fichier */ + if (result.title) this.metaForm.patchValue({ title: result.title }); + if (result.version) this.metaForm.patchValue({ version: String(result.version) }); + if (result.description) this.metaForm.patchValue({ description: result.description }); + } + }, + error: (err) => { + this.validating.set(false); + this.validationDone.set(true); + this.validationErrors.set([{ file: '', message: err?.error?.message ?? 'Erreur de validation.' }]); + }, + }); + } + goToStep3() { if (this.metaForm.invalid) { this.metaForm.markAllAsTouched();