feat(upload): validation YAML côté serveur dès la sélection du fichier

- 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 <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
z3n
2026-06-10 08:48:40 +00:00
parent abc20bc9e8
commit ef3884c68d
3 changed files with 224 additions and 12 deletions

View File

@@ -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<string, unknown>;
const info = parsed['info'] as Record<string, unknown> | 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, {