Files
datacat/documentation/CLAUDE.md
z3n 921a6e652b 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>
2026-05-22 08:42:54 +00:00

20 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 via des CLI spécialisées.

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 generate fromFile input.yaml --output /docs-output/<id>/
      → OR 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é)
pnpm run dev           # docker compose up
pnpm run dev:build     # docker compose up --build

# Local (sans Docker)
cd back && pnpm run start:dev         # NestJS watch mode — port 3000
cd front-public && pnpm run start     # Angular dev server — port 4200

# Install deps
cd back && pnpm install
cd front-public && pnpm install
cd shared && pnpm install

# Build prod
cd back && pnpm run build
cd front-public && pnpm run build:prod

Proxy Angular → API

Créer front-public/proxy.conf.json :

{
  "/api": {
    "target": "http://localhost: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.7.0
@asyncapi/cli latest (in Docker)
@redocly/cli latest (in Docker)

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(),
  title         VARCHAR(255) NOT NULL,
  version       VARCHAR(50)  NOT NULL,
  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,
  created_at    TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
  updated_at    TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

Entity TypeORM (back/src/modules/apis/api-entry.entity.ts)

import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { ApiType, ApiStatus } from '@datacat/shared';

@Entity('api_entries')
export class ApiEntryEntity {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  title: string;

  @Column()
  version: string;

  @Column({ nullable: true, type: 'text' })
  description: string | null;

  @Column({ type: 'varchar', length: 10 })
  type: ApiType;

  @Column({ type: 'text', name: 'yaml_content' })
  yamlContent: string;

  @Column({ nullable: true, 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;

  @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
│   ├── uploads/
│   │   ├── uploads.module.ts
│   │   └── uploads.controller.ts   # POST /api/uploads (multipart)
│   └── docs-generation/
│       ├── docs-generation.module.ts
│       └── docs-generation.service.ts  # Appels AsyncAPI/Redocly CLI
├── database/
│   └── database.module.ts          # TypeORM config (si séparé)
└── common/
    ├── filters/
    │   └── all-exceptions.filter.ts
    └── interceptors/
        └── transform.interceptor.ts

API REST

GET    /health                         → { status: 'ok' }
GET    /api/apis                       → ApiListResponse (+ ?search=&type=&page=&limit=)
GET    /api/apis/:id                   → ApiEntry
POST   /api/uploads                    → ApiEntry (multipart: file + title + version + description + type)
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/yaml              → download du YAML brut (Content-Disposition: attachment)

ApisController

import { Controller, Get, Patch, Delete, Post, Param, Body, Query, Res, NotFoundException } 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.title}.yaml"`);
    res.setHeader('Content-Type', 'application/x-yaml');
    res.send(entry.yamlContent);
  }
}

UploadsController

import { Controller, Post, UseInterceptors, UploadedFile, Body } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';

@Controller('uploads')
export class UploadsController {
  constructor(
    private readonly apisService: ApisService,
    private readonly docsService: DocsGenerationService,
  ) {}

  @Post()
  @UseInterceptors(FileInterceptor('file', {
    storage: diskStorage({ destination: '/tmp/uploads' }),
    fileFilter: (req, file, cb) => {
      if (!file.originalname.match(/\.(yaml|yml)$/)) {
        return cb(new Error('Only YAML files are allowed'), false);
      }
      cb(null, true);
    },
  }))
  async upload(
    @UploadedFile() file: Express.Multer.File,
    @Body() body: { title: string; version: string; description?: string; type: 'ASYNCAPI' | 'OPENAPI' },
  ) {
    const yamlContent = fs.readFileSync(file.path, 'utf-8');
    const entry = await this.apisService.create({ ...body, yamlContent });
    // 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 execFileAsync = promisify(execFile);

@Injectable()
export class DocsGenerationService {
  private readonly outputDir = process.env.DOCS_OUTPUT_DIR ?? '/app/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);
      }

      const htmlPath = path.join(entryDir, 'index.html');
      await this.apisService.update(entryId, { htmlPath, status: 'GENERATED' });
    } catch (error) {
      await this.apisService.update(entryId, {
        status: 'ERROR',
        errorMessage: String(error),
      });
    }
  }

  private async generateAsyncApi(yamlPath: string, outputDir: string): Promise<void> {
    // asyncapi generate fromFile <input> --output <dir>
    await execFileAsync('asyncapi', [
      'generate', 'fromFile',
      yamlPath,
      '--output', outputDir,
    ]);
  }

  private async generateOpenApi(yamlPath: string, outputDir: string): Promise<void> {
    // redocly build-docs <input> --output <file>
    await execFileAsync('redocly', [
      'build-docs',
      yamlPath,
      '--output', path.join(outputDir, 'index.html'),
    ]);
  }
}

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-catalog',
  standalone: true,
  imports: [CommonModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="fr-container fr-mt-4w">
      <h1 class="fr-h1">Catalogue des APIs</h1>
      @if (loading()) {
        <div class="fr-callout">Chargement...</div>
      }
      @for (api of apis(); track api.id) {
        <div class="fr-card fr-mb-2w">
          <h2>{{ api.title }}</h2>
        </div>
      }
    </div>
  `,
})
export class CatalogComponent implements OnInit {
  private http = inject(HttpClient);

  apis = signal<ApiEntryListItem[]>([]);
  loading = signal(false);

  ngOnInit() {
    this.loadApis();
  }

  private loadApis() {
    this.loading.set(true);
    this.http.get<ApiListResponse>('/api/apis').subscribe({
      next: (res) => {
        this.apis.set(res.items);
        this.loading.set(false);
      },
      error: () => this.loading.set(false),
    });
  }
}

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 (assets statiques — pas de package npm)

Télécharger la dernière version depuis https://www.systeme-de-design.gouv.fr/ et placer les assets dans front-public/src/assets/dsfr/.

Structure attendue :

src/assets/dsfr/
├── dsfr.min.css
├── dsfr.module.min.js
├── dsfr.nomodule.min.js
└── utility/
    └── icons/
        └── icons.min.css

Déjà référencé dans index.html.

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

8. Commandes AsyncAPI CLI & Redocly CLI

AsyncAPI CLI

# Générer HTML depuis un fichier YAML AsyncAPI
asyncapi generate fromFile ./api.yaml --output ./output-dir/

# Valider un fichier
asyncapi validate ./api.yaml

# Format de sortie : ./output-dir/index.html

Redocly CLI

# 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

# Format de sortie : ./output-dir/index.html

Notes importantes

  • Les deux CLIs sont installés dans l'image Docker prod (infra/Dockerfile.prod, target back)
  • En dev, ils sont installés dans infra/Dockerfile.back.dev
  • 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)
  • 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

Dépôt Gitea

Créer le dépôt datacat dans l'organisation z3n sur https://git.chmod777.dev.

cd /home/flo/dev/datacat
git init
git remote add origin https://git.chmod777.dev/z3n/datacat.git
git add .
git commit -m "feat: initial scaffold"
git push -u origin main

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)
  • 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

/catalog — CatalogComponent

  • Grille DSFR de cartes (fr-card)
  • Champ de recherche (fr-input) filtrant titre + description
  • Select DSFR pour filtrer par type (ASYNCAPI / OPENAPI)
  • Pagination DSFR (fr-pagination)
  • Lien vers /upload avec bouton fr-btn fr-btn--icon-left fr-icon-upload-2-line

/catalog/:id — ApiDetailComponent

  • Métadonnées : titre, version, description, type, statut (badge), dates
  • 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
    • "Supprimer" → DELETE /api/apis/:id + redirect /catalog

/catalog/:id/docs — DocViewerComponent

  • <iframe> plein écran affichant /api/apis/:id/docs
  • Bouton "Retour" en haut

/upload — UploadComponent

  • Stepper DSFR 3 étapes : (1) Choisir le fichier, (2) Métadonnées, (3) Confirmation
  • Étape 1 : Zone de drop DSFR (fr-upload-group) pour fichier .yaml / .yml
  • Étape 2 : Formulaire (titre, version, description, type select AsyncAPI/OpenAPI)
  • Étape 3 : Récap + bouton "Importer"
  • POST multipart vers /api/uploads
  • Redirect vers /catalog/:id après succès