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:
@@ -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, {
|
||||
|
||||
@@ -48,4 +48,24 @@ export class ApiService {
|
||||
upload(formData: FormData): Observable<ApiEntry> {
|
||||
return this.http.post<ApiEntry>('/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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,26 @@ import { Category } from '@datacat/shared';
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (validating()) {
|
||||
<div class="fr-callout fr-mb-3w">
|
||||
<p>Validation en cours...</p>
|
||||
</div>
|
||||
} @else if (validationDone()) {
|
||||
@if (validationErrors().length > 0) {
|
||||
<div class="fr-alert fr-alert--error fr-mb-3w">
|
||||
<p class="fr-alert__title">Fichier(s) invalide(s)</p>
|
||||
@for (err of validationErrors(); track err.file) {
|
||||
<p>{{ err.file ? err.file + ' : ' : '' }}{{ err.message }}</p>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="fr-alert fr-alert--success fr-mb-3w">
|
||||
<p class="fr-alert__title">Fichier(s) valide(s)</p>
|
||||
<p>Le fichier a été validé avec succès.</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<div class="fr-btns-group fr-btns-group--inline-sm">
|
||||
@@ -136,14 +156,19 @@ import { Category } from '@datacat/shared';
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="fr-select-group fr-mb-3w">
|
||||
<label class="fr-label" for="type-input">
|
||||
Type <span style="color:var(--text-default-error)">*</span>
|
||||
</label>
|
||||
<select id="type-input" class="fr-select" formControlName="type">
|
||||
<option value="ASYNCAPI">AsyncAPI</option>
|
||||
<option value="OPENAPI">OpenAPI</option>
|
||||
</select>
|
||||
<div class="fr-mb-3w">
|
||||
<p class="fr-label fr-mb-1w">Type <span style="color:var(--text-default-error)">*</span></p>
|
||||
@if (detectedType()) {
|
||||
<span class="fr-tag">{{ detectedType() }}</span>
|
||||
<span class="fr-hint-text fr-mt-1w">Détecté automatiquement depuis le fichier YAML</span>
|
||||
} @else {
|
||||
<div class="fr-select-group">
|
||||
<select id="type-input" class="fr-select" formControlName="type">
|
||||
<option value="ASYNCAPI">AsyncAPI</option>
|
||||
<option value="OPENAPI">OpenAPI</option>
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="fr-select-group fr-mb-3w">
|
||||
@@ -339,6 +364,9 @@ export class UploadComponent implements OnInit {
|
||||
fileError = signal<string | null>(null);
|
||||
uploading = signal(false);
|
||||
uploadError = signal<string | null>(null);
|
||||
validating = signal(false);
|
||||
validationDone = signal(false);
|
||||
validationErrors = signal<Array<{ file: string; message: string }>>([]);
|
||||
categories = signal<Category[]>([]);
|
||||
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user