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:
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Generated docs output
|
||||||
|
docs-output/
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Angular cache
|
||||||
|
.angular/
|
||||||
56
.woodpecker.yaml
Normal file
56
.woodpecker.yaml
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Pipeline CI/CD pour datacat
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Build backend
|
||||||
|
build-back:
|
||||||
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
|
commands:
|
||||||
|
- echo '{"auths":{"git.chmod777.dev":{"username":"'"$REGISTRY_USER"'","password":"'"$REGISTRY_PASSWORD"'"}}}' > /kaniko/.docker/config.json
|
||||||
|
- /kaniko/executor
|
||||||
|
--dockerfile=infra/Dockerfile.prod
|
||||||
|
--target=back
|
||||||
|
--context=dir:///woodpecker/src/git.chmod777.dev/z3n/datacat
|
||||||
|
--destination=git.chmod777.dev/z3n/datacat/back:${CI_COMMIT_SHA:0:8}
|
||||||
|
--destination=git.chmod777.dev/z3n/datacat/back:latest
|
||||||
|
--cache=true
|
||||||
|
environment:
|
||||||
|
REGISTRY_USER:
|
||||||
|
from_secret: registry_user
|
||||||
|
REGISTRY_PASSWORD:
|
||||||
|
from_secret: registry_password
|
||||||
|
|
||||||
|
# Build frontend
|
||||||
|
build-front:
|
||||||
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
|
commands:
|
||||||
|
- echo '{"auths":{"git.chmod777.dev":{"username":"'"$REGISTRY_USER"'","password":"'"$REGISTRY_PASSWORD"'"}}}' > /kaniko/.docker/config.json
|
||||||
|
- /kaniko/executor
|
||||||
|
--dockerfile=infra/Dockerfile.prod
|
||||||
|
--target=front
|
||||||
|
--context=dir:///woodpecker/src/git.chmod777.dev/z3n/datacat
|
||||||
|
--build-arg ANGULAR_API_URL=https://api.datacat.dev.chmod777.dev
|
||||||
|
--destination=git.chmod777.dev/z3n/datacat/front:${CI_COMMIT_SHA:0:8}
|
||||||
|
--destination=git.chmod777.dev/z3n/datacat/front:latest
|
||||||
|
--cache=true
|
||||||
|
environment:
|
||||||
|
REGISTRY_USER:
|
||||||
|
from_secret: registry_user
|
||||||
|
REGISTRY_PASSWORD:
|
||||||
|
from_secret: registry_password
|
||||||
|
|
||||||
|
# Deploy sur K8s
|
||||||
|
deploy:
|
||||||
|
image: bitnami/kubectl:latest
|
||||||
|
environment:
|
||||||
|
KUBECONFIG: /tmp/kubeconfig.yaml
|
||||||
|
DEPLOY_KUBECONFIG:
|
||||||
|
from_secret: deploy_kubeconfig
|
||||||
|
commands:
|
||||||
|
- echo "$DEPLOY_KUBECONFIG" > /tmp/kubeconfig.yaml
|
||||||
|
- kubectl set image deployment/backend backend=git.chmod777.dev/z3n/datacat/back:${CI_COMMIT_SHA:0:8} -n datacat
|
||||||
|
- kubectl set image deployment/frontend frontend=git.chmod777.dev/z3n/datacat/front:${CI_COMMIT_SHA:0:8} -n datacat
|
||||||
|
- kubectl rollout status deployment/backend -n datacat --timeout=120s
|
||||||
|
- kubectl rollout status deployment/frontend -n datacat --timeout=120s
|
||||||
|
depends_on:
|
||||||
|
- build-back
|
||||||
|
- build-front
|
||||||
86
README.md
Normal file
86
README.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Datacat
|
||||||
|
|
||||||
|
Catalogue de données d'API. Importez des fichiers YAML (AsyncAPI ou OpenAPI) et générez automatiquement de la documentation HTML interactive.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Monorepo pnpm avec quatre sous-projets :
|
||||||
|
|
||||||
|
```
|
||||||
|
datacat/
|
||||||
|
├── back/ # API REST — NestJS + TypeORM + PostgreSQL
|
||||||
|
├── front-public/ # Interface web — Angular 19 + DSFR
|
||||||
|
├── shared/ # Types TypeScript partagés (@datacat/shared)
|
||||||
|
└── infra/ # Docker Compose, Dockerfiles, Kubernetes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prérequis
|
||||||
|
|
||||||
|
- [Node.js 22 LTS](https://nodejs.org/)
|
||||||
|
- [pnpm 10](https://pnpm.io/)
|
||||||
|
- [Docker](https://www.docker.com/) (recommandé)
|
||||||
|
|
||||||
|
## Démarrage rapide
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Démarrer tous les services (PostgreSQL + backend + frontend)
|
||||||
|
docker compose -f infra/docker-compose.yml up -d
|
||||||
|
|
||||||
|
# Suivre les logs
|
||||||
|
docker compose -f infra/docker-compose.yml logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## URLs de développement
|
||||||
|
|
||||||
|
| Service | URL |
|
||||||
|
|---------|-----|
|
||||||
|
| Frontend | http://localhost:4200 |
|
||||||
|
| API | http://localhost:3010/api |
|
||||||
|
| Health check | http://localhost:3010/health |
|
||||||
|
|
||||||
|
## Utilisation
|
||||||
|
|
||||||
|
1. Ouvrir http://localhost:4200
|
||||||
|
2. Naviguer par catégories via **Parcourir** ou voir toutes les APIs via **Catalogue**
|
||||||
|
3. Cliquer sur **Importer** pour ajouter un fichier YAML AsyncAPI ou OpenAPI
|
||||||
|
4. Une fois la génération terminée (statut **GENERATED**), cliquer sur **Voir la documentation**
|
||||||
|
|
||||||
|
## Stack technique
|
||||||
|
|
||||||
|
| Couche | Technologie |
|
||||||
|
|--------|-------------|
|
||||||
|
| Backend | NestJS 11, TypeORM 0.3, PostgreSQL 17 |
|
||||||
|
| Frontend | Angular 19, DSFR |
|
||||||
|
| Types partagés | TypeScript 5.8 strict |
|
||||||
|
| Génération docs AsyncAPI | `@asyncapi/cli@6.0.0` |
|
||||||
|
| Génération docs OpenAPI | `@redocly/cli` |
|
||||||
|
|
||||||
|
## Développement sans Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend (port 3000)
|
||||||
|
cd back && pnpm install && pnpm run start:dev
|
||||||
|
|
||||||
|
# Frontend (port 4200, avec proxy vers l'API)
|
||||||
|
cd front-public && pnpm install && pnpm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
> Créer `front-public/proxy.conf.json` si absent :
|
||||||
|
> ```json
|
||||||
|
> { "/api": { "target": "http://localhost:3010", "secure": false } }
|
||||||
|
> ```
|
||||||
|
|
||||||
|
## Rebuild complet
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f infra/docker-compose.yml down
|
||||||
|
docker volume rm datacat_front_node_modules datacat_back_node_modules
|
||||||
|
docker compose -f infra/docker-compose.yml build --no-cache
|
||||||
|
docker compose -f infra/docker-compose.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [`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/CLAUDE.md`](documentation/CLAUDE.md) — guide d'implémentation interne
|
||||||
13
back/.env.example
Normal file
13
back/.env.example
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Database
|
||||||
|
DATABASE_HOST=localhost
|
||||||
|
DATABASE_PORT=5432
|
||||||
|
DATABASE_USER=datacat
|
||||||
|
DATABASE_PASSWORD=datacat
|
||||||
|
DATABASE_NAME=datacat
|
||||||
|
|
||||||
|
# App
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Docs generation
|
||||||
|
DOCS_OUTPUT_DIR=/app/docs-output
|
||||||
9
back/nest-cli.json
Normal file
9
back/nest-cli.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
},
|
||||||
|
"entryFile": "back/src/main"
|
||||||
|
}
|
||||||
42
back/package.json
Normal file
42
back/package.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "@datacat/back",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"start": "node dist/main",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^11.0.0",
|
||||||
|
"@nestjs/core": "^11.0.0",
|
||||||
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
|
"@nestjs/config": "^4.0.0",
|
||||||
|
"@nestjs/serve-static": "^5.0.0",
|
||||||
|
"typeorm": "^0.3.20",
|
||||||
|
"pg": "^8.13.0",
|
||||||
|
"reflect-metadata": "^0.2.0",
|
||||||
|
"rxjs": "^7.8.0",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"uuid": "^11.0.0",
|
||||||
|
"pino": "^9.0.0",
|
||||||
|
"pino-http": "^10.0.0",
|
||||||
|
"class-validator": "^0.14.0",
|
||||||
|
"class-transformer": "^0.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
"@nestjs/testing": "^11.0.0",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
36
back/src/app.module.ts
Normal file
36
back/src/app.module.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { HealthModule } from './health/health.module';
|
||||||
|
import { ApisModule } from './modules/apis/apis.module';
|
||||||
|
import { UploadsModule } from './modules/uploads/uploads.module';
|
||||||
|
import { DocsGenerationModule } from './modules/docs-generation/docs-generation.module';
|
||||||
|
import { CategoriesModule } from './modules/categories/categories.module';
|
||||||
|
import { SeederModule } from './modules/seeder/seeder.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
|
TypeOrmModule.forRootAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
type: 'postgres',
|
||||||
|
host: config.get('DATABASE_HOST', 'localhost'),
|
||||||
|
port: config.get<number>('DATABASE_PORT', 5432),
|
||||||
|
username: config.get('DATABASE_USER', 'datacat'),
|
||||||
|
password: config.get('DATABASE_PASSWORD', 'datacat'),
|
||||||
|
database: config.get('DATABASE_NAME', 'datacat'),
|
||||||
|
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||||
|
synchronize: config.get('NODE_ENV') !== 'production',
|
||||||
|
logging: config.get('NODE_ENV') === 'development',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
HealthModule,
|
||||||
|
ApisModule,
|
||||||
|
UploadsModule,
|
||||||
|
DocsGenerationModule,
|
||||||
|
CategoriesModule,
|
||||||
|
SeederModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
0
back/src/common/.gitkeep
Normal file
0
back/src/common/.gitkeep
Normal file
28
back/src/common/filters/all-exceptions.filter.ts
Normal file
28
back/src/common/filters/all-exceptions.filter.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
|
||||||
|
@Catch()
|
||||||
|
export class AllExceptionsFilter implements ExceptionFilter {
|
||||||
|
catch(exception: unknown, host: ArgumentsHost) {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
const response = ctx.getResponse<Response>();
|
||||||
|
const request = ctx.getRequest<Request>();
|
||||||
|
|
||||||
|
const status =
|
||||||
|
exception instanceof HttpException
|
||||||
|
? exception.getStatus()
|
||||||
|
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
|
||||||
|
const message =
|
||||||
|
exception instanceof HttpException
|
||||||
|
? exception.getResponse()
|
||||||
|
: 'Internal server error';
|
||||||
|
|
||||||
|
response.status(status).json({
|
||||||
|
statusCode: status,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
path: request.url,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
0
back/src/database/.gitkeep
Normal file
0
back/src/database/.gitkeep
Normal file
9
back/src/health/health.controller.ts
Normal file
9
back/src/health/health.controller.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
@Get()
|
||||||
|
check() {
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
7
back/src/health/health.module.ts
Normal file
7
back/src/health/health.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
||||||
22
back/src/main.ts
Normal file
22
back/src/main.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ValidationPipe, RequestMethod } from '@nestjs/common';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
app.setGlobalPrefix('api', {
|
||||||
|
exclude: [{ path: 'health', method: RequestMethod.GET }],
|
||||||
|
});
|
||||||
|
|
||||||
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||||
|
app.useGlobalFilters(new AllExceptionsFilter());
|
||||||
|
app.enableCors();
|
||||||
|
|
||||||
|
const port = process.env.PORT ?? 3000;
|
||||||
|
await app.listen(port);
|
||||||
|
console.log(`Datacat API running on port ${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
0
back/src/modules/apis/.gitkeep
Normal file
0
back/src/modules/apis/.gitkeep
Normal file
41
back/src/modules/apis/api-entry.entity.ts
Normal file
41
back/src/modules/apis/api-entry.entity.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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, type: 'varchar', length: 500, name: 'html_path' })
|
||||||
|
htmlPath!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 10, default: 'PENDING' })
|
||||||
|
status!: ApiStatus;
|
||||||
|
|
||||||
|
@Column({ nullable: true, type: 'text', name: 'error_message' })
|
||||||
|
errorMessage!: string | null;
|
||||||
|
|
||||||
|
@Column({ nullable: true, type: 'varchar', length: 36, name: 'category_id' })
|
||||||
|
categoryId!: string | null;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
82
back/src/modules/apis/apis.controller.ts
Normal file
82
back/src/modules/apis/apis.controller.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Post,
|
||||||
|
Param,
|
||||||
|
Body,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
HttpCode,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { ApisService } from './apis.service';
|
||||||
|
import { UpdateApiEntryDto } from './dto/update-api-entry.dto';
|
||||||
|
import { ApiListQuery } from '@datacat/shared';
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sert les assets statiques (CSS, JS) générés par AsyncAPI CLI */
|
||||||
|
@Get(':id/*path')
|
||||||
|
async getDocAsset(@Param('id') id: string, @Req() req: Request, @Res() res: Response) {
|
||||||
|
const entry = await this.apisService.findOneOrThrow(id);
|
||||||
|
if (!entry.htmlPath) throw new NotFoundException('Documentation not generated yet');
|
||||||
|
const entryDir = path.dirname(entry.htmlPath);
|
||||||
|
/* Extraire le path depuis l'URL brute pour éviter le bug path-to-regexp v8 (virgules au lieu de /) */
|
||||||
|
const prefix = `/api/apis/${id}/`;
|
||||||
|
const assetPath = req.url.startsWith(prefix) ? req.url.slice(prefix.length) : '';
|
||||||
|
/* Prévenir la traversée de répertoire */
|
||||||
|
const resolved = path.resolve(entryDir, assetPath);
|
||||||
|
if (!resolved.startsWith(path.resolve(entryDir) + path.sep)) {
|
||||||
|
throw new NotFoundException('Asset not found');
|
||||||
|
}
|
||||||
|
res.sendFile(resolved);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
back/src/modules/apis/apis.module.ts
Normal file
17
back/src/modules/apis/apis.module.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ApiEntryEntity } from './api-entry.entity';
|
||||||
|
import { ApisService } from './apis.service';
|
||||||
|
import { ApisController } from './apis.controller';
|
||||||
|
import { DocsGenerationModule } from '../docs-generation/docs-generation.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([ApiEntryEntity]),
|
||||||
|
forwardRef(() => DocsGenerationModule),
|
||||||
|
],
|
||||||
|
providers: [ApisService],
|
||||||
|
controllers: [ApisController],
|
||||||
|
exports: [ApisService],
|
||||||
|
})
|
||||||
|
export class ApisModule {}
|
||||||
135
back/src/modules/apis/apis.service.ts
Normal file
135
back/src/modules/apis/apis.service.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { Injectable, NotFoundException, Inject, forwardRef } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ApiEntryEntity } from './api-entry.entity';
|
||||||
|
import { CreateApiEntryDto } from './dto/create-api-entry.dto';
|
||||||
|
import { UpdateApiEntryDto } from './dto/update-api-entry.dto';
|
||||||
|
import { ApiListResponse, ApiListQuery, ApiEntry, ApiEntryListItem, ApiType, ApiStatus } from '@datacat/shared';
|
||||||
|
import { DocsGenerationService } from '../docs-generation/docs-generation.service';
|
||||||
|
|
||||||
|
/** Champs internes mis à jour par DocsGenerationService */
|
||||||
|
interface InternalUpdateFields {
|
||||||
|
title?: string;
|
||||||
|
version?: string;
|
||||||
|
description?: string;
|
||||||
|
htmlPath?: string | null;
|
||||||
|
status?: ApiStatus;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApisService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ApiEntryEntity)
|
||||||
|
private readonly repo: Repository<ApiEntryEntity>,
|
||||||
|
@Inject(forwardRef(() => DocsGenerationService))
|
||||||
|
private readonly docsService: DocsGenerationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(dto: CreateApiEntryDto): Promise<ApiEntry> {
|
||||||
|
const entity = this.repo.create({
|
||||||
|
title: dto.title,
|
||||||
|
version: dto.version,
|
||||||
|
description: dto.description ?? null,
|
||||||
|
type: dto.type,
|
||||||
|
yamlContent: dto.yamlContent,
|
||||||
|
status: 'PENDING',
|
||||||
|
categoryId: dto.categoryId ?? null,
|
||||||
|
});
|
||||||
|
const saved = await this.repo.save(entity);
|
||||||
|
return toApiEntry(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(query: ApiListQuery): Promise<ApiListResponse> {
|
||||||
|
const page = Math.max(1, Number(query.page ?? 1));
|
||||||
|
const limit = Math.min(100, Math.max(1, Number(query.limit ?? 20)));
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const qb = this.repo.createQueryBuilder('api');
|
||||||
|
|
||||||
|
if (query.type) {
|
||||||
|
qb.andWhere('api.type = :type', { type: query.type as ApiType });
|
||||||
|
}
|
||||||
|
if (query.search) {
|
||||||
|
qb.andWhere(
|
||||||
|
'(LOWER(api.title) LIKE :search OR LOWER(api.description) LIKE :search)',
|
||||||
|
{ search: `%${query.search.toLowerCase()}%` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (query.categoryId) {
|
||||||
|
qb.andWhere('api.category_id = :categoryId', { categoryId: query.categoryId });
|
||||||
|
}
|
||||||
|
|
||||||
|
qb.orderBy('api.created_at', 'DESC').skip(offset).take(limit);
|
||||||
|
|
||||||
|
const [entities, total] = await qb.getManyAndCount();
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: entities.map(toApiEntryListItem),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOneOrThrow(id: string): Promise<ApiEntry> {
|
||||||
|
const entity = await this.repo.findOne({ where: { id } });
|
||||||
|
if (!entity) throw new NotFoundException(`API entry ${id} not found`);
|
||||||
|
return toApiEntry(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mise à jour publique (champs éditables par l'utilisateur) */
|
||||||
|
async update(id: string, dto: UpdateApiEntryDto): Promise<ApiEntry> {
|
||||||
|
await this.repo.update(id, dto);
|
||||||
|
return this.findOneOrThrow(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mise à jour interne (statut, htmlPath, errorMessage) */
|
||||||
|
async updateInternal(id: string, fields: InternalUpdateFields): Promise<void> {
|
||||||
|
await this.repo.update(id, fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
const entity = await this.repo.findOne({ where: { id } });
|
||||||
|
if (!entity) throw new NotFoundException(`API entry ${id} not found`);
|
||||||
|
await this.repo.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async regenerate(id: string): Promise<ApiEntry> {
|
||||||
|
await this.findOneOrThrow(id);
|
||||||
|
await this.updateInternal(id, { status: 'PENDING', errorMessage: null });
|
||||||
|
this.docsService.generate(id).catch(console.error);
|
||||||
|
return this.findOneOrThrow(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toApiEntry(entity: ApiEntryEntity): ApiEntry {
|
||||||
|
return {
|
||||||
|
id: entity.id,
|
||||||
|
title: entity.title,
|
||||||
|
version: entity.version,
|
||||||
|
description: entity.description,
|
||||||
|
type: entity.type,
|
||||||
|
yamlContent: entity.yamlContent,
|
||||||
|
htmlPath: entity.htmlPath,
|
||||||
|
status: entity.status,
|
||||||
|
errorMessage: entity.errorMessage,
|
||||||
|
categoryId: entity.categoryId,
|
||||||
|
createdAt: entity.createdAt.toISOString(),
|
||||||
|
updatedAt: entity.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem {
|
||||||
|
return {
|
||||||
|
id: entity.id,
|
||||||
|
title: entity.title,
|
||||||
|
version: entity.version,
|
||||||
|
description: entity.description,
|
||||||
|
type: entity.type,
|
||||||
|
status: entity.status,
|
||||||
|
categoryId: entity.categoryId,
|
||||||
|
createdAt: entity.createdAt.toISOString(),
|
||||||
|
updatedAt: entity.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
27
back/src/modules/apis/dto/create-api-entry.dto.ts
Normal file
27
back/src/modules/apis/dto/create-api-entry.dto.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { IsString, IsNotEmpty, IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||||
|
import { ApiType } from '@datacat/shared';
|
||||||
|
|
||||||
|
export class CreateApiEntryDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
version!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsIn(['ASYNCAPI', 'OPENAPI'])
|
||||||
|
type!: ApiType;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
yamlContent!: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsOptional()
|
||||||
|
categoryId?: string;
|
||||||
|
}
|
||||||
19
back/src/modules/apis/dto/update-api-entry.dto.ts
Normal file
19
back/src/modules/apis/dto/update-api-entry.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsString, IsOptional, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateApiEntryDto {
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
version?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsOptional()
|
||||||
|
categoryId?: string | null;
|
||||||
|
}
|
||||||
55
back/src/modules/categories/categories.controller.ts
Normal file
55
back/src/modules/categories/categories.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Param,
|
||||||
|
Body,
|
||||||
|
HttpCode,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { CategoriesService } from './categories.service';
|
||||||
|
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||||
|
import { UpdateCategoryDto } from './dto/update-category.dto';
|
||||||
|
|
||||||
|
@Controller('categories')
|
||||||
|
export class CategoriesController {
|
||||||
|
constructor(private readonly categoriesService: CategoriesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findAll() {
|
||||||
|
return this.categoriesService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Déclarés AVANT :id pour éviter que NestJS interprète "browse" comme un UUID */
|
||||||
|
@Get('browse')
|
||||||
|
browseRoot() {
|
||||||
|
return this.categoriesService.browse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('browse/:id')
|
||||||
|
browseCategory(@Param('id') id: string) {
|
||||||
|
return this.categoriesService.browse(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
findOne(@Param('id') id: string) {
|
||||||
|
return this.categoriesService.findOneOrThrow(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() dto: CreateCategoryDto) {
|
||||||
|
return this.categoriesService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
update(@Param('id') id: string, @Body() dto: UpdateCategoryDto) {
|
||||||
|
return this.categoriesService.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(204)
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.categoriesService.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
back/src/modules/categories/categories.module.ts
Normal file
14
back/src/modules/categories/categories.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { CategoryEntity } from './category.entity';
|
||||||
|
import { ApiEntryEntity } from '../apis/api-entry.entity';
|
||||||
|
import { CategoriesService } from './categories.service';
|
||||||
|
import { CategoriesController } from './categories.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([CategoryEntity, ApiEntryEntity])],
|
||||||
|
providers: [CategoriesService],
|
||||||
|
controllers: [CategoriesController],
|
||||||
|
exports: [CategoriesService],
|
||||||
|
})
|
||||||
|
export class CategoriesModule {}
|
||||||
155
back/src/modules/categories/categories.service.ts
Normal file
155
back/src/modules/categories/categories.service.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, IsNull } from 'typeorm';
|
||||||
|
import { CategoryEntity } from './category.entity';
|
||||||
|
import { ApiEntryEntity } from '../apis/api-entry.entity';
|
||||||
|
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||||
|
import { UpdateCategoryDto } from './dto/update-category.dto';
|
||||||
|
import {
|
||||||
|
Category,
|
||||||
|
CategoryWithCounts,
|
||||||
|
CategoryBrowseResponse,
|
||||||
|
CategoryListResponse,
|
||||||
|
ApiEntryListItem,
|
||||||
|
ApiType,
|
||||||
|
ApiStatus,
|
||||||
|
} from '@datacat/shared';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CategoriesService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(CategoryEntity)
|
||||||
|
private readonly categoryRepo: Repository<CategoryEntity>,
|
||||||
|
@InjectRepository(ApiEntryEntity)
|
||||||
|
private readonly apiRepo: Repository<ApiEntryEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findAll(): Promise<CategoryListResponse> {
|
||||||
|
const entities = await this.categoryRepo.find({
|
||||||
|
order: { orderIndex: 'ASC', name: 'ASC' },
|
||||||
|
});
|
||||||
|
return { items: entities.map(toCategory) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOneOrThrow(id: string): Promise<Category> {
|
||||||
|
const entity = await this.categoryRepo.findOne({ where: { id } });
|
||||||
|
if (!entity) throw new NotFoundException(`Category ${id} not found`);
|
||||||
|
return toCategory(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateCategoryDto): Promise<Category> {
|
||||||
|
const entity = this.categoryRepo.create({
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description ?? null,
|
||||||
|
parentId: dto.parentId ?? null,
|
||||||
|
orderIndex: dto.orderIndex ?? 0,
|
||||||
|
});
|
||||||
|
const saved = await this.categoryRepo.save(entity);
|
||||||
|
return toCategory(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateCategoryDto): Promise<Category> {
|
||||||
|
await this.findOneOrThrow(id);
|
||||||
|
const fields: Partial<CategoryEntity> = {};
|
||||||
|
if (dto.name !== undefined) fields.name = dto.name;
|
||||||
|
if (dto.description !== undefined) fields.description = dto.description;
|
||||||
|
if (dto.parentId !== undefined) fields.parentId = dto.parentId ?? null;
|
||||||
|
if (dto.orderIndex !== undefined) fields.orderIndex = dto.orderIndex;
|
||||||
|
await this.categoryRepo.update(id, fields);
|
||||||
|
return this.findOneOrThrow(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
await this.findOneOrThrow(id);
|
||||||
|
/* Désassocier les APIs enfants */
|
||||||
|
await this.apiRepo
|
||||||
|
.createQueryBuilder()
|
||||||
|
.update(ApiEntryEntity)
|
||||||
|
.set({ categoryId: null })
|
||||||
|
.where('category_id = :id', { id })
|
||||||
|
.execute();
|
||||||
|
/* Détacher les sous-catégories enfants */
|
||||||
|
await this.categoryRepo
|
||||||
|
.createQueryBuilder()
|
||||||
|
.update(CategoryEntity)
|
||||||
|
.set({ parentId: null })
|
||||||
|
.where('parent_id = :id', { id })
|
||||||
|
.execute();
|
||||||
|
await this.categoryRepo.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async browse(categoryId: string | null): Promise<CategoryBrowseResponse> {
|
||||||
|
/* 1. Charge la catégorie courante */
|
||||||
|
let currentCategory: CategoryEntity | null = null;
|
||||||
|
if (categoryId) {
|
||||||
|
currentCategory = await this.categoryRepo.findOne({ where: { id: categoryId } });
|
||||||
|
if (!currentCategory) throw new NotFoundException(`Category ${categoryId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. Construit le fil d'Ariane (racine → courant) */
|
||||||
|
const breadcrumb: Category[] = [];
|
||||||
|
if (currentCategory) {
|
||||||
|
breadcrumb.push(toCategory(currentCategory));
|
||||||
|
let node = currentCategory;
|
||||||
|
while (node.parentId) {
|
||||||
|
const parent = await this.categoryRepo.findOne({ where: { id: node.parentId } });
|
||||||
|
if (!parent) break;
|
||||||
|
breadcrumb.unshift(toCategory(parent));
|
||||||
|
node = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3. Sous-catégories directes avec comptages */
|
||||||
|
const directChildren = await this.categoryRepo.find({
|
||||||
|
where: { parentId: categoryId ?? IsNull() },
|
||||||
|
order: { orderIndex: 'ASC', name: 'ASC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const subcategories: CategoryWithCounts[] = await Promise.all(
|
||||||
|
directChildren.map(async (child) => {
|
||||||
|
const subcategoryCount = await this.categoryRepo.count({ where: { parentId: child.id } });
|
||||||
|
const apiCount = await this.apiRepo.count({ where: { categoryId: child.id } });
|
||||||
|
return { ...toCategory(child), subcategoryCount, apiCount };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/* 4. APIs directement dans cette catégorie */
|
||||||
|
const apiEntities = await this.apiRepo.find({
|
||||||
|
where: { categoryId: categoryId ?? IsNull() },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
category: currentCategory ? toCategory(currentCategory) : null,
|
||||||
|
breadcrumb,
|
||||||
|
subcategories,
|
||||||
|
apis: apiEntities.map(toApiEntryListItem),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCategory(entity: CategoryEntity): Category {
|
||||||
|
return {
|
||||||
|
id: entity.id,
|
||||||
|
name: entity.name,
|
||||||
|
description: entity.description,
|
||||||
|
parentId: entity.parentId,
|
||||||
|
orderIndex: entity.orderIndex,
|
||||||
|
createdAt: entity.createdAt.toISOString(),
|
||||||
|
updatedAt: entity.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem {
|
||||||
|
return {
|
||||||
|
id: entity.id,
|
||||||
|
title: entity.title,
|
||||||
|
version: entity.version,
|
||||||
|
description: entity.description,
|
||||||
|
type: entity.type as ApiType,
|
||||||
|
status: entity.status as ApiStatus,
|
||||||
|
categoryId: entity.categoryId,
|
||||||
|
createdAt: entity.createdAt.toISOString(),
|
||||||
|
updatedAt: entity.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
25
back/src/modules/categories/category.entity.ts
Normal file
25
back/src/modules/categories/category.entity.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('categories')
|
||||||
|
export class CategoryEntity {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true, type: 'text' })
|
||||||
|
description!: string | null;
|
||||||
|
|
||||||
|
@Column({ nullable: true, type: 'varchar', length: 36, name: 'parent_id' })
|
||||||
|
parentId!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 0, name: 'order_index' })
|
||||||
|
orderIndex!: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
21
back/src/modules/categories/dto/create-category.dto.ts
Normal file
21
back/src/modules/categories/dto/create-category.dto.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { IsString, IsNotEmpty, IsOptional, IsUUID, IsInt } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class CreateCategoryDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsOptional()
|
||||||
|
parentId?: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
orderIndex?: number;
|
||||||
|
}
|
||||||
21
back/src/modules/categories/dto/update-category.dto.ts
Normal file
21
back/src/modules/categories/dto/update-category.dto.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { IsString, IsOptional, IsUUID, IsInt } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class UpdateCategoryDto {
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsOptional()
|
||||||
|
parentId?: string | null;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
orderIndex?: number;
|
||||||
|
}
|
||||||
0
back/src/modules/docs-generation/.gitkeep
Normal file
0
back/src/modules/docs-generation/.gitkeep
Normal file
10
back/src/modules/docs-generation/docs-generation.module.ts
Normal file
10
back/src/modules/docs-generation/docs-generation.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { DocsGenerationService } from './docs-generation.service';
|
||||||
|
import { ApisModule } from '../apis/apis.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [forwardRef(() => ApisModule)],
|
||||||
|
providers: [DocsGenerationService],
|
||||||
|
exports: [DocsGenerationService],
|
||||||
|
})
|
||||||
|
export class DocsGenerationModule {}
|
||||||
64
back/src/modules/docs-generation/docs-generation.service.ts
Normal file
64
back/src/modules/docs-generation/docs-generation.service.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Injectable, Inject, forwardRef } from '@nestjs/common';
|
||||||
|
import { execFile } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { ApisService } from '../apis/apis.service';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DocsGenerationService {
|
||||||
|
private readonly outputDir = process.env.DOCS_OUTPUT_DIR ?? '/app/docs-output';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(forwardRef(() => ApisService))
|
||||||
|
private readonly apisService: ApisService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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.updateInternal(entryId, { htmlPath, status: 'GENERATED' });
|
||||||
|
} catch (error) {
|
||||||
|
await this.apisService.updateInternal(entryId, {
|
||||||
|
status: 'ERROR',
|
||||||
|
errorMessage: String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateAsyncApi(yamlPath: string, outputDir: string): Promise<void> {
|
||||||
|
await execFileAsync('asyncapi', [
|
||||||
|
'generate', 'fromTemplate',
|
||||||
|
yamlPath,
|
||||||
|
'@asyncapi/html-template',
|
||||||
|
'--output', outputDir,
|
||||||
|
'--no-interactive',
|
||||||
|
'--install',
|
||||||
|
'--force-write',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateOpenApi(yamlPath: string, outputDir: string): Promise<void> {
|
||||||
|
await execFileAsync('redocly', [
|
||||||
|
'build-docs',
|
||||||
|
yamlPath,
|
||||||
|
'--output', path.join(outputDir, 'index.html'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
back/src/modules/seeder/seeder.module.ts
Normal file
17
back/src/modules/seeder/seeder.module.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ApiEntryEntity } from '../apis/api-entry.entity';
|
||||||
|
import { CategoryEntity } from '../categories/category.entity';
|
||||||
|
import { SeederService } from './seeder.service';
|
||||||
|
import { ApisModule } from '../apis/apis.module';
|
||||||
|
import { DocsGenerationModule } from '../docs-generation/docs-generation.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([ApiEntryEntity, CategoryEntity]),
|
||||||
|
ApisModule,
|
||||||
|
DocsGenerationModule,
|
||||||
|
],
|
||||||
|
providers: [SeederService],
|
||||||
|
})
|
||||||
|
export class SeederModule {}
|
||||||
730
back/src/modules/seeder/seeder.service.ts
Normal file
730
back/src/modules/seeder/seeder.service.ts
Normal file
@@ -0,0 +1,730 @@
|
|||||||
|
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ApiEntryEntity } from '../apis/api-entry.entity';
|
||||||
|
import { CategoryEntity } from '../categories/category.entity';
|
||||||
|
import { DocsGenerationService } from '../docs-generation/docs-generation.service';
|
||||||
|
|
||||||
|
/* ─── YAML demo data ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const PETSTORE_YAML = `openapi: "3.0.0"
|
||||||
|
info:
|
||||||
|
title: Swagger Petstore
|
||||||
|
description: A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.
|
||||||
|
version: "3.0.4"
|
||||||
|
license:
|
||||||
|
name: Apache 2.0
|
||||||
|
servers:
|
||||||
|
- url: https://petstore.swagger.io/v3
|
||||||
|
paths:
|
||||||
|
/pets:
|
||||||
|
get:
|
||||||
|
summary: List all pets
|
||||||
|
operationId: listPets
|
||||||
|
tags: [pets]
|
||||||
|
parameters:
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
description: How many items to return at one time (max 100)
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int32
|
||||||
|
maximum: 100
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: A list of pets
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Pets"
|
||||||
|
default:
|
||||||
|
description: unexpected error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
post:
|
||||||
|
summary: Create a pet
|
||||||
|
operationId: createPets
|
||||||
|
tags: [pets]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/NewPet"
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Null response
|
||||||
|
default:
|
||||||
|
description: unexpected error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
/pets/{petId}:
|
||||||
|
get:
|
||||||
|
summary: Info for a specific pet
|
||||||
|
operationId: showPetById
|
||||||
|
tags: [pets]
|
||||||
|
parameters:
|
||||||
|
- name: petId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: The id of the pet to retrieve
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Expected response to a valid request
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Pet"
|
||||||
|
default:
|
||||||
|
description: unexpected error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
Pet:
|
||||||
|
type: object
|
||||||
|
required: [id, name]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
tag:
|
||||||
|
type: string
|
||||||
|
NewPet:
|
||||||
|
type: object
|
||||||
|
required: [name]
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
tag:
|
||||||
|
type: string
|
||||||
|
Pets:
|
||||||
|
type: array
|
||||||
|
maxItems: 100
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/Pet"
|
||||||
|
Error:
|
||||||
|
type: object
|
||||||
|
required: [code, message]
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: integer
|
||||||
|
format: int32
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
`;
|
||||||
|
|
||||||
|
const STREETLIGHTS_YAML = `asyncapi: "3.0.0"
|
||||||
|
info:
|
||||||
|
title: Streetlights Kafka API
|
||||||
|
version: "1.0.0"
|
||||||
|
description: The Smartylighting Streetlights API allows you to remotely manage the city lights.
|
||||||
|
license:
|
||||||
|
name: Apache 2.0
|
||||||
|
servers:
|
||||||
|
scram-connections:
|
||||||
|
host: test.mykafkacluster.org:18092
|
||||||
|
protocol: kafka-secure
|
||||||
|
description: Test broker (SCRAM-SHA-512 security)
|
||||||
|
channels:
|
||||||
|
lightingMeasured:
|
||||||
|
address: "smartylighting.streetlights.1.0.event.{streetlightId}.lighting.measured"
|
||||||
|
messages:
|
||||||
|
lightMeasured:
|
||||||
|
$ref: "#/components/messages/LightMeasured"
|
||||||
|
parameters:
|
||||||
|
streetlightId:
|
||||||
|
$ref: "#/components/parameters/streetlightId"
|
||||||
|
lightTurnOn:
|
||||||
|
address: "smartylighting.streetlights.1.0.action.{streetlightId}.turn.on"
|
||||||
|
messages:
|
||||||
|
turnOn:
|
||||||
|
$ref: "#/components/messages/TurnOn"
|
||||||
|
parameters:
|
||||||
|
streetlightId:
|
||||||
|
$ref: "#/components/parameters/streetlightId"
|
||||||
|
lightTurnOff:
|
||||||
|
address: "smartylighting.streetlights.1.0.action.{streetlightId}.turn.off"
|
||||||
|
messages:
|
||||||
|
turnOff:
|
||||||
|
$ref: "#/components/messages/TurnOff"
|
||||||
|
parameters:
|
||||||
|
streetlightId:
|
||||||
|
$ref: "#/components/parameters/streetlightId"
|
||||||
|
operations:
|
||||||
|
receiveLightMeasurement:
|
||||||
|
action: receive
|
||||||
|
channel:
|
||||||
|
$ref: "#/channels/lightingMeasured"
|
||||||
|
messages:
|
||||||
|
- $ref: "#/channels/lightingMeasured/messages/lightMeasured"
|
||||||
|
turnOn:
|
||||||
|
action: send
|
||||||
|
channel:
|
||||||
|
$ref: "#/channels/lightTurnOn"
|
||||||
|
messages:
|
||||||
|
- $ref: "#/channels/lightTurnOn/messages/turnOn"
|
||||||
|
turnOff:
|
||||||
|
action: send
|
||||||
|
channel:
|
||||||
|
$ref: "#/channels/lightTurnOff"
|
||||||
|
messages:
|
||||||
|
- $ref: "#/channels/lightTurnOff/messages/turnOff"
|
||||||
|
components:
|
||||||
|
messages:
|
||||||
|
LightMeasured:
|
||||||
|
name: LightMeasured
|
||||||
|
title: Light measured
|
||||||
|
summary: Inform about environmental lighting conditions.
|
||||||
|
payload:
|
||||||
|
$ref: "#/components/schemas/LightMeasuredPayload"
|
||||||
|
TurnOn:
|
||||||
|
name: TurnOn
|
||||||
|
title: Turn on
|
||||||
|
summary: Command a specific streetlight to turn on.
|
||||||
|
payload:
|
||||||
|
$ref: "#/components/schemas/TurnOnPayload"
|
||||||
|
TurnOff:
|
||||||
|
name: TurnOff
|
||||||
|
title: Turn off
|
||||||
|
summary: Command a specific streetlight to turn off.
|
||||||
|
payload:
|
||||||
|
$ref: "#/components/schemas/TurnOffPayload"
|
||||||
|
parameters:
|
||||||
|
streetlightId:
|
||||||
|
description: The ID of the streetlight.
|
||||||
|
schemas:
|
||||||
|
LightMeasuredPayload:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
description: ID of the streetlight.
|
||||||
|
lumens:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
description: Light intensity measured in lumens.
|
||||||
|
sentAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
TurnOnPayload:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
command:
|
||||||
|
type: string
|
||||||
|
enum: [on]
|
||||||
|
sentAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
TurnOffPayload:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
command:
|
||||||
|
type: string
|
||||||
|
enum: [off]
|
||||||
|
sentAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ACCOUNT_SERVICE_YAML = `asyncapi: "3.0.0"
|
||||||
|
info:
|
||||||
|
title: Account Service
|
||||||
|
version: "1.0.0"
|
||||||
|
description: This service manages user accounts and publishes events on user lifecycle changes.
|
||||||
|
channels:
|
||||||
|
userSignedUp:
|
||||||
|
address: user/signedup
|
||||||
|
messages:
|
||||||
|
userSignedUp:
|
||||||
|
$ref: "#/components/messages/UserSignedUp"
|
||||||
|
userPasswordChanged:
|
||||||
|
address: user/passwordchanged
|
||||||
|
messages:
|
||||||
|
userPasswordChanged:
|
||||||
|
$ref: "#/components/messages/UserPasswordChanged"
|
||||||
|
operations:
|
||||||
|
sendUserSignedUp:
|
||||||
|
action: send
|
||||||
|
channel:
|
||||||
|
$ref: "#/channels/userSignedUp"
|
||||||
|
messages:
|
||||||
|
- $ref: "#/channels/userSignedUp/messages/userSignedUp"
|
||||||
|
sendUserPasswordChanged:
|
||||||
|
action: send
|
||||||
|
channel:
|
||||||
|
$ref: "#/channels/userPasswordChanged"
|
||||||
|
messages:
|
||||||
|
- $ref: "#/channels/userPasswordChanged/messages/userPasswordChanged"
|
||||||
|
components:
|
||||||
|
messages:
|
||||||
|
UserSignedUp:
|
||||||
|
payload:
|
||||||
|
$ref: "#/components/schemas/UserSignedUpPayload"
|
||||||
|
UserPasswordChanged:
|
||||||
|
payload:
|
||||||
|
$ref: "#/components/schemas/UserPasswordChangedPayload"
|
||||||
|
schemas:
|
||||||
|
UserSignedUpPayload:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
displayName:
|
||||||
|
type: string
|
||||||
|
description: Name of the user
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
description: Email of the user
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
UserPasswordChangedPayload:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
userId:
|
||||||
|
type: string
|
||||||
|
changedAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ORDERS_YAML = `openapi: "3.0.0"
|
||||||
|
info:
|
||||||
|
title: Orders API
|
||||||
|
version: "1.0.0"
|
||||||
|
description: API de gestion des commandes clients.
|
||||||
|
servers:
|
||||||
|
- url: https://api.example.com/v1
|
||||||
|
paths:
|
||||||
|
/orders:
|
||||||
|
get:
|
||||||
|
summary: Lister les commandes
|
||||||
|
operationId: listOrders
|
||||||
|
tags: [orders]
|
||||||
|
parameters:
|
||||||
|
- name: status
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [pending, confirmed, shipped, delivered, cancelled]
|
||||||
|
- name: page
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 1
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 20
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Liste des commandes
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/OrderList"
|
||||||
|
post:
|
||||||
|
summary: Créer une commande
|
||||||
|
operationId: createOrder
|
||||||
|
tags: [orders]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CreateOrder"
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Commande créée
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Order"
|
||||||
|
/orders/{orderId}:
|
||||||
|
get:
|
||||||
|
summary: Obtenir une commande
|
||||||
|
operationId: getOrder
|
||||||
|
tags: [orders]
|
||||||
|
parameters:
|
||||||
|
- name: orderId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Détails de la commande
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Order"
|
||||||
|
"404":
|
||||||
|
description: Commande non trouvée
|
||||||
|
patch:
|
||||||
|
summary: Mettre à jour le statut
|
||||||
|
operationId: updateOrderStatus
|
||||||
|
tags: [orders]
|
||||||
|
parameters:
|
||||||
|
- name: orderId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [confirmed, shipped, delivered, cancelled]
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Commande mise à jour
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Order"
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
Order:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
customerId:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [pending, confirmed, shipped, delivered, cancelled]
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/OrderItem"
|
||||||
|
totalAmount:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
OrderItem:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
productId:
|
||||||
|
type: string
|
||||||
|
quantity:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
price:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
CreateOrder:
|
||||||
|
type: object
|
||||||
|
required: [customerId, items]
|
||||||
|
properties:
|
||||||
|
customerId:
|
||||||
|
type: string
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
minItems: 1
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/OrderItem"
|
||||||
|
OrderList:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/Order"
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
|
page:
|
||||||
|
type: integer
|
||||||
|
limit:
|
||||||
|
type: integer
|
||||||
|
`;
|
||||||
|
|
||||||
|
const AUTH_YAML = `openapi: "3.0.0"
|
||||||
|
info:
|
||||||
|
title: Auth API
|
||||||
|
version: "1.0.0"
|
||||||
|
description: Service d'authentification et d'autorisation avec JWT.
|
||||||
|
servers:
|
||||||
|
- url: https://auth.example.com/v1
|
||||||
|
paths:
|
||||||
|
/auth/login:
|
||||||
|
post:
|
||||||
|
summary: Connexion
|
||||||
|
operationId: login
|
||||||
|
tags: [auth]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/LoginRequest"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Authentification réussie
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/TokenResponse"
|
||||||
|
"401":
|
||||||
|
description: Identifiants invalides
|
||||||
|
/auth/refresh:
|
||||||
|
post:
|
||||||
|
summary: Rafraîchir le token
|
||||||
|
operationId: refreshToken
|
||||||
|
tags: [auth]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [refreshToken]
|
||||||
|
properties:
|
||||||
|
refreshToken:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Nouveau token émis
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/TokenResponse"
|
||||||
|
"401":
|
||||||
|
description: Refresh token invalide ou expiré
|
||||||
|
/auth/logout:
|
||||||
|
post:
|
||||||
|
summary: Déconnexion
|
||||||
|
operationId: logout
|
||||||
|
tags: [auth]
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Déconnecté avec succès
|
||||||
|
/users/me:
|
||||||
|
get:
|
||||||
|
summary: Profil utilisateur courant
|
||||||
|
operationId: getCurrentUser
|
||||||
|
tags: [users]
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Profil de l'utilisateur connecté
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/User"
|
||||||
|
"401":
|
||||||
|
description: Non authentifié
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
schemas:
|
||||||
|
LoginRequest:
|
||||||
|
type: object
|
||||||
|
required: [email, password]
|
||||||
|
properties:
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
format: password
|
||||||
|
TokenResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
accessToken:
|
||||||
|
type: string
|
||||||
|
refreshToken:
|
||||||
|
type: string
|
||||||
|
expiresIn:
|
||||||
|
type: integer
|
||||||
|
description: Durée de validité en secondes
|
||||||
|
User:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
displayName:
|
||||||
|
type: string
|
||||||
|
roles:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
`;
|
||||||
|
|
||||||
|
/* ─── Seeder ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SeederService implements OnApplicationBootstrap {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ApiEntryEntity)
|
||||||
|
private readonly apiRepo: Repository<ApiEntryEntity>,
|
||||||
|
@InjectRepository(CategoryEntity)
|
||||||
|
private readonly categoryRepo: Repository<CategoryEntity>,
|
||||||
|
private readonly docsService: DocsGenerationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onApplicationBootstrap(): Promise<void> {
|
||||||
|
const count = await this.apiRepo.count();
|
||||||
|
if (count > 0) return; /* Idempotent — ne ré-exécute pas si des données existent */
|
||||||
|
await this.seed();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seed(): Promise<void> {
|
||||||
|
/* Catégories racines */
|
||||||
|
const ecommerce = await this.categoryRepo.save(
|
||||||
|
this.categoryRepo.create({
|
||||||
|
name: 'E-commerce',
|
||||||
|
description: 'APIs liées au commerce électronique',
|
||||||
|
parentId: null,
|
||||||
|
orderIndex: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const infrastructure = await this.categoryRepo.save(
|
||||||
|
this.categoryRepo.create({
|
||||||
|
name: 'Infrastructure',
|
||||||
|
description: "APIs d'infrastructure et événements système",
|
||||||
|
parentId: null,
|
||||||
|
orderIndex: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const identite = await this.categoryRepo.save(
|
||||||
|
this.categoryRepo.create({
|
||||||
|
name: 'Identité & Accès',
|
||||||
|
description: "APIs d'authentification et contrôle d'accès",
|
||||||
|
parentId: null,
|
||||||
|
orderIndex: 2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Catégories feuilles */
|
||||||
|
const produits = await this.categoryRepo.save(
|
||||||
|
this.categoryRepo.create({
|
||||||
|
name: 'Produits',
|
||||||
|
description: 'Catalogue de produits',
|
||||||
|
parentId: ecommerce.id,
|
||||||
|
orderIndex: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const commandes = await this.categoryRepo.save(
|
||||||
|
this.categoryRepo.create({
|
||||||
|
name: 'Commandes',
|
||||||
|
description: 'Gestion des commandes',
|
||||||
|
parentId: ecommerce.id,
|
||||||
|
orderIndex: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const notifications = await this.categoryRepo.save(
|
||||||
|
this.categoryRepo.create({
|
||||||
|
name: 'Notifications',
|
||||||
|
description: 'Événements et notifications utilisateur',
|
||||||
|
parentId: ecommerce.id,
|
||||||
|
orderIndex: 2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const evenements = await this.categoryRepo.save(
|
||||||
|
this.categoryRepo.create({
|
||||||
|
name: 'Événements',
|
||||||
|
description: "Événements d'infrastructure temps réel",
|
||||||
|
parentId: infrastructure.id,
|
||||||
|
orderIndex: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const auth = await this.categoryRepo.save(
|
||||||
|
this.categoryRepo.create({
|
||||||
|
name: 'Authentification',
|
||||||
|
description: 'Authentification et autorisation',
|
||||||
|
parentId: identite.id,
|
||||||
|
orderIndex: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/* APIs de démo */
|
||||||
|
const entries = await this.apiRepo.save([
|
||||||
|
this.apiRepo.create({
|
||||||
|
title: 'Swagger Petstore',
|
||||||
|
version: '3.0.4',
|
||||||
|
description:
|
||||||
|
'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.',
|
||||||
|
type: 'OPENAPI',
|
||||||
|
yamlContent: PETSTORE_YAML,
|
||||||
|
status: 'PENDING',
|
||||||
|
categoryId: produits.id,
|
||||||
|
}),
|
||||||
|
this.apiRepo.create({
|
||||||
|
title: 'Streetlights Kafka API',
|
||||||
|
version: '1.0.0',
|
||||||
|
description:
|
||||||
|
'The Smartylighting Streetlights API allows you to remotely manage the city lights using Kafka messaging.',
|
||||||
|
type: 'ASYNCAPI',
|
||||||
|
yamlContent: STREETLIGHTS_YAML,
|
||||||
|
status: 'PENDING',
|
||||||
|
categoryId: evenements.id,
|
||||||
|
}),
|
||||||
|
this.apiRepo.create({
|
||||||
|
title: 'Account Service',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: "Service de gestion des comptes utilisateurs avec publication d'événements.",
|
||||||
|
type: 'ASYNCAPI',
|
||||||
|
yamlContent: ACCOUNT_SERVICE_YAML,
|
||||||
|
status: 'PENDING',
|
||||||
|
categoryId: notifications.id,
|
||||||
|
}),
|
||||||
|
this.apiRepo.create({
|
||||||
|
title: 'Orders API',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'API de gestion des commandes clients.',
|
||||||
|
type: 'OPENAPI',
|
||||||
|
yamlContent: ORDERS_YAML,
|
||||||
|
status: 'PENDING',
|
||||||
|
categoryId: commandes.id,
|
||||||
|
}),
|
||||||
|
this.apiRepo.create({
|
||||||
|
title: 'Auth API',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: "Service d'authentification et d'autorisation.",
|
||||||
|
type: 'OPENAPI',
|
||||||
|
yamlContent: AUTH_YAML,
|
||||||
|
status: 'PENDING',
|
||||||
|
categoryId: auth.id,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log('[Seeder] 8 catégories et 5 APIs de démo créées — génération docs en cours...');
|
||||||
|
|
||||||
|
/* Déclenche la génération de docs en fire-and-forget pour chaque API */
|
||||||
|
for (const entry of entries) {
|
||||||
|
this.docsService.generate(entry.id).catch(console.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
0
back/src/modules/uploads/.gitkeep
Normal file
0
back/src/modules/uploads/.gitkeep
Normal file
147
back/src/modules/uploads/uploads.controller.ts
Normal file
147
back/src/modules/uploads/uploads.controller.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
UseInterceptors,
|
||||||
|
UploadedFiles,
|
||||||
|
Body,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { diskStorage } from 'multer';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as fsPromises from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { execFile } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { ApisService } from '../apis/apis.service';
|
||||||
|
import { DocsGenerationService } from '../docs-generation/docs-generation.service';
|
||||||
|
import { ApiType } from '@datacat/shared';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
@Controller('uploads')
|
||||||
|
export class UploadsController {
|
||||||
|
constructor(
|
||||||
|
private readonly apisService: ApisService,
|
||||||
|
private readonly docsService: DocsGenerationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseInterceptors(
|
||||||
|
FilesInterceptor('files', 20, {
|
||||||
|
storage: diskStorage({ destination: '/tmp/uploads' }),
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
if (!file.originalname.match(/\.(yaml|yml)$/i)) {
|
||||||
|
return cb(new BadRequestException('Only YAML files are allowed'), false);
|
||||||
|
}
|
||||||
|
cb(null, true);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
async upload(
|
||||||
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
title: string;
|
||||||
|
version: string;
|
||||||
|
description?: string;
|
||||||
|
type?: ApiType;
|
||||||
|
categoryId?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (!files || files.length === 0) throw new BadRequestException('YAML file is required');
|
||||||
|
if (!body.title) throw new BadRequestException('title is required');
|
||||||
|
if (!body.version) throw new BadRequestException('version is required');
|
||||||
|
|
||||||
|
/* Crée le répertoire si absent */
|
||||||
|
if (!fs.existsSync('/tmp/uploads')) {
|
||||||
|
fs.mkdirSync('/tmp/uploads', { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
let yamlContent: string;
|
||||||
|
let detectedType: ApiType | null = null;
|
||||||
|
|
||||||
|
if (files.length === 1) {
|
||||||
|
/* Cas simple : un seul fichier, comportement identique à l'ancien */
|
||||||
|
yamlContent = fs.readFileSync(files[0].path, 'utf-8');
|
||||||
|
detectedType = this.detectTypeFromContent(yamlContent);
|
||||||
|
} else {
|
||||||
|
/* Cas multi-fichiers : détecter le fichier principal et bundler */
|
||||||
|
const { mainFile, type } = this.detectMainFile(files);
|
||||||
|
detectedType = type;
|
||||||
|
yamlContent = await this.bundleFiles(files, mainFile, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utiliser le type détecté ou le fallback du body */
|
||||||
|
const apiType = detectedType ?? body.type;
|
||||||
|
if (!apiType || !['ASYNCAPI', 'OPENAPI'].includes(apiType)) {
|
||||||
|
throw new BadRequestException('type must be ASYNCAPI or OPENAPI');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = await this.apisService.create({
|
||||||
|
title: body.title,
|
||||||
|
version: body.version,
|
||||||
|
description: body.description,
|
||||||
|
type: apiType,
|
||||||
|
yamlContent,
|
||||||
|
categoryId: body.categoryId || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Fire-and-forget : génération asynchrone */
|
||||||
|
this.docsService.generate(entry.id).catch(console.error);
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
private detectTypeFromContent(content: string): ApiType | null {
|
||||||
|
/* Cherche la clé racine openapi: ou asyncapi: sans dépendance à js-yaml */
|
||||||
|
if (/^openapi\s*:/m.test(content)) return 'OPENAPI';
|
||||||
|
if (/^asyncapi\s*:/m.test(content)) return 'ASYNCAPI';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private detectMainFile(files: Express.Multer.File[]): { mainFile: Express.Multer.File; type: ApiType } {
|
||||||
|
for (const file of files) {
|
||||||
|
const content = fs.readFileSync(file.path, 'utf-8');
|
||||||
|
const type = this.detectTypeFromContent(content);
|
||||||
|
if (type) {
|
||||||
|
return { mainFile: file, type };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Aucun fichier principal trouvé (clé openapi: ou asyncapi: manquante)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async bundleFiles(
|
||||||
|
files: Express.Multer.File[],
|
||||||
|
mainFile: Express.Multer.File,
|
||||||
|
type: ApiType,
|
||||||
|
): Promise<string> {
|
||||||
|
const tempDir = `/tmp/uploads/${randomUUID()}`;
|
||||||
|
const bundledPath = path.join(tempDir, 'bundled.yaml');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fsPromises.mkdir(tempDir, { recursive: true });
|
||||||
|
|
||||||
|
/* Copier tous les fichiers dans le répertoire temp avec leurs noms d'origine */
|
||||||
|
await Promise.all(
|
||||||
|
files.map((f) => fsPromises.copyFile(f.path, path.join(tempDir, f.originalname))),
|
||||||
|
);
|
||||||
|
|
||||||
|
const mainPath = path.join(tempDir, mainFile.originalname);
|
||||||
|
|
||||||
|
if (type === 'OPENAPI') {
|
||||||
|
await execFileAsync('redocly', ['bundle', mainPath, '--output', bundledPath]);
|
||||||
|
} else {
|
||||||
|
await execFileAsync('asyncapi', ['bundle', mainPath, '--output', bundledPath]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await fsPromises.readFile(bundledPath, 'utf-8');
|
||||||
|
} finally {
|
||||||
|
/* Nettoyage du répertoire temp même en cas d'erreur */
|
||||||
|
await fsPromises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
back/src/modules/uploads/uploads.module.ts
Normal file
10
back/src/modules/uploads/uploads.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { UploadsController } from './uploads.controller';
|
||||||
|
import { ApisModule } from '../apis/apis.module';
|
||||||
|
import { DocsGenerationModule } from '../docs-generation/docs-generation.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ApisModule, DocsGenerationModule],
|
||||||
|
controllers: [UploadsController],
|
||||||
|
})
|
||||||
|
export class UploadsModule {}
|
||||||
4
back/tsconfig.build.json
Normal file
4
back/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
19
back/tsconfig.json
Normal file
19
back/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2021",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"paths": {
|
||||||
|
"@datacat/shared": ["../shared/src/index.ts"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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.
|
||||||
74
front-public/angular.json
Normal file
74
front-public/angular.json
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"front-public": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {
|
||||||
|
"@schematics/angular:component": {
|
||||||
|
"style": "scss",
|
||||||
|
"standalone": true,
|
||||||
|
"changeDetection": "OnPush"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular-devkit/build-angular:application",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/front-public",
|
||||||
|
"index": "src/index.html",
|
||||||
|
"browser": "src/main.ts",
|
||||||
|
"polyfills": ["zone.js"],
|
||||||
|
"tsConfig": "tsconfig.json",
|
||||||
|
"assets": [
|
||||||
|
"src/favicon.ico",
|
||||||
|
"src/assets",
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "node_modules/@gouvfr/dsfr/dist/dsfr",
|
||||||
|
"output": "assets/dsfr"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "node_modules/@gouvfr/dsfr/dist/utility",
|
||||||
|
"output": "assets/dsfr/utility"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": ["src/styles.scss"],
|
||||||
|
"scripts": []
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{ "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" },
|
||||||
|
{ "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" }
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
|
"configurations": {
|
||||||
|
"production": { "buildTarget": "front-public:build:production" },
|
||||||
|
"development": { "buildTarget": "front-public:build:development" }
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development",
|
||||||
|
"options": {
|
||||||
|
"proxyConfig": "proxy.conf.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
front-public/package.json
Normal file
34
front-public/package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "@datacat/front-public",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"start": "ng serve",
|
||||||
|
"build": "ng build",
|
||||||
|
"build:prod": "ng build --configuration production",
|
||||||
|
"test": "ng test --watch=false --browsers=ChromeHeadless",
|
||||||
|
"lint": "ng lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/animations": "^19.0.0",
|
||||||
|
"@angular/common": "^19.0.0",
|
||||||
|
"@angular/compiler": "^19.0.0",
|
||||||
|
"@angular/core": "^19.0.0",
|
||||||
|
"@angular/forms": "^19.0.0",
|
||||||
|
"@angular/platform-browser": "^19.0.0",
|
||||||
|
"@angular/platform-browser-dynamic": "^19.0.0",
|
||||||
|
"@angular/router": "^19.0.0",
|
||||||
|
"rxjs": "^7.8.0",
|
||||||
|
"tslib": "^2.8.0",
|
||||||
|
"zone.js": "^0.15.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular-devkit/build-angular": "^19.0.0",
|
||||||
|
"@angular/cli": "^19.0.0",
|
||||||
|
"@angular/compiler-cli": "^19.0.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@gouvfr/dsfr": "^1.13.0",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"sass": "^1.83.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
front-public/proxy.conf.docker.json
Normal file
6
front-public/proxy.conf.docker.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"/api": {
|
||||||
|
"target": "http://backend:3000",
|
||||||
|
"secure": false
|
||||||
|
}
|
||||||
|
}
|
||||||
6
front-public/proxy.conf.json
Normal file
6
front-public/proxy.conf.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"/api": {
|
||||||
|
"target": "http://localhost:3000",
|
||||||
|
"secure": false
|
||||||
|
}
|
||||||
|
}
|
||||||
63
front-public/src/app/app.component.ts
Normal file
63
front-public/src/app/app.component.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterOutlet, RouterLink, RouterLinkActive],
|
||||||
|
template: `
|
||||||
|
<header role="banner" class="fr-header">
|
||||||
|
<div class="fr-header__body">
|
||||||
|
<div class="fr-container">
|
||||||
|
<div class="fr-header__body-row">
|
||||||
|
<div class="fr-header__brand fr-enlarge-link">
|
||||||
|
<div class="fr-header__brand-top">
|
||||||
|
<div class="fr-header__logo">
|
||||||
|
<p class="fr-logo">République<br />Française</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fr-header__service">
|
||||||
|
<a href="/" routerLink="/browse" title="Accueil — Datacat">
|
||||||
|
<p class="fr-header__service-title">Datacat</p>
|
||||||
|
</a>
|
||||||
|
<p class="fr-header__service-tagline">Catalogue de données d'API</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fr-header__menu">
|
||||||
|
<div class="fr-container">
|
||||||
|
<nav class="fr-nav" role="navigation" aria-label="Menu principal">
|
||||||
|
<ul class="fr-nav__list">
|
||||||
|
<li class="fr-nav__item">
|
||||||
|
<a
|
||||||
|
class="fr-nav__link"
|
||||||
|
routerLink="/browse"
|
||||||
|
routerLinkActive="fr-nav__link--active"
|
||||||
|
[routerLinkActiveOptions]="{ exact: false }"
|
||||||
|
>
|
||||||
|
Parcourir
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="fr-nav__item">
|
||||||
|
<a
|
||||||
|
class="fr-nav__link"
|
||||||
|
routerLink="/catalog"
|
||||||
|
routerLinkActive="fr-nav__link--active"
|
||||||
|
[routerLinkActiveOptions]="{ exact: false }"
|
||||||
|
>
|
||||||
|
Catalogue
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<router-outlet />
|
||||||
|
</main>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class AppComponent {}
|
||||||
12
front-public/src/app/app.config.ts
Normal file
12
front-public/src/app/app.config.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||||
|
import { provideRouter, withComponentInputBinding } from '@angular/router';
|
||||||
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||||
|
provideRouter(routes, withComponentInputBinding()),
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
],
|
||||||
|
};
|
||||||
43
front-public/src/app/app.routes.ts
Normal file
43
front-public/src/app/app.routes.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
|
export const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
redirectTo: 'browse',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'browse',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./pages/browse/browse.component').then((m) => m.BrowseComponent),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'browse/:id',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./pages/browse/browse.component').then((m) => m.BrowseComponent),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'catalog',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./pages/catalog/catalog.component').then((m) => m.CatalogComponent),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'catalog/:id',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./pages/api-detail/api-detail.component').then((m) => m.ApiDetailComponent),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'catalog/:id/docs',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./pages/doc-viewer/doc-viewer.component').then((m) => m.DocViewerComponent),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'upload',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./pages/upload/upload.component').then((m) => m.UploadComponent),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '**',
|
||||||
|
redirectTo: 'browse',
|
||||||
|
},
|
||||||
|
];
|
||||||
0
front-public/src/app/core/.gitkeep
Normal file
0
front-public/src/app/core/.gitkeep
Normal file
40
front-public/src/app/core/api.service.ts
Normal file
40
front-public/src/app/core/api.service.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
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> {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (query?.search) params['search'] = query.search;
|
||||||
|
if (query?.type) params['type'] = query.type;
|
||||||
|
if (query?.categoryId) params['categoryId'] = query.categoryId;
|
||||||
|
if (query?.page) params['page'] = String(query.page);
|
||||||
|
if (query?.limit) params['limit'] = String(query.limit);
|
||||||
|
return this.http.get<ApiListResponse>(this.baseUrl, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id: string): Observable<ApiEntry> {
|
||||||
|
return this.http.get<ApiEntry>(`${this.baseUrl}/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id: string, data: { title?: string; version?: string; description?: string }): Observable<ApiEntry> {
|
||||||
|
return this.http.patch<ApiEntry>(`${this.baseUrl}/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
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`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
upload(formData: FormData): Observable<ApiEntry> {
|
||||||
|
return this.http.post<ApiEntry>('/api/uploads', formData);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
front-public/src/app/core/category.service.ts
Normal file
39
front-public/src/app/core/category.service.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import {
|
||||||
|
Category,
|
||||||
|
CategoryListResponse,
|
||||||
|
CategoryBrowseResponse,
|
||||||
|
} from '@datacat/shared';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class CategoryService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
private baseUrl = '/api/categories';
|
||||||
|
|
||||||
|
list(): Observable<CategoryListResponse> {
|
||||||
|
return this.http.get<CategoryListResponse>(this.baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
browse(id: string | null): Observable<CategoryBrowseResponse> {
|
||||||
|
const url = id ? `${this.baseUrl}/browse/${id}` : `${this.baseUrl}/browse`;
|
||||||
|
return this.http.get<CategoryBrowseResponse>(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id: string): Observable<Category> {
|
||||||
|
return this.http.get<Category>(`${this.baseUrl}/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
create(data: { name: string; description?: string; parentId?: string; orderIndex?: number }): Observable<Category> {
|
||||||
|
return this.http.post<Category>(this.baseUrl, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id: string, data: Partial<{ name: string; description: string; parentId: string | null; orderIndex: number }>): Observable<Category> {
|
||||||
|
return this.http.patch<Category>(`${this.baseUrl}/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(id: string): Observable<void> {
|
||||||
|
return this.http.delete<void>(`${this.baseUrl}/${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
0
front-public/src/app/pages/api-detail/.gitkeep
Normal file
0
front-public/src/app/pages/api-detail/.gitkeep
Normal file
237
front-public/src/app/pages/api-detail/api-detail.component.ts
Normal file
237
front-public/src/app/pages/api-detail/api-detail.component.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
signal,
|
||||||
|
inject,
|
||||||
|
OnInit,
|
||||||
|
OnDestroy,
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
import { ApiService } from '../../core/api.service';
|
||||||
|
import { ApiEntry } from '@datacat/shared';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-api-detail',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterLink],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `
|
||||||
|
<div class="fr-container fr-mt-4w fr-mb-4w">
|
||||||
|
<!-- Fil d'Ariane -->
|
||||||
|
<nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici">
|
||||||
|
<ol class="fr-breadcrumb__list">
|
||||||
|
<li>
|
||||||
|
<a class="fr-breadcrumb__link" routerLink="/catalog">Catalogue</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="fr-breadcrumb__link" aria-current="page">
|
||||||
|
{{ api()?.title ?? 'Chargement...' }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="fr-callout fr-mb-4w">
|
||||||
|
<p class="fr-callout__text">Chargement...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (error()) {
|
||||||
|
<div class="fr-alert fr-alert--error fr-mb-4w">
|
||||||
|
<p class="fr-alert__title">Erreur</p>
|
||||||
|
<p>{{ error() }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (api(); as entry) {
|
||||||
|
<!-- En-tête -->
|
||||||
|
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
|
||||||
|
<div class="fr-col">
|
||||||
|
<h1 class="fr-h2 fr-mb-1w">{{ entry.title }}</h1>
|
||||||
|
<div class="fr-tags-group">
|
||||||
|
<span class="fr-tag">{{ entry.type }}</span>
|
||||||
|
<span class="fr-tag">v{{ entry.version }}</span>
|
||||||
|
<span [class]="badgeClass(entry.status)">{{ entry.status }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alerte statut PENDING -->
|
||||||
|
@if (entry.status === 'PENDING') {
|
||||||
|
<div class="fr-alert fr-alert--info fr-mb-3w">
|
||||||
|
<p class="fr-alert__title">Génération en cours</p>
|
||||||
|
<p>La documentation est en cours de génération. Cette page se rafraîchit automatiquement.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Alerte statut ERROR -->
|
||||||
|
@if (entry.status === 'ERROR') {
|
||||||
|
<div class="fr-alert fr-alert--error fr-mb-3w">
|
||||||
|
<p class="fr-alert__title">Erreur de génération</p>
|
||||||
|
<p>{{ entry.errorMessage }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Métadonnées -->
|
||||||
|
<div class="fr-card fr-mb-4w">
|
||||||
|
<div class="fr-card__body">
|
||||||
|
<div class="fr-card__content">
|
||||||
|
<h2 class="fr-h5 fr-mb-2w">Informations</h2>
|
||||||
|
<dl class="fr-grid-row fr-grid-row--gutters">
|
||||||
|
<div class="fr-col-12 fr-col-md-4">
|
||||||
|
<dt class="fr-text--bold">Titre</dt>
|
||||||
|
<dd>{{ entry.title }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="fr-col-12 fr-col-md-4">
|
||||||
|
<dt class="fr-text--bold">Version</dt>
|
||||||
|
<dd>{{ entry.version }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="fr-col-12 fr-col-md-4">
|
||||||
|
<dt class="fr-text--bold">Type</dt>
|
||||||
|
<dd>{{ entry.type }}</dd>
|
||||||
|
</div>
|
||||||
|
@if (entry.description) {
|
||||||
|
<div class="fr-col-12">
|
||||||
|
<dt class="fr-text--bold">Description</dt>
|
||||||
|
<dd>{{ entry.description }}</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="fr-col-12 fr-col-md-6">
|
||||||
|
<dt class="fr-text--bold">Créé le</dt>
|
||||||
|
<dd>{{ entry.createdAt | date:'dd/MM/yyyy HH:mm' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="fr-col-12 fr-col-md-6">
|
||||||
|
<dt class="fr-text--bold">Mis à jour le</dt>
|
||||||
|
<dd>{{ entry.updatedAt | date:'dd/MM/yyyy HH:mm' }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="fr-btns-group fr-btns-group--inline-sm">
|
||||||
|
<a
|
||||||
|
[routerLink]="['/catalog', entry.id, 'docs']"
|
||||||
|
class="fr-btn"
|
||||||
|
[class.fr-btn--disabled]="entry.status !== 'GENERATED'"
|
||||||
|
[attr.aria-disabled]="entry.status !== 'GENERATED' ? 'true' : null"
|
||||||
|
>
|
||||||
|
Voir la documentation
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
[href]="'/api/apis/' + entry.id + '/yaml'"
|
||||||
|
class="fr-btn fr-btn--secondary"
|
||||||
|
download
|
||||||
|
>
|
||||||
|
Télécharger YAML
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
class="fr-btn fr-btn--secondary"
|
||||||
|
(click)="onRegenerate()"
|
||||||
|
[disabled]="regenerating()"
|
||||||
|
>
|
||||||
|
{{ regenerating() ? 'Régénération...' : 'Régénérer' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="fr-btn fr-btn--secondary"
|
||||||
|
(click)="onDelete()"
|
||||||
|
[disabled]="deleting()"
|
||||||
|
>
|
||||||
|
{{ deleting() ? 'Suppression...' : 'Supprimer' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class ApiDetailComponent implements OnInit, OnDestroy {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
private apiService = inject(ApiService);
|
||||||
|
|
||||||
|
api = signal<ApiEntry | null>(null);
|
||||||
|
loading = signal(false);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
regenerating = signal(false);
|
||||||
|
deleting = signal(false);
|
||||||
|
|
||||||
|
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private id = '';
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.id = this.route.snapshot.paramMap.get('id') ?? '';
|
||||||
|
this.loadApi();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.clearPoll();
|
||||||
|
}
|
||||||
|
|
||||||
|
onRegenerate() {
|
||||||
|
this.regenerating.set(true);
|
||||||
|
this.apiService.regenerate(this.id).subscribe({
|
||||||
|
next: (entry) => {
|
||||||
|
this.api.set(entry);
|
||||||
|
this.regenerating.set(false);
|
||||||
|
this.startPollIfPending();
|
||||||
|
},
|
||||||
|
error: () => this.regenerating.set(false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onDelete() {
|
||||||
|
if (!confirm('Supprimer cette API ?')) return;
|
||||||
|
this.deleting.set(true);
|
||||||
|
this.apiService.delete(this.id).subscribe({
|
||||||
|
next: () => this.router.navigate(['/catalog']),
|
||||||
|
error: () => this.deleting.set(false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
badgeClass(status: string): string {
|
||||||
|
if (status === 'GENERATED') return 'fr-badge fr-badge--success';
|
||||||
|
if (status === 'ERROR') return 'fr-badge fr-badge--error';
|
||||||
|
return 'fr-badge fr-badge--info';
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadApi() {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.apiService.get(this.id).subscribe({
|
||||||
|
next: (entry) => {
|
||||||
|
this.api.set(entry);
|
||||||
|
this.loading.set(false);
|
||||||
|
this.startPollIfPending();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.error.set(err.message ?? 'Erreur lors du chargement');
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private startPollIfPending() {
|
||||||
|
this.clearPoll();
|
||||||
|
if (this.api()?.status === 'PENDING') {
|
||||||
|
this.pollInterval = setInterval(() => this.pollStatus(), 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private pollStatus() {
|
||||||
|
this.apiService.get(this.id).subscribe({
|
||||||
|
next: (entry) => {
|
||||||
|
this.api.set(entry);
|
||||||
|
if (entry.status !== 'PENDING') this.clearPoll();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearPoll() {
|
||||||
|
if (this.pollInterval) {
|
||||||
|
clearInterval(this.pollInterval);
|
||||||
|
this.pollInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
171
front-public/src/app/pages/browse/browse.component.ts
Normal file
171
front-public/src/app/pages/browse/browse.component.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
signal,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
OnInit,
|
||||||
|
DestroyRef,
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { CategoryService } from '../../core/category.service';
|
||||||
|
import { CategoryBrowseResponse, Category } from '@datacat/shared';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-browse',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterLink],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `
|
||||||
|
<div class="fr-container fr-mt-4w fr-mb-4w">
|
||||||
|
<!-- Fil d'Ariane -->
|
||||||
|
<nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici">
|
||||||
|
<ol class="fr-breadcrumb__list">
|
||||||
|
<li>
|
||||||
|
<a class="fr-breadcrumb__link" routerLink="/browse">Parcourir</a>
|
||||||
|
</li>
|
||||||
|
@for (crumb of ancestors(); track crumb.id) {
|
||||||
|
<li>
|
||||||
|
<a class="fr-breadcrumb__link" [routerLink]="['/browse', crumb.id]">{{ crumb.name }}</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
@if (data()?.category) {
|
||||||
|
<li>
|
||||||
|
<a class="fr-breadcrumb__link" aria-current="page">{{ data()!.category!.name }}</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1 class="fr-h1 fr-mb-4w">{{ data()?.category?.name ?? 'Parcourir le catalogue' }}</h1>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="fr-callout fr-mb-4w">
|
||||||
|
<p class="fr-callout__text">Chargement...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (error()) {
|
||||||
|
<div class="fr-alert fr-alert--error fr-mb-4w">
|
||||||
|
<p>{{ error() }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!loading() && data()) {
|
||||||
|
<!-- Sous-catégories -->
|
||||||
|
@if (data()!.subcategories.length > 0) {
|
||||||
|
<h2 class="fr-h4 fr-mb-3w">Catégories</h2>
|
||||||
|
<div class="fr-grid-row fr-grid-row--gutters fr-mb-4w">
|
||||||
|
@for (cat of data()!.subcategories; track cat.id) {
|
||||||
|
<div class="fr-col-12 fr-col-md-4">
|
||||||
|
<div class="fr-tile fr-enlarge-link fr-tile--sm">
|
||||||
|
<div class="fr-tile__body">
|
||||||
|
<div class="fr-tile__content">
|
||||||
|
<h3 class="fr-tile__title">
|
||||||
|
<a [routerLink]="['/browse', cat.id]">{{ cat.name }}</a>
|
||||||
|
</h3>
|
||||||
|
@if (cat.description) {
|
||||||
|
<p class="fr-tile__desc">{{ cat.description }}</p>
|
||||||
|
}
|
||||||
|
<p class="fr-tile__detail">
|
||||||
|
{{ cat.subcategoryCount }} sous-catégorie(s) · {{ cat.apiCount }} API(s)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- APIs -->
|
||||||
|
@if (data()!.apis.length > 0) {
|
||||||
|
<h2 class="fr-h4 fr-mb-3w">APIs</h2>
|
||||||
|
<div class="fr-grid-row fr-grid-row--gutters">
|
||||||
|
@for (api of data()!.apis; track api.id) {
|
||||||
|
<div class="fr-col-12 fr-col-md-4">
|
||||||
|
<div class="fr-tile fr-enlarge-link fr-tile--sm">
|
||||||
|
<div class="fr-tile__body">
|
||||||
|
<div class="fr-tile__content">
|
||||||
|
<h3 class="fr-tile__title">
|
||||||
|
<a [routerLink]="['/catalog', api.id]">{{ api.title }}</a>
|
||||||
|
</h3>
|
||||||
|
@if (api.description) {
|
||||||
|
<p class="fr-tile__desc">{{ api.description }}</p>
|
||||||
|
}
|
||||||
|
<p class="fr-tile__detail">
|
||||||
|
<span [class]="typeBadgeClass(api.type)">{{ api.type }}</span>
|
||||||
|
|
||||||
|
<span [class]="statusBadgeClass(api.status)">{{ api.status }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (data()!.subcategories.length === 0 && data()!.apis.length === 0) {
|
||||||
|
<div class="fr-callout fr-callout--blue-cumulus fr-mb-4w">
|
||||||
|
<p class="fr-callout__text">Aucun contenu dans cette catégorie.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class BrowseComponent implements OnInit {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private categoryService = inject(CategoryService);
|
||||||
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
data = signal<CategoryBrowseResponse | null>(null);
|
||||||
|
loading = signal(false);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
|
||||||
|
/* Ancêtres = breadcrumb sans le nœud courant (déjà dans le h1) */
|
||||||
|
ancestors = computed<Category[]>(() => {
|
||||||
|
const d = this.data();
|
||||||
|
if (!d || !d.category) return [];
|
||||||
|
return d.breadcrumb.slice(0, -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.route.paramMap
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe((params) => {
|
||||||
|
this.loadBrowse(params.get('id'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadBrowse(id: string | null) {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.error.set(null);
|
||||||
|
this.categoryService.browse(id).subscribe({
|
||||||
|
next: (data) => {
|
||||||
|
this.data.set(data);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.error.set('Erreur lors du chargement des données.');
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
typeBadgeClass(type: string): string {
|
||||||
|
return type === 'ASYNCAPI'
|
||||||
|
? 'fr-badge fr-badge--sm fr-badge--purple-glycine'
|
||||||
|
: 'fr-badge fr-badge--sm fr-badge--blue-ecume';
|
||||||
|
}
|
||||||
|
|
||||||
|
statusBadgeClass(status: string): string {
|
||||||
|
if (status === 'GENERATED') return 'fr-badge fr-badge--sm fr-badge--success';
|
||||||
|
if (status === 'ERROR') return 'fr-badge fr-badge--sm fr-badge--error';
|
||||||
|
return 'fr-badge fr-badge--sm fr-badge--info';
|
||||||
|
}
|
||||||
|
}
|
||||||
0
front-public/src/app/pages/catalog/.gitkeep
Normal file
0
front-public/src/app/pages/catalog/.gitkeep
Normal file
232
front-public/src/app/pages/catalog/catalog.component.ts
Normal file
232
front-public/src/app/pages/catalog/catalog.component.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
signal,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
OnInit,
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ApiService } from '../../core/api.service';
|
||||||
|
import { ApiEntryListItem, ApiType } from '@datacat/shared';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-catalog',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterLink, FormsModule],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `
|
||||||
|
<div class="fr-container fr-mt-4w fr-mb-4w">
|
||||||
|
<!-- En-tête page -->
|
||||||
|
<div class="fr-grid-row fr-grid-row--middle fr-mb-4w">
|
||||||
|
<div class="fr-col">
|
||||||
|
<h1 class="fr-h2 fr-mb-0">Catalogue des APIs</h1>
|
||||||
|
</div>
|
||||||
|
<div class="fr-col-auto">
|
||||||
|
<a routerLink="/upload" class="fr-btn fr-btn--icon-left fr-icon-upload-2-line">
|
||||||
|
Importer une API
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtres -->
|
||||||
|
<div class="fr-grid-row fr-grid-row--gutters fr-grid-row--middle fr-mb-4w">
|
||||||
|
<div class="fr-col-12 fr-col-md-8">
|
||||||
|
<div class="fr-search-bar" role="search">
|
||||||
|
<label class="fr-label" for="search-input">Recherche</label>
|
||||||
|
<input
|
||||||
|
id="search-input"
|
||||||
|
class="fr-input"
|
||||||
|
type="search"
|
||||||
|
placeholder="Rechercher par titre ou description..."
|
||||||
|
[ngModel]="search()"
|
||||||
|
(ngModelChange)="onSearchChange($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fr-col-12 fr-col-md-4">
|
||||||
|
<label class="fr-sr-only" for="type-filter">Type</label>
|
||||||
|
<select
|
||||||
|
id="type-filter"
|
||||||
|
class="fr-select"
|
||||||
|
[ngModel]="typeFilter()"
|
||||||
|
(ngModelChange)="onTypeChange($event)"
|
||||||
|
>
|
||||||
|
<option value="">Tous les types</option>
|
||||||
|
<option value="ASYNCAPI">AsyncAPI</option>
|
||||||
|
<option value="OPENAPI">OpenAPI</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chargement -->
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="fr-callout fr-mb-4w">
|
||||||
|
<p class="fr-callout__text">Chargement...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Liste vide -->
|
||||||
|
@if (!loading() && apis().length === 0) {
|
||||||
|
<div class="fr-callout fr-callout--blue-cumulus fr-mb-4w">
|
||||||
|
<p class="fr-callout__title">Aucune API trouvée</p>
|
||||||
|
<p class="fr-callout__text">
|
||||||
|
Importez votre première API en cliquant sur "Importer une API".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Grille de cartes -->
|
||||||
|
<div class="fr-grid-row fr-grid-row--gutters">
|
||||||
|
@for (api of apis(); track api.id) {
|
||||||
|
<div class="fr-col-12 fr-col-md-6 fr-col-lg-4">
|
||||||
|
<div class="fr-card fr-card--shadow fr-enlarge-link">
|
||||||
|
<div class="fr-card__body">
|
||||||
|
<div class="fr-card__content">
|
||||||
|
<h3 class="fr-card__title">
|
||||||
|
<a [routerLink]="['/catalog', api.id]">{{ api.title }}</a>
|
||||||
|
</h3>
|
||||||
|
<p class="fr-card__desc">{{ api.description || 'Pas de description' }}</p>
|
||||||
|
<div class="fr-card__start">
|
||||||
|
<ul class="fr-tags-group">
|
||||||
|
<li>
|
||||||
|
<p class="fr-tag">{{ api.type }}</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p class="fr-tag">v{{ api.version }}</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fr-card__footer">
|
||||||
|
<span [class]="badgeClass(api.status)" aria-hidden="true">
|
||||||
|
{{ api.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
@if (totalPages() > 1) {
|
||||||
|
<nav class="fr-pagination fr-mt-4w" role="navigation" aria-label="Pagination">
|
||||||
|
<ul class="fr-pagination__list">
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class="fr-pagination__link fr-pagination__link--prev fr-pagination__link--lg-label"
|
||||||
|
[disabled]="page() <= 1"
|
||||||
|
(click)="setPage(page() - 1)"
|
||||||
|
>
|
||||||
|
Page précédente
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
@for (p of pageNumbers(); track p) {
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class="fr-pagination__link"
|
||||||
|
[class.fr-pagination__link--active]="p === page()"
|
||||||
|
[attr.aria-current]="p === page() ? 'page' : null"
|
||||||
|
(click)="setPage(p)"
|
||||||
|
>
|
||||||
|
{{ p }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class="fr-pagination__link fr-pagination__link--next fr-pagination__link--lg-label"
|
||||||
|
[disabled]="page() >= totalPages()"
|
||||||
|
(click)="setPage(page() + 1)"
|
||||||
|
>
|
||||||
|
Page suivante
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class CatalogComponent implements OnInit {
|
||||||
|
private apiService = inject(ApiService);
|
||||||
|
|
||||||
|
apis = signal<ApiEntryListItem[]>([]);
|
||||||
|
loading = signal(false);
|
||||||
|
search = signal('');
|
||||||
|
typeFilter = signal<ApiType | ''>('');
|
||||||
|
page = signal(1);
|
||||||
|
total = signal(0);
|
||||||
|
readonly limit = 12;
|
||||||
|
|
||||||
|
totalPages = computed(() => Math.ceil(this.total() / this.limit));
|
||||||
|
pageNumbers = computed(() => {
|
||||||
|
const pages: number[] = [];
|
||||||
|
for (let i = 1; i <= this.totalPages(); i++) pages.push(i);
|
||||||
|
return pages;
|
||||||
|
});
|
||||||
|
|
||||||
|
private searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
/* Rechargement si page change */
|
||||||
|
effect(() => {
|
||||||
|
const _ = this.page();
|
||||||
|
this.loadApis();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.loadApis();
|
||||||
|
}
|
||||||
|
|
||||||
|
onSearchChange(value: string) {
|
||||||
|
this.search.set(value);
|
||||||
|
if (this.searchTimer) clearTimeout(this.searchTimer);
|
||||||
|
this.searchTimer = setTimeout(() => {
|
||||||
|
this.page.set(1);
|
||||||
|
this.loadApis();
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
onTypeChange(value: string) {
|
||||||
|
this.typeFilter.set(value as ApiType | '');
|
||||||
|
this.page.set(1);
|
||||||
|
this.loadApis();
|
||||||
|
}
|
||||||
|
|
||||||
|
setPage(p: number) {
|
||||||
|
if (p < 1 || p > this.totalPages()) return;
|
||||||
|
this.page.set(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
badgeClass(status: string): string {
|
||||||
|
const base = 'fr-badge';
|
||||||
|
if (status === 'GENERATED') return `${base} fr-badge--success`;
|
||||||
|
if (status === 'ERROR') return `${base} fr-badge--error`;
|
||||||
|
return `${base} fr-badge--info`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadApis() {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.apiService
|
||||||
|
.list({
|
||||||
|
search: this.search() || undefined,
|
||||||
|
type: (this.typeFilter() as ApiType) || undefined,
|
||||||
|
page: this.page(),
|
||||||
|
limit: this.limit,
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (res) => {
|
||||||
|
this.apis.set(res.items);
|
||||||
|
this.total.set(res.total);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: () => this.loading.set(false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
0
front-public/src/app/pages/doc-viewer/.gitkeep
Normal file
0
front-public/src/app/pages/doc-viewer/.gitkeep
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
OnInit,
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
|
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-doc-viewer',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterLink],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
styles: [`
|
||||||
|
.doc-toolbar {
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 1rem;
|
||||||
|
background: var(--background-default-grey);
|
||||||
|
border-bottom: 1px solid var(--border-default-grey);
|
||||||
|
}
|
||||||
|
.doc-iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100vh - 48px - 73px);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
`],
|
||||||
|
template: `
|
||||||
|
<div class="doc-toolbar">
|
||||||
|
<a
|
||||||
|
[routerLink]="['/catalog', id()]"
|
||||||
|
class="fr-btn fr-btn--tertiary-no-outline fr-btn--icon-left fr-icon-arrow-left-line fr-btn--sm"
|
||||||
|
>
|
||||||
|
Retour
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@if (docUrl()) {
|
||||||
|
<iframe
|
||||||
|
class="doc-iframe"
|
||||||
|
[src]="docUrl()!"
|
||||||
|
title="Documentation API"
|
||||||
|
sandbox="allow-scripts allow-same-origin"
|
||||||
|
></iframe>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class DocViewerComponent implements OnInit {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private sanitizer = inject(DomSanitizer);
|
||||||
|
|
||||||
|
id = signal('');
|
||||||
|
docUrl = signal<SafeResourceUrl | null>(null);
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
const apiId = this.route.snapshot.paramMap.get('id') ?? '';
|
||||||
|
this.id.set(apiId);
|
||||||
|
this.docUrl.set(
|
||||||
|
this.sanitizer.bypassSecurityTrustResourceUrl(`/api/apis/${apiId}/docs`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
0
front-public/src/app/pages/upload/.gitkeep
Normal file
0
front-public/src/app/pages/upload/.gitkeep
Normal file
422
front-public/src/app/pages/upload/upload.component.ts
Normal file
422
front-public/src/app/pages/upload/upload.component.ts
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
signal,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
OnInit,
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
|
||||||
|
import { Router, RouterLink } from '@angular/router';
|
||||||
|
import { ApiService } from '../../core/api.service';
|
||||||
|
import { CategoryService } from '../../core/category.service';
|
||||||
|
import { Category } from '@datacat/shared';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-upload',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, ReactiveFormsModule, RouterLink],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `
|
||||||
|
<div class="fr-container fr-mt-4w fr-mb-4w">
|
||||||
|
<!-- Fil d'Ariane -->
|
||||||
|
<nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici">
|
||||||
|
<ol class="fr-breadcrumb__list">
|
||||||
|
<li><a class="fr-breadcrumb__link" routerLink="/browse">Parcourir</a></li>
|
||||||
|
<li><a class="fr-breadcrumb__link" routerLink="/catalog">Catalogue</a></li>
|
||||||
|
<li><a class="fr-breadcrumb__link" aria-current="page">Importer une API</a></li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1 class="fr-h2 fr-mb-4w">Importer une API</h1>
|
||||||
|
|
||||||
|
<!-- Stepper -->
|
||||||
|
<div class="fr-stepper fr-mb-4w">
|
||||||
|
<h2 class="fr-stepper__title">
|
||||||
|
{{ stepTitles[step() - 1] }}
|
||||||
|
<span class="fr-stepper__state">Étape {{ step() }} sur 3</span>
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
class="fr-stepper__steps"
|
||||||
|
[attr.data-fr-current-step]="step()"
|
||||||
|
data-fr-steps="3"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Étape 1 : Fichier(s) YAML -->
|
||||||
|
@if (step() === 1) {
|
||||||
|
<div class="fr-upload-group fr-mb-4w">
|
||||||
|
<label class="fr-label" for="file-input">
|
||||||
|
Fichier(s) YAML
|
||||||
|
<span class="fr-hint-text">
|
||||||
|
Formats acceptés : .yaml, .yml — Sélectionnez plusieurs fichiers si votre spec utilise des $ref
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="file-input"
|
||||||
|
class="fr-upload"
|
||||||
|
type="file"
|
||||||
|
accept=".yaml,.yml"
|
||||||
|
multiple
|
||||||
|
(change)="onFileChange($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (fileError()) {
|
||||||
|
<div class="fr-alert fr-alert--error fr-mb-3w">
|
||||||
|
<p>{{ fileError() }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (selectedFiles().length > 0) {
|
||||||
|
<div class="fr-mb-3w">
|
||||||
|
@for (file of selectedFiles(); track file.name) {
|
||||||
|
<div class="fr-callout fr-callout--blue-cumulus fr-mb-1w" style="display:flex;align-items:center;justify-content:space-between;gap:1rem;">
|
||||||
|
<span>
|
||||||
|
<strong>{{ file.name }}</strong>
|
||||||
|
({{ (file.size / 1024).toFixed(1) }} Ko)
|
||||||
|
</span>
|
||||||
|
@if (file.name === detectedMainFilename()) {
|
||||||
|
<span class="fr-badge fr-badge--success fr-badge--sm">Principal</span>
|
||||||
|
} @else if (selectedFiles().length > 1) {
|
||||||
|
<span class="fr-badge fr-badge--blue-cumulus fr-badge--sm">Secondaire</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="fr-btns-group fr-btns-group--inline-sm">
|
||||||
|
<button
|
||||||
|
class="fr-btn"
|
||||||
|
(click)="goToStep2()"
|
||||||
|
[disabled]="!canProceedFromStep1()"
|
||||||
|
>
|
||||||
|
Suivant
|
||||||
|
</button>
|
||||||
|
<a routerLink="/catalog" class="fr-btn fr-btn--secondary">Annuler</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Étape 2 : Métadonnées -->
|
||||||
|
@if (step() === 2) {
|
||||||
|
<form [formGroup]="metaForm" (ngSubmit)="goToStep3()" novalidate>
|
||||||
|
<div class="fr-input-group fr-mb-3w" [class.fr-input-group--error]="titleInvalid()">
|
||||||
|
<label class="fr-label" for="title-input">
|
||||||
|
Titre <span class="fr-hint-text">Obligatoire</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="title-input"
|
||||||
|
class="fr-input"
|
||||||
|
[class.fr-input--error]="titleInvalid()"
|
||||||
|
formControlName="title"
|
||||||
|
type="text"
|
||||||
|
placeholder="Nom de l'API"
|
||||||
|
/>
|
||||||
|
@if (titleInvalid()) {
|
||||||
|
<p class="fr-error-text">Le titre est obligatoire.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fr-input-group fr-mb-3w" [class.fr-input-group--error]="versionInvalid()">
|
||||||
|
<label class="fr-label" for="version-input">
|
||||||
|
Version <span class="fr-hint-text">Obligatoire</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="version-input"
|
||||||
|
class="fr-input"
|
||||||
|
[class.fr-input--error]="versionInvalid()"
|
||||||
|
formControlName="version"
|
||||||
|
type="text"
|
||||||
|
placeholder="1.0.0"
|
||||||
|
/>
|
||||||
|
@if (versionInvalid()) {
|
||||||
|
<p class="fr-error-text">La version est obligatoire.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fr-select-group fr-mb-3w">
|
||||||
|
<label class="fr-label" for="type-input">
|
||||||
|
Type <span class="fr-hint-text">Obligatoire</span>
|
||||||
|
</label>
|
||||||
|
<select id="type-input" class="fr-select" formControlName="type">
|
||||||
|
<option value="ASYNCAPI">AsyncAPI</option>
|
||||||
|
<option value="OPENAPI">OpenAPI</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fr-select-group fr-mb-3w">
|
||||||
|
<label class="fr-label" for="category-input">
|
||||||
|
Catégorie <span class="fr-hint-text">Optionnel</span>
|
||||||
|
</label>
|
||||||
|
<select id="category-input" class="fr-select" formControlName="categoryId">
|
||||||
|
<option value="">— Sans catégorie —</option>
|
||||||
|
@for (cat of categories(); track cat.id) {
|
||||||
|
<option [value]="cat.id">{{ categoryLabel(cat) }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fr-input-group fr-mb-4w">
|
||||||
|
<label class="fr-label" for="description-input">
|
||||||
|
Description <span class="fr-hint-text">Optionnel</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="description-input"
|
||||||
|
class="fr-input"
|
||||||
|
formControlName="description"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Description de l'API..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fr-btns-group fr-btns-group--inline-sm">
|
||||||
|
<button type="submit" class="fr-btn" [disabled]="metaForm.invalid">Suivant</button>
|
||||||
|
<button type="button" class="fr-btn fr-btn--secondary" (click)="step.set(1)">
|
||||||
|
Retour
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Étape 3 : Confirmation -->
|
||||||
|
@if (step() === 3) {
|
||||||
|
<div class="fr-card fr-mb-4w">
|
||||||
|
<div class="fr-card__body">
|
||||||
|
<div class="fr-card__content">
|
||||||
|
<h2 class="fr-h5 fr-mb-3w">Récapitulatif</h2>
|
||||||
|
<dl class="fr-grid-row fr-grid-row--gutters">
|
||||||
|
<div class="fr-col-12 fr-col-md-6">
|
||||||
|
<dt class="fr-text--bold">Fichier{{ selectedFiles().length > 1 ? 's' : '' }}</dt>
|
||||||
|
<dd>
|
||||||
|
@if (selectedFiles().length === 1) {
|
||||||
|
{{ selectedFiles()[0].name }}
|
||||||
|
} @else {
|
||||||
|
@for (f of selectedFiles(); track f.name) {
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||||
|
{{ f.name }}
|
||||||
|
@if (f.name === detectedMainFilename()) {
|
||||||
|
<span class="fr-badge fr-badge--success fr-badge--sm">Principal</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="fr-col-12 fr-col-md-6">
|
||||||
|
<dt class="fr-text--bold">Type</dt>
|
||||||
|
<dd>{{ metaForm.value.type }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="fr-col-12 fr-col-md-6">
|
||||||
|
<dt class="fr-text--bold">Titre</dt>
|
||||||
|
<dd>{{ metaForm.value.title }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="fr-col-12 fr-col-md-6">
|
||||||
|
<dt class="fr-text--bold">Version</dt>
|
||||||
|
<dd>{{ metaForm.value.version }}</dd>
|
||||||
|
</div>
|
||||||
|
@if (metaForm.value.categoryId) {
|
||||||
|
<div class="fr-col-12 fr-col-md-6">
|
||||||
|
<dt class="fr-text--bold">Catégorie</dt>
|
||||||
|
<dd>{{ selectedCategoryName() }}</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (metaForm.value.description) {
|
||||||
|
<div class="fr-col-12">
|
||||||
|
<dt class="fr-text--bold">Description</dt>
|
||||||
|
<dd>{{ metaForm.value.description }}</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (uploadError()) {
|
||||||
|
<div class="fr-alert fr-alert--error fr-mb-3w">
|
||||||
|
<p class="fr-alert__title">Erreur lors de l'import</p>
|
||||||
|
<p>{{ uploadError() }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="fr-btns-group fr-btns-group--inline-sm">
|
||||||
|
<button
|
||||||
|
class="fr-btn"
|
||||||
|
(click)="onSubmit()"
|
||||||
|
[disabled]="uploading()"
|
||||||
|
>
|
||||||
|
{{ uploading() ? 'Import en cours...' : 'Importer' }}
|
||||||
|
</button>
|
||||||
|
<button class="fr-btn fr-btn--secondary" (click)="step.set(2)" [disabled]="uploading()">
|
||||||
|
Retour
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class UploadComponent implements OnInit {
|
||||||
|
private fb = inject(FormBuilder);
|
||||||
|
private apiService = inject(ApiService);
|
||||||
|
private categoryService = inject(CategoryService);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
step = signal(1);
|
||||||
|
selectedFiles = signal<File[]>([]);
|
||||||
|
detectedType = signal<'ASYNCAPI' | 'OPENAPI' | null>(null);
|
||||||
|
detectedMainFilename = signal<string | null>(null);
|
||||||
|
fileError = signal<string | null>(null);
|
||||||
|
uploading = signal(false);
|
||||||
|
uploadError = signal<string | null>(null);
|
||||||
|
categories = signal<Category[]>([]);
|
||||||
|
|
||||||
|
readonly stepTitles = [
|
||||||
|
'Choisir le fichier',
|
||||||
|
'Renseigner les métadonnées',
|
||||||
|
"Confirmer l'import",
|
||||||
|
];
|
||||||
|
|
||||||
|
metaForm = this.fb.group({
|
||||||
|
title: ['', Validators.required],
|
||||||
|
version: ['', Validators.required],
|
||||||
|
type: ['ASYNCAPI', Validators.required],
|
||||||
|
categoryId: [''],
|
||||||
|
description: [''],
|
||||||
|
});
|
||||||
|
|
||||||
|
titleInvalid = computed(
|
||||||
|
() => !!(this.metaForm.get('title')?.invalid && this.metaForm.get('title')?.touched),
|
||||||
|
);
|
||||||
|
versionInvalid = computed(
|
||||||
|
() => !!(this.metaForm.get('version')?.invalid && this.metaForm.get('version')?.touched),
|
||||||
|
);
|
||||||
|
selectedCategoryName = computed(() => {
|
||||||
|
const id = this.metaForm.value.categoryId;
|
||||||
|
if (!id) return '';
|
||||||
|
return this.categories().find((c) => c.id === id)?.name ?? '';
|
||||||
|
});
|
||||||
|
canProceedFromStep1 = computed(() => {
|
||||||
|
if (this.selectedFiles().length === 0) return false;
|
||||||
|
if (this.selectedFiles().length === 1) return true;
|
||||||
|
/* Plusieurs fichiers : un fichier principal doit être détecté */
|
||||||
|
return this.detectedMainFilename() !== null;
|
||||||
|
});
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.categoryService.list().subscribe({
|
||||||
|
next: (res) => this.categories.set(res.items),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryLabel(cat: Category): string {
|
||||||
|
/* Indente visuellement les sous-catégories via le parentId */
|
||||||
|
const hasParent = !!cat.parentId;
|
||||||
|
return hasParent ? ` └ ${cat.name}` : cat.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
onFileChange(event: Event) {
|
||||||
|
this.fileError.set(null);
|
||||||
|
this.detectedType.set(null);
|
||||||
|
this.detectedMainFilename.set(null);
|
||||||
|
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const fileList = input.files;
|
||||||
|
if (!fileList || fileList.length === 0) {
|
||||||
|
this.selectedFiles.set([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = Array.from(fileList);
|
||||||
|
const invalid = files.find((f) => !f.name.match(/\.(yaml|yml)$/i));
|
||||||
|
if (invalid) {
|
||||||
|
this.fileError.set('Seuls les fichiers .yaml et .yml sont acceptés.');
|
||||||
|
this.selectedFiles.set([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedFiles.set(files);
|
||||||
|
|
||||||
|
/* Détection côté client du fichier principal (pré-remplissage du type) */
|
||||||
|
void this.detectMainFileClient(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
goToStep2() {
|
||||||
|
if (!this.canProceedFromStep1()) return;
|
||||||
|
this.step.set(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
goToStep3() {
|
||||||
|
if (this.metaForm.invalid) {
|
||||||
|
this.metaForm.markAllAsTouched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.step.set(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit() {
|
||||||
|
const files = this.selectedFiles();
|
||||||
|
if (files.length === 0 || this.metaForm.invalid) return;
|
||||||
|
|
||||||
|
this.uploading.set(true);
|
||||||
|
this.uploadError.set(null);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const f of files) {
|
||||||
|
formData.append('files', f);
|
||||||
|
}
|
||||||
|
formData.append('title', this.metaForm.value.title ?? '');
|
||||||
|
formData.append('version', this.metaForm.value.version ?? '');
|
||||||
|
formData.append('type', this.metaForm.value.type ?? 'ASYNCAPI');
|
||||||
|
if (this.metaForm.value.categoryId) {
|
||||||
|
formData.append('categoryId', this.metaForm.value.categoryId);
|
||||||
|
}
|
||||||
|
if (this.metaForm.value.description) {
|
||||||
|
formData.append('description', this.metaForm.value.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.apiService.upload(formData).subscribe({
|
||||||
|
next: (entry) => {
|
||||||
|
this.uploading.set(false);
|
||||||
|
this.router.navigate(['/catalog', entry.id]);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.uploading.set(false);
|
||||||
|
this.uploadError.set(err.error?.message ?? "Erreur lors de l'import");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async detectMainFileClient(files: File[]): Promise<void> {
|
||||||
|
for (const file of files) {
|
||||||
|
const content = await this.readFileAsText(file);
|
||||||
|
if (/^openapi\s*:/m.test(content)) {
|
||||||
|
this.detectedType.set('OPENAPI');
|
||||||
|
this.detectedMainFilename.set(file.name);
|
||||||
|
this.metaForm.patchValue({ type: 'OPENAPI' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (/^asyncapi\s*:/m.test(content)) {
|
||||||
|
this.detectedType.set('ASYNCAPI');
|
||||||
|
this.detectedMainFilename.set(file.name);
|
||||||
|
this.metaForm.patchValue({ type: 'ASYNCAPI' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Aucun fichier principal détecté parmi plusieurs fichiers */
|
||||||
|
if (files.length > 1) {
|
||||||
|
this.fileError.set(
|
||||||
|
'Aucun fichier principal détecté (clé openapi: ou asyncapi: requise dans au moins un fichier).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readFileAsText(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => resolve((e.target?.result as string) ?? '');
|
||||||
|
reader.onerror = () => reject(new Error('Lecture fichier échouée'));
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
0
front-public/src/app/shared/.gitkeep
Normal file
0
front-public/src/app/shared/.gitkeep
Normal file
19
front-public/src/index.html
Normal file
19
front-public/src/index.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Datacat</title>
|
||||||
|
<base href="/" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
|
<!-- DSFR -->
|
||||||
|
<link rel="stylesheet" href="assets/dsfr/dsfr.min.css" />
|
||||||
|
<link rel="stylesheet" href="assets/dsfr/utility/icons/icons.min.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<app-root></app-root>
|
||||||
|
<!-- DSFR JS -->
|
||||||
|
<script type="module" src="assets/dsfr/dsfr.module.min.js"></script>
|
||||||
|
<script type="text/javascript" nomodule src="assets/dsfr/dsfr.nomodule.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
7
front-public/src/main.ts
Normal file
7
front-public/src/main.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { AppComponent } from './app/app.component';
|
||||||
|
import { appConfig } from './app/app.config';
|
||||||
|
|
||||||
|
bootstrapApplication(AppComponent, appConfig).catch((err) =>
|
||||||
|
console.error(err),
|
||||||
|
);
|
||||||
8
front-public/src/styles.scss
Normal file
8
front-public/src/styles.scss
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// DSFR is loaded via assets (see index.html)
|
||||||
|
// This file is for global app styles only
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
25
front-public/tsconfig.json
Normal file
25
front-public/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"lib": ["ES2022", "dom"],
|
||||||
|
"outDir": "./dist/out-tsc",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"paths": {
|
||||||
|
"@datacat/shared": ["../shared/src/index.ts"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
24
infra/Dockerfile.back.dev
Normal file
24
infra/Dockerfile.back.dev
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
# Install git (required by asyncapi html-template installer)
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
# Install pnpm
|
||||||
|
RUN npm install -g pnpm@10.33.0
|
||||||
|
|
||||||
|
# Install AsyncAPI CLI and Redocly CLI for docs generation
|
||||||
|
RUN npm install -g @asyncapi/cli@6.0.0 @redocly/cli
|
||||||
|
|
||||||
|
# Pre-cache the AsyncAPI html-template so generation works on first startup
|
||||||
|
RUN mkdir -p /tmp/asyncapi-warmup /tmp/asyncapi-warmup/out && \
|
||||||
|
printf 'asyncapi: "3.0.0"\ninfo:\n title: Warmup\n version: "1.0.0"\nchannels: {}\n' \
|
||||||
|
> /tmp/asyncapi-warmup/spec.yaml && \
|
||||||
|
asyncapi generate fromTemplate /tmp/asyncapi-warmup/spec.yaml @asyncapi/html-template \
|
||||||
|
--output /tmp/asyncapi-warmup/out --no-interactive --install --force-write 2>&1 || true && \
|
||||||
|
rm -rf /tmp/asyncapi-warmup
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["pnpm", "run", "start:dev"]
|
||||||
10
infra/Dockerfile.front.dev
Normal file
10
infra/Dockerfile.front.dev
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
# Install pnpm
|
||||||
|
RUN npm install -g pnpm@10.33.0
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
EXPOSE 4200
|
||||||
|
|
||||||
|
CMD ["pnpm", "run", "start", "--", "--host", "0.0.0.0", "--poll", "500"]
|
||||||
113
infra/Dockerfile.prod
Normal file
113
infra/Dockerfile.prod
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Dockerfile Multi-Target pour datacat
|
||||||
|
# Usage:
|
||||||
|
# docker build --target back -t datacat/back:latest .
|
||||||
|
# docker build --target front -t datacat/front:latest .
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Stage: base-node
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
FROM node:22-alpine AS base-node
|
||||||
|
RUN npm install -g pnpm@10.33.0
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Stage: deps-back
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
FROM base-node AS deps-back
|
||||||
|
COPY pnpm-workspace.yaml ./
|
||||||
|
COPY package.json ./
|
||||||
|
COPY shared/package.json ./shared/
|
||||||
|
COPY back/package.json ./back/
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Stage: build-back
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
FROM deps-back AS build-back
|
||||||
|
COPY shared ./shared
|
||||||
|
COPY back ./back
|
||||||
|
WORKDIR /app/back
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Stage: deps-front
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
FROM base-node AS deps-front
|
||||||
|
COPY pnpm-workspace.yaml ./
|
||||||
|
COPY package.json ./
|
||||||
|
COPY shared/package.json ./shared/
|
||||||
|
COPY front-public/package.json ./front-public/
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Stage: build-front
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
FROM deps-front AS build-front
|
||||||
|
COPY shared ./shared
|
||||||
|
COPY front-public ./front-public
|
||||||
|
WORKDIR /app/front-public
|
||||||
|
ARG ANGULAR_API_URL=https://api.datacat.dev.chmod777.dev
|
||||||
|
ENV ANGULAR_API_URL=$ANGULAR_API_URL
|
||||||
|
RUN pnpm run build:prod
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# IMAGES FINALES
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Target: back
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
FROM node:22-alpine AS back
|
||||||
|
|
||||||
|
# Install git (required by asyncapi html-template installer) and tools
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
# Install AsyncAPI CLI and Redocly CLI for docs generation
|
||||||
|
RUN npm install -g pnpm@10.33.0 @asyncapi/cli@6.0.0 @redocly/cli
|
||||||
|
|
||||||
|
# Pre-cache the AsyncAPI html-template so generation works on first startup
|
||||||
|
RUN mkdir -p /tmp/asyncapi-warmup /tmp/asyncapi-warmup/out && \
|
||||||
|
printf 'asyncapi: "3.0.0"\ninfo:\n title: Warmup\n version: "1.0.0"\nchannels: {}\n' \
|
||||||
|
> /tmp/asyncapi-warmup/spec.yaml && \
|
||||||
|
asyncapi generate fromTemplate /tmp/asyncapi-warmup/spec.yaml @asyncapi/html-template \
|
||||||
|
--output /tmp/asyncapi-warmup/out --no-interactive --install --force-write 2>&1 || true && \
|
||||||
|
rm -rf /tmp/asyncapi-warmup
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=build-back /app/node_modules ./node_modules
|
||||||
|
COPY --from=build-back /app/shared ./shared
|
||||||
|
COPY --from=build-back /app/back/dist ./back/dist
|
||||||
|
COPY --from=build-back /app/back/package.json ./back/
|
||||||
|
|
||||||
|
# Volume mount point for generated docs
|
||||||
|
RUN mkdir -p /app/docs-output
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV DOCS_OUTPUT_DIR=/app/docs-output
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
|
||||||
|
|
||||||
|
WORKDIR /app/back
|
||||||
|
CMD ["node", "dist/main"]
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Target: front
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
FROM nginx:alpine AS front
|
||||||
|
|
||||||
|
COPY infra/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build-front /app/front-public/dist/front-public/browser /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
72
infra/docker-compose.yml
Normal file
72
infra/docker-compose.yml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
name: datacat
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: infra/Dockerfile.back.dev
|
||||||
|
ports:
|
||||||
|
- "3010:3000"
|
||||||
|
volumes:
|
||||||
|
- ..:/workspace
|
||||||
|
- back_node_modules:/workspace/back/node_modules
|
||||||
|
- shared_node_modules:/workspace/shared/node_modules
|
||||||
|
- docs_output:/app/docs-output
|
||||||
|
environment:
|
||||||
|
DATABASE_HOST: db
|
||||||
|
DATABASE_PORT: 5432
|
||||||
|
DATABASE_USER: datacat
|
||||||
|
DATABASE_PASSWORD: datacat
|
||||||
|
DATABASE_NAME: datacat
|
||||||
|
NODE_ENV: development
|
||||||
|
DOCS_OUTPUT_DIR: /app/docs-output
|
||||||
|
working_dir: /workspace/back
|
||||||
|
command: sh -c "cd /workspace && pnpm install && cd /workspace/back && pnpm run start:dev"
|
||||||
|
networks:
|
||||||
|
- network
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: infra/Dockerfile.front.dev
|
||||||
|
ports:
|
||||||
|
- "4200:4200"
|
||||||
|
volumes:
|
||||||
|
- ..:/workspace
|
||||||
|
- front_node_modules:/workspace/front-public/node_modules
|
||||||
|
working_dir: /workspace/front-public
|
||||||
|
command: sh -c "cd /workspace && pnpm install && cd /workspace/front-public && node_modules/.bin/ng serve --host 0.0.0.0 --proxy-config proxy.conf.docker.json"
|
||||||
|
networks:
|
||||||
|
- network
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: datacat
|
||||||
|
POSTGRES_PASSWORD: datacat
|
||||||
|
POSTGRES_DB: datacat
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U datacat"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
networks:
|
||||||
|
network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
back_node_modules:
|
||||||
|
shared_node_modules:
|
||||||
|
front_node_modules:
|
||||||
|
postgres_data:
|
||||||
|
docs_output:
|
||||||
83
infra/k8s/backend.yaml
Normal file
83
infra/k8s/backend.yaml
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: backend
|
||||||
|
namespace: datacat
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: backend
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: backend
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: backend
|
||||||
|
image: git.chmod777.dev/z3n/datacat/back:latest
|
||||||
|
ports:
|
||||||
|
- containerPort: 3000
|
||||||
|
env:
|
||||||
|
- name: NODE_ENV
|
||||||
|
value: production
|
||||||
|
- name: PORT
|
||||||
|
value: "3000"
|
||||||
|
- name: DATABASE_HOST
|
||||||
|
value: postgres
|
||||||
|
- name: DATABASE_PORT
|
||||||
|
value: "5432"
|
||||||
|
- name: DATABASE_NAME
|
||||||
|
value: datacat
|
||||||
|
- name: DATABASE_USER
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: datacat-secrets
|
||||||
|
key: db-user
|
||||||
|
- name: DATABASE_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: datacat-secrets
|
||||||
|
key: db-password
|
||||||
|
- name: DOCS_OUTPUT_DIR
|
||||||
|
value: /app/docs-output
|
||||||
|
volumeMounts:
|
||||||
|
- name: docs-output
|
||||||
|
mountPath: /app/docs-output
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "256Mi"
|
||||||
|
cpu: "100m"
|
||||||
|
limits:
|
||||||
|
memory: "512Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 15
|
||||||
|
periodSeconds: 20
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
volumes:
|
||||||
|
- name: docs-output
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: datacat-docs-pvc
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: registry-secret
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: backend
|
||||||
|
namespace: datacat
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: backend
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 3000
|
||||||
47
infra/k8s/frontend.yaml
Normal file
47
infra/k8s/frontend.yaml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: frontend
|
||||||
|
namespace: datacat
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: frontend
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: frontend
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: frontend
|
||||||
|
image: git.chmod777.dev/z3n/datacat/front:latest
|
||||||
|
ports:
|
||||||
|
- containerPort: 80
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "64Mi"
|
||||||
|
cpu: "50m"
|
||||||
|
limits:
|
||||||
|
memory: "128Mi"
|
||||||
|
cpu: "200m"
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: 80
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 20
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: registry-secret
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: frontend
|
||||||
|
namespace: datacat
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: frontend
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 80
|
||||||
79
infra/k8s/ingressroutes.yaml
Normal file
79
infra/k8s/ingressroutes.yaml
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# IngressRoutes pour datacat dans le namespace dev
|
||||||
|
# Frontend: https://datacat.dev.chmod777.dev
|
||||||
|
# API: https://api.datacat.dev.chmod777.dev
|
||||||
|
|
||||||
|
# --- Frontend HTTP → HTTPS redirect ---
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: IngressRoute
|
||||||
|
metadata:
|
||||||
|
name: frontend-http
|
||||||
|
namespace: datacat
|
||||||
|
spec:
|
||||||
|
entryPoints: [web]
|
||||||
|
routes:
|
||||||
|
- match: Host(`datacat.dev.chmod777.dev`)
|
||||||
|
kind: Rule
|
||||||
|
middlewares:
|
||||||
|
- name: redirect-https
|
||||||
|
namespace: dev
|
||||||
|
services:
|
||||||
|
- name: frontend
|
||||||
|
port: 80
|
||||||
|
---
|
||||||
|
# --- Frontend HTTPS avec Basic Auth ---
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: IngressRoute
|
||||||
|
metadata:
|
||||||
|
name: frontend-https
|
||||||
|
namespace: datacat
|
||||||
|
spec:
|
||||||
|
entryPoints: [websecure]
|
||||||
|
routes:
|
||||||
|
- match: Host(`datacat.dev.chmod777.dev`)
|
||||||
|
kind: Rule
|
||||||
|
middlewares:
|
||||||
|
- name: dev-basic-auth
|
||||||
|
namespace: dev
|
||||||
|
services:
|
||||||
|
- name: frontend
|
||||||
|
port: 80
|
||||||
|
tls:
|
||||||
|
certResolver: letsencrypt
|
||||||
|
---
|
||||||
|
# --- API HTTP → HTTPS redirect ---
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: IngressRoute
|
||||||
|
metadata:
|
||||||
|
name: backend-http
|
||||||
|
namespace: datacat
|
||||||
|
spec:
|
||||||
|
entryPoints: [web]
|
||||||
|
routes:
|
||||||
|
- match: Host(`api.datacat.dev.chmod777.dev`)
|
||||||
|
kind: Rule
|
||||||
|
middlewares:
|
||||||
|
- name: redirect-https
|
||||||
|
namespace: dev
|
||||||
|
services:
|
||||||
|
- name: backend
|
||||||
|
port: 80
|
||||||
|
---
|
||||||
|
# --- API HTTPS avec Basic Auth ---
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: IngressRoute
|
||||||
|
metadata:
|
||||||
|
name: backend-https
|
||||||
|
namespace: datacat
|
||||||
|
spec:
|
||||||
|
entryPoints: [websecure]
|
||||||
|
routes:
|
||||||
|
- match: Host(`api.datacat.dev.chmod777.dev`)
|
||||||
|
kind: Rule
|
||||||
|
middlewares:
|
||||||
|
- name: dev-basic-auth
|
||||||
|
namespace: dev
|
||||||
|
services:
|
||||||
|
- name: backend
|
||||||
|
port: 80
|
||||||
|
tls:
|
||||||
|
certResolver: letsencrypt
|
||||||
10
infra/k8s/kustomization.yaml
Normal file
10
infra/k8s/kustomization.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- namespace.yaml
|
||||||
|
- pvc.yaml
|
||||||
|
- postgres.yaml
|
||||||
|
- backend.yaml
|
||||||
|
- frontend.yaml
|
||||||
|
- ingressroutes.yaml
|
||||||
4
infra/k8s/namespace.yaml
Normal file
4
infra/k8s/namespace.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: datacat
|
||||||
59
infra/k8s/postgres.yaml
Normal file
59
infra/k8s/postgres.yaml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
namespace: datacat
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: postgres
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: postgres
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: postgres
|
||||||
|
image: postgres:17-alpine
|
||||||
|
ports:
|
||||||
|
- containerPort: 5432
|
||||||
|
env:
|
||||||
|
- name: POSTGRES_USER
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: datacat-secrets
|
||||||
|
key: db-user
|
||||||
|
- name: POSTGRES_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: datacat-secrets
|
||||||
|
key: db-password
|
||||||
|
- name: POSTGRES_DB
|
||||||
|
value: datacat
|
||||||
|
volumeMounts:
|
||||||
|
- name: postgres-data
|
||||||
|
mountPath: /var/lib/postgresql/data
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "256Mi"
|
||||||
|
cpu: "100m"
|
||||||
|
limits:
|
||||||
|
memory: "512Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
volumes:
|
||||||
|
- name: postgres-data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: datacat-postgres-pvc
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
namespace: datacat
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: postgres
|
||||||
|
ports:
|
||||||
|
- port: 5432
|
||||||
|
targetPort: 5432
|
||||||
23
infra/k8s/pvc.yaml
Normal file
23
infra/k8s/pvc.yaml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: datacat-docs-pvc
|
||||||
|
namespace: datacat
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 5Gi
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: datacat-postgres-pvc
|
||||||
|
namespace: datacat
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 10Gi
|
||||||
26
infra/nginx.conf
Normal file
26
infra/nginx.conf
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
|
||||||
|
|
||||||
|
# Angular SPA routing
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# No cache for index.html
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
}
|
||||||
|
}
|
||||||
11
package.json
Normal file
11
package.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "datacat",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "docker compose -f infra/docker-compose.yml up",
|
||||||
|
"dev:build": "docker compose -f infra/docker-compose.yml up --build",
|
||||||
|
"back:dev": "cd back && pnpm run start:dev",
|
||||||
|
"front:dev": "cd front-public && pnpm run start"
|
||||||
|
}
|
||||||
|
}
|
||||||
11343
pnpm-lock.yaml
generated
Normal file
11343
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
4
pnpm-workspace.yaml
Normal file
4
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
packages:
|
||||||
|
- "back"
|
||||||
|
- "front-public"
|
||||||
|
- "shared"
|
||||||
7
shared/package.json
Normal file
7
shared/package.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "@datacat/shared",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"types": "src/index.ts"
|
||||||
|
}
|
||||||
3
shared/src/index.ts
Normal file
3
shared/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './types/api-entry.types';
|
||||||
|
export * from './schemas/api-entry.schema';
|
||||||
|
export * from './types/category.types';
|
||||||
19
shared/src/schemas/api-entry.schema.ts
Normal file
19
shared/src/schemas/api-entry.schema.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Zod schemas for validation (used in backend)
|
||||||
|
// Install zod in back/ if needed: pnpm add zod
|
||||||
|
|
||||||
|
export const API_TYPES = ['ASYNCAPI', 'OPENAPI'] as const;
|
||||||
|
export const API_STATUSES = ['PENDING', 'GENERATED', 'ERROR'] as const;
|
||||||
|
|
||||||
|
export interface CreateApiEntryDto {
|
||||||
|
title: string;
|
||||||
|
version: string;
|
||||||
|
description?: string;
|
||||||
|
type: 'ASYNCAPI' | 'OPENAPI';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateApiEntryDto {
|
||||||
|
title?: string;
|
||||||
|
version?: string;
|
||||||
|
description?: string;
|
||||||
|
categoryId?: string | null;
|
||||||
|
}
|
||||||
45
shared/src/types/api-entry.types.ts
Normal file
45
shared/src/types/api-entry.types.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
export type ApiType = 'ASYNCAPI' | 'OPENAPI';
|
||||||
|
|
||||||
|
export type ApiStatus = 'PENDING' | 'GENERATED' | 'ERROR';
|
||||||
|
|
||||||
|
export interface ApiEntry {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
version: string;
|
||||||
|
description: string | null;
|
||||||
|
type: ApiType;
|
||||||
|
yamlContent: string;
|
||||||
|
htmlPath: string | null;
|
||||||
|
status: ApiStatus;
|
||||||
|
errorMessage: string | null;
|
||||||
|
categoryId: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiEntryListItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
version: string;
|
||||||
|
description: string | null;
|
||||||
|
type: ApiType;
|
||||||
|
status: ApiStatus;
|
||||||
|
categoryId: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiListResponse {
|
||||||
|
items: ApiEntryListItem[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiListQuery {
|
||||||
|
search?: string;
|
||||||
|
type?: ApiType;
|
||||||
|
categoryId?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
27
shared/src/types/category.types.ts
Normal file
27
shared/src/types/category.types.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { ApiEntryListItem } from './api-entry.types';
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
parentId: string | null;
|
||||||
|
orderIndex: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryWithCounts extends Category {
|
||||||
|
subcategoryCount: number;
|
||||||
|
apiCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryBrowseResponse {
|
||||||
|
category: Category | null;
|
||||||
|
breadcrumb: Category[];
|
||||||
|
subcategories: CategoryWithCounts[];
|
||||||
|
apis: ApiEntryListItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryListResponse {
|
||||||
|
items: Category[];
|
||||||
|
}
|
||||||
10
shared/tsconfig.json
Normal file
10
shared/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"outDir": "./dist"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
13
tsconfig.base.json
Normal file
13
tsconfig.base.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user