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

676 lines
20 KiB
Markdown

# 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
```bash
# 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` :
```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`
```sql
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`)
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```html
<!-- 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
```bash
# 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
```bash
# 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
```bash
# 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
```bash
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
```bash
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`.
```bash
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