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

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