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,237 @@
import {
Component,
signal,
inject,
OnInit,
OnDestroy,
ChangeDetectionStrategy,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ApiService } from '../../core/api.service';
import { ApiEntry } from '@datacat/shared';
@Component({
selector: 'app-api-detail',
standalone: true,
imports: [CommonModule, 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="/catalog">Catalogue</a>
</li>
<li>
<a class="fr-breadcrumb__link" aria-current="page">
{{ api()?.title ?? 'Chargement...' }}
</a>
</li>
</ol>
</nav>
@if (loading()) {
<div class="fr-callout fr-mb-4w">
<p class="fr-callout__text">Chargement...</p>
</div>
}
@if (error()) {
<div class="fr-alert fr-alert--error fr-mb-4w">
<p class="fr-alert__title">Erreur</p>
<p>{{ error() }}</p>
</div>
}
@if (api(); as entry) {
<!-- En-tête -->
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
<div class="fr-col">
<h1 class="fr-h2 fr-mb-1w">{{ entry.title }}</h1>
<div class="fr-tags-group">
<span class="fr-tag">{{ entry.type }}</span>
<span class="fr-tag">v{{ entry.version }}</span>
<span [class]="badgeClass(entry.status)">{{ entry.status }}</span>
</div>
</div>
</div>
<!-- Alerte statut PENDING -->
@if (entry.status === 'PENDING') {
<div class="fr-alert fr-alert--info fr-mb-3w">
<p class="fr-alert__title">Génération en cours</p>
<p>La documentation est en cours de génération. Cette page se rafraîchit automatiquement.</p>
</div>
}
<!-- Alerte statut ERROR -->
@if (entry.status === 'ERROR') {
<div class="fr-alert fr-alert--error fr-mb-3w">
<p class="fr-alert__title">Erreur de génération</p>
<p>{{ entry.errorMessage }}</p>
</div>
}
<!-- Métadonnées -->
<div class="fr-card fr-mb-4w">
<div class="fr-card__body">
<div class="fr-card__content">
<h2 class="fr-h5 fr-mb-2w">Informations</h2>
<dl class="fr-grid-row fr-grid-row--gutters">
<div class="fr-col-12 fr-col-md-4">
<dt class="fr-text--bold">Titre</dt>
<dd>{{ entry.title }}</dd>
</div>
<div class="fr-col-12 fr-col-md-4">
<dt class="fr-text--bold">Version</dt>
<dd>{{ entry.version }}</dd>
</div>
<div class="fr-col-12 fr-col-md-4">
<dt class="fr-text--bold">Type</dt>
<dd>{{ entry.type }}</dd>
</div>
@if (entry.description) {
<div class="fr-col-12">
<dt class="fr-text--bold">Description</dt>
<dd>{{ entry.description }}</dd>
</div>
}
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Créé le</dt>
<dd>{{ entry.createdAt | date:'dd/MM/yyyy HH:mm' }}</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Mis à jour le</dt>
<dd>{{ entry.updatedAt | date:'dd/MM/yyyy HH:mm' }}</dd>
</div>
</dl>
</div>
</div>
</div>
<!-- Actions -->
<div class="fr-btns-group fr-btns-group--inline-sm">
<a
[routerLink]="['/catalog', entry.id, 'docs']"
class="fr-btn"
[class.fr-btn--disabled]="entry.status !== 'GENERATED'"
[attr.aria-disabled]="entry.status !== 'GENERATED' ? 'true' : null"
>
Voir la documentation
</a>
<a
[href]="'/api/apis/' + entry.id + '/yaml'"
class="fr-btn fr-btn--secondary"
download
>
Télécharger YAML
</a>
<button
class="fr-btn fr-btn--secondary"
(click)="onRegenerate()"
[disabled]="regenerating()"
>
{{ regenerating() ? 'Régénération...' : 'Régénérer' }}
</button>
<button
class="fr-btn fr-btn--secondary"
(click)="onDelete()"
[disabled]="deleting()"
>
{{ deleting() ? 'Suppression...' : 'Supprimer' }}
</button>
</div>
}
</div>
`,
})
export class ApiDetailComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private router = inject(Router);
private apiService = inject(ApiService);
api = signal<ApiEntry | null>(null);
loading = signal(false);
error = signal<string | null>(null);
regenerating = signal(false);
deleting = signal(false);
private pollInterval: ReturnType<typeof setInterval> | null = null;
private id = '';
ngOnInit() {
this.id = this.route.snapshot.paramMap.get('id') ?? '';
this.loadApi();
}
ngOnDestroy() {
this.clearPoll();
}
onRegenerate() {
this.regenerating.set(true);
this.apiService.regenerate(this.id).subscribe({
next: (entry) => {
this.api.set(entry);
this.regenerating.set(false);
this.startPollIfPending();
},
error: () => this.regenerating.set(false),
});
}
onDelete() {
if (!confirm('Supprimer cette API ?')) return;
this.deleting.set(true);
this.apiService.delete(this.id).subscribe({
next: () => this.router.navigate(['/catalog']),
error: () => this.deleting.set(false),
});
}
badgeClass(status: string): string {
if (status === 'GENERATED') return 'fr-badge fr-badge--success';
if (status === 'ERROR') return 'fr-badge fr-badge--error';
return 'fr-badge fr-badge--info';
}
private loadApi() {
this.loading.set(true);
this.apiService.get(this.id).subscribe({
next: (entry) => {
this.api.set(entry);
this.loading.set(false);
this.startPollIfPending();
},
error: (err) => {
this.error.set(err.message ?? 'Erreur lors du chargement');
this.loading.set(false);
},
});
}
private startPollIfPending() {
this.clearPoll();
if (this.api()?.status === 'PENDING') {
this.pollInterval = setInterval(() => this.pollStatus(), 3000);
}
}
private pollStatus() {
this.apiService.get(this.id).subscribe({
next: (entry) => {
this.api.set(entry);
if (entry.status !== 'PENDING') this.clearPoll();
},
});
}
private clearPoll() {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
}
}

View File

@@ -0,0 +1,171 @@
import {
Component,
signal,
computed,
inject,
OnInit,
DestroyRef,
ChangeDetectionStrategy,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { ActivatedRoute } from '@angular/router';
import { CategoryService } from '../../core/category.service';
import { CategoryBrowseResponse, Category } from '@datacat/shared';
@Component({
selector: 'app-browse',
standalone: true,
imports: [CommonModule, 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>
@for (crumb of ancestors(); track crumb.id) {
<li>
<a class="fr-breadcrumb__link" [routerLink]="['/browse', crumb.id]">{{ crumb.name }}</a>
</li>
}
@if (data()?.category) {
<li>
<a class="fr-breadcrumb__link" aria-current="page">{{ data()!.category!.name }}</a>
</li>
}
</ol>
</nav>
<h1 class="fr-h1 fr-mb-4w">{{ data()?.category?.name ?? 'Parcourir le catalogue' }}</h1>
@if (loading()) {
<div class="fr-callout fr-mb-4w">
<p class="fr-callout__text">Chargement...</p>
</div>
}
@if (error()) {
<div class="fr-alert fr-alert--error fr-mb-4w">
<p>{{ error() }}</p>
</div>
}
@if (!loading() && data()) {
<!-- Sous-catégories -->
@if (data()!.subcategories.length > 0) {
<h2 class="fr-h4 fr-mb-3w">Catégories</h2>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-4w">
@for (cat of data()!.subcategories; track cat.id) {
<div class="fr-col-12 fr-col-md-4">
<div class="fr-tile fr-enlarge-link fr-tile--sm">
<div class="fr-tile__body">
<div class="fr-tile__content">
<h3 class="fr-tile__title">
<a [routerLink]="['/browse', cat.id]">{{ cat.name }}</a>
</h3>
@if (cat.description) {
<p class="fr-tile__desc">{{ cat.description }}</p>
}
<p class="fr-tile__detail">
{{ cat.subcategoryCount }} sous-catégorie(s) · {{ cat.apiCount }} API(s)
</p>
</div>
</div>
</div>
</div>
}
</div>
}
<!-- APIs -->
@if (data()!.apis.length > 0) {
<h2 class="fr-h4 fr-mb-3w">APIs</h2>
<div class="fr-grid-row fr-grid-row--gutters">
@for (api of data()!.apis; track api.id) {
<div class="fr-col-12 fr-col-md-4">
<div class="fr-tile fr-enlarge-link fr-tile--sm">
<div class="fr-tile__body">
<div class="fr-tile__content">
<h3 class="fr-tile__title">
<a [routerLink]="['/catalog', api.id]">{{ api.title }}</a>
</h3>
@if (api.description) {
<p class="fr-tile__desc">{{ api.description }}</p>
}
<p class="fr-tile__detail">
<span [class]="typeBadgeClass(api.type)">{{ api.type }}</span>
&nbsp;
<span [class]="statusBadgeClass(api.status)">{{ api.status }}</span>
</p>
</div>
</div>
</div>
</div>
}
</div>
}
@if (data()!.subcategories.length === 0 && data()!.apis.length === 0) {
<div class="fr-callout fr-callout--blue-cumulus fr-mb-4w">
<p class="fr-callout__text">Aucun contenu dans cette catégorie.</p>
</div>
}
}
</div>
`,
})
export class BrowseComponent implements OnInit {
private route = inject(ActivatedRoute);
private categoryService = inject(CategoryService);
private destroyRef = inject(DestroyRef);
data = signal<CategoryBrowseResponse | null>(null);
loading = signal(false);
error = signal<string | null>(null);
/* Ancêtres = breadcrumb sans le nœud courant (déjà dans le h1) */
ancestors = computed<Category[]>(() => {
const d = this.data();
if (!d || !d.category) return [];
return d.breadcrumb.slice(0, -1);
});
ngOnInit() {
this.route.paramMap
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => {
this.loadBrowse(params.get('id'));
});
}
private loadBrowse(id: string | null) {
this.loading.set(true);
this.error.set(null);
this.categoryService.browse(id).subscribe({
next: (data) => {
this.data.set(data);
this.loading.set(false);
},
error: () => {
this.error.set('Erreur lors du chargement des données.');
this.loading.set(false);
},
});
}
typeBadgeClass(type: string): string {
return type === 'ASYNCAPI'
? 'fr-badge fr-badge--sm fr-badge--purple-glycine'
: 'fr-badge fr-badge--sm fr-badge--blue-ecume';
}
statusBadgeClass(status: string): string {
if (status === 'GENERATED') return 'fr-badge fr-badge--sm fr-badge--success';
if (status === 'ERROR') return 'fr-badge fr-badge--sm fr-badge--error';
return 'fr-badge fr-badge--sm fr-badge--info';
}
}

