feat: initial scaffold — MVP catalogue de données d'API
- Backend NestJS : CRUD api_entries + categories, upload YAML multi-fichiers, génération docs AsyncAPI (@asyncapi/cli@6.0.0) et OpenAPI (redocly) - Fix: route wildcard GET /api/apis/:id/*path pour servir les assets statiques (CSS/JS) générés par AsyncAPI HTML template (contournement bug path-to-regexp v8) - Frontend Angular 19 : pages catalog, browse, api-detail, doc-viewer, upload (DSFR) - Seeder de données de démo (idempotent) - Docker Compose dev + Dockerfiles + manifests K8s - Documentation : README, architecture, référence API REST 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:
422
front-public/src/app/pages/upload/upload.component.ts
Normal file
422
front-public/src/app/pages/upload/upload.component.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
import {
|
||||
Component,
|
||||
signal,
|
||||
computed,
|
||||
inject,
|
||||
OnInit,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { CategoryService } from '../../core/category.service';
|
||||
import { Category } from '@datacat/shared';
|
||||
|
||||
@Component({
|
||||
selector: 'app-upload',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="fr-container fr-mt-4w fr-mb-4w">
|
||||
<!-- Fil d'Ariane -->
|
||||
<nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici">
|
||||
<ol class="fr-breadcrumb__list">
|
||||
<li><a class="fr-breadcrumb__link" routerLink="/browse">Parcourir</a></li>
|
||||
<li><a class="fr-breadcrumb__link" routerLink="/catalog">Catalogue</a></li>
|
||||
<li><a class="fr-breadcrumb__link" aria-current="page">Importer une API</a></li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1 class="fr-h2 fr-mb-4w">Importer une API</h1>
|
||||
|
||||
<!-- Stepper -->
|
||||
<div class="fr-stepper fr-mb-4w">
|
||||
<h2 class="fr-stepper__title">
|
||||
{{ stepTitles[step() - 1] }}
|
||||
<span class="fr-stepper__state">Étape {{ step() }} sur 3</span>
|
||||
</h2>
|
||||
<div
|
||||
class="fr-stepper__steps"
|
||||
[attr.data-fr-current-step]="step()"
|
||||
data-fr-steps="3"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Étape 1 : Fichier(s) YAML -->
|
||||
@if (step() === 1) {
|
||||
<div class="fr-upload-group fr-mb-4w">
|
||||
<label class="fr-label" for="file-input">
|
||||
Fichier(s) YAML
|
||||
<span class="fr-hint-text">
|
||||
Formats acceptés : .yaml, .yml — Sélectionnez plusieurs fichiers si votre spec utilise des $ref
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="file-input"
|
||||
class="fr-upload"
|
||||
type="file"
|
||||
accept=".yaml,.yml"
|
||||
multiple
|
||||
(change)="onFileChange($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@if (fileError()) {
|
||||
<div class="fr-alert fr-alert--error fr-mb-3w">
|
||||
<p>{{ fileError() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selectedFiles().length > 0) {
|
||||
<div class="fr-mb-3w">
|
||||
@for (file of selectedFiles(); track file.name) {
|
||||
<div class="fr-callout fr-callout--blue-cumulus fr-mb-1w" style="display:flex;align-items:center;justify-content:space-between;gap:1rem;">
|
||||
<span>
|
||||
<strong>{{ file.name }}</strong>
|
||||
({{ (file.size / 1024).toFixed(1) }} Ko)
|
||||
</span>
|
||||
@if (file.name === detectedMainFilename()) {
|
||||
<span class="fr-badge fr-badge--success fr-badge--sm">Principal</span>
|
||||
} @else if (selectedFiles().length > 1) {
|
||||
<span class="fr-badge fr-badge--blue-cumulus fr-badge--sm">Secondaire</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="fr-btns-group fr-btns-group--inline-sm">
|
||||
<button
|
||||
class="fr-btn"
|
||||
(click)="goToStep2()"
|
||||
[disabled]="!canProceedFromStep1()"
|
||||
>
|
||||
Suivant
|
||||
</button>
|
||||
<a routerLink="/catalog" class="fr-btn fr-btn--secondary">Annuler</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Étape 2 : Métadonnées -->
|
||||
@if (step() === 2) {
|
||||
<form [formGroup]="metaForm" (ngSubmit)="goToStep3()" novalidate>
|
||||
<div class="fr-input-group fr-mb-3w" [class.fr-input-group--error]="titleInvalid()">
|
||||
<label class="fr-label" for="title-input">
|
||||
Titre <span class="fr-hint-text">Obligatoire</span>
|
||||
</label>
|
||||
<input
|
||||
id="title-input"
|
||||
class="fr-input"
|
||||
[class.fr-input--error]="titleInvalid()"
|
||||
formControlName="title"
|
||||
type="text"
|
||||
placeholder="Nom de l'API"
|
||||
/>
|
||||
@if (titleInvalid()) {
|
||||
<p class="fr-error-text">Le titre est obligatoire.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="fr-input-group fr-mb-3w" [class.fr-input-group--error]="versionInvalid()">
|
||||
<label class="fr-label" for="version-input">
|
||||
Version <span class="fr-hint-text">Obligatoire</span>
|
||||
</label>
|
||||
<input
|
||||
id="version-input"
|
||||
class="fr-input"
|
||||
[class.fr-input--error]="versionInvalid()"
|
||||
formControlName="version"
|
||||
type="text"
|
||||
placeholder="1.0.0"
|
||||
/>
|
||||
@if (versionInvalid()) {
|
||||
<p class="fr-error-text">La version est obligatoire.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="fr-select-group fr-mb-3w">
|
||||
<label class="fr-label" for="type-input">
|
||||
Type <span class="fr-hint-text">Obligatoire</span>
|
||||
</label>
|
||||
<select id="type-input" class="fr-select" formControlName="type">
|
||||
<option value="ASYNCAPI">AsyncAPI</option>
|
||||
<option value="OPENAPI">OpenAPI</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="fr-select-group fr-mb-3w">
|
||||
<label class="fr-label" for="category-input">
|
||||
Catégorie <span class="fr-hint-text">Optionnel</span>
|
||||
</label>
|
||||
<select id="category-input" class="fr-select" formControlName="categoryId">
|
||||
<option value="">— Sans catégorie —</option>
|
||||
@for (cat of categories(); track cat.id) {
|
||||
<option [value]="cat.id">{{ categoryLabel(cat) }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="fr-input-group fr-mb-4w">
|
||||
<label class="fr-label" for="description-input">
|
||||
Description <span class="fr-hint-text">Optionnel</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="description-input"
|
||||
class="fr-input"
|
||||
formControlName="description"
|
||||
rows="3"
|
||||
placeholder="Description de l'API..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="fr-btns-group fr-btns-group--inline-sm">
|
||||
<button type="submit" class="fr-btn" [disabled]="metaForm.invalid">Suivant</button>
|
||||
<button type="button" class="fr-btn fr-btn--secondary" (click)="step.set(1)">
|
||||
Retour
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
<!-- Étape 3 : Confirmation -->
|
||||
@if (step() === 3) {
|
||||
<div class="fr-card fr-mb-4w">
|
||||
<div class="fr-card__body">
|
||||
<div class="fr-card__content">
|
||||
<h2 class="fr-h5 fr-mb-3w">Récapitulatif</h2>
|
||||
<dl class="fr-grid-row fr-grid-row--gutters">
|
||||
<div class="fr-col-12 fr-col-md-6">
|
||||
<dt class="fr-text--bold">Fichier{{ selectedFiles().length > 1 ? 's' : '' }}</dt>
|
||||
<dd>
|
||||
@if (selectedFiles().length === 1) {
|
||||
{{ selectedFiles()[0].name }}
|
||||
} @else {
|
||||
@for (f of selectedFiles(); track f.name) {
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||
{{ f.name }}
|
||||
@if (f.name === detectedMainFilename()) {
|
||||
<span class="fr-badge fr-badge--success fr-badge--sm">Principal</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="fr-col-12 fr-col-md-6">
|
||||
<dt class="fr-text--bold">Type</dt>
|
||||
<dd>{{ metaForm.value.type }}</dd>
|
||||
</div>
|
||||
<div class="fr-col-12 fr-col-md-6">
|
||||
<dt class="fr-text--bold">Titre</dt>
|
||||
<dd>{{ metaForm.value.title }}</dd>
|
||||
</div>
|
||||
<div class="fr-col-12 fr-col-md-6">
|
||||
<dt class="fr-text--bold">Version</dt>
|
||||
<dd>{{ metaForm.value.version }}</dd>
|
||||
</div>
|
||||
@if (metaForm.value.categoryId) {
|
||||
<div class="fr-col-12 fr-col-md-6">
|
||||
<dt class="fr-text--bold">Catégorie</dt>
|
||||
<dd>{{ selectedCategoryName() }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (metaForm.value.description) {
|
||||
<div class="fr-col-12">
|
||||
<dt class="fr-text--bold">Description</dt>
|
||||
<dd>{{ metaForm.value.description }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (uploadError()) {
|
||||
<div class="fr-alert fr-alert--error fr-mb-3w">
|
||||
<p class="fr-alert__title">Erreur lors de l'import</p>
|
||||
<p>{{ uploadError() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="fr-btns-group fr-btns-group--inline-sm">
|
||||
<button
|
||||
class="fr-btn"
|
||||
(click)="onSubmit()"
|
||||
[disabled]="uploading()"
|
||||
>
|
||||
{{ uploading() ? 'Import en cours...' : 'Importer' }}
|
||||
</button>
|
||||
<button class="fr-btn fr-btn--secondary" (click)="step.set(2)" [disabled]="uploading()">
|
||||
Retour
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class UploadComponent implements OnInit {
|
||||
private fb = inject(FormBuilder);
|
||||
private apiService = inject(ApiService);
|
||||
private categoryService = inject(CategoryService);
|
||||
private router = inject(Router);
|
||||
|
||||
step = signal(1);
|
||||
selectedFiles = signal<File[]>([]);
|
||||
detectedType = signal<'ASYNCAPI' | 'OPENAPI' | null>(null);
|
||||
detectedMainFilename = signal<string | null>(null);
|
||||
fileError = signal<string | null>(null);
|
||||
uploading = signal(false);
|
||||
uploadError = signal<string | null>(null);
|
||||
categories = signal<Category[]>([]);
|
||||
|
||||
readonly stepTitles = [
|
||||
'Choisir le fichier',
|
||||
'Renseigner les métadonnées',
|
||||
"Confirmer l'import",
|
||||
];
|
||||
|
||||
metaForm = this.fb.group({
|
||||
title: ['', Validators.required],
|
||||
version: ['', Validators.required],
|
||||
type: ['ASYNCAPI', Validators.required],
|
||||
categoryId: [''],
|
||||
description: [''],
|
||||
});
|
||||
|
||||
titleInvalid = computed(
|
||||
() => !!(this.metaForm.get('title')?.invalid && this.metaForm.get('title')?.touched),
|
||||
);
|
||||
versionInvalid = computed(
|
||||
() => !!(this.metaForm.get('version')?.invalid && this.metaForm.get('version')?.touched),
|
||||
);
|
||||
selectedCategoryName = computed(() => {
|
||||
const id = this.metaForm.value.categoryId;
|
||||
if (!id) return '';
|
||||
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;
|
||||
});
|
||||
|
||||
ngOnInit() {
|
||||
this.categoryService.list().subscribe({
|
||||
next: (res) => this.categories.set(res.items),
|
||||
});
|
||||
}
|
||||
|
||||
categoryLabel(cat: Category): string {
|
||||
/* Indente visuellement les sous-catégories via le parentId */
|
||||
const hasParent = !!cat.parentId;
|
||||
return hasParent ? ` └ ${cat.name}` : cat.name;
|
||||
}
|
||||
|
||||
onFileChange(event: Event) {
|
||||
this.fileError.set(null);
|
||||
this.detectedType.set(null);
|
||||
this.detectedMainFilename.set(null);
|
||||
|
||||
const input = event.target as HTMLInputElement;
|
||||
const fileList = input.files;
|
||||
if (!fileList || fileList.length === 0) {
|
||||
this.selectedFiles.set([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.from(fileList);
|
||||
const invalid = files.find((f) => !f.name.match(/\.(yaml|yml)$/i));
|
||||
if (invalid) {
|
||||
this.fileError.set('Seuls les fichiers .yaml et .yml sont acceptés.');
|
||||
this.selectedFiles.set([]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedFiles.set(files);
|
||||
|
||||
/* Détection côté client du fichier principal (pré-remplissage du type) */
|
||||
void this.detectMainFileClient(files);
|
||||
}
|
||||
|
||||
goToStep2() {
|
||||
if (!this.canProceedFromStep1()) return;
|
||||
this.step.set(2);
|
||||
}
|
||||
|
||||
goToStep3() {
|
||||
if (this.metaForm.invalid) {
|
||||
this.metaForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
this.step.set(3);
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
const files = this.selectedFiles();
|
||||
if (files.length === 0 || this.metaForm.invalid) return;
|
||||
|
||||
this.uploading.set(true);
|
||||
this.uploadError.set(null);
|
||||
|
||||
const formData = new FormData();
|
||||
for (const f of files) {
|
||||
formData.append('files', f);
|
||||
}
|
||||
formData.append('title', this.metaForm.value.title ?? '');
|
||||
formData.append('version', this.metaForm.value.version ?? '');
|
||||
formData.append('type', this.metaForm.value.type ?? 'ASYNCAPI');
|
||||
if (this.metaForm.value.categoryId) {
|
||||
formData.append('categoryId', this.metaForm.value.categoryId);
|
||||
}
|
||||
if (this.metaForm.value.description) {
|
||||
formData.append('description', this.metaForm.value.description);
|
||||
}
|
||||
|
||||
this.apiService.upload(formData).subscribe({
|
||||
next: (entry) => {
|
||||
this.uploading.set(false);
|
||||
this.router.navigate(['/catalog', entry.id]);
|
||||
},
|
||||
error: (err) => {
|
||||
this.uploading.set(false);
|
||||
this.uploadError.set(err.error?.message ?? "Erreur lors de l'import");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async detectMainFileClient(files: File[]): Promise<void> {
|
||||
for (const file of files) {
|
||||
const content = await this.readFileAsText(file);
|
||||
if (/^openapi\s*:/m.test(content)) {
|
||||
this.detectedType.set('OPENAPI');
|
||||
this.detectedMainFilename.set(file.name);
|
||||
this.metaForm.patchValue({ type: 'OPENAPI' });
|
||||
return;
|
||||
}
|
||||
if (/^asyncapi\s*:/m.test(content)) {
|
||||
this.detectedType.set('ASYNCAPI');
|
||||
this.detectedMainFilename.set(file.name);
|
||||
this.metaForm.patchValue({ type: 'ASYNCAPI' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
/* Aucun fichier principal détecté parmi plusieurs fichiers */
|
||||
if (files.length > 1) {
|
||||
this.fileError.set(
|
||||
'Aucun fichier principal détecté (clé openapi: ou asyncapi: requise dans au moins un fichier).',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private readFileAsText(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve((e.target?.result as string) ?? '');
|
||||
reader.onerror = () => reject(new Error('Lecture fichier échouée'));
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user