- 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>
750 lines
26 KiB
Markdown
750 lines
26 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.
|
|
|
|
```
|
|
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
|
|
|
|
```bash
|
|
# 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) :
|
|
```json
|
|
{
|
|
"/api": {
|
|
"target": "http://localhost:3010",
|
|
"secure": false
|
|
}
|
|
}
|
|
```
|
|
|
|
`front-public/proxy.conf.docker.json` (dans Docker) :
|
|
```json
|
|
{
|
|
"/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`
|
|
|
|
```sql
|
|
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`)
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```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 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
|
|
|
|
```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-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
|
|
|
|
```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 via npm
|
|
|
|
```bash
|
|
# 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
|
|
|
|
```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
|
|
- `fr-icon-folder-2-line` — dossier
|
|
- `fr-icon-edit-line` — éditer
|
|
|
|
---
|
|
|
|
## 8. Génération de documentation
|
|
|
|
### AsyncAPI — API programmatique (pas CLI)
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```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
|
|
```
|
|
|
|
### 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
|
|
|
|
```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`
|
|
|
|
---
|
|
|
|
## 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
|