Files
datacat/documentation/CLAUDE.md
z3n 0976c33059 feat(api-detail): isCurrent + setCurrent + màj doc et seeder
- 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>
2026-06-19 14:23:31 +00:00

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: true partout
  • 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()
);

title et version sont 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

: ApisService expose deux méthodes de mise à jour :

  • update(id, dto: UpdateApiEntryDto) — pour le controller (champs éditables par l'utilisateur)
  • updateInternal(id, fields) — pour DocsGenerationService (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 — upload
  • fr-icon-file-line — fichier
  • fr-icon-search-line — recherche
  • fr-icon-arrow-right-line — flèche droite
  • fr-icon-external-link-line — lien externe
  • fr-icon-delete-bin-line — supprimer
  • fr-icon-refresh-line — régénérer
  • fr-icon-eye-line — voir
  • fr-icon-folder-2-line — dossier
  • fr-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/generator et @asyncapi/html-template sont installés dans l'image Docker prod et dev
  • @redocly/cli est 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-output en 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-auth et redirect-https (dans namespace dev)

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-output dans le pod backend
  • datacat-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 :

  1. build-back : Kaniko → git.chmod777.dev/z3n/datacat/back:latest
  2. build-front : Kaniko → git.chmod777.dev/z3n/datacat/front:latest
  3. 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 : z3n
  • registry_password : token Gitea avec accès packages
  • deploy_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 test pour le frontend
  • Pas de console.log en 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-tile ou cartes fr-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
  • BrowseComponent réutilisé pour /browse et /browse/:id — s'abonne à paramMap via takeUntilDestroyed

/catalog — CatalogComponent

  • Grille DSFR de cartes (fr-card)
  • Champ de recherche (fr-input) filtrant name, 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

/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/validate dès la sélection
    • Pré-remplit les champs name, versionMajor/Minor/Patch, description depuis le YAML
  • É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/:id aprè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