# 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//index.html → ApiEntry updated (status: GENERATED, htmlPath: /docs-output//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 { 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 { // 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 { 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: `

Parcourir les APIs

@if (loading()) {
Chargement...
} @for (cat of subcategories(); track cat.id) {

{{ cat.name }}

}
`, }) export class BrowseComponent implements OnInit { private http = inject(HttpClient); subcategories = signal([]); 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 { return this.http.get(this.baseUrl, { params: query as Record }); } get(id: string): Observable { return this.http.get(`${this.baseUrl}/${id}`); } delete(id: string): Observable { return this.http.delete(`${this.baseUrl}/${id}`); } regenerate(id: string): Observable { return this.http.post(`${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