diff --git a/README.md b/README.md index 40ecceb..2de8182 100644 --- a/README.md +++ b/README.md @@ -83,4 +83,5 @@ docker compose -f infra/docker-compose.yml up -d - [`documentation/architecture.md`](documentation/architecture.md) — flux, schéma DB, services - [`documentation/api-reference.md`](documentation/api-reference.md) — référence complète des endpoints REST +- [`documentation/local-setup.md`](documentation/local-setup.md) — installation locale (sans Docker) - [`documentation/CLAUDE.md`](documentation/CLAUDE.md) — guide d'implémentation interne diff --git a/back/src/modules/apis/api-entry.entity.ts b/back/src/modules/apis/api-entry.entity.ts index a702650..5f37162 100644 --- a/back/src/modules/apis/api-entry.entity.ts +++ b/back/src/modules/apis/api-entry.entity.ts @@ -45,6 +45,9 @@ export class ApiEntryEntity { @Column({ nullable: true, type: 'varchar', length: 36, name: 'category_id' }) categoryId!: string | null; + @Column({ type: 'boolean', default: true, name: 'is_current' }) + isCurrent!: boolean; + @Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_name' }) contactFunctionalName!: string; diff --git a/back/src/modules/apis/api-entry.mapper.ts b/back/src/modules/apis/api-entry.mapper.ts index 60674dc..8de1962 100644 --- a/back/src/modules/apis/api-entry.mapper.ts +++ b/back/src/modules/apis/api-entry.mapper.ts @@ -13,6 +13,7 @@ export function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem { provider: entity.provider, status: entity.status, categoryId: entity.categoryId, + isCurrent: entity.isCurrent, createdAt: entity.createdAt.toISOString(), updatedAt: entity.updatedAt.toISOString(), }; diff --git a/back/src/modules/apis/apis.controller.ts b/back/src/modules/apis/apis.controller.ts index 77218b9..527e9de 100644 --- a/back/src/modules/apis/apis.controller.ts +++ b/back/src/modules/apis/apis.controller.ts @@ -43,6 +43,11 @@ export class ApisController { return this.apisService.remove(id); } + @Post(':id/set-current') + setCurrent(@Param('id') id: string) { + return this.apisService.setCurrent(id); + } + @Post(':id/regenerate') regenerate(@Param('id') id: string) { return this.apisService.regenerate(id); diff --git a/back/src/modules/apis/apis.service.ts b/back/src/modules/apis/apis.service.ts index 8f257e9..7cfb4d1 100644 --- a/back/src/modules/apis/apis.service.ts +++ b/back/src/modules/apis/apis.service.ts @@ -25,6 +25,12 @@ export class ApisService { ) {} async create(dto: CreateApiEntryDto): Promise { + // Première version si aucune entrée avec le même name + convention + const existingCount = await this.repo.count({ + where: { name: dto.name, convention: dto.convention }, + }); + const isCurrent = existingCount === 0; + const entity = this.repo.create({ convention: dto.convention, name: dto.name, @@ -37,6 +43,7 @@ export class ApisService { yamlContent: dto.yamlContent, status: 'PENDING', categoryId: dto.categoryId ?? null, + isCurrent, contactFunctionalName: dto.contactFunctionalName, contactFunctionalEntity: dto.contactFunctionalEntity, contactFunctionalEmail: dto.contactFunctionalEmail, @@ -188,6 +195,18 @@ export class ApisService { return entities.map(toApiEntryListItem); } + async setCurrent(id: string): Promise { + const entry = await this.findOneOrThrow(id); + // Mettre tous les siblings à false + await this.repo.update( + { name: entry.name, convention: entry.convention }, + { isCurrent: false }, + ); + // Mettre cet entry à true + await this.repo.update({ id }, { isCurrent: true }); + return this.findOneOrThrow(id); + } + async regenerate(id: string): Promise { await this.findOneOrThrow(id); await this.updateInternal(id, { status: 'PENDING', errorMessage: null }); @@ -222,6 +241,7 @@ function toApiEntry(entity: ApiEntryEntity): ApiEntry { status: entity.status, errorMessage: entity.errorMessage, categoryId: entity.categoryId, + isCurrent: entity.isCurrent, contactFunctionalName: entity.contactFunctionalName, contactFunctionalEntity: entity.contactFunctionalEntity, contactFunctionalEmail: entity.contactFunctionalEmail, diff --git a/back/src/modules/categories/categories.controller.ts b/back/src/modules/categories/categories.controller.ts index da68a19..f018e3b 100644 --- a/back/src/modules/categories/categories.controller.ts +++ b/back/src/modules/categories/categories.controller.ts @@ -6,6 +6,7 @@ import { Delete, Param, Body, + Query, HttpCode, } from '@nestjs/common'; import { CategoriesService } from './categories.service'; @@ -17,8 +18,8 @@ export class CategoriesController { constructor(private readonly categoriesService: CategoriesService) {} @Get() - findAll() { - return this.categoriesService.findAll(); + findAll(@Query('search') search?: string) { + return this.categoriesService.findAll(search); } /* Déclarés AVANT :id pour éviter que NestJS interprète "browse" comme un UUID */ diff --git a/back/src/modules/seeder/seeder.service.ts b/back/src/modules/seeder/seeder.service.ts index 95ffb18..5e09e04 100644 --- a/back/src/modules/seeder/seeder.service.ts +++ b/back/src/modules/seeder/seeder.service.ts @@ -605,10 +605,41 @@ export class SeederService implements OnApplicationBootstrap { await this.reset(); } const count = await this.apiRepo.count(); - if (count > 0) return; /* Idempotent — ne ré-exécute pas si des données existent */ + if (count > 0) { + await this.fixIsCurrentFlags(); + return; + } await this.seed(); } + /** + * Corrige les flags is_current après une migration : + * si toutes les versions d'un même groupe (name+convention) sont marquées courantes, + * on ne garde que la plus haute version. + */ + private async fixIsCurrentFlags(): Promise { + await this.dataSource.query(` + UPDATE api_entries SET is_current = false + WHERE id IN ( + SELECT a.id + FROM api_entries a + WHERE a.is_current = true + AND EXISTS ( + SELECT 1 FROM api_entries b + WHERE b.name = a.name AND b.convention = a.convention + AND b.is_current = true + AND b.id != a.id + AND ( + b.version_major > a.version_major + OR (b.version_major = a.version_major AND b.version_minor > a.version_minor) + OR (b.version_major = a.version_major AND b.version_minor = a.version_minor + AND b.version_patch > a.version_patch) + ) + ) + ) + `); + } + private async reset(): Promise { /* Supprimer toutes les entrées puis les catégories */ await this.apiRepo.delete({}); @@ -697,6 +728,7 @@ export class SeederService implements OnApplicationBootstrap { versionMajor: 3, versionMinor: 0, versionPatch: 4, + isCurrent: true, description: 'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.', type: 'OPENAPI', @@ -775,6 +807,7 @@ export class SeederService implements OnApplicationBootstrap { versionMajor: 3, versionMinor: 0, versionPatch: 3, + isCurrent: false, description: 'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.', type: 'OPENAPI', @@ -795,6 +828,7 @@ export class SeederService implements OnApplicationBootstrap { versionMajor: 3, versionMinor: 0, versionPatch: 2, + isCurrent: false, description: 'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.', type: 'OPENAPI', diff --git a/documentation/CLAUDE.md b/documentation/CLAUDE.md index a72df87..f174c05 100644 --- a/documentation/CLAUDE.md +++ b/documentation/CLAUDE.md @@ -3,7 +3,7 @@ ## 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. +(AsyncAPI ou OpenAPI) et de les transformer en documentation HTML. ``` datacat/ @@ -19,8 +19,8 @@ datacat/ 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 + → 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) ``` @@ -31,17 +31,15 @@ User uploads YAML → POST /api/uploads ```bash # Tout en Docker (recommandé) -pnpm run dev # docker compose up -pnpm run dev:build # docker compose up --build +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 +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 -cd back && pnpm install -cd front-public && pnpm install -cd shared && pnpm install +# Install deps (depuis la racine workspace) +pnpm install # Build prod cd back && pnpm run build @@ -49,11 +47,21 @@ cd front-public && pnpm run build:prod ``` ### Proxy Angular → API -Créer `front-public/proxy.conf.json` : +`front-public/proxy.conf.json` (développement local) : ```json { "/api": { - "target": "http://localhost:3000", + "target": "http://localhost:3010", + "secure": false + } +} +``` + +`front-public/proxy.conf.docker.json` (dans Docker) : +```json +{ + "/api": { + "target": "http://backend:3000", "secure": false } } @@ -71,9 +79,10 @@ Créer `front-public/proxy.conf.json` : | TypeORM | ^0.3.20 | | PostgreSQL | 17 | | Angular | ^19.0.0 | -| TypeScript | ^5.7.0 | -| @asyncapi/cli | latest (in Docker) | +| 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` @@ -88,61 +97,110 @@ Créer `front-public/proxy.conf.json` : ```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() + 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 } from '@datacat/shared'; +import { ApiType, ApiStatus, ApiConvention } from '@datacat/shared'; @Entity('api_entries') export class ApiEntryEntity { @PrimaryGeneratedColumn('uuid') - id: string; + id!: string; - @Column() - title: string; + @Column({ type: 'varchar', length: 30, default: 'CONSULTER' }) + convention!: ApiConvention; - @Column() - version: string; + @Column({ type: 'varchar', length: 255, default: '' }) + name!: string; @Column({ nullable: true, type: 'text' }) - description: string | null; + description!: string | null; @Column({ type: 'varchar', length: 10 }) - type: ApiType; + 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; + yamlContent!: string; - @Column({ nullable: true, name: 'html_path' }) - htmlPath: string | null; + @Column({ nullable: true, type: 'varchar', length: 500, name: 'html_path' }) + htmlPath!: string | null; @Column({ type: 'varchar', length: 10, default: 'PENDING' }) - status: ApiStatus; + status!: ApiStatus; @Column({ nullable: true, type: 'text', name: 'error_message' }) - errorMessage: string | null; + 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; + createdAt!: Date; @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; + updatedAt!: Date; } ``` @@ -161,40 +219,60 @@ src/ │ │ ├── apis.module.ts │ │ ├── apis.controller.ts # REST endpoints │ │ ├── apis.service.ts # CRUD logique -│ │ └── api-entry.entity.ts # TypeORM entity +│ │ ├── 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 (multipart) -│ └── docs-generation/ -│ ├── docs-generation.module.ts -│ └── docs-generation.service.ts # Appels AsyncAPI/Redocly CLI -├── database/ -│ └── database.module.ts # TypeORM config (si séparé) +│ │ └── 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 - └── interceptors/ - └── transform.interceptor.ts + └── filters/ + └── all-exceptions.filter.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) +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 } from '@nestjs/common'; +import { Controller, Get, Patch, Delete, Post, Param, Body, Query, Res, NotFoundException, HttpCode } from '@nestjs/common'; import { Response } from 'express'; @Controller('apis') @@ -237,7 +315,7 @@ export class ApisController { @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-Disposition', `attachment; filename="${entry.name}.yaml"`); res.setHeader('Content-Type', 'application/x-yaml'); res.send(entry.yamlContent); } @@ -247,8 +325,8 @@ export class ApisController { ### UploadsController ```typescript -import { Controller, Post, UseInterceptors, UploadedFile, Body } from '@nestjs/common'; -import { FileInterceptor } from '@nestjs/platform-express'; +import { Controller, Post, UseInterceptors, UploadedFiles, Body, BadRequestException } from '@nestjs/common'; +import { FilesInterceptor } from '@nestjs/platform-express'; import { diskStorage } from 'multer'; @Controller('uploads') @@ -258,22 +336,38 @@ export class UploadsController { 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(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); - }, - })) + @UseInterceptors(FilesInterceptor('files', 20, { /* diskStorage + fileFilter yaml */ })) async upload( - @UploadedFile() file: Express.Multer.File, - @Body() body: { title: string; version: string; description?: string; type: 'ASYNCAPI' | 'OPENAPI' }, + @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; + }, ) { - const yamlContent = fs.readFileSync(file.path, 'utf-8'); - const entry = await this.apisService.create({ ...body, yamlContent }); + // 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; @@ -289,19 +383,19 @@ 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 ?? '/app/docs-output'; + 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'); @@ -311,37 +405,32 @@ export class DocsGenerationService { } else { await this.generateOpenApi(yamlPath, entryDir); } - - const htmlPath = path.join(entryDir, 'index.html'); - await this.apisService.update(entryId, { htmlPath, status: 'GENERATED' }); + await this.apisService.updateInternal(entryId, { htmlPath: path.join(entryDir, 'index.html'), status: 'GENERATED' }); } catch (error) { - await this.apisService.update(entryId, { - status: 'ERROR', - errorMessage: String(error), - }); + await this.apisService.updateInternal(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, - ]); + // 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 { - // redocly build-docs --output await execFileAsync('redocly', [ - 'build-docs', - yamlPath, + '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 @@ -362,44 +451,31 @@ import { ActivatedRoute } from '@angular/router'; import { CommonModule } from '@angular/common'; @Component({ - selector: 'app-catalog', + selector: 'app-browse', standalone: true, imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, template: `
-

Catalogue des APIs

+

Parcourir les APIs

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

{{ api.title }}

+

{{ cat.name }}

}
`, }) -export class CatalogComponent implements OnInit { +export class BrowseComponent implements OnInit { private http = inject(HttpClient); - apis = signal([]); + subcategories = 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), - }); - } + ngOnInit() { /* ... */ } } ``` @@ -438,23 +514,13 @@ export class ApiService { ## 7. DSFR (Système de Design de l'État) -### Installation (assets statiques — pas de package npm) +### Installation via 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 : +```bash +# Déjà dans front-public/package.json : +# "@gouvfr/dsfr": "^1.13.0" (devDependencies) +# Assets copiés automatiquement via angular.json (assets config) ``` -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 @@ -506,24 +572,24 @@ fr-mt-4w fr-mb-2w fr-ml-1w fr-p-3w fr-mx-auto - `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. Commandes AsyncAPI CLI & Redocly CLI +## 8. Génération de documentation -### AsyncAPI CLI +### AsyncAPI — API programmatique (pas 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 +```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); ``` -### Redocly CLI +### OpenAPI — CLI Redocly ```bash # Générer HTML depuis un fichier OpenAPI YAML @@ -531,15 +597,17 @@ 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 ``` +### Bundling multi-fichiers +- **OpenAPI** : `redocly bundle
--output ` +- **AsyncAPI** : API programmatique `@asyncapi/bundler` + ### 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` +- `@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) +- 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//index.html` --- @@ -618,18 +686,6 @@ Dans `https://ci.chmod777.dev` → Settings → Secrets pour le dépôt `datacat - `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 @@ -637,7 +693,7 @@ git push -u origin main - **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`) +- **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 @@ -646,30 +702,48 @@ git push -u origin main ## 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 titre + description +- Champ de recherche (`fr-input`) filtrant `name`, `provider`, `description`, contacts - 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 +- 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` - - "Supprimer" → DELETE `/api/apis/:id` + redirect `/catalog` + - "Modifier" → modale d'édition (tous les champs sauf yamlContent) + - "Supprimer" → modale de confirmation → DELETE `/api/apis/:id` + redirect `/browse` ### `/catalog/:id/docs` — DocViewerComponent - `