feat: initial scaffold — MVP catalogue de données d'API
- Backend NestJS : CRUD api_entries + categories, upload YAML multi-fichiers, génération docs AsyncAPI (@asyncapi/cli@6.0.0) et OpenAPI (redocly) - Fix: route wildcard GET /api/apis/:id/*path pour servir les assets statiques (CSS/JS) générés par AsyncAPI HTML template (contournement bug path-to-regexp v8) - Frontend Angular 19 : pages catalog, browse, api-detail, doc-viewer, upload (DSFR) - Seeder de données de démo (idempotent) - Docker Compose dev + Dockerfiles + manifests K8s - Documentation : README, architecture, référence API REST 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:
675
documentation/CLAUDE.md
Normal file
675
documentation/CLAUDE.md
Normal file
@@ -0,0 +1,675 @@
|
||||
# Datacat — Guide d'implémentation complet
|
||||
|
||||
## 1. Purpose & Architecture
|
||||
|
||||
**Datacat** est un catalogue de données d'API. Il permet d'importer des fichiers YAML
|
||||
(AsyncAPI ou OpenAPI) et de les transformer en documentation HTML via des CLI spécialisées.
|
||||
|
||||
```
|
||||
datacat/
|
||||
├── back/ # API NestJS + TypeORM + PostgreSQL
|
||||
├── front-public/ # Frontend Angular 19 (DSFR)
|
||||
├── shared/ # Types TypeScript partagés
|
||||
├── infra/ # Docker, Dockerfiles, K8s
|
||||
└── documentation/ # Ce fichier + README
|
||||
```
|
||||
|
||||
### Flux principal
|
||||
```
|
||||
User uploads YAML → POST /api/uploads
|
||||
→ ApiEntry created (status: PENDING)
|
||||
→ DocsGenerationService.generate()
|
||||
→ asyncapi generate fromFile input.yaml --output /docs-output/<id>/
|
||||
→ OR redocly build-docs input.yaml --output /docs-output/<id>/index.html
|
||||
→ ApiEntry updated (status: GENERATED, htmlPath: /docs-output/<id>/index.html)
|
||||
→ GET /api/apis/:id/docs → sendFile(htmlPath)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Commandes dev
|
||||
|
||||
```bash
|
||||
# Tout en Docker (recommandé)
|
||||
pnpm run dev # docker compose up
|
||||
pnpm run dev:build # docker compose up --build
|
||||
|
||||
# Local (sans Docker)
|
||||
cd back && pnpm run start:dev # NestJS watch mode — port 3000
|
||||
cd front-public && pnpm run start # Angular dev server — port 4200
|
||||
|
||||
# Install deps
|
||||
cd back && pnpm install
|
||||
cd front-public && pnpm install
|
||||
cd shared && pnpm install
|
||||
|
||||
# Build prod
|
||||
cd back && pnpm run build
|
||||
cd front-public && pnpm run build:prod
|
||||
```
|
||||
|
||||
### Proxy Angular → API
|
||||
Créer `front-public/proxy.conf.json` :
|
||||
```json
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:3000",
|
||||
"secure": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Stack & Versions
|
||||
|
||||
| Outil | Version |
|
||||
|-------|---------|
|
||||
| Node.js | 22 LTS |
|
||||
| pnpm | 10.33.0 |
|
||||
| NestJS | ^11.0.0 |
|
||||
| TypeORM | ^0.3.20 |
|
||||
| PostgreSQL | 17 |
|
||||
| Angular | ^19.0.0 |
|
||||
| TypeScript | ^5.7.0 |
|
||||
| @asyncapi/cli | latest (in Docker) |
|
||||
| @redocly/cli | latest (in Docker) |
|
||||
|
||||
### Conventions TypeScript
|
||||
- Pas de `any` — utiliser les types de `@datacat/shared`
|
||||
- `strict: true` partout
|
||||
- English pour le code, Français pour les commentaires
|
||||
|
||||
---
|
||||
|
||||
## 4. Schéma DB
|
||||
|
||||
### Table `api_entries`
|
||||
|
||||
```sql
|
||||
CREATE TABLE api_entries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
version VARCHAR(50) NOT NULL,
|
||||
description TEXT,
|
||||
type VARCHAR(10) NOT NULL CHECK (type IN ('ASYNCAPI', 'OPENAPI')),
|
||||
yaml_content TEXT NOT NULL,
|
||||
html_path VARCHAR(500),
|
||||
status VARCHAR(10) NOT NULL DEFAULT 'PENDING'
|
||||
CHECK (status IN ('PENDING', 'GENERATED', 'ERROR')),
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### Entity TypeORM (`back/src/modules/apis/api-entry.entity.ts`)
|
||||
|
||||
```typescript
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
import { ApiType, ApiStatus } from '@datacat/shared';
|
||||
|
||||
@Entity('api_entries')
|
||||
export class ApiEntryEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
title: string;
|
||||
|
||||
@Column()
|
||||
version: string;
|
||||
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
description: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 10 })
|
||||
type: ApiType;
|
||||
|
||||
@Column({ type: 'text', name: 'yaml_content' })
|
||||
yamlContent: string;
|
||||
|
||||
@Column({ nullable: true, name: 'html_path' })
|
||||
htmlPath: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 10, default: 'PENDING' })
|
||||
status: ApiStatus;
|
||||
|
||||
@Column({ nullable: true, type: 'text', name: 'error_message' })
|
||||
errorMessage: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Architecture NestJS
|
||||
|
||||
### Structure `back/src/`
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.ts
|
||||
├── app.module.ts
|
||||
├── modules/
|
||||
│ ├── apis/
|
||||
│ │ ├── apis.module.ts
|
||||
│ │ ├── apis.controller.ts # REST endpoints
|
||||
│ │ ├── apis.service.ts # CRUD logique
|
||||
│ │ └── api-entry.entity.ts # TypeORM entity
|
||||
│ ├── uploads/
|
||||
│ │ ├── uploads.module.ts
|
||||
│ │ └── uploads.controller.ts # POST /api/uploads (multipart)
|
||||
│ └── docs-generation/
|
||||
│ ├── docs-generation.module.ts
|
||||
│ └── docs-generation.service.ts # Appels AsyncAPI/Redocly CLI
|
||||
├── database/
|
||||
│ └── database.module.ts # TypeORM config (si séparé)
|
||||
└── common/
|
||||
├── filters/
|
||||
│ └── all-exceptions.filter.ts
|
||||
└── interceptors/
|
||||
└── transform.interceptor.ts
|
||||
```
|
||||
|
||||
### API REST
|
||||
|
||||
```
|
||||
GET /health → { status: 'ok' }
|
||||
GET /api/apis → ApiListResponse (+ ?search=&type=&page=&limit=)
|
||||
GET /api/apis/:id → ApiEntry
|
||||
POST /api/uploads → ApiEntry (multipart: file + title + version + description + type)
|
||||
PATCH /api/apis/:id → ApiEntry (body: UpdateApiEntryDto)
|
||||
DELETE /api/apis/:id → 204 No Content
|
||||
POST /api/apis/:id/regenerate → ApiEntry (relance la génération de docs)
|
||||
GET /api/apis/:id/docs → sendFile(htmlPath) ou 404 si pas encore généré
|
||||
GET /api/apis/:id/yaml → download du YAML brut (Content-Disposition: attachment)
|
||||
```
|
||||
|
||||
### ApisController
|
||||
|
||||
```typescript
|
||||
import { Controller, Get, Patch, Delete, Post, Param, Body, Query, Res, NotFoundException } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
|
||||
@Controller('apis')
|
||||
export class ApisController {
|
||||
constructor(private readonly apisService: ApisService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@Query() query: ApiListQuery) {
|
||||
return this.apisService.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.apisService.findOneOrThrow(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateApiEntryDto) {
|
||||
return this.apisService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
remove(@Param('id') id: string) {
|
||||
return this.apisService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/regenerate')
|
||||
regenerate(@Param('id') id: string) {
|
||||
return this.apisService.regenerate(id);
|
||||
}
|
||||
|
||||
@Get(':id/docs')
|
||||
async getDocs(@Param('id') id: string, @Res() res: Response) {
|
||||
const entry = await this.apisService.findOneOrThrow(id);
|
||||
if (!entry.htmlPath) throw new NotFoundException('Documentation not generated yet');
|
||||
res.sendFile(entry.htmlPath);
|
||||
}
|
||||
|
||||
@Get(':id/yaml')
|
||||
async getYaml(@Param('id') id: string, @Res() res: Response) {
|
||||
const entry = await this.apisService.findOneOrThrow(id);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${entry.title}.yaml"`);
|
||||
res.setHeader('Content-Type', 'application/x-yaml');
|
||||
res.send(entry.yamlContent);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### UploadsController
|
||||
|
||||
```typescript
|
||||
import { Controller, Post, UseInterceptors, UploadedFile, Body } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
|
||||
@Controller('uploads')
|
||||
export class UploadsController {
|
||||
constructor(
|
||||
private readonly apisService: ApisService,
|
||||
private readonly docsService: DocsGenerationService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(FileInterceptor('file', {
|
||||
storage: diskStorage({ destination: '/tmp/uploads' }),
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (!file.originalname.match(/\.(yaml|yml)$/)) {
|
||||
return cb(new Error('Only YAML files are allowed'), false);
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
}))
|
||||
async upload(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body() body: { title: string; version: string; description?: string; type: 'ASYNCAPI' | 'OPENAPI' },
|
||||
) {
|
||||
const yamlContent = fs.readFileSync(file.path, 'utf-8');
|
||||
const entry = await this.apisService.create({ ...body, yamlContent });
|
||||
// Fire and forget — génération asynchrone
|
||||
this.docsService.generate(entry.id).catch(console.error);
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### DocsGenerationService
|
||||
|
||||
```typescript
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@Injectable()
|
||||
export class DocsGenerationService {
|
||||
private readonly outputDir = process.env.DOCS_OUTPUT_DIR ?? '/app/docs-output';
|
||||
|
||||
async generate(entryId: string): Promise<void> {
|
||||
const entry = await this.apisService.findOneOrThrow(entryId);
|
||||
const entryDir = path.join(this.outputDir, entryId);
|
||||
|
||||
await fs.mkdir(entryDir, { recursive: true });
|
||||
|
||||
const yamlPath = path.join(entryDir, 'input.yaml');
|
||||
await fs.writeFile(yamlPath, entry.yamlContent, 'utf-8');
|
||||
|
||||
try {
|
||||
if (entry.type === 'ASYNCAPI') {
|
||||
await this.generateAsyncApi(yamlPath, entryDir);
|
||||
} else {
|
||||
await this.generateOpenApi(yamlPath, entryDir);
|
||||
}
|
||||
|
||||
const htmlPath = path.join(entryDir, 'index.html');
|
||||
await this.apisService.update(entryId, { htmlPath, status: 'GENERATED' });
|
||||
} catch (error) {
|
||||
await this.apisService.update(entryId, {
|
||||
status: 'ERROR',
|
||||
errorMessage: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async generateAsyncApi(yamlPath: string, outputDir: string): Promise<void> {
|
||||
// asyncapi generate fromFile <input> --output <dir>
|
||||
await execFileAsync('asyncapi', [
|
||||
'generate', 'fromFile',
|
||||
yamlPath,
|
||||
'--output', outputDir,
|
||||
]);
|
||||
}
|
||||
|
||||
private async generateOpenApi(yamlPath: string, outputDir: string): Promise<void> {
|
||||
// redocly build-docs <input> --output <file>
|
||||
await execFileAsync('redocly', [
|
||||
'build-docs',
|
||||
yamlPath,
|
||||
'--output', path.join(outputDir, 'index.html'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Conventions Angular 19
|
||||
|
||||
### Règles absolues
|
||||
- **Standalone components** : toujours `standalone: true`, jamais de NgModule
|
||||
- **Signals** : utiliser `signal()`, `computed()`, `effect()` pour le state local
|
||||
- **inject()** : utiliser `inject()` à la place de constructor injection
|
||||
- **OnPush** : toujours `changeDetection: ChangeDetectionStrategy.OnPush`
|
||||
- **Lazy loading** : toutes les pages sont lazy-loadées via `loadComponent`
|
||||
|
||||
### Pattern type pour un composant de page
|
||||
|
||||
```typescript
|
||||
import { Component, signal, computed, inject, OnInit, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-catalog',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="fr-container fr-mt-4w">
|
||||
<h1 class="fr-h1">Catalogue des APIs</h1>
|
||||
@if (loading()) {
|
||||
<div class="fr-callout">Chargement...</div>
|
||||
}
|
||||
@for (api of apis(); track api.id) {
|
||||
<div class="fr-card fr-mb-2w">
|
||||
<h2>{{ api.title }}</h2>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class CatalogComponent implements OnInit {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
apis = signal<ApiEntryListItem[]>([]);
|
||||
loading = signal(false);
|
||||
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Service type
|
||||
|
||||
```typescript
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiEntry, ApiListResponse, ApiListQuery } from '@datacat/shared';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApiService {
|
||||
private http = inject(HttpClient);
|
||||
private baseUrl = '/api/apis';
|
||||
|
||||
list(query?: ApiListQuery): Observable<ApiListResponse> {
|
||||
return this.http.get<ApiListResponse>(this.baseUrl, { params: query as Record<string, string> });
|
||||
}
|
||||
|
||||
get(id: string): Observable<ApiEntry> {
|
||||
return this.http.get<ApiEntry>(`${this.baseUrl}/${id}`);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/${id}`);
|
||||
}
|
||||
|
||||
regenerate(id: string): Observable<ApiEntry> {
|
||||
return this.http.post<ApiEntry>(`${this.baseUrl}/${id}/regenerate`, {});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. DSFR (Système de Design de l'État)
|
||||
|
||||
### Installation (assets statiques — pas de package npm)
|
||||
|
||||
Télécharger la dernière version depuis https://www.systeme-de-design.gouv.fr/
|
||||
et placer les assets dans `front-public/src/assets/dsfr/`.
|
||||
|
||||
Structure attendue :
|
||||
```
|
||||
src/assets/dsfr/
|
||||
├── dsfr.min.css
|
||||
├── dsfr.module.min.js
|
||||
├── dsfr.nomodule.min.js
|
||||
└── utility/
|
||||
└── icons/
|
||||
└── icons.min.css
|
||||
```
|
||||
|
||||
Déjà référencé dans `index.html`.
|
||||
|
||||
### Classes CSS essentielles
|
||||
|
||||
```html
|
||||
<!-- Layout -->
|
||||
<div class="fr-container"> <!-- Conteneur centré -->
|
||||
<div class="fr-grid-row fr-grid-row--gutters"> <!-- Grille -->
|
||||
<div class="fr-col-12 fr-col-md-6"> <!-- Colonnes -->
|
||||
|
||||
<!-- Typographie -->
|
||||
<h1 class="fr-h1"> <!-- Titre niveau 1 -->
|
||||
<p class="fr-text--lead"> <!-- Texte mise en avant -->
|
||||
|
||||
<!-- Composants -->
|
||||
<div class="fr-card"> <!-- Carte -->
|
||||
<div class="fr-card__body">
|
||||
<div class="fr-card__content">
|
||||
<h3 class="fr-card__title">
|
||||
|
||||
<div class="fr-badge fr-badge--info"> <!-- Badge status -->
|
||||
<div class="fr-badge fr-badge--success">
|
||||
<div class="fr-badge fr-badge--error">
|
||||
|
||||
<button class="fr-btn"> <!-- Bouton principal -->
|
||||
<button class="fr-btn fr-btn--secondary"> <!-- Bouton secondaire -->
|
||||
<button class="fr-btn fr-btn--icon-left fr-icon-upload-2-line"> <!-- Avec icône -->
|
||||
|
||||
<div class="fr-input-group"> <!-- Champ de formulaire -->
|
||||
<label class="fr-label">
|
||||
<input class="fr-input" />
|
||||
|
||||
<div class="fr-select-group"> <!-- Select -->
|
||||
<select class="fr-select">
|
||||
|
||||
<nav class="fr-stepper"> <!-- Stepper 3 étapes -->
|
||||
<span class="fr-stepper__steps" data-fr-current-step="1" data-fr-steps="3">
|
||||
|
||||
<!-- Espacement (t=top, b=bottom, l=left, r=right, h=horizontal, v=vertical) -->
|
||||
<!-- Unités: 1w=8px, 2w=16px, 3w=24px, 4w=32px -->
|
||||
fr-mt-4w fr-mb-2w fr-ml-1w fr-p-3w fr-mx-auto
|
||||
```
|
||||
|
||||
### Icônes DSFR disponibles
|
||||
- `fr-icon-upload-2-line` — upload
|
||||
- `fr-icon-file-line` — fichier
|
||||
- `fr-icon-search-line` — recherche
|
||||
- `fr-icon-arrow-right-line` — flèche droite
|
||||
- `fr-icon-external-link-line` — lien externe
|
||||
- `fr-icon-delete-bin-line` — supprimer
|
||||
- `fr-icon-refresh-line` — régénérer
|
||||
- `fr-icon-eye-line` — voir
|
||||
|
||||
---
|
||||
|
||||
## 8. Commandes AsyncAPI CLI & Redocly CLI
|
||||
|
||||
### AsyncAPI 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
|
||||
```
|
||||
|
||||
### Redocly CLI
|
||||
|
||||
```bash
|
||||
# Générer HTML depuis un fichier OpenAPI YAML
|
||||
redocly build-docs ./api.yaml --output ./output-dir/index.html
|
||||
|
||||
# Valider un fichier OpenAPI
|
||||
redocly lint ./api.yaml
|
||||
|
||||
# Format de sortie : ./output-dir/index.html
|
||||
```
|
||||
|
||||
### 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`
|
||||
- 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)
|
||||
- Chaque API a son propre sous-répertoire : `/app/docs-output/<uuid>/index.html`
|
||||
|
||||
---
|
||||
|
||||
## 9. Déploiement K3s
|
||||
|
||||
### Namespaces
|
||||
- **`datacat`** : tous les pods (backend, frontend, postgres)
|
||||
- Middlewares Traefik : `dev-basic-auth` et `redirect-https` (dans namespace `dev`)
|
||||
|
||||
### URLs
|
||||
- Frontend : `https://datacat.dev.chmod777.dev`
|
||||
- API : `https://api.datacat.dev.chmod777.dev`
|
||||
- Basic Auth : user `dev`, password dans `~/server-setup/.dev-password`
|
||||
|
||||
### Images
|
||||
- Backend : `git.chmod777.dev/z3n/datacat/back:latest`
|
||||
- Frontend : `git.chmod777.dev/z3n/datacat/front:latest`
|
||||
|
||||
### PVC
|
||||
- `datacat-docs-pvc` (5Gi) — monté sur `/app/docs-output` dans le pod backend
|
||||
- `datacat-postgres-pvc` (10Gi) — données PostgreSQL
|
||||
|
||||
### Secret K8s requis
|
||||
|
||||
```bash
|
||||
# Créer le secret pour les credentials de la DB
|
||||
kubectl create secret generic datacat-secrets \
|
||||
--from-literal=db-user=datacat \
|
||||
--from-literal=db-password=<PASSWORD> \
|
||||
-n datacat
|
||||
|
||||
# Créer le secret pour le registry Gitea
|
||||
kubectl create secret docker-registry registry-secret \
|
||||
--docker-server=git.chmod777.dev \
|
||||
--docker-username=z3n \
|
||||
--docker-password=<TOKEN> \
|
||||
-n datacat
|
||||
```
|
||||
|
||||
### Déploiement initial
|
||||
|
||||
```bash
|
||||
cd infra/k8s
|
||||
kubectl apply -k .
|
||||
# ou
|
||||
kubectl apply -f namespace.yaml
|
||||
kubectl apply -f pvc.yaml
|
||||
kubectl apply -f postgres.yaml
|
||||
kubectl apply -f backend.yaml
|
||||
kubectl apply -f frontend.yaml
|
||||
kubectl apply -f ingressroutes.yaml
|
||||
```
|
||||
|
||||
### Mettre à jour les images
|
||||
|
||||
```bash
|
||||
kubectl set image deployment/backend backend=git.chmod777.dev/z3n/datacat/back:<sha> -n datacat
|
||||
kubectl set image deployment/frontend frontend=git.chmod777.dev/z3n/datacat/front:<sha> -n datacat
|
||||
kubectl rollout status deployment/backend -n datacat
|
||||
kubectl rollout status deployment/frontend -n datacat
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. CI/CD Woodpecker
|
||||
|
||||
Le pipeline `.woodpecker.yaml` à la racine gère :
|
||||
1. **build-back** : Kaniko → `git.chmod777.dev/z3n/datacat/back:latest`
|
||||
2. **build-front** : Kaniko → `git.chmod777.dev/z3n/datacat/front:latest`
|
||||
3. **deploy** : `kubectl set image` + `kubectl rollout status`
|
||||
|
||||
### Secrets Woodpecker à configurer
|
||||
Dans `https://ci.chmod777.dev` → Settings → Secrets pour le dépôt `datacat` :
|
||||
- `registry_user` : `z3n`
|
||||
- `registry_password` : token Gitea avec accès packages
|
||||
- `deploy_kubeconfig` : contenu du fichier `~/.kube/config`
|
||||
|
||||
### 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
|
||||
|
||||
- **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`)
|
||||
- **Angular** : standalone components, signals, inject(), OnPush, lazy loading
|
||||
- **Tests** : Vitest pour le backend, `ng test` pour le frontend
|
||||
- **Pas de `console.log`** en production — utiliser Pino (NestJS) ou le logger Angular
|
||||
|
||||
---
|
||||
|
||||
## 12. Pages Angular — Détail d'implémentation
|
||||
|
||||
### `/catalog` — CatalogComponent
|
||||
- Grille DSFR de cartes (`fr-card`)
|
||||
- Champ de recherche (`fr-input`) filtrant titre + description
|
||||
- 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
|
||||
- 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`
|
||||
|
||||
### `/catalog/:id/docs` — DocViewerComponent
|
||||
- `<iframe>` plein écran affichant `/api/apis/:id/docs`
|
||||
- Bouton "Retour" en haut
|
||||
|
||||
### `/upload` — UploadComponent
|
||||
- Stepper DSFR 3 étapes : (1) Choisir le fichier, (2) Métadonnées, (3) Confirmation
|
||||
- Étape 1 : Zone de drop DSFR (`fr-upload-group`) pour fichier `.yaml` / `.yml`
|
||||
- Étape 2 : Formulaire (titre, version, description, type select AsyncAPI/OpenAPI)
|
||||
- Étape 3 : Récap + bouton "Importer"
|
||||
- POST multipart vers `/api/uploads`
|
||||
- Redirect vers `/catalog/:id` après succès
|
||||
24
documentation/README.md
Normal file
24
documentation/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Datacat
|
||||
|
||||
A data catalog for API documentation. Import YAML files (AsyncAPI / OpenAPI) and generate
|
||||
HTML documentation automatically via AsyncAPI CLI and Redocly CLI.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Dev (Docker)
|
||||
pnpm run dev
|
||||
|
||||
# Dev (local)
|
||||
cd back && pnpm run start:dev
|
||||
cd front-public && pnpm run start
|
||||
```
|
||||
|
||||
## URLs (dev)
|
||||
|
||||
- Frontend: https://datacat.dev.chmod777.dev
|
||||
- API: https://api.datacat.dev.chmod777.dev
|
||||
- Local frontend: http://localhost:4200
|
||||
- Local API: http://localhost:3000
|
||||
|
||||
## See CLAUDE.md for the full implementation guide.
|
||||
367
documentation/api-reference.md
Normal file
367
documentation/api-reference.md
Normal file
@@ -0,0 +1,367 @@
|
||||
# Référence API — Datacat
|
||||
|
||||
Base URL en développement : `http://localhost:3010`
|
||||
|
||||
Toutes les routes JSON retournent `Content-Type: application/json`.
|
||||
|
||||
---
|
||||
|
||||
## Health
|
||||
|
||||
### `GET /health`
|
||||
|
||||
Vérifie que le serveur est opérationnel.
|
||||
|
||||
**Réponse 200**
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## APIs (`/api/apis`)
|
||||
|
||||
### `GET /api/apis`
|
||||
|
||||
Liste les entrées avec filtres optionnels et pagination.
|
||||
|
||||
**Query parameters**
|
||||
|
||||
| Paramètre | Type | Défaut | Description |
|
||||
|-----------|------|--------|-------------|
|
||||
| `search` | string | — | Recherche dans le titre et la description (case-insensitive) |
|
||||
| `type` | `ASYNCAPI` \| `OPENAPI` | — | Filtre par type |
|
||||
| `categoryId` | UUID | — | Filtre par catégorie |
|
||||
| `page` | number | `1` | Page (min 1) |
|
||||
| `limit` | number | `20` | Taille de page (min 1, max 100) |
|
||||
|
||||
**Réponse 200**
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "My API",
|
||||
"version": "1.0.0",
|
||||
"description": "...",
|
||||
"type": "OPENAPI",
|
||||
"status": "GENERATED",
|
||||
"categoryId": "uuid-or-null",
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
],
|
||||
"total": 42,
|
||||
"page": 1,
|
||||
"limit": 20
|
||||
}
|
||||
```
|
||||
|
||||
> `items` contient des `ApiEntryListItem` (sans `yamlContent` ni `htmlPath`).
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/apis/:id`
|
||||
|
||||
Retourne le détail complet d'une entrée.
|
||||
|
||||
**Réponse 200**
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "My API",
|
||||
"version": "1.0.0",
|
||||
"description": "...",
|
||||
"type": "OPENAPI",
|
||||
"yamlContent": "openapi: '3.0.0'\n...",
|
||||
"htmlPath": "/app/docs-output/uuid/index.html",
|
||||
"status": "GENERATED",
|
||||
"errorMessage": null,
|
||||
"categoryId": "uuid-or-null",
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Réponse 404** — entrée introuvable
|
||||
|
||||
---
|
||||
|
||||
### `PATCH /api/apis/:id`
|
||||
|
||||
Met à jour les métadonnées d'une entrée (champs éditables par l'utilisateur).
|
||||
|
||||
**Body JSON** (tous les champs sont optionnels)
|
||||
```json
|
||||
{
|
||||
"title": "New title",
|
||||
"version": "2.0.0",
|
||||
"description": "Updated description",
|
||||
"categoryId": "uuid-or-null"
|
||||
}
|
||||
```
|
||||
|
||||
**Réponse 200** — `ApiEntry` complet mis à jour
|
||||
|
||||
**Réponse 404** — entrée introuvable
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /api/apis/:id`
|
||||
|
||||
Supprime une entrée.
|
||||
|
||||
**Réponse 204** — succès (pas de corps)
|
||||
|
||||
**Réponse 404** — entrée introuvable
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/apis/:id/regenerate`
|
||||
|
||||
Relance la génération de documentation pour une entrée existante.
|
||||
Remet le statut à `PENDING` et déclenche `DocsGenerationService.generate()` en arrière-plan.
|
||||
|
||||
**Réponse 200** — `ApiEntry` avec `status: "PENDING"`
|
||||
|
||||
**Réponse 404** — entrée introuvable
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/apis/:id/docs`
|
||||
|
||||
Retourne le fichier HTML de documentation généré.
|
||||
|
||||
**Réponse 200** — `text/html` (fichier statique servi via `res.sendFile`)
|
||||
|
||||
**Réponse 404**
|
||||
- Entrée introuvable
|
||||
- Documentation pas encore générée (`htmlPath` est null)
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/apis/:id/yaml`
|
||||
|
||||
Télécharge le fichier YAML source.
|
||||
|
||||
**Réponse 200**
|
||||
- `Content-Type: application/x-yaml`
|
||||
- `Content-Disposition: attachment; filename="<title>.yaml"`
|
||||
- Corps : contenu YAML brut
|
||||
|
||||
**Réponse 404** — entrée introuvable
|
||||
|
||||
---
|
||||
|
||||
## Upload (`/api/uploads`)
|
||||
|
||||
### `POST /api/uploads`
|
||||
|
||||
Crée une nouvelle entrée à partir d'un ou plusieurs fichiers YAML.
|
||||
Lance la génération de documentation en arrière-plan (fire-and-forget).
|
||||
|
||||
**Content-Type** : `multipart/form-data`
|
||||
|
||||
**Champs du formulaire**
|
||||
|
||||
| Champ | Type | Requis | Description |
|
||||
|-------|------|--------|-------------|
|
||||
| `files` | file (1 à 20) | Oui | Fichiers YAML (`.yaml` ou `.yml`) |
|
||||
| `title` | string | Oui | Titre de l'API |
|
||||
| `version` | string | Oui | Version (ex: `1.0.0`) |
|
||||
| `description` | string | Non | Description libre |
|
||||
| `type` | `ASYNCAPI` \| `OPENAPI` | Non | Type (détecté automatiquement si absent) |
|
||||
| `categoryId` | UUID | Non | Catégorie parente |
|
||||
|
||||
**Détection automatique du type**
|
||||
|
||||
Le type est détecté à partir du contenu YAML :
|
||||
- Présence de `openapi:` en début de ligne → `OPENAPI`
|
||||
- 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.
|
||||
Les autres sont bundlés via `redocly bundle` (OpenAPI) ou `asyncapi bundle` (AsyncAPI).
|
||||
|
||||
**Réponse 201** — `ApiEntry` créé avec `status: "PENDING"`
|
||||
|
||||
**Réponse 400**
|
||||
- Aucun fichier fourni
|
||||
- `title` ou `version` manquant
|
||||
- Fichier non YAML
|
||||
- Type non détectable et non fourni
|
||||
- Aucun fichier principal trouvé dans un upload multi-fichiers
|
||||
|
||||
---
|
||||
|
||||
## Catégories (`/api/categories`)
|
||||
|
||||
### `GET /api/categories`
|
||||
|
||||
Retourne toutes les catégories, triées par `order_index` puis `name`.
|
||||
|
||||
**Réponse 200**
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "Infrastructure",
|
||||
"description": "...",
|
||||
"parentId": null,
|
||||
"orderIndex": 0,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/categories/browse`
|
||||
|
||||
Navigation à la racine : retourne les catégories de premier niveau et les APIs sans catégorie.
|
||||
|
||||
**Réponse 200** — `CategoryBrowseResponse`
|
||||
```json
|
||||
{
|
||||
"category": null,
|
||||
"breadcrumb": [],
|
||||
"subcategories": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "Infrastructure",
|
||||
"description": "...",
|
||||
"parentId": null,
|
||||
"orderIndex": 0,
|
||||
"subcategoryCount": 2,
|
||||
"apiCount": 5,
|
||||
"createdAt": "...",
|
||||
"updatedAt": "..."
|
||||
}
|
||||
],
|
||||
"apis": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "API sans catégorie",
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/categories/browse/:id`
|
||||
|
||||
Navigation dans une catégorie : retourne ses sous-catégories et ses APIs directes.
|
||||
|
||||
**Réponse 200** — `CategoryBrowseResponse`
|
||||
```json
|
||||
{
|
||||
"category": { "id": "uuid", "name": "Infrastructure", ... },
|
||||
"breadcrumb": [
|
||||
{ "id": "parent-uuid", "name": "Catégorie parente", ... },
|
||||
{ "id": "uuid", "name": "Infrastructure", ... }
|
||||
],
|
||||
"subcategories": [ ... ],
|
||||
"apis": [ ... ]
|
||||
}
|
||||
```
|
||||
|
||||
**Réponse 404** — catégorie introuvable
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/categories/:id`
|
||||
|
||||
Retourne le détail d'une catégorie.
|
||||
|
||||
**Réponse 200** — `Category`
|
||||
|
||||
**Réponse 404** — catégorie introuvable
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/categories`
|
||||
|
||||
Crée une nouvelle catégorie.
|
||||
|
||||
**Body JSON**
|
||||
```json
|
||||
{
|
||||
"name": "Infrastructure",
|
||||
"description": "APIs d'infrastructure",
|
||||
"parentId": "uuid-or-null",
|
||||
"orderIndex": 0
|
||||
}
|
||||
```
|
||||
|
||||
| Champ | Type | Requis |
|
||||
|-------|------|--------|
|
||||
| `name` | string | Oui |
|
||||
| `description` | string | Non |
|
||||
| `parentId` | UUID | Non |
|
||||
| `orderIndex` | number | Non (défaut: 0) |
|
||||
|
||||
**Réponse 201** — `Category` créée
|
||||
|
||||
---
|
||||
|
||||
### `PATCH /api/categories/:id`
|
||||
|
||||
Met à jour une catégorie.
|
||||
|
||||
**Body JSON** (tous les champs sont optionnels)
|
||||
```json
|
||||
{
|
||||
"name": "Nouveau nom",
|
||||
"description": "...",
|
||||
"parentId": "uuid-or-null",
|
||||
"orderIndex": 1
|
||||
}
|
||||
```
|
||||
|
||||
**Réponse 200** — `Category` mise à jour
|
||||
|
||||
**Réponse 404** — catégorie introuvable
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /api/categories/:id`
|
||||
|
||||
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 404** — catégorie introuvable
|
||||
|
||||
---
|
||||
|
||||
## Codes d'erreur communs
|
||||
|
||||
| Code | Description |
|
||||
|------|-------------|
|
||||
| 400 | Requête invalide (champ manquant, format incorrect) |
|
||||
| 404 | Ressource introuvable |
|
||||
| 500 | Erreur interne serveur |
|
||||
|
||||
**Format d'erreur**
|
||||
```json
|
||||
{
|
||||
"statusCode": 404,
|
||||
"message": "API entry <uuid> not found",
|
||||
"error": "Not Found"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Statuts d'une entrée API
|
||||
|
||||
| Statut | Description |
|
||||
|--------|-------------|
|
||||
| `PENDING` | Entrée créée, génération de doc en cours |
|
||||
| `GENERATED` | Documentation HTML disponible |
|
||||
| `ERROR` | La génération a échoué (voir `errorMessage`) |
|
||||
165
documentation/architecture.md
Normal file
165
documentation/architecture.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Architecture — Datacat
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Datacat est un catalogue de données d'API organisé en monorepo pnpm :
|
||||
|
||||
```
|
||||
datacat/
|
||||
├── back/ # API NestJS + TypeORM + PostgreSQL (port 3000 interne, 3010 hôte)
|
||||
├── front-public/ # Frontend Angular 19 avec DSFR (port 4200)
|
||||
├── shared/ # Types TypeScript partagés (@datacat/shared)
|
||||
└── infra/ # Docker Compose, Dockerfiles, Kubernetes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flux principal
|
||||
|
||||
```
|
||||
Utilisateur upload YAML
|
||||
│
|
||||
▼
|
||||
POST /api/uploads (multipart, 1 à 20 fichiers)
|
||||
│
|
||||
├─ 1 fichier → lecture directe
|
||||
└─ N fichiers → détection du fichier principal + bundling (redocly bundle / asyncapi bundle)
|
||||
│
|
||||
▼
|
||||
ApisService.create()
|
||||
→ ApiEntry en base (status: PENDING)
|
||||
│
|
||||
▼ (fire-and-forget)
|
||||
DocsGenerationService.generate(id)
|
||||
│
|
||||
├─ type ASYNCAPI → asyncapi generate fromTemplate input.yaml @asyncapi/html-template
|
||||
└─ type OPENAPI → redocly build-docs input.yaml --output index.html
|
||||
│
|
||||
▼
|
||||
ApiEntry mis à jour
|
||||
→ status: GENERATED + htmlPath: /app/docs-output/<uuid>/index.html
|
||||
OU status: ERROR + errorMessage: <raison>
|
||||
│
|
||||
▼
|
||||
GET /api/apis/:id/docs → res.sendFile(htmlPath)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Services NestJS
|
||||
|
||||
### `ApisService` (`back/src/modules/apis/apis.service.ts`)
|
||||
CRUD sur la table `api_entries`. Expose deux méthodes de mise à jour :
|
||||
- `update(id, dto)` — pour le controller (champs éditables par l'utilisateur)
|
||||
- `updateInternal(id, fields)` — pour `DocsGenerationService` (status, htmlPath, errorMessage)
|
||||
|
||||
### `UploadsController` (`back/src/modules/uploads/uploads.controller.ts`)
|
||||
Gère l'upload multipart via `FilesInterceptor` (jusqu'à 20 fichiers).
|
||||
- Détecte automatiquement le type (`openapi:` ou `asyncapi:` en début de fichier)
|
||||
- Si plusieurs fichiers : détecte le fichier principal et bundle les autres via la CLI correspondante
|
||||
- Lance `DocsGenerationService.generate()` en fire-and-forget après création
|
||||
|
||||
### `DocsGenerationService` (`back/src/modules/docs-generation/docs-generation.service.ts`)
|
||||
Génère la documentation HTML à partir du YAML stocké en base :
|
||||
- **AsyncAPI** : `asyncapi generate fromTemplate <yaml> @asyncapi/html-template --output <dir> --no-interactive --install --force-write`
|
||||
- **OpenAPI** : `redocly build-docs <yaml> --output <dir>/index.html`
|
||||
- Sortie : `$DOCS_OUTPUT_DIR/<uuid>/index.html` (défaut : `/app/docs-output`)
|
||||
|
||||
### `CategoriesService` (`back/src/modules/categories/categories.service.ts`)
|
||||
CRUD sur la table `categories` + endpoint `browse` qui retourne :
|
||||
- La catégorie courante
|
||||
- Le fil d'Ariane (breadcrumb)
|
||||
- Les sous-catégories directes avec comptages
|
||||
- Les APIs directement dans la catégorie
|
||||
|
||||
### `SeederService` (`back/src/modules/seeder/seeder.service.ts`)
|
||||
Injecte des données de démonstration au démarrage (idempotent : ne re-seed que si la table est vide).
|
||||
|
||||
---
|
||||
|
||||
## Schéma de base de données
|
||||
|
||||
### Table `api_entries`
|
||||
|
||||
```sql
|
||||
CREATE TABLE api_entries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
version VARCHAR(50) NOT NULL,
|
||||
description TEXT,
|
||||
type VARCHAR(10) NOT NULL CHECK (type IN ('ASYNCAPI', 'OPENAPI')),
|
||||
yaml_content TEXT NOT NULL,
|
||||
html_path VARCHAR(500),
|
||||
status VARCHAR(10) NOT NULL DEFAULT 'PENDING'
|
||||
CHECK (status IN ('PENDING', 'GENERATED', 'ERROR')),
|
||||
error_message TEXT,
|
||||
category_id VARCHAR(36),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### Table `categories`
|
||||
|
||||
```sql
|
||||
CREATE TABLE categories (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
parent_id VARCHAR(36), -- auto-référence (pas de FK TypeORM)
|
||||
order_index INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
> 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).
|
||||
|
||||
---
|
||||
|
||||
## Dépendances externes
|
||||
|
||||
| Outil | Version | Usage |
|
||||
|-------|---------|-------|
|
||||
| `@asyncapi/cli` | `6.0.0` | Génération HTML AsyncAPI (bundles generator 3.x) |
|
||||
| `@redocly/cli` | latest | Génération HTML OpenAPI + bundling multi-fichiers |
|
||||
|
||||
Les deux CLIs sont installés dans les images Docker (`infra/Dockerfile.back.dev` pour le développement).
|
||||
|
||||
> **Important** : utiliser `@asyncapi/cli@6.0.0` — la version 4.x ne supporte qu'AsyncAPI spec ≤ 2.6.0.
|
||||
> Le template `@asyncapi/html-template` est pré-installé dans l'image pour éviter les téléchargements au premier démarrage.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Angular 19
|
||||
|
||||
Structure des pages (lazy loading via `loadComponent`) :
|
||||
|
||||
| Route | Composant | Description |
|
||||
|-------|-----------|-------------|
|
||||
| `/` | redirect → `/browse` | |
|
||||
| `/browse` | `BrowseComponent` | Navigation par catégories (racine) |
|
||||
| `/browse/:id` | `BrowseComponent` | Navigation dans une catégorie |
|
||||
| `/catalog` | `CatalogComponent` | Liste toutes les APIs avec recherche/filtre |
|
||||
| `/catalog/:id` | `ApiDetailComponent` | Détail d'une API (métadonnées + actions) |
|
||||
| `/catalog/:id/docs` | `DocViewerComponent` | Iframe plein écran vers la doc générée |
|
||||
| `/upload` | `UploadComponent` | Stepper 3 étapes pour importer un YAML |
|
||||
|
||||
**Conventions** : standalone components, signals (`signal()`, `computed()`), `inject()`, `ChangeDetectionStrategy.OnPush`.
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure Docker (développement)
|
||||
|
||||
```
|
||||
docker compose -f infra/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
| Service | Image | Port interne | Port hôte |
|
||||
|---------|-------|-------------|-----------|
|
||||
| `postgres` | `postgres:17` | 5432 | 5432 |
|
||||
| `backend` | `Dockerfile.back.dev` | 3000 | 3010 |
|
||||
| `frontend` | `Dockerfile.front.dev` | 4200 | 4200 |
|
||||
|
||||
> Le backend est exposé sur **3010** (pas 3000) car Gitea tourne sur 3000 sur la machine hôte.
|
||||
Reference in New Issue
Block a user