# 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// → OR 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é) 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 { 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 { // asyncapi generate fromFile --output await execFileAsync('asyncapi', [ 'generate', 'fromFile', yamlPath, '--output', outputDir, ]); } private async generateOpenApi(yamlPath: string, outputDir: string): Promise { // redocly build-docs --output 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: `

Catalogue des APIs

@if (loading()) {
Chargement...
} @for (api of apis(); track api.id) {

{{ api.title }}

}
`, }) export class CatalogComponent implements OnInit { private http = inject(HttpClient); apis = signal([]); loading = signal(false); ngOnInit() { this.loadApis(); } private loadApis() { this.loading.set(true); this.http.get('/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 { 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 (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