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:
z3n
2026-05-22 08:42:54 +00:00
commit 921a6e652b
87 changed files with 16719 additions and 0 deletions

View 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);
});
}
}