View File

@@ -0,0 +1,232 @@
import {
Component,
signal,
computed,
effect,
inject,
OnInit,
ChangeDetectionStrategy,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { ApiService } from '../../core/api.service';
import { ApiEntryListItem, ApiType } from '@datacat/shared';
@Component({
selector: 'app-catalog',
standalone: true,
imports: [CommonModule, RouterLink, FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="fr-container fr-mt-4w fr-mb-4w">
<!-- En-tête page -->
<div class="fr-grid-row fr-grid-row--middle fr-mb-4w">
<div class="fr-col">
<h1 class="fr-h2 fr-mb-0">Catalogue des APIs</h1>
</div>
<div class="fr-col-auto">
<a routerLink="/upload" class="fr-btn fr-btn--icon-left fr-icon-upload-2-line">
Importer une API
</a>
</div>
</div>
<!-- Filtres -->
<div class="fr-grid-row fr-grid-row--gutters fr-grid-row--middle fr-mb-4w">
<div class="fr-col-12 fr-col-md-8">
<div class="fr-search-bar" role="search">
<label class="fr-label" for="search-input">Recherche</label>
<input
id="search-input"
class="fr-input"
type="search"
placeholder="Rechercher par titre ou description..."
[ngModel]="search()"
(ngModelChange)="onSearchChange($event)"
/>
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<label class="fr-sr-only" for="type-filter">Type</label>
<select
id="type-filter"
class="fr-select"
[ngModel]="typeFilter()"
(ngModelChange)="onTypeChange($event)"
>
<option value="">Tous les types</option>
<option value="ASYNCAPI">AsyncAPI</option>
<option value="OPENAPI">OpenAPI</option>
</select>
</div>
</div>
<!-- Chargement -->
@if (loading()) {
<div class="fr-callout fr-mb-4w">
<p class="fr-callout__text">Chargement...</p>
</div>
}
<!-- Liste vide -->
@if (!loading() && apis().length === 0) {
<div class="fr-callout fr-callout--blue-cumulus fr-mb-4w">
<p class="fr-callout__title">Aucune API trouvée</p>
<p class="fr-callout__text">
Importez votre première API en cliquant sur "Importer une API".
</p>
</div>
}
<!-- Grille de cartes -->
<div class="fr-grid-row fr-grid-row--gutters">
@for (api of apis(); track api.id) {
<div class="fr-col-12 fr-col-md-6 fr-col-lg-4">
<div class="fr-card fr-card--shadow fr-enlarge-link">
<div class="fr-card__body">
<div class="fr-card__content">
<h3 class="fr-card__title">
<a [routerLink]="['/catalog', api.id]">{{ api.title }}</a>
</h3>
<p class="fr-card__desc">{{ api.description || 'Pas de description' }}</p>
<div class="fr-card__start">
<ul class="fr-tags-group">
<li>
<p class="fr-tag">{{ api.type }}</p>
</li>
<li>
<p class="fr-tag">v{{ api.version }}</p>
</li>
</ul>
</div>
</div>
<div class="fr-card__footer">
<span [class]="badgeClass(api.status)" aria-hidden="true">
{{ api.status }}
</span>
</div>
</div>
</div>
</div>
}
</div>
<!-- Pagination -->
@if (totalPages() > 1) {
<nav class="fr-pagination fr-mt-4w" role="navigation" aria-label="Pagination">
<ul class="fr-pagination__list">
<li>
<button
class="fr-pagination__link fr-pagination__link--prev fr-pagination__link--lg-label"
[disabled]="page() <= 1"
(click)="setPage(page() - 1)"
>
Page précédente
</button>
</li>
@for (p of pageNumbers(); track p) {
<li>
<button
class="fr-pagination__link"
[class.fr-pagination__link--active]="p === page()"
[attr.aria-current]="p === page() ? 'page' : null"
(click)="setPage(p)"
>
{{ p }}
</button>
</li>
}
<li>
<button
class="fr-pagination__link fr-pagination__link--next fr-pagination__link--lg-label"
[disabled]="page() >= totalPages()"
(click)="setPage(page() + 1)"
>
Page suivante
</button>
</li>
</ul>
</nav>
}
</div>
`,
})
export class CatalogComponent implements OnInit {
private apiService = inject(ApiService);
apis = signal<ApiEntryListItem[]>([]);
loading = signal(false);
search = signal('');
typeFilter = signal<ApiType | ''>('');
page = signal(1);
total = signal(0);
readonly limit = 12;
totalPages = computed(() => Math.ceil(this.total() / this.limit));
pageNumbers = computed(() => {
const pages: number[] = [];
for (let i = 1; i <= this.totalPages(); i++) pages.push(i);
return pages;
});
private searchTimer: ReturnType<typeof setTimeout> | null = null;
constructor() {
/* Rechargement si page change */
effect(() => {
const _ = this.page();
this.loadApis();
});
}
ngOnInit() {
this.loadApis();
}
onSearchChange(value: string) {
this.search.set(value);
if (this.searchTimer) clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => {
this.page.set(1);
this.loadApis();
}, 400);
}
onTypeChange(value: string) {
this.typeFilter.set(value as ApiType | '');
this.page.set(1);
this.loadApis();
}
setPage(p: number) {
if (p < 1 || p > this.totalPages()) return;
this.page.set(p);
}
badgeClass(status: string): string {
const base = 'fr-badge';
if (status === 'GENERATED') return `${base} fr-badge--success`;
if (status === 'ERROR') return `${base} fr-badge--error`;
return `${base} fr-badge--info`;
}
private loadApis() {
this.loading.set(true);
this.apiService
.list({
search: this.search() || undefined,
type: (this.typeFilter() as ApiType) || undefined,
page: this.page(),
limit: this.limit,
})
.subscribe({
next: (res) => {
this.apis.set(res.items);
this.total.set(res.total);
this.loading.set(false);
},
error: () => this.loading.set(false),
});
}
}

View File

@@ -0,0 +1,65 @@
import {
Component,
inject,
OnInit,
ChangeDetectionStrategy,
signal,
} from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-doc-viewer',
standalone: true,
imports: [CommonModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [`
.doc-toolbar {
height: 48px;
display: flex;
align-items: center;
padding: 0 1rem;
background: var(--background-default-grey);
border-bottom: 1px solid var(--border-default-grey);
}
.doc-iframe {
width: 100%;
height: calc(100vh - 48px - 73px);
border: none;
}
`],
template: `
<div class="doc-toolbar">
<a
[routerLink]="['/catalog', id()]"
class="fr-btn fr-btn--tertiary-no-outline fr-btn--icon-left fr-icon-arrow-left-line fr-btn--sm"
>
Retour
</a>
</div>
@if (docUrl()) {
<iframe
class="doc-iframe"
[src]="docUrl()!"
title="Documentation API"
sandbox="allow-scripts allow-same-origin"
></iframe>
}
`,
})
export class DocViewerComponent implements OnInit {
private route = inject(ActivatedRoute);
private sanitizer = inject(DomSanitizer);
id = signal('');
docUrl = signal<SafeResourceUrl | null>(null);
ngOnInit() {
const apiId = this.route.snapshot.paramMap.get('id') ?? '';
this.id.set(apiId);
this.docUrl.set(
this.sanitizer.bypassSecurityTrustResourceUrl(`/api/apis/${apiId}/docs`),
);
}
}

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