feat(api-detail): isCurrent + setCurrent + màj doc et seeder

- 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>
This commit is contained in:
z3n
2026-06-19 14:23:31 +00:00
parent c8b3e393ea
commit 0976c33059
15 changed files with 610 additions and 235 deletions

View File

@@ -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/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/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 - [`documentation/CLAUDE.md`](documentation/CLAUDE.md) — guide d'implémentation interne

View File

@@ -45,6 +45,9 @@ export class ApiEntryEntity {
@Column({ nullable: true, type: 'varchar', length: 36, name: 'category_id' }) @Column({ nullable: true, type: 'varchar', length: 36, name: 'category_id' })
categoryId!: string | null; categoryId!: string | null;
@Column({ type: 'boolean', default: true, name: 'is_current' })
isCurrent!: boolean;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_name' }) @Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_name' })
contactFunctionalName!: string; contactFunctionalName!: string;

View File

@@ -13,6 +13,7 @@ export function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem {
provider: entity.provider, provider: entity.provider,
status: entity.status, status: entity.status,
categoryId: entity.categoryId, categoryId: entity.categoryId,
isCurrent: entity.isCurrent,
createdAt: entity.createdAt.toISOString(), createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(), updatedAt: entity.updatedAt.toISOString(),
}; };

View File

@@ -43,6 +43,11 @@ export class ApisController {
return this.apisService.remove(id); return this.apisService.remove(id);
} }
@Post(':id/set-current')
setCurrent(@Param('id') id: string) {
return this.apisService.setCurrent(id);
}
@Post(':id/regenerate') @Post(':id/regenerate')
regenerate(@Param('id') id: string) { regenerate(@Param('id') id: string) {
return this.apisService.regenerate(id); return this.apisService.regenerate(id);

View File

@@ -25,6 +25,12 @@ export class ApisService {
) {} ) {}
async create(dto: CreateApiEntryDto): Promise<ApiEntry> { async create(dto: CreateApiEntryDto): Promise<ApiEntry> {
// 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({ const entity = this.repo.create({
convention: dto.convention, convention: dto.convention,
name: dto.name, name: dto.name,
@@ -37,6 +43,7 @@ export class ApisService {
yamlContent: dto.yamlContent, yamlContent: dto.yamlContent,
status: 'PENDING', status: 'PENDING',
categoryId: dto.categoryId ?? null, categoryId: dto.categoryId ?? null,
isCurrent,
contactFunctionalName: dto.contactFunctionalName, contactFunctionalName: dto.contactFunctionalName,
contactFunctionalEntity: dto.contactFunctionalEntity, contactFunctionalEntity: dto.contactFunctionalEntity,
contactFunctionalEmail: dto.contactFunctionalEmail, contactFunctionalEmail: dto.contactFunctionalEmail,
@@ -188,6 +195,18 @@ export class ApisService {
return entities.map(toApiEntryListItem); return entities.map(toApiEntryListItem);
} }
async setCurrent(id: string): Promise<ApiEntry> {
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<ApiEntry> { async regenerate(id: string): Promise<ApiEntry> {
await this.findOneOrThrow(id); await this.findOneOrThrow(id);
await this.updateInternal(id, { status: 'PENDING', errorMessage: null }); await this.updateInternal(id, { status: 'PENDING', errorMessage: null });
@@ -222,6 +241,7 @@ function toApiEntry(entity: ApiEntryEntity): ApiEntry {
status: entity.status, status: entity.status,
errorMessage: entity.errorMessage, errorMessage: entity.errorMessage,
categoryId: entity.categoryId, categoryId: entity.categoryId,
isCurrent: entity.isCurrent,
contactFunctionalName: entity.contactFunctionalName, contactFunctionalName: entity.contactFunctionalName,
contactFunctionalEntity: entity.contactFunctionalEntity, contactFunctionalEntity: entity.contactFunctionalEntity,
contactFunctionalEmail: entity.contactFunctionalEmail, contactFunctionalEmail: entity.contactFunctionalEmail,

View File

@@ -6,6 +6,7 @@ import {
Delete, Delete,
Param, Param,
Body, Body,
Query,
HttpCode, HttpCode,
} from '@nestjs/common'; } from '@nestjs/common';
import { CategoriesService } from './categories.service'; import { CategoriesService } from './categories.service';
@@ -17,8 +18,8 @@ export class CategoriesController {
constructor(private readonly categoriesService: CategoriesService) {} constructor(private readonly categoriesService: CategoriesService) {}
@Get() @Get()
findAll() { findAll(@Query('search') search?: string) {
return this.categoriesService.findAll(); return this.categoriesService.findAll(search);
} }
/* Déclarés AVANT :id pour éviter que NestJS interprète "browse" comme un UUID */ /* Déclarés AVANT :id pour éviter que NestJS interprète "browse" comme un UUID */

View File

@@ -605,10 +605,41 @@ export class SeederService implements OnApplicationBootstrap {
await this.reset(); await this.reset();
} }
const count = await this.apiRepo.count(); 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(); 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<void> {
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<void> { private async reset(): Promise<void> {
/* Supprimer toutes les entrées puis les catégories */ /* Supprimer toutes les entrées puis les catégories */
await this.apiRepo.delete({}); await this.apiRepo.delete({});
@@ -697,6 +728,7 @@ export class SeederService implements OnApplicationBootstrap {
versionMajor: 3, versionMajor: 3,
versionMinor: 0, versionMinor: 0,
versionPatch: 4, versionPatch: 4,
isCurrent: true,
description: description:
'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.', 'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.',
type: 'OPENAPI', type: 'OPENAPI',
@@ -775,6 +807,7 @@ export class SeederService implements OnApplicationBootstrap {
versionMajor: 3, versionMajor: 3,
versionMinor: 0, versionMinor: 0,
versionPatch: 3, versionPatch: 3,
isCurrent: false,
description: description:
'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.', 'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.',
type: 'OPENAPI', type: 'OPENAPI',
@@ -795,6 +828,7 @@ export class SeederService implements OnApplicationBootstrap {
versionMajor: 3, versionMajor: 3,
versionMinor: 0, versionMinor: 0,
versionPatch: 2, versionPatch: 2,
isCurrent: false,
description: description:
'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.', 'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.',
type: 'OPENAPI', type: 'OPENAPI',

View File

@@ -3,7 +3,7 @@
## 1. Purpose & Architecture ## 1. Purpose & Architecture
**Datacat** est un catalogue de données d'API. Il permet d'importer des fichiers YAML **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/ datacat/
@@ -19,8 +19,8 @@ datacat/
User uploads YAML → POST /api/uploads User uploads YAML → POST /api/uploads
→ ApiEntry created (status: PENDING) → ApiEntry created (status: PENDING)
→ DocsGenerationService.generate() → DocsGenerationService.generate()
→ asyncapi generate fromFile input.yaml --output /docs-output/<id>/ AsyncAPI : API programmatique @asyncapi/generator (html-template local)
→ OR redocly build-docs input.yaml --output /docs-output/<id>/index.html → OpenAPI : redocly build-docs input.yaml --output /docs-output/<id>/index.html
→ ApiEntry updated (status: GENERATED, htmlPath: /docs-output/<id>/index.html) → ApiEntry updated (status: GENERATED, htmlPath: /docs-output/<id>/index.html)
→ GET /api/apis/:id/docs → sendFile(htmlPath) → GET /api/apis/:id/docs → sendFile(htmlPath)
``` ```
@@ -31,17 +31,15 @@ User uploads YAML → POST /api/uploads
```bash ```bash
# Tout en Docker (recommandé) # Tout en Docker (recommandé)
pnpm run dev # docker compose up docker compose -f infra/docker-compose.yml up -d
pnpm run dev:build # docker compose up --build docker compose -f infra/docker-compose.yml up -d --build # avec rebuild
# Local (sans Docker) # 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 cd front-public && pnpm run start # Angular dev server — port 4200
# Install deps # Install deps (depuis la racine workspace)
cd back && pnpm install pnpm install
cd front-public && pnpm install
cd shared && pnpm install
# Build prod # Build prod
cd back && pnpm run build cd back && pnpm run build
@@ -49,11 +47,21 @@ cd front-public && pnpm run build:prod
``` ```
### Proxy Angular → API ### Proxy Angular → API
Créer `front-public/proxy.conf.json` : `front-public/proxy.conf.json` (développement local) :
```json ```json
{ {
"/api": { "/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 "secure": false
} }
} }
@@ -71,9 +79,10 @@ Créer `front-public/proxy.conf.json` :
| TypeORM | ^0.3.20 | | TypeORM | ^0.3.20 |
| PostgreSQL | 17 | | PostgreSQL | 17 |
| Angular | ^19.0.0 | | Angular | ^19.0.0 |
| TypeScript | ^5.7.0 | | TypeScript | ~5.8.3 |
| @asyncapi/cli | latest (in Docker) | | @asyncapi/cli | 6.0.0 (in Docker) |
| @redocly/cli | latest (in Docker) | | @redocly/cli | latest (in Docker) |
| @gouvfr/dsfr | ^1.13.0 (npm devDep front-public) |
### Conventions TypeScript ### Conventions TypeScript
- Pas de `any` — utiliser les types de `@datacat/shared` - Pas de `any` — utiliser les types de `@datacat/shared`
@@ -89,8 +98,13 @@ Créer `front-public/proxy.conf.json` :
```sql ```sql
CREATE TABLE api_entries ( CREATE TABLE api_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL, convention VARCHAR(30) NOT NULL DEFAULT 'CONSULTER'
version VARCHAR(50) NOT NULL, 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, description TEXT,
type VARCHAR(10) NOT NULL CHECK (type IN ('ASYNCAPI', 'OPENAPI')), type VARCHAR(10) NOT NULL CHECK (type IN ('ASYNCAPI', 'OPENAPI')),
yaml_content TEXT NOT NULL, yaml_content TEXT NOT NULL,
@@ -98,51 +112,95 @@ CREATE TABLE api_entries (
status VARCHAR(10) NOT NULL DEFAULT 'PENDING' status VARCHAR(10) NOT NULL DEFAULT 'PENDING'
CHECK (status IN ('PENDING', 'GENERATED', 'ERROR')), CHECK (status IN ('PENDING', 'GENERATED', 'ERROR')),
error_message TEXT, 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(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_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`) ### Entity TypeORM (`back/src/modules/apis/api-entry.entity.ts`)
```typescript ```typescript
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { ApiType, ApiStatus } from '@datacat/shared'; import { ApiType, ApiStatus, ApiConvention } from '@datacat/shared';
@Entity('api_entries') @Entity('api_entries')
export class ApiEntryEntity { export class ApiEntryEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id!: string;
@Column() @Column({ type: 'varchar', length: 30, default: 'CONSULTER' })
title: string; convention!: ApiConvention;
@Column() @Column({ type: 'varchar', length: 255, default: '' })
version: string; name!: string;
@Column({ nullable: true, type: 'text' }) @Column({ nullable: true, type: 'text' })
description: string | null; description!: string | null;
@Column({ type: 'varchar', length: 10 }) @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' }) @Column({ type: 'text', name: 'yaml_content' })
yamlContent: string; yamlContent!: string;
@Column({ nullable: true, name: 'html_path' }) @Column({ nullable: true, type: 'varchar', length: 500, name: 'html_path' })
htmlPath: string | null; htmlPath!: string | null;
@Column({ type: 'varchar', length: 10, default: 'PENDING' }) @Column({ type: 'varchar', length: 10, default: 'PENDING' })
status: ApiStatus; status!: ApiStatus;
@Column({ nullable: true, type: 'text', name: 'error_message' }) @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' }) @CreateDateColumn({ name: 'created_at' })
createdAt: Date; createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' }) @UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date; updatedAt!: Date;
} }
``` ```
@@ -161,40 +219,60 @@ src/
│ │ ├── apis.module.ts │ │ ├── apis.module.ts
│ │ ├── apis.controller.ts # REST endpoints │ │ ├── apis.controller.ts # REST endpoints
│ │ ├── apis.service.ts # CRUD logique │ │ ├── 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/
│ │ ├── uploads.module.ts │ │ ├── uploads.module.ts
│ │ └── uploads.controller.ts # POST /api/uploads (multipart) │ │ └── uploads.controller.ts # POST /api/uploads + POST /api/uploads/validate
── docs-generation/ ── docs-generation/
├── docs-generation.module.ts ├── docs-generation.module.ts
└── docs-generation.service.ts # Appels AsyncAPI/Redocly CLI └── docs-generation.service.ts # API programmatique @asyncapi/generator + redocly CLI
├── database/ ├── categories/
── database.module.ts # TypeORM config (si séparé) │ ├── 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/ └── common/
── filters/ ── filters/
└── all-exceptions.filter.ts └── all-exceptions.filter.ts
└── interceptors/
└── transform.interceptor.ts
``` ```
### API REST ### API REST
``` ```
GET /health → { status: 'ok' } GET /health → { status: 'ok' }
GET /api/apis → ApiListResponse (+ ?search=&type=&page=&limit=) GET /api/apis → ApiListResponse (+ ?search=&type=&categoryId=&page=&limit=)
GET /api/apis/:id → ApiEntry GET /api/apis/:id → ApiEntry
POST /api/uploads → ApiEntry (multipart: file + title + version + description + type)
PATCH /api/apis/:id → ApiEntry (body: UpdateApiEntryDto) PATCH /api/apis/:id → ApiEntry (body: UpdateApiEntryDto)
DELETE /api/apis/:id → 204 No Content DELETE /api/apis/:id → 204 No Content
POST /api/apis/:id/regenerate → ApiEntry (relance la génération de docs) 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/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) 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 ### ApisController
```typescript ```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'; import { Response } from 'express';
@Controller('apis') @Controller('apis')
@@ -237,7 +315,7 @@ export class ApisController {
@Get(':id/yaml') @Get(':id/yaml')
async getYaml(@Param('id') id: string, @Res() res: Response) { async getYaml(@Param('id') id: string, @Res() res: Response) {
const entry = await this.apisService.findOneOrThrow(id); 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.setHeader('Content-Type', 'application/x-yaml');
res.send(entry.yamlContent); res.send(entry.yamlContent);
} }
@@ -247,8 +325,8 @@ export class ApisController {
### UploadsController ### UploadsController
```typescript ```typescript
import { Controller, Post, UseInterceptors, UploadedFile, Body } from '@nestjs/common'; import { Controller, Post, UseInterceptors, UploadedFiles, Body, BadRequestException } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FilesInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer'; import { diskStorage } from 'multer';
@Controller('uploads') @Controller('uploads')
@@ -258,22 +336,38 @@ export class UploadsController {
private readonly docsService: DocsGenerationService, private readonly docsService: DocsGenerationService,
) {} ) {}
@Post() // POST /api/uploads/validate — valide sans sauvegarder
@UseInterceptors(FileInterceptor('file', { @Post('validate')
storage: diskStorage({ destination: '/tmp/uploads' }), @UseInterceptors(FilesInterceptor('files', 20, { /* diskStorage + fileFilter yaml */ }))
fileFilter: (req, file, cb) => { async validate(@UploadedFiles() files: Express.Multer.File[]) {
if (!file.originalname.match(/\.(yaml|yml)$/)) { // Retourne { valid, type, mainFile, name, versionMajor, versionMinor, versionPatch, description, errors }
return cb(new Error('Only YAML files are allowed'), false);
} }
cb(null, true);
}, // POST /api/uploads — crée l'entrée + lance la génération
})) @Post()
@UseInterceptors(FilesInterceptor('files', 20, { /* diskStorage + fileFilter yaml */ }))
async upload( async upload(
@UploadedFile() file: Express.Multer.File, @UploadedFiles() files: Express.Multer.File[],
@Body() body: { title: string; version: string; description?: string; type: 'ASYNCAPI' | 'OPENAPI' }, @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'); // 1 fichier → lecture directe
const entry = await this.apisService.create({ ...body, yamlContent }); // N fichiers → détection fichier principal + bundling (redocly bundle / @asyncapi/bundler)
// Fire and forget — génération asynchrone // Fire and forget — génération asynchrone
this.docsService.generate(entry.id).catch(console.error); this.docsService.generate(entry.id).catch(console.error);
return entry; return entry;
@@ -289,19 +383,19 @@ import { execFile } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
const AsyncApiGenerator = require('@asyncapi/generator');
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@Injectable() @Injectable()
export class DocsGenerationService { 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<void> { async generate(entryId: string): Promise<void> {
const entry = await this.apisService.findOneOrThrow(entryId); const entry = await this.apisService.findOneOrThrow(entryId);
const entryDir = path.join(this.outputDir, entryId); const entryDir = path.join(this.outputDir, entryId);
await fs.mkdir(entryDir, { recursive: true }); await fs.mkdir(entryDir, { recursive: true });
const yamlPath = path.join(entryDir, 'input.yaml'); const yamlPath = path.join(entryDir, 'input.yaml');
await fs.writeFile(yamlPath, entry.yamlContent, 'utf-8'); await fs.writeFile(yamlPath, entry.yamlContent, 'utf-8');
@@ -311,37 +405,32 @@ export class DocsGenerationService {
} else { } else {
await this.generateOpenApi(yamlPath, entryDir); await this.generateOpenApi(yamlPath, entryDir);
} }
await this.apisService.updateInternal(entryId, { htmlPath: path.join(entryDir, 'index.html'), status: 'GENERATED' });
const htmlPath = path.join(entryDir, 'index.html');
await this.apisService.update(entryId, { htmlPath, status: 'GENERATED' });
} catch (error) { } catch (error) {
await this.apisService.update(entryId, { await this.apisService.updateInternal(entryId, { status: 'ERROR', errorMessage: String(error) });
status: 'ERROR',
errorMessage: String(error),
});
} }
} }
private async generateAsyncApi(yamlPath: string, outputDir: string): Promise<void> { private async generateAsyncApi(yamlPath: string, outputDir: string): Promise<void> {
// asyncapi generate fromFile <input> --output <dir> // API programmatique — aucun appel réseau, template résolu localement
await execFileAsync('asyncapi', [ const templatePath = path.join(process.cwd(), 'node_modules', '@asyncapi', 'html-template');
'generate', 'fromFile', const generator = new AsyncApiGenerator(templatePath, outputDir, { forceWrite: true });
yamlPath, await generator.generateFromFile(yamlPath);
'--output', outputDir,
]);
} }
private async generateOpenApi(yamlPath: string, outputDir: string): Promise<void> { private async generateOpenApi(yamlPath: string, outputDir: string): Promise<void> {
// redocly build-docs <input> --output <file>
await execFileAsync('redocly', [ await execFileAsync('redocly', [
'build-docs', 'build-docs', yamlPath,
yamlPath,
'--output', path.join(outputDir, 'index.html'), '--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 ## 6. Conventions Angular 19
@@ -362,44 +451,31 @@ import { ActivatedRoute } from '@angular/router';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
@Component({ @Component({
selector: 'app-catalog', selector: 'app-browse',
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="fr-container fr-mt-4w"> <div class="fr-container fr-mt-4w">
<h1 class="fr-h1">Catalogue des APIs</h1> <h1 class="fr-h1">Parcourir les APIs</h1>
@if (loading()) { @if (loading()) {
<div class="fr-callout">Chargement...</div> <div class="fr-callout">Chargement...</div>
} }
@for (api of apis(); track api.id) { @for (cat of subcategories(); track cat.id) {
<div class="fr-card fr-mb-2w"> <div class="fr-card fr-mb-2w">
<h2>{{ api.title }}</h2> <h2>{{ cat.name }}</h2>
</div> </div>
} }
</div> </div>
`, `,
}) })
export class CatalogComponent implements OnInit { export class BrowseComponent implements OnInit {
private http = inject(HttpClient); private http = inject(HttpClient);
apis = signal<ApiEntryListItem[]>([]); subcategories = signal<CategoryWithCounts[]>([]);
loading = signal(false); loading = signal(false);
ngOnInit() { 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),
});
}
} }
``` ```
@@ -438,23 +514,13 @@ export class ApiService {
## 7. DSFR (Système de Design de l'État) ## 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/ ```bash
et placer les assets dans `front-public/src/assets/dsfr/`. # Déjà dans front-public/package.json :
# "@gouvfr/dsfr": "^1.13.0" (devDependencies)
Structure attendue : # 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 ### 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-delete-bin-line` — supprimer
- `fr-icon-refresh-line` — régénérer - `fr-icon-refresh-line` — régénérer
- `fr-icon-eye-line` — voir - `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 ```typescript
# Générer HTML depuis un fichier YAML AsyncAPI // Utilise @asyncapi/generator directement (installé dans node_modules)
asyncapi generate fromFile ./api.yaml --output ./output-dir/ // Template @asyncapi/html-template pré-installé dans l'image Docker
const templatePath = path.join(process.cwd(), 'node_modules', '@asyncapi', 'html-template');
# Valider un fichier const generator = new AsyncApiGenerator(templatePath, outputDir, { forceWrite: true });
asyncapi validate ./api.yaml await generator.generateFromFile(yamlPath);
# Format de sortie : ./output-dir/index.html
``` ```
### Redocly CLI ### OpenAPI — CLI Redocly
```bash ```bash
# Générer HTML depuis un fichier OpenAPI YAML # 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 # Valider un fichier OpenAPI
redocly lint ./api.yaml redocly lint ./api.yaml
# Format de sortie : ./output-dir/index.html
``` ```
### Bundling multi-fichiers
- **OpenAPI** : `redocly bundle <main> --output <bundled.yaml>`
- **AsyncAPI** : API programmatique `@asyncapi/bundler`
### Notes importantes ### Notes importantes
- Les deux CLIs sont installés dans l'image Docker prod (`infra/Dockerfile.prod`, target `back`) - `@asyncapi/generator` et `@asyncapi/html-template` sont installés dans l'image Docker prod et dev
- En dev, ils sont installés dans `infra/Dockerfile.back.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 - 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/<uuid>/index.html` - Chaque API a son propre sous-répertoire : `/app/docs-output/<uuid>/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 - `registry_password` : token Gitea avec accès packages
- `deploy_kubeconfig` : contenu du fichier `~/.kube/config` - `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 ## 11. Règles de code
@@ -637,7 +693,7 @@ git push -u origin main
- **Langue** : code en anglais, commentaires en français - **Langue** : code en anglais, commentaires en français
- **Commits** : Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`, `refactor:`) - **Commits** : Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`, `refactor:`)
- **TypeScript** : pas de `any`, `strict: true`, types depuis `@datacat/shared` - **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 - **Angular** : standalone components, signals, inject(), OnPush, lazy loading
- **Tests** : Vitest pour le backend, `ng test` pour le frontend - **Tests** : Vitest pour le backend, `ng test` pour le frontend
- **Pas de `console.log`** en production — utiliser Pino (NestJS) ou le logger Angular - **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 ## 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 ### `/catalog` — CatalogComponent
- Grille DSFR de cartes (`fr-card`) - 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) - Select DSFR pour filtrer par type (ASYNCAPI / OPENAPI)
- Pagination DSFR (`fr-pagination`) - Pagination DSFR (`fr-pagination`)
- Lien vers `/upload` avec bouton `fr-btn fr-btn--icon-left fr-icon-upload-2-line`
### `/catalog/:id` — ApiDetailComponent ### `/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) - Badge de statut : `fr-badge--success` (GENERATED), `fr-badge--warning` (PENDING), `fr-badge--error` (ERROR)
- Boutons : - Boutons :
- "Voir la documentation" → `/catalog/:id/docs` (désactivé si status ≠ GENERATED) - "Voir la documentation" → `/catalog/:id/docs` (désactivé si status ≠ GENERATED)
- "Télécharger YAML" → `/api/apis/:id/yaml` - "Télécharger YAML" → `/api/apis/:id/yaml`
- "Régénérer" → POST `/api/apis/:id/regenerate` - "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 ### `/catalog/:id/docs` — DocViewerComponent
- `<iframe>` plein écran affichant `/api/apis/:id/docs` - `<iframe>` plein écran affichant `/api/apis/:id/docs`
- Bouton "Retour" en haut - Bouton "Retour" en haut
### `/upload` — UploadComponent ### `/upload` — UploadComponent
- Stepper DSFR 3 étapes : (1) Choisir le fichier, (2) Métadonnées, (3) Confirmation - Stepper DSFR 3 étapes : (1) Choisir les fichiers, (2) Métadonnées, (3) Confirmation
- Étape 1 : Zone de drop DSFR (`fr-upload-group`) pour fichier `.yaml` / `.yml` - Étape 1 : Zone de drop DSFR (`fr-upload-group`) pour 1 à 20 fichiers `.yaml` / `.yml`
- Étape 2 : Formulaire (titre, version, description, type select AsyncAPI/OpenAPI) - 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" - Étape 3 : Récap + bouton "Importer"
- POST multipart vers `/api/uploads` - POST multipart vers `/api/uploads`
- Redirect vers `/catalog/:id` après succès - 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

View File

@@ -29,7 +29,7 @@ Liste les entrées avec filtres optionnels et pagination.
| Paramètre | Type | Défaut | Description | | Paramètre | Type | Défaut | Description |
|-----------|------|--------|-------------| |-----------|------|--------|-------------|
| `search` | string | — | Recherche dans le titre et la description (case-insensitive) | | `search` | string | — | Recherche dans `name`, `provider`, `description`, contacts (case-insensitive) |
| `type` | `ASYNCAPI` \| `OPENAPI` | — | Filtre par type | | `type` | `ASYNCAPI` \| `OPENAPI` | — | Filtre par type |
| `categoryId` | UUID | — | Filtre par catégorie | | `categoryId` | UUID | — | Filtre par catégorie |
| `page` | number | `1` | Page (min 1) | | `page` | number | `1` | Page (min 1) |
@@ -41,10 +41,13 @@ Liste les entrées avec filtres optionnels et pagination.
"items": [ "items": [
{ {
"id": "uuid", "id": "uuid",
"title": "My API", "title": "Consulter Petstore",
"name": "Petstore",
"convention": "CONSULTER",
"version": "1.0.0", "version": "1.0.0",
"description": "...", "description": "...",
"type": "OPENAPI", "type": "OPENAPI",
"provider": "Équipe Produit",
"status": "GENERATED", "status": "GENERATED",
"categoryId": "uuid-or-null", "categoryId": "uuid-or-null",
"createdAt": "2025-01-01T00:00:00.000Z", "createdAt": "2025-01-01T00:00:00.000Z",
@@ -57,7 +60,7 @@ Liste les entrées avec filtres optionnels et pagination.
} }
``` ```
> `items` contient des `ApiEntryListItem` (sans `yamlContent` ni `htmlPath`). > `items` contient des `ApiEntryListItem` (sans `yamlContent`, `htmlPath`, contacts ni `versionMajor/Minor/Patch`).
--- ---
@@ -69,15 +72,27 @@ Retourne le détail complet d'une entrée.
```json ```json
{ {
"id": "uuid", "id": "uuid",
"title": "My API", "title": "Consulter Petstore",
"name": "Petstore",
"convention": "CONSULTER",
"version": "1.0.0", "version": "1.0.0",
"versionMajor": 1,
"versionMinor": 0,
"versionPatch": 0,
"description": "...", "description": "...",
"type": "OPENAPI", "type": "OPENAPI",
"provider": "Équipe Produit",
"yamlContent": "openapi: '3.0.0'\n...", "yamlContent": "openapi: '3.0.0'\n...",
"htmlPath": "/app/docs-output/uuid/index.html", "htmlPath": "/app/docs-output/uuid/index.html",
"status": "GENERATED", "status": "GENERATED",
"errorMessage": null, "errorMessage": null,
"categoryId": "uuid-or-null", "categoryId": "uuid-or-null",
"contactFunctionalName": "Alice Martin",
"contactFunctionalEntity": "DSI",
"contactFunctionalEmail": "alice@example.com",
"contactTechnicalName": "Bob Dupont",
"contactTechnicalEntity": "Équipe Backend",
"contactTechnicalEmail": "bob@example.com",
"createdAt": "2025-01-01T00:00:00.000Z", "createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z" "updatedAt": "2025-01-01T00:00:00.000Z"
} }
@@ -94,10 +109,20 @@ Met à jour les métadonnées d'une entrée (champs éditables par l'utilisateur
**Body JSON** (tous les champs sont optionnels) **Body JSON** (tous les champs sont optionnels)
```json ```json
{ {
"title": "New title", "convention": "CONSULTER",
"version": "2.0.0", "name": "Nouveau nom",
"description": "Updated description", "provider": "Équipe X",
"categoryId": "uuid-or-null" "versionMajor": 2,
"versionMinor": 0,
"versionPatch": 0,
"description": "Description mise à jour",
"categoryId": "uuid-or-null",
"contactFunctionalName": "...",
"contactFunctionalEntity": "...",
"contactFunctionalEmail": "...",
"contactTechnicalName": "...",
"contactTechnicalEntity": "...",
"contactTechnicalEmail": "..."
} }
``` ```
@@ -146,7 +171,7 @@ Télécharge le fichier YAML source.
**Réponse 200** **Réponse 200**
- `Content-Type: application/x-yaml` - `Content-Type: application/x-yaml`
- `Content-Disposition: attachment; filename="<title>.yaml"` - `Content-Disposition: attachment; filename="<name>.yaml"`
- Corps : contenu YAML brut - Corps : contenu YAML brut
**Réponse 404** — entrée introuvable **Réponse 404** — entrée introuvable
@@ -155,6 +180,52 @@ Télécharge le fichier YAML source.
## Upload (`/api/uploads`) ## Upload (`/api/uploads`)
### `POST /api/uploads/validate`
Valide 1 à 20 fichiers YAML sans créer d'entrée en base. Utilisé pour la validation en temps réel lors de l'upload.
**Content-Type** : `multipart/form-data`
**Champs du formulaire**
| Champ | Type | Requis | Description |
|-------|------|--------|-------------|
| `files` | file (1 à 20) | Oui | Fichiers YAML (`.yaml` ou `.yml`) |
**Réponse 200**
```json
{
"valid": true,
"type": "OPENAPI",
"mainFile": "petstore.yaml",
"name": "Petstore",
"versionMajor": 1,
"versionMinor": 0,
"versionPatch": 0,
"description": "A sample API",
"errors": []
}
```
En cas d'erreur :
```json
{
"valid": false,
"type": null,
"mainFile": null,
"name": null,
"versionMajor": null,
"versionMinor": null,
"versionPatch": null,
"description": null,
"errors": [
{ "file": "api.yaml", "message": "erreur de syntaxe YAML — unexpected token" }
]
}
```
---
### `POST /api/uploads` ### `POST /api/uploads`
Crée une nouvelle entrée à partir d'un ou plusieurs fichiers YAML. Crée une nouvelle entrée à partir d'un ou plusieurs fichiers YAML.
@@ -167,11 +238,21 @@ Lance la génération de documentation en arrière-plan (fire-and-forget).
| Champ | Type | Requis | Description | | Champ | Type | Requis | Description |
|-------|------|--------|-------------| |-------|------|--------|-------------|
| `files` | file (1 à 20) | Oui | Fichiers YAML (`.yaml` ou `.yml`) | | `files` | file (1 à 20) | Oui | Fichiers YAML (`.yaml` ou `.yml`) |
| `title` | string | Oui | Titre de l'API | | `convention` | `CONSULTER` \| `ENREGISTRER` \| `ETRE_NOTIFIE` | Oui | Convention d'échange |
| `version` | string | Oui | Version (ex: `1.0.0`) | | `name` | string | Oui | Nom de l'API |
| `provider` | string | Oui | Fournisseur / équipe responsable |
| `versionMajor` | string (number) | Non | Version majeure (défaut: 0) |
| `versionMinor` | string (number) | Non | Version mineure (défaut: 0) |
| `versionPatch` | string (number) | Non | Version patch (défaut: 0) |
| `description` | string | Non | Description libre | | `description` | string | Non | Description libre |
| `type` | `ASYNCAPI` \| `OPENAPI` | Non | Type (détecté automatiquement si absent) | | `type` | `ASYNCAPI` \| `OPENAPI` | Non | Type (détecté automatiquement si absent) |
| `categoryId` | UUID | Non | Catégorie parente | | `categoryId` | UUID | Non | Catégorie parente |
| `contactFunctionalName` | string | Non | Contact métier — nom |
| `contactFunctionalEntity` | string | Non | Contact métier — entité |
| `contactFunctionalEmail` | string | Non | Contact métier — email |
| `contactTechnicalName` | string | Non | Contact technique — nom |
| `contactTechnicalEntity` | string | Non | Contact technique — entité |
| `contactTechnicalEmail` | string | Non | Contact technique — email |
**Détection automatique du type** **Détection automatique du type**
@@ -180,13 +261,13 @@ Le type est détecté à partir du contenu YAML :
- Présence de `asyncapi:` en début de ligne → `ASYNCAPI` - Présence de `asyncapi:` en début de ligne → `ASYNCAPI`
Si plusieurs fichiers sont fournis, le fichier contenant la clé racine est considéré comme le fichier principal. Si plusieurs fichiers sont fournis, le fichier contenant la clé racine est considéré comme le fichier principal.
Les autres sont bundlés via `redocly bundle` (OpenAPI) ou `asyncapi bundle` (AsyncAPI). Les autres sont bundlés via `redocly bundle` (OpenAPI) ou `@asyncapi/bundler` (AsyncAPI).
**Réponse 201**`ApiEntry` créé avec `status: "PENDING"` **Réponse 201**`ApiEntry` créé avec `status: "PENDING"`
**Réponse 400** **Réponse 400**
- Aucun fichier fourni - Aucun fichier fourni
- `title` ou `version` manquant - `name` ou `convention` manquant
- Fichier non YAML - Fichier non YAML
- Type non détectable et non fourni - Type non détectable et non fourni
- Aucun fichier principal trouvé dans un upload multi-fichiers - Aucun fichier principal trouvé dans un upload multi-fichiers
@@ -243,8 +324,14 @@ Navigation à la racine : retourne les catégories de premier niveau et les APIs
"apis": [ "apis": [
{ {
"id": "uuid", "id": "uuid",
"title": "API sans catégorie", "title": "Consulter API sans catégorie",
... "name": "API sans catégorie",
"convention": "CONSULTER",
"version": "1.0.0",
"provider": "...",
"status": "GENERATED",
"categoryId": null,
"..."
} }
] ]
} }
@@ -331,10 +418,11 @@ Met à jour une catégorie.
### `DELETE /api/categories/:id` ### `DELETE /api/categories/:id`
Supprime une catégorie. Supprime une catégorie.
Les APIs et sous-catégories enfants sont désassociées (`categoryId`/`parentId` mis à null).
**Réponse 204** — succès (pas de corps) **Réponse 204** — succès (pas de corps)
**Réponse 400** — le dossier contient des APIs ou des sous-dossiers (vérification applicative avant suppression)
**Réponse 404** — catégorie introuvable **Réponse 404** — catégorie introuvable
--- ---
@@ -343,7 +431,7 @@ Les APIs et sous-catégories enfants sont désassociées (`categoryId`/`parentId
| Code | Description | | Code | Description |
|------|-------------| |------|-------------|
| 400 | Requête invalide (champ manquant, format incorrect) | | 400 | Requête invalide (champ manquant, format incorrect, suppression non vide) |
| 404 | Ressource introuvable | | 404 | Ressource introuvable |
| 500 | Erreur interne serveur | | 500 | Erreur interne serveur |
@@ -365,3 +453,11 @@ Les APIs et sous-catégories enfants sont désassociées (`categoryId`/`parentId
| `PENDING` | Entrée créée, génération de doc en cours | | `PENDING` | Entrée créée, génération de doc en cours |
| `GENERATED` | Documentation HTML disponible | | `GENERATED` | Documentation HTML disponible |
| `ERROR` | La génération a échoué (voir `errorMessage`) | | `ERROR` | La génération a échoué (voir `errorMessage`) |
## Conventions d'échange
| Convention | Label affiché |
|------------|---------------|
| `CONSULTER` | Consulter |
| `ENREGISTRER` | Enregistrer |
| `ETRE_NOTIFIE` | Être notifié |

View File

@@ -84,8 +84,13 @@ Injecte des données de démonstration au démarrage (idempotent : ne re-seed qu
```sql ```sql
CREATE TABLE api_entries ( CREATE TABLE api_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL, convention VARCHAR(30) NOT NULL DEFAULT 'CONSULTER'
version VARCHAR(50) NOT NULL, 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, description TEXT,
type VARCHAR(10) NOT NULL CHECK (type IN ('ASYNCAPI', 'OPENAPI')), type VARCHAR(10) NOT NULL CHECK (type IN ('ASYNCAPI', 'OPENAPI')),
yaml_content TEXT NOT NULL, yaml_content TEXT NOT NULL,
@@ -94,11 +99,21 @@ CREATE TABLE api_entries (
CHECK (status IN ('PENDING', 'GENERATED', 'ERROR')), CHECK (status IN ('PENDING', 'GENERATED', 'ERROR')),
error_message TEXT, error_message TEXT,
category_id VARCHAR(36), 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(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_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.0.0"`)
### Table `categories` ### Table `categories`
```sql ```sql
@@ -114,7 +129,7 @@ CREATE TABLE categories (
``` ```
> Les relations `category_id` / `parent_id` sont des colonnes varchar sans contrainte de clé étrangère TypeORM. > Les relations `category_id` / `parent_id` sont des colonnes varchar sans contrainte de clé étrangère TypeORM.
> La suppression d'une catégorie désassocie automatiquement ses APIs et sous-catégories enfants (logique applicative). > La suppression d'une catégorie est **rejetée avec 400** si le dossier contient des APIs ou des sous-dossiers (vérification applicative avant `DELETE`).
--- ---

View File

@@ -138,6 +138,65 @@ Des exemples sont disponibles dans `documentation/` :
--- ---
## Fixtures & re-seed
Les données de démo (catégories + APIs) sont définies dans `back/src/modules/seeder/seeder.service.ts`.
### Structure des fixtures
```
E-commerce (racine)
└─ Produits
└─ Commandes
└─ Notifications
Infrastructure (racine)
└─ Événements
Identité & Accès (racine)
└─ Authentification
```
5 APIs de démo y sont rattachées (Petstore, Streetlights Kafka, Account Service, Orders, Auth JWT).
### Modifier les fixtures
Éditer directement `back/src/modules/seeder/seeder.service.ts` :
- **Catégories** : bloc `categoryRepo.save(categoryRepo.create({...}))` dans la méthode `seed()`. Utiliser `parentId: null` pour une racine, ou `parentId: autreCategorie.id` pour un enfant.
- **APIs** : tableau passé à `apiRepo.save([...])`. Chaque entrée référence une catégorie via `categoryId`.
- **YAML** : les constantes en haut du fichier (`PETSTORE_YAML`, `ORDERS_YAML`, etc.) contiennent le YAML inliné de chaque API.
### Régénérer la base de données
Le seeder est **idempotent** : il ne s'exécute pas si la table `api_entries` contient déjà des lignes.
Pour forcer un re-seed complet (vide tout et recrée), utiliser la variable `FORCE_RESEED=true` :
**Avec Docker :**
```bash
docker compose -f infra/docker-compose.yml stop backend
docker compose -f infra/docker-compose.yml run --rm -e FORCE_RESEED=true backend
```
**Sans Docker (local) :**
```bash
cd back
FORCE_RESEED=true pnpm run start:dev
```
Ou via `back/.env` :
```env
FORCE_RESEED=true
```
> Penser à retirer `FORCE_RESEED=true` du `.env` après le redémarrage, sinon la base sera réinitialisée à chaque démarrage.
Le seeder supprime toutes les entrées `api_entries` et `categories`, nettoie le répertoire `docs-output`, puis recrée tout depuis `seed()`. Les logs confirment l'opération :
```
[Seeder] Reset complet — données supprimées, docs nettoyées.
[Seeder] 8 catégories et 5 APIs de démo créées — génération docs en cours...
```
---
## Notes ## Notes
- **Schéma DB** : TypeORM crée et synchronise automatiquement les tables au démarrage (mode `synchronize: true` hors production). Aucune migration manuelle nécessaire. - **Schéma DB** : TypeORM crée et synchronise automatiquement les tables au démarrage (mode `synchronize: true` hors production). Aucune migration manuelle nécessaire.

View File

@@ -49,6 +49,10 @@ export class ApiService {
return this.http.get<ApiEntryListItem[]>(`${this.baseUrl}/${id}/versions`); return this.http.get<ApiEntryListItem[]>(`${this.baseUrl}/${id}/versions`);
} }
setCurrent(id: string): Observable<ApiEntry> {
return this.http.post<ApiEntry>(`${this.baseUrl}/${id}/set-current`, {});
}
regenerate(id: string): Observable<ApiEntry> { regenerate(id: string): Observable<ApiEntry> {
return this.http.post<ApiEntry>(`${this.baseUrl}/${id}/regenerate`, {}); return this.http.post<ApiEntry>(`${this.baseUrl}/${id}/regenerate`, {});
} }

View File

@@ -381,20 +381,15 @@ export class BrowseComponent implements OnInit {
loadSearch() { loadSearch() {
this.searchLoading.set(true); this.searchLoading.set(true);
const raw = this.searchQuery(); const raw = this.searchQuery();
const q = raw.trim().toLowerCase();
forkJoin({ forkJoin({
apis: this.apiService.list({ search: raw || undefined, page: this.searchPage(), limit: this.searchLimit }), apis: this.apiService.list({ search: raw || undefined, page: this.searchPage(), limit: this.searchLimit }),
categories: this.categoryService.list(), categories: this.categoryService.list(raw || undefined),
}).subscribe({ }).subscribe({
next: ({ apis, categories }) => { next: ({ apis, categories }) => {
this.searchResults.set(apis.items); this.searchResults.set(apis.items);
this.searchTotal.set(apis.total); this.searchTotal.set(apis.total);
this.searchCategoryResults.set( this.searchCategoryResults.set(categories.items);
categories.items.filter(
(c) => c.name.toLowerCase().includes(q) || (c.description ?? '').toLowerCase().includes(q),
),
);
this.searchLoading.set(false); this.searchLoading.set(false);
}, },
error: () => this.searchLoading.set(false), error: () => this.searchLoading.set(false),

View File

@@ -32,17 +32,32 @@ import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datac
<h1 class="fr-h2 fr-mb-4w">Importer une API</h1> <h1 class="fr-h2 fr-mb-4w">Importer une API</h1>
<!-- Stepper --> <!-- Stepper numéroté -->
<div class="fr-stepper fr-mb-4w"> <div class="fr-mb-4w" style="display:flex;align-items:flex-start;">
<h2 class="fr-stepper__title"> @for (title of stepTitles; track $index) {
{{ stepTitles[step() - 1] }} <div style="display:flex;flex-direction:column;align-items:center;flex:none;min-width:6rem;">
<span class="fr-stepper__state">Étape {{ step() }} sur 3</span>
</h2>
<div <div
class="fr-stepper__steps" [style.background]="step() > $index + 1 ? '#1f8d49' : step() === $index + 1 ? '#000091' : '#e5e5e5'"
[attr.data-fr-current-step]="step()" [style.color]="step() >= $index + 1 ? 'white' : '#666'"
data-fr-steps="3" style="width:2rem;height:2rem;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:0.875rem;transition:background 0.2s;">
></div> @if (step() > $index + 1) {
<span class="fr-icon-check-line" aria-hidden="true" style="font-size:0.875rem"></span>
} @else {
{{ $index + 1 }}
}
</div>
<span style="font-size:0.75rem;margin-top:0.4rem;text-align:center;line-height:1.2;"
[style.font-weight]="step() === $index + 1 ? '700' : '400'"
[style.color]="step() === $index + 1 ? '#000091' : step() > $index + 1 ? '#1f8d49' : '#666'">
{{ title }}
</span>
</div>
@if ($index < stepTitles.length - 1) {
<div style="flex:1;height:2px;margin-top:1rem;transition:background 0.2s;"
[style.background]="step() > $index + 1 ? '#1f8d49' : '#e5e5e5'">
</div>
}
}
</div> </div>
<!-- Étape 1 : Fichier(s) YAML --> <!-- Étape 1 : Fichier(s) YAML -->
@@ -120,7 +135,7 @@ import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datac
</div> </div>
} }
<!-- Étape 2 : Métadonnées --> <!-- Étape 2 : Informations -->
@if (step() === 2) { @if (step() === 2) {
<form [formGroup]="metaForm" (ngSubmit)="goToStep3()" novalidate> <form [formGroup]="metaForm" (ngSubmit)="goToStep3()" novalidate>
@@ -270,6 +285,19 @@ import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datac
</div> </div>
</div> </div>
<div class="fr-btns-group fr-btns-group--inline-sm">
<button type="submit" class="fr-btn">Suivant</button>
<button type="button" class="fr-btn fr-btn--secondary" (click)="step.set(1)">
Retour
</button>
</div>
</form>
}
<!-- Étape 3 : Contacts -->
@if (step() === 3) {
<form [formGroup]="metaForm" (ngSubmit)="goToStep4()" novalidate>
<!-- Contact métier --> <!-- Contact métier -->
<p class="fr-label fr-mb-1w">Contact métier <span style="color:var(--text-default-error)">*</span></p> <p class="fr-label fr-mb-1w">Contact métier <span style="color:var(--text-default-error)">*</span></p>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-3w"> <div class="fr-grid-row fr-grid-row--gutters fr-mb-3w">
@@ -347,16 +375,16 @@ import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datac
</div> </div>
<div class="fr-btns-group fr-btns-group--inline-sm"> <div class="fr-btns-group fr-btns-group--inline-sm">
<button type="submit" class="fr-btn" [disabled]="metaForm.invalid">Suivant</button> <button type="submit" class="fr-btn">Suivant</button>
<button type="button" class="fr-btn fr-btn--secondary" (click)="step.set(1)"> <button type="button" class="fr-btn fr-btn--secondary" (click)="step.set(2)">
Retour Retour
</button> </button>
</div> </div>
</form> </form>
} }
<!-- Étape 3 : Confirmation --> <!-- Étape 4 : Confirmation -->
@if (step() === 3) { @if (step() === 4) {
<div class="fr-card fr-mb-4w"> <div class="fr-card fr-mb-4w">
<div class="fr-card__body"> <div class="fr-card__body">
<div class="fr-card__content"> <div class="fr-card__content">
@@ -407,6 +435,22 @@ import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datac
<dd>{{ metaForm.value.description }}</dd> <dd>{{ metaForm.value.description }}</dd>
</div> </div>
} }
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Contact métier</dt>
<dd>
{{ metaForm.value.contactFunctionalName }}<br>
<span class="fr-text--sm">{{ metaForm.value.contactFunctionalEntity }}</span><br>
<span class="fr-text--sm">{{ metaForm.value.contactFunctionalEmail }}</span>
</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Contact technique</dt>
<dd>
{{ metaForm.value.contactTechnicalName }}<br>
<span class="fr-text--sm">{{ metaForm.value.contactTechnicalEntity }}</span><br>
<span class="fr-text--sm">{{ metaForm.value.contactTechnicalEmail }}</span>
</dd>
</div>
</dl> </dl>
</div> </div>
</div> </div>
@@ -427,7 +471,7 @@ import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datac
> >
{{ uploading() ? 'Import en cours...' : 'Importer' }} {{ uploading() ? 'Import en cours...' : 'Importer' }}
</button> </button>
<button class="fr-btn fr-btn--secondary" (click)="step.set(2)" [disabled]="uploading()"> <button class="fr-btn fr-btn--secondary" (click)="step.set(3)" [disabled]="uploading()">
Retour Retour
</button> </button>
</div> </div>
@@ -456,10 +500,21 @@ export class UploadComponent implements OnInit {
readonly stepTitles = [ readonly stepTitles = [
'Choisir le fichier', 'Choisir le fichier',
'Renseigner les métadonnées', 'Informations',
'Contacts',
"Confirmer l'import", "Confirmer l'import",
]; ];
private readonly INFO_FIELDS = [
'convention', 'name', 'provider', 'type',
'versionMajor', 'versionMinor', 'versionPatch',
];
private readonly CONTACT_FIELDS = [
'contactFunctionalName', 'contactFunctionalEntity', 'contactFunctionalEmail',
'contactTechnicalName', 'contactTechnicalEntity', 'contactTechnicalEmail',
];
metaForm = this.fb.group({ metaForm = this.fb.group({
convention: ['CONSULTER', Validators.required], convention: ['CONSULTER', Validators.required],
name: ['', Validators.required], name: ['', Validators.required],
@@ -609,6 +664,24 @@ export class UploadComponent implements OnInit {
this.step.set(2); this.step.set(2);
} }
goToStep3() {
const invalid = this.INFO_FIELDS.some((f) => this.metaForm.get(f)?.invalid);
if (invalid) {
this.INFO_FIELDS.forEach((f) => this.metaForm.get(f)?.markAsTouched());
return;
}
this.step.set(3);
}
goToStep4() {
const invalid = this.CONTACT_FIELDS.some((f) => this.metaForm.get(f)?.invalid);
if (invalid) {
this.CONTACT_FIELDS.forEach((f) => this.metaForm.get(f)?.markAsTouched());
return;
}
this.step.set(4);
}
private runValidation(files: File[]): void { private runValidation(files: File[]): void {
this.validating.set(true); this.validating.set(true);
this.validationDone.set(false); this.validationDone.set(false);
@@ -651,14 +724,6 @@ export class UploadComponent implements OnInit {
}); });
} }
goToStep3() {
if (this.metaForm.invalid) {
this.metaForm.markAllAsTouched();
return;
}
this.step.set(3);
}
onSubmit() { onSubmit() {
const files = this.selectedFiles(); const files = this.selectedFiles();
if (files.length === 0 || this.metaForm.invalid) return; if (files.length === 0 || this.metaForm.invalid) return;

View File

@@ -27,6 +27,7 @@ export interface ApiEntry {
status: ApiStatus; status: ApiStatus;
errorMessage: string | null; errorMessage: string | null;
categoryId: string | null; categoryId: string | null;
isCurrent: boolean;
contactFunctionalName: string; contactFunctionalName: string;
contactFunctionalEntity: string; contactFunctionalEntity: string;
contactFunctionalEmail: string; contactFunctionalEmail: string;
@@ -48,6 +49,7 @@ export interface ApiEntryListItem {
provider: string; provider: string;
status: ApiStatus; status: ApiStatus;
categoryId: string | null; categoryId: string | null;
isCurrent: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }