- Shared types : ajout isCurrent sur ApiEntry et ApiEntryListItem - Backend entity : colonne is_current (boolean, default true) - Backend service : isCurrent calculé à la création, setCurrent() marque une version comme courante (siblings → false) - Backend controller : POST /api/apis/:id/set-current - Frontend ApiService : méthode setCurrent() - Browse/upload : affichage badge "Courante" via isCurrent - Seeder : données de démo mises à jour - Documentation : api-reference, architecture, local-setup, CLAUDE.md 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>
26 KiB
26 KiB
Datacat — Guide d'implémentation complet
1. Purpose & Architecture
Datacat est un catalogue de données d'API. Il permet d'importer des fichiers YAML (AsyncAPI ou OpenAPI) et de les transformer en documentation HTML.
datacat/
├── back/ # API NestJS + TypeORM + PostgreSQL
├── front-public/ # Frontend Angular 19 (DSFR)
├── shared/ # Types TypeScript partagés
├── infra/ # Docker, Dockerfiles, K8s
└── documentation/ # Ce fichier + README
Flux principal
User uploads YAML → POST /api/uploads
→ ApiEntry created (status: PENDING)
→ DocsGenerationService.generate()
→ AsyncAPI : API programmatique @asyncapi/generator (html-template local)
→ OpenAPI : redocly build-docs input.yaml --output /docs-output/<id>/index.html
→ ApiEntry updated (status: GENERATED, htmlPath: /docs-output/<id>/index.html)
→ GET /api/apis/:id/docs → sendFile(htmlPath)
2. Commandes dev
# Tout en Docker (recommandé)
docker compose -f infra/docker-compose.yml up -d
docker compose -f infra/docker-compose.yml up -d --build # avec rebuild
# Local (sans Docker)
cd back && pnpm run start:dev # NestJS watch mode — port 3000 interne / 3010 hôte
cd front-public && pnpm run start # Angular dev server — port 4200
# Install deps (depuis la racine workspace)
pnpm install
# Build prod
cd back && pnpm run build
cd front-public && pnpm run build:prod
Proxy Angular → API
front-public/proxy.conf.json (développement local) :
{
"/api": {
"target": "http://localhost:3010",
"secure": false
}
}
front-public/proxy.conf.docker.json (dans Docker) :
{
"/api": {
"target": "http://backend:3000",
"secure": false
}
}
3. Stack & Versions
| Outil | Version |
|---|---|
| Node.js | 22 LTS |
| pnpm | 10.33.0 |
| NestJS | ^11.0.0 |
| TypeORM | ^0.3.20 |
| PostgreSQL | 17 |
| Angular | ^19.0.0 |
| TypeScript | ~5.8.3 |
| @asyncapi/cli | 6.0.0 (in Docker) |
| @redocly/cli | latest (in Docker) |
| @gouvfr/dsfr | ^1.13.0 (npm devDep front-public) |
Conventions TypeScript
- Pas de
any— utiliser les types de@datacat/shared strict: truepartout- English pour le code, Français pour les commentaires
4. Schéma DB
Table api_entries
CREATE TABLE api_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
convention VARCHAR(30) NOT NULL DEFAULT 'CONSULTER'
CHECK (convention IN ('CONSULTER','ENREGISTRER','ETRE_NOTIFIE')),
name VARCHAR(255) NOT NULL DEFAULT '',
provider VARCHAR(255) NOT NULL DEFAULT '',
version_major INT NOT NULL DEFAULT 0,
version_minor INT NOT NULL DEFAULT 0,
version_patch INT NOT NULL DEFAULT 0,
description TEXT,
type VARCHAR(10) NOT NULL CHECK (type IN ('ASYNCAPI', 'OPENAPI')),
yaml_content TEXT NOT NULL,
html_path VARCHAR(500),
status VARCHAR(10) NOT NULL DEFAULT 'PENDING'
CHECK (status IN ('PENDING', 'GENERATED', 'ERROR')),
error_message TEXT,
category_id VARCHAR(36),
contact_functional_name VARCHAR(255) NOT NULL DEFAULT '',
contact_functional_entity VARCHAR(255) NOT NULL DEFAULT '',
contact_functional_email VARCHAR(255) NOT NULL DEFAULT '',
contact_technical_name VARCHAR(255) NOT NULL DEFAULT '',
contact_technical_entity VARCHAR(255) NOT NULL DEFAULT '',
contact_technical_email VARCHAR(255) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
titleetversionsont des champs calculés (pas en DB) :
title=CONVENTION_LABEL + ' ' + name(ex:"Consulter Petstore")version="major.minor.patch"(ex:"1.2.0")
Entity TypeORM (back/src/modules/apis/api-entry.entity.ts)
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { ApiType, ApiStatus, ApiConvention } from '@datacat/shared';
@Entity('api_entries')
export class ApiEntryEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 30, default: 'CONSULTER' })
convention!: ApiConvention;
@Column({ type: 'varchar', length: 255, default: '' })
name!: string;
@Column({ nullable: true, type: 'text' })
description!: string | null;
@Column({ type: 'varchar', length: 10 })
type!: ApiType;
@Column({ type: 'varchar', length: 255, default: '', name: 'provider' })
provider!: string;
@Column({ type: 'int', default: 0, name: 'version_major' })
versionMajor!: number;
@Column({ type: 'int', default: 0, name: 'version_minor' })
versionMinor!: number;
@Column({ type: 'int', default: 0, name: 'version_patch' })
versionPatch!: number;
@Column({ type: 'text', name: 'yaml_content' })
yamlContent!: string;
@Column({ nullable: true, type: 'varchar', length: 500, name: 'html_path' })
htmlPath!: string | null;
@Column({ type: 'varchar', length: 10, default: 'PENDING' })
status!: ApiStatus;
@Column({ nullable: true, type: 'text', name: 'error_message' })
errorMessage!: string | null;
@Column({ nullable: true, type: 'varchar', length: 36, name: 'category_id' })
categoryId!: string | null;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_name' })
contactFunctionalName!: string;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_entity' })
contactFunctionalEntity!: string;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_email' })
contactFunctionalEmail!: string;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_technical_name' })
contactTechnicalName!: string;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_technical_entity' })
contactTechnicalEntity!: string;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_technical_email' })
contactTechnicalEmail!: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
5. Architecture NestJS
Structure back/src/
src/
├── main.ts
├── app.module.ts
├── modules/
│ ├── apis/
│ │ ├── apis.module.ts
│ │ ├── apis.controller.ts # REST endpoints
│ │ ├── apis.service.ts # CRUD logique
│ │ ├── api-entry.entity.ts # TypeORM entity
│ │ ├── api-entry.mapper.ts # entity → DTO (calcul title/version)
│ │ └── dto/
│ │ ├── create-api-entry.dto.ts
│ │ └── update-api-entry.dto.ts
│ ├── uploads/
│ │ ├── uploads.module.ts
│ │ └── uploads.controller.ts # POST /api/uploads + POST /api/uploads/validate
│ ├── docs-generation/
│ │ ├── docs-generation.module.ts
│ │ └── docs-generation.service.ts # API programmatique @asyncapi/generator + redocly CLI
│ ├── categories/
│ │ ├── categories.module.ts
│ │ ├── categories.controller.ts
│ │ ├── categories.service.ts
│ │ └── category.entity.ts
│ └── seeder/
│ ├── seeder.module.ts
│ └── seeder.service.ts # Données de démo (idempotent, OnApplicationBootstrap)
├── health/
│ ├── health.module.ts
│ └── health.controller.ts
└── common/
└── filters/
└── all-exceptions.filter.ts
API REST
GET /health → { status: 'ok' }
GET /api/apis → ApiListResponse (+ ?search=&type=&categoryId=&page=&limit=)
GET /api/apis/:id → ApiEntry
PATCH /api/apis/:id → ApiEntry (body: UpdateApiEntryDto)
DELETE /api/apis/:id → 204 No Content
POST /api/apis/:id/regenerate → ApiEntry (relance la génération de docs)
GET /api/apis/:id/docs → sendFile(htmlPath) ou 404 si pas encore généré
GET /api/apis/:id/*path → assets générés (CSS/JS référencés par index.html)
GET /api/apis/:id/yaml → download du YAML brut (Content-Disposition: attachment)
POST /api/uploads → ApiEntry (multipart: files 1..20 + métadonnées)
POST /api/uploads/validate → résultat de validation (sans sauvegarde)
GET /api/categories → liste flat toutes catégories
GET /api/categories/browse → navigation racine (sous-catégories + APIs sans catégorie)
GET /api/categories/browse/:id → navigation dans une catégorie
GET /api/categories/:id → détail catégorie
POST /api/categories → créer catégorie
PATCH /api/categories/:id → modifier catégorie
DELETE /api/categories/:id → supprimer (400 si non vide)
ApisController
import { Controller, Get, Patch, Delete, Post, Param, Body, Query, Res, NotFoundException, HttpCode } from '@nestjs/common';
import { Response } from 'express';
@Controller('apis')
export class ApisController {
constructor(private readonly apisService: ApisService) {}
@Get()
findAll(@Query() query: ApiListQuery) {
return this.apisService.findAll(query);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.apisService.findOneOrThrow(id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateApiEntryDto) {
return this.apisService.update(id, dto);
}
@Delete(':id')
@HttpCode(204)
remove(@Param('id') id: string) {
return this.apisService.remove(id);
}
@Post(':id/regenerate')
regenerate(@Param('id') id: string) {
return this.apisService.regenerate(id);
}
@Get(':id/docs')
async getDocs(@Param('id') id: string, @Res() res: Response) {
const entry = await this.apisService.findOneOrThrow(id);
if (!entry.htmlPath) throw new NotFoundException('Documentation not generated yet');
res.sendFile(entry.htmlPath);
}
@Get(':id/yaml')
async getYaml(@Param('id') id: string, @Res() res: Response) {
const entry = await this.apisService.findOneOrThrow(id);
res.setHeader('Content-Disposition', `attachment; filename="${entry.name}.yaml"`);
res.setHeader('Content-Type', 'application/x-yaml');
res.send(entry.yamlContent);
}
}
UploadsController
import { Controller, Post, UseInterceptors, UploadedFiles, Body, BadRequestException } from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
@Controller('uploads')
export class UploadsController {
constructor(
private readonly apisService: ApisService,
private readonly docsService: DocsGenerationService,
) {}
// POST /api/uploads/validate — valide sans sauvegarder
@Post('validate')
@UseInterceptors(FilesInterceptor('files', 20, { /* diskStorage + fileFilter yaml */ }))
async validate(@UploadedFiles() files: Express.Multer.File[]) {
// Retourne { valid, type, mainFile, name, versionMajor, versionMinor, versionPatch, description, errors }
}
// POST /api/uploads — crée l'entrée + lance la génération
@Post()
@UseInterceptors(FilesInterceptor('files', 20, { /* diskStorage + fileFilter yaml */ }))
async upload(
@UploadedFiles() files: Express.Multer.File[],
@Body() body: {
convention: string;
name: string;
provider: string;
versionMajor: string; // string car multipart form-data
versionMinor: string;
versionPatch: string;
description?: string;
type?: 'ASYNCAPI' | 'OPENAPI'; // optionnel, détecté depuis le YAML
categoryId?: string;
contactFunctionalName: string;
contactFunctionalEntity: string;
contactFunctionalEmail: string;
contactTechnicalName: string;
contactTechnicalEntity: string;
contactTechnicalEmail: string;
},
) {
// 1 fichier → lecture directe
// N fichiers → détection fichier principal + bundling (redocly bundle / @asyncapi/bundler)
// Fire and forget — génération asynchrone
this.docsService.generate(entry.id).catch(console.error);
return entry;
}
}
DocsGenerationService
import { Injectable } from '@nestjs/common';
import { execFile } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs/promises';
import * as path from 'path';
const AsyncApiGenerator = require('@asyncapi/generator');
const execFileAsync = promisify(execFile);
@Injectable()
export class DocsGenerationService {
private readonly outputDir = process.env.DOCS_OUTPUT_DIR ?? './docs-output';
async generate(entryId: string): Promise<void> {
const entry = await this.apisService.findOneOrThrow(entryId);
const entryDir = path.join(this.outputDir, entryId);
await fs.mkdir(entryDir, { recursive: true });
const yamlPath = path.join(entryDir, 'input.yaml');
await fs.writeFile(yamlPath, entry.yamlContent, 'utf-8');
try {
if (entry.type === 'ASYNCAPI') {
await this.generateAsyncApi(yamlPath, entryDir);
} else {
await this.generateOpenApi(yamlPath, entryDir);
}
await this.apisService.updateInternal(entryId, { htmlPath: path.join(entryDir, 'index.html'), status: 'GENERATED' });
} catch (error) {
await this.apisService.updateInternal(entryId, { status: 'ERROR', errorMessage: String(error) });
}
}
private async generateAsyncApi(yamlPath: string, outputDir: string): Promise<void> {
// API programmatique — aucun appel réseau, template résolu localement
const templatePath = path.join(process.cwd(), 'node_modules', '@asyncapi', 'html-template');
const generator = new AsyncApiGenerator(templatePath, outputDir, { forceWrite: true });
await generator.generateFromFile(yamlPath);
}
private async generateOpenApi(yamlPath: string, outputDir: string): Promise<void> {
await execFileAsync('redocly', [
'build-docs', yamlPath,
'--output', path.join(outputDir, 'index.html'),
], { shell: true, env: execEnv });
}
}
Note
:
ApisServiceexpose deux méthodes de mise à jour :
update(id, dto: UpdateApiEntryDto)— pour le controller (champs éditables par l'utilisateur)updateInternal(id, fields)— pourDocsGenerationService(status / htmlPath / errorMessage)
6. Conventions Angular 19
Règles absolues
- Standalone components : toujours
standalone: true, jamais de NgModule - Signals : utiliser
signal(),computed(),effect()pour le state local - inject() : utiliser
inject()à la place de constructor injection - OnPush : toujours
changeDetection: ChangeDetectionStrategy.OnPush - Lazy loading : toutes les pages sont lazy-loadées via
loadComponent
Pattern type pour un composant de page
import { Component, signal, computed, inject, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { ActivatedRoute } from '@angular/router';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-browse',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="fr-container fr-mt-4w">
<h1 class="fr-h1">Parcourir les APIs</h1>
@if (loading()) {
<div class="fr-callout">Chargement...</div>
}
@for (cat of subcategories(); track cat.id) {
<div class="fr-card fr-mb-2w">
<h2>{{ cat.name }}</h2>
</div>
}
</div>
`,
})
export class BrowseComponent implements OnInit {
private http = inject(HttpClient);
subcategories = signal<CategoryWithCounts[]>([]);
loading = signal(false);
ngOnInit() { /* ... */ }
}
Service type
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApiEntry, ApiListResponse, ApiListQuery } from '@datacat/shared';
@Injectable({ providedIn: 'root' })
export class ApiService {
private http = inject(HttpClient);
private baseUrl = '/api/apis';
list(query?: ApiListQuery): Observable<ApiListResponse> {
return this.http.get<ApiListResponse>(this.baseUrl, { params: query as Record<string, string> });
}
get(id: string): Observable<ApiEntry> {
return this.http.get<ApiEntry>(`${this.baseUrl}/${id}`);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
regenerate(id: string): Observable<ApiEntry> {
return this.http.post<ApiEntry>(`${this.baseUrl}/${id}/regenerate`, {});
}
}
7. DSFR (Système de Design de l'État)
Installation via npm
# Déjà dans front-public/package.json :
# "@gouvfr/dsfr": "^1.13.0" (devDependencies)
# Assets copiés automatiquement via angular.json (assets config)
Classes CSS essentielles
<!-- Layout -->
<div class="fr-container"> <!-- Conteneur centré -->
<div class="fr-grid-row fr-grid-row--gutters"> <!-- Grille -->
<div class="fr-col-12 fr-col-md-6"> <!-- Colonnes -->
<!-- Typographie -->
<h1 class="fr-h1"> <!-- Titre niveau 1 -->
<p class="fr-text--lead"> <!-- Texte mise en avant -->
<!-- Composants -->
<div class="fr-card"> <!-- Carte -->
<div class="fr-card__body">
<div class="fr-card__content">
<h3 class="fr-card__title">
<div class="fr-badge fr-badge--info"> <!-- Badge status -->
<div class="fr-badge fr-badge--success">
<div class="fr-badge fr-badge--error">
<button class="fr-btn"> <!-- Bouton principal -->
<button class="fr-btn fr-btn--secondary"> <!-- Bouton secondaire -->
<button class="fr-btn fr-btn--icon-left fr-icon-upload-2-line"> <!-- Avec icône -->
<div class="fr-input-group"> <!-- Champ de formulaire -->
<label class="fr-label">
<input class="fr-input" />
<div class="fr-select-group"> <!-- Select -->
<select class="fr-select">
<nav class="fr-stepper"> <!-- Stepper 3 étapes -->
<span class="fr-stepper__steps" data-fr-current-step="1" data-fr-steps="3">
<!-- Espacement (t=top, b=bottom, l=left, r=right, h=horizontal, v=vertical) -->
<!-- Unités: 1w=8px, 2w=16px, 3w=24px, 4w=32px -->
fr-mt-4w fr-mb-2w fr-ml-1w fr-p-3w fr-mx-auto
Icônes DSFR disponibles
fr-icon-upload-2-line— uploadfr-icon-file-line— fichierfr-icon-search-line— recherchefr-icon-arrow-right-line— flèche droitefr-icon-external-link-line— lien externefr-icon-delete-bin-line— supprimerfr-icon-refresh-line— régénérerfr-icon-eye-line— voirfr-icon-folder-2-line— dossierfr-icon-edit-line— éditer
8. Génération de documentation
AsyncAPI — API programmatique (pas CLI)
// Utilise @asyncapi/generator directement (installé dans node_modules)
// Template @asyncapi/html-template pré-installé dans l'image Docker
const templatePath = path.join(process.cwd(), 'node_modules', '@asyncapi', 'html-template');
const generator = new AsyncApiGenerator(templatePath, outputDir, { forceWrite: true });
await generator.generateFromFile(yamlPath);
OpenAPI — CLI Redocly
# Générer HTML depuis un fichier OpenAPI YAML
redocly build-docs ./api.yaml --output ./output-dir/index.html
# Valider un fichier OpenAPI
redocly lint ./api.yaml
Bundling multi-fichiers
- OpenAPI :
redocly bundle <main> --output <bundled.yaml> - AsyncAPI : API programmatique
@asyncapi/bundler
Notes importantes
@asyncapi/generatoret@asyncapi/html-templatesont installés dans l'image Docker prod et dev@redocly/cliest installé globalement dans les images Docker- La génération est asynchrone : l'upload crée l'entrée, puis la génération se fait en background
- Le répertoire de sortie :
DOCS_OUTPUT_DIR=/app/docs-output(PVC en prod),./docs-outputen dev - Chaque API a son propre sous-répertoire :
/app/docs-output/<uuid>/index.html
9. Déploiement K3s
Namespaces
datacat: tous les pods (backend, frontend, postgres)- Middlewares Traefik :
dev-basic-authetredirect-https(dans namespacedev)
URLs
- Frontend :
https://datacat.dev.chmod777.dev - API :
https://api.datacat.dev.chmod777.dev - Basic Auth : user
dev, password dans~/server-setup/.dev-password
Images
- Backend :
git.chmod777.dev/z3n/datacat/back:latest - Frontend :
git.chmod777.dev/z3n/datacat/front:latest
PVC
datacat-docs-pvc(5Gi) — monté sur/app/docs-outputdans le pod backenddatacat-postgres-pvc(10Gi) — données PostgreSQL
Secret K8s requis
# Créer le secret pour les credentials de la DB
kubectl create secret generic datacat-secrets \
--from-literal=db-user=datacat \
--from-literal=db-password=<PASSWORD> \
-n datacat
# Créer le secret pour le registry Gitea
kubectl create secret docker-registry registry-secret \
--docker-server=git.chmod777.dev \
--docker-username=z3n \
--docker-password=<TOKEN> \
-n datacat
Déploiement initial
cd infra/k8s
kubectl apply -k .
# ou
kubectl apply -f namespace.yaml
kubectl apply -f pvc.yaml
kubectl apply -f postgres.yaml
kubectl apply -f backend.yaml
kubectl apply -f frontend.yaml
kubectl apply -f ingressroutes.yaml
Mettre à jour les images
kubectl set image deployment/backend backend=git.chmod777.dev/z3n/datacat/back:<sha> -n datacat
kubectl set image deployment/frontend frontend=git.chmod777.dev/z3n/datacat/front:<sha> -n datacat
kubectl rollout status deployment/backend -n datacat
kubectl rollout status deployment/frontend -n datacat
10. CI/CD Woodpecker
Le pipeline .woodpecker.yaml à la racine gère :
- build-back : Kaniko →
git.chmod777.dev/z3n/datacat/back:latest - build-front : Kaniko →
git.chmod777.dev/z3n/datacat/front:latest - deploy :
kubectl set image+kubectl rollout status
Secrets Woodpecker à configurer
Dans https://ci.chmod777.dev → Settings → Secrets pour le dépôt datacat :
registry_user:z3nregistry_password: token Gitea avec accès packagesdeploy_kubeconfig: contenu du fichier~/.kube/config
11. Règles de code
- Langue : code en anglais, commentaires en français
- Commits : Conventional Commits (
feat:,fix:,chore:,docs:,refactor:) - TypeScript : pas de
any,strict: true, types depuis@datacat/shared - NestJS : un module par feature (
ApisModule,UploadsModule,DocsGenerationModule,CategoriesModule) - Angular : standalone components, signals, inject(), OnPush, lazy loading
- Tests : Vitest pour le backend,
ng testpour le frontend - Pas de
console.logen production — utiliser Pino (NestJS) ou le logger Angular
12. Pages Angular — Détail d'implémentation
/browse et /browse/:id — BrowseComponent
- Navigation par dossiers DSFR (tuiles
fr-tileou cartesfr-card) - Fil d'Ariane DSFR (
fr-breadcrumb) depuis le breadcrumb retourné par l'API - Boutons : créer dossier, éditer dossier (crayon), supprimer dossier
- Modales partagées :
CategoryFormModalComponent(créer/éditer),CategoryDeleteModalComponent - Recherche dans les APIs du dossier courant
BrowseComponentréutilisé pour/browseet/browse/:id— s'abonne àparamMapviatakeUntilDestroyed
/catalog — CatalogComponent
- Grille DSFR de cartes (
fr-card) - Champ de recherche (
fr-input) filtrantname,provider,description, contacts - Select DSFR pour filtrer par type (ASYNCAPI / OPENAPI)
- Pagination DSFR (
fr-pagination)
/catalog/:id — ApiDetailComponent
- Fil d'Ariane aligné sur la hiérarchie des catégories
- Titre affiché :
CONVENTION_LABEL + ' ' + name(ex:"Consulter Petstore") - Métadonnées : convention (badge), name, provider, version, description, type, statut, dates
- Contacts métier (nom, entité, email) et technique (nom, entité, email)
- Badge de statut :
fr-badge--success(GENERATED),fr-badge--warning(PENDING),fr-badge--error(ERROR) - Boutons :
- "Voir la documentation" →
/catalog/:id/docs(désactivé si status ≠ GENERATED) - "Télécharger YAML" →
/api/apis/:id/yaml - "Régénérer" → POST
/api/apis/:id/regenerate - "Modifier" → modale d'édition (tous les champs sauf yamlContent)
- "Supprimer" → modale de confirmation → DELETE
/api/apis/:id+ redirect/browse
- "Voir la documentation" →
/catalog/:id/docs — DocViewerComponent
<iframe>plein écran affichant/api/apis/:id/docs- Bouton "Retour" en haut
/upload — UploadComponent
- Stepper DSFR 3 étapes : (1) Choisir les fichiers, (2) Métadonnées, (3) Confirmation
- Étape 1 : Zone de drop DSFR (
fr-upload-group) pour 1 à 20 fichiers.yaml/.yml- Validation serveur via
POST /api/uploads/validatedès la sélection - Pré-remplit les champs
name,versionMajor/Minor/Patch,descriptiondepuis le YAML
- Validation serveur via
- Étape 2 : Formulaire —
convention(select),name,provider,versionMajor/Minor/Patch,description,categoryId(optionnel), contacts métier + technique - Étape 3 : Récap + bouton "Importer"
- POST multipart vers
/api/uploads - Redirect vers
/catalog/:idaprès succès
Composants partagés
CategoryFormModalComponent— modale DSFR pour créer ou éditer une catégorie (nom, description, ordre)CategoryDeleteModalComponent— modale DSFR de confirmation de suppression d'une catégorie