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:
z3n
2026-05-22 08:42:54 +00:00
commit 921a6e652b
87 changed files with 16719 additions and 0 deletions

31
.gitignore vendored Normal file
View 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
View 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

1
CLAUDE.md Symbolic link
View File

@@ -0,0 +1 @@
documentation/CLAUDE.md

86
README.md Normal file
View 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
View 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
View 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
View 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
View 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
View File

View 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,
});
}
}

View File

View File

@@ -0,0 +1,9 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return { status: 'ok' };
}
}

View 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
View 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();

View File

View 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;
}

View 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);
}
}

View 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 {}

View 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(),
};
}

View 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;
}

View 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;
}

View 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);
}
}

View 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 {}

View 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(),
};
}

View 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;
}

View 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;
}

View 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;
}

View 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 {}

View 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'),
]);
}
}

View 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 {}

View 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);
}
}
}

View File

View 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);
}
}
}

View 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
View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

19
back/tsconfig.json Normal file
View 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
View 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
View 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.

View 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`) |

View 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
View 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
View 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"
}
}

View File

@@ -0,0 +1,6 @@
{
"/api": {
"target": "http://backend:3000",
"secure": false
}
}

View File

@@ -0,0 +1,6 @@
{
"/api": {
"target": "http://localhost:3000",
"secure": false
}
}

View 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 {}

View 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()),
],
};

View 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',
},
];

View File

View 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);
}
}

View 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}`);
}
}

View 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;
}
}
}

View 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>
&nbsp;
<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';
}
}

View 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),
});
}
}

View 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`),
);
}
}

View 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);
});
}
}

View File

View 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
View 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),
);

View 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;
}

View 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
View 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"]

View 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
View 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
View 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
View 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
View 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

View 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

View 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
View File

@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: datacat

59
infra/k8s/postgres.yaml Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

4
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,4 @@
packages:
- "back"
- "front-public"
- "shared"

7
shared/package.json Normal file
View 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
View File

@@ -0,0 +1,3 @@
export * from './types/api-entry.types';
export * from './schemas/api-entry.schema';
export * from './types/category.types';

View 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;
}

View 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;
}

View 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
View 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
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
}
}