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:
13
back/.env.example
Normal file
13
back/.env.example
Normal file
@@ -0,0 +1,13 @@
|
||||
# Database
|
||||
DATABASE_HOST=localhost
|
||||
DATABASE_PORT=5432
|
||||
DATABASE_USER=datacat
|
||||
DATABASE_PASSWORD=datacat
|
||||
DATABASE_NAME=datacat
|
||||
|
||||
# App
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# Docs generation
|
||||
DOCS_OUTPUT_DIR=/app/docs-output
|
||||
9
back/nest-cli.json
Normal file
9
back/nest-cli.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
},
|
||||
"entryFile": "back/src/main"
|
||||
}
|
||||
42
back/package.json
Normal file
42
back/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@datacat/back",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "node dist/main",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/serve-static": "^5.0.0",
|
||||
"typeorm": "^0.3.20",
|
||||
"pg": "^8.13.0",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"uuid": "^11.0.0",
|
||||
"pino": "^9.0.0",
|
||||
"pino-http": "^10.0.0",
|
||||
"class-validator": "^0.14.0",
|
||||
"class-transformer": "^0.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
36
back/src/app.module.ts
Normal file
36
back/src/app.module.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { ApisModule } from './modules/apis/apis.module';
|
||||
import { UploadsModule } from './modules/uploads/uploads.module';
|
||||
import { DocsGenerationModule } from './modules/docs-generation/docs-generation.module';
|
||||
import { CategoriesModule } from './modules/categories/categories.module';
|
||||
import { SeederModule } from './modules/seeder/seeder.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
type: 'postgres',
|
||||
host: config.get('DATABASE_HOST', 'localhost'),
|
||||
port: config.get<number>('DATABASE_PORT', 5432),
|
||||
username: config.get('DATABASE_USER', 'datacat'),
|
||||
password: config.get('DATABASE_PASSWORD', 'datacat'),
|
||||
database: config.get('DATABASE_NAME', 'datacat'),
|
||||
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||
synchronize: config.get('NODE_ENV') !== 'production',
|
||||
logging: config.get('NODE_ENV') === 'development',
|
||||
}),
|
||||
}),
|
||||
HealthModule,
|
||||
ApisModule,
|
||||
UploadsModule,
|
||||
DocsGenerationModule,
|
||||
CategoriesModule,
|
||||
SeederModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
0
back/src/common/.gitkeep
Normal file
0
back/src/common/.gitkeep
Normal file
28
back/src/common/filters/all-exceptions.filter.ts
Normal file
28
back/src/common/filters/all-exceptions.filter.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const status =
|
||||
exception instanceof HttpException
|
||||
? exception.getStatus()
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
const message =
|
||||
exception instanceof HttpException
|
||||
? exception.getResponse()
|
||||
: 'Internal server error';
|
||||
|
||||
response.status(status).json({
|
||||
statusCode: status,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
0
back/src/database/.gitkeep
Normal file
0
back/src/database/.gitkeep
Normal file
9
back/src/health/health.controller.ts
Normal file
9
back/src/health/health.controller.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
}
|
||||
7
back/src/health/health.module.ts
Normal file
7
back/src/health/health.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
22
back/src/main.ts
Normal file
22
back/src/main.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe, RequestMethod } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.setGlobalPrefix('api', {
|
||||
exclude: [{ path: 'health', method: RequestMethod.GET }],
|
||||
});
|
||||
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
app.useGlobalFilters(new AllExceptionsFilter());
|
||||
app.enableCors();
|
||||
|
||||
const port = process.env.PORT ?? 3000;
|
||||
await app.listen(port);
|
||||
console.log(`Datacat API running on port ${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
0
back/src/modules/apis/.gitkeep
Normal file
0
back/src/modules/apis/.gitkeep
Normal file
41
back/src/modules/apis/api-entry.entity.ts
Normal file
41
back/src/modules/apis/api-entry.entity.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
import { ApiType, ApiStatus } from '@datacat/shared';
|
||||
|
||||
@Entity('api_entries')
|
||||
export class ApiEntryEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column()
|
||||
title!: string;
|
||||
|
||||
@Column()
|
||||
version!: string;
|
||||
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
description!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 10 })
|
||||
type!: ApiType;
|
||||
|
||||
@Column({ type: 'text', name: 'yaml_content' })
|
||||
yamlContent!: string;
|
||||
|
||||
@Column({ nullable: true, type: 'varchar', length: 500, name: 'html_path' })
|
||||
htmlPath!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 10, default: 'PENDING' })
|
||||
status!: ApiStatus;
|
||||
|
||||
@Column({ nullable: true, type: 'text', name: 'error_message' })
|
||||
errorMessage!: string | null;
|
||||
|
||||
@Column({ nullable: true, type: 'varchar', length: 36, name: 'category_id' })
|
||||
categoryId!: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
82
back/src/modules/apis/apis.controller.ts
Normal file
82
back/src/modules/apis/apis.controller.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Patch,
|
||||
Delete,
|
||||
Post,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
HttpCode,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import * as path from 'path';
|
||||
import { ApisService } from './apis.service';
|
||||
import { UpdateApiEntryDto } from './dto/update-api-entry.dto';
|
||||
import { ApiListQuery } from '@datacat/shared';
|
||||
|
||||
@Controller('apis')
|
||||
export class ApisController {
|
||||
constructor(private readonly apisService: ApisService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@Query() query: ApiListQuery) {
|
||||
return this.apisService.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.apisService.findOneOrThrow(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateApiEntryDto) {
|
||||
return this.apisService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
remove(@Param('id') id: string) {
|
||||
return this.apisService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/regenerate')
|
||||
regenerate(@Param('id') id: string) {
|
||||
return this.apisService.regenerate(id);
|
||||
}
|
||||
|
||||
@Get(':id/docs')
|
||||
async getDocs(@Param('id') id: string, @Res() res: Response) {
|
||||
const entry = await this.apisService.findOneOrThrow(id);
|
||||
if (!entry.htmlPath) throw new NotFoundException('Documentation not generated yet');
|
||||
res.sendFile(entry.htmlPath);
|
||||
}
|
||||
|
||||
@Get(':id/yaml')
|
||||
async getYaml(@Param('id') id: string, @Res() res: Response) {
|
||||
const entry = await this.apisService.findOneOrThrow(id);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${entry.title}.yaml"`);
|
||||
res.setHeader('Content-Type', 'application/x-yaml');
|
||||
res.send(entry.yamlContent);
|
||||
}
|
||||
|
||||
/** Sert les assets statiques (CSS, JS) générés par AsyncAPI CLI */
|
||||
@Get(':id/*path')
|
||||
async getDocAsset(@Param('id') id: string, @Req() req: Request, @Res() res: Response) {
|
||||
const entry = await this.apisService.findOneOrThrow(id);
|
||||
if (!entry.htmlPath) throw new NotFoundException('Documentation not generated yet');
|
||||
const entryDir = path.dirname(entry.htmlPath);
|
||||
/* Extraire le path depuis l'URL brute pour éviter le bug path-to-regexp v8 (virgules au lieu de /) */
|
||||
const prefix = `/api/apis/${id}/`;
|
||||
const assetPath = req.url.startsWith(prefix) ? req.url.slice(prefix.length) : '';
|
||||
/* Prévenir la traversée de répertoire */
|
||||
const resolved = path.resolve(entryDir, assetPath);
|
||||
if (!resolved.startsWith(path.resolve(entryDir) + path.sep)) {
|
||||
throw new NotFoundException('Asset not found');
|
||||
}
|
||||
res.sendFile(resolved);
|
||||
}
|
||||
}
|
||||
17
back/src/modules/apis/apis.module.ts
Normal file
17
back/src/modules/apis/apis.module.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ApiEntryEntity } from './api-entry.entity';
|
||||
import { ApisService } from './apis.service';
|
||||
import { ApisController } from './apis.controller';
|
||||
import { DocsGenerationModule } from '../docs-generation/docs-generation.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ApiEntryEntity]),
|
||||
forwardRef(() => DocsGenerationModule),
|
||||
],
|
||||
providers: [ApisService],
|
||||
controllers: [ApisController],
|
||||
exports: [ApisService],
|
||||
})
|
||||
export class ApisModule {}
|
||||
135
back/src/modules/apis/apis.service.ts
Normal file
135
back/src/modules/apis/apis.service.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { Injectable, NotFoundException, Inject, forwardRef } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ApiEntryEntity } from './api-entry.entity';
|
||||
import { CreateApiEntryDto } from './dto/create-api-entry.dto';
|
||||
import { UpdateApiEntryDto } from './dto/update-api-entry.dto';
|
||||
import { ApiListResponse, ApiListQuery, ApiEntry, ApiEntryListItem, ApiType, ApiStatus } from '@datacat/shared';
|
||||
import { DocsGenerationService } from '../docs-generation/docs-generation.service';
|
||||
|
||||
/** Champs internes mis à jour par DocsGenerationService */
|
||||
interface InternalUpdateFields {
|
||||
title?: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
htmlPath?: string | null;
|
||||
status?: ApiStatus;
|
||||
errorMessage?: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApisService {
|
||||
constructor(
|
||||
@InjectRepository(ApiEntryEntity)
|
||||
private readonly repo: Repository<ApiEntryEntity>,
|
||||
@Inject(forwardRef(() => DocsGenerationService))
|
||||
private readonly docsService: DocsGenerationService,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateApiEntryDto): Promise<ApiEntry> {
|
||||
const entity = this.repo.create({
|
||||
title: dto.title,
|
||||
version: dto.version,
|
||||
description: dto.description ?? null,
|
||||
type: dto.type,
|
||||
yamlContent: dto.yamlContent,
|
||||
status: 'PENDING',
|
||||
categoryId: dto.categoryId ?? null,
|
||||
});
|
||||
const saved = await this.repo.save(entity);
|
||||
return toApiEntry(saved);
|
||||
}
|
||||
|
||||
async findAll(query: ApiListQuery): Promise<ApiListResponse> {
|
||||
const page = Math.max(1, Number(query.page ?? 1));
|
||||
const limit = Math.min(100, Math.max(1, Number(query.limit ?? 20)));
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const qb = this.repo.createQueryBuilder('api');
|
||||
|
||||
if (query.type) {
|
||||
qb.andWhere('api.type = :type', { type: query.type as ApiType });
|
||||
}
|
||||
if (query.search) {
|
||||
qb.andWhere(
|
||||
'(LOWER(api.title) LIKE :search OR LOWER(api.description) LIKE :search)',
|
||||
{ search: `%${query.search.toLowerCase()}%` },
|
||||
);
|
||||
}
|
||||
if (query.categoryId) {
|
||||
qb.andWhere('api.category_id = :categoryId', { categoryId: query.categoryId });
|
||||
}
|
||||
|
||||
qb.orderBy('api.created_at', 'DESC').skip(offset).take(limit);
|
||||
|
||||
const [entities, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
items: entities.map(toApiEntryListItem),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
async findOneOrThrow(id: string): Promise<ApiEntry> {
|
||||
const entity = await this.repo.findOne({ where: { id } });
|
||||
if (!entity) throw new NotFoundException(`API entry ${id} not found`);
|
||||
return toApiEntry(entity);
|
||||
}
|
||||
|
||||
/** Mise à jour publique (champs éditables par l'utilisateur) */
|
||||
async update(id: string, dto: UpdateApiEntryDto): Promise<ApiEntry> {
|
||||
await this.repo.update(id, dto);
|
||||
return this.findOneOrThrow(id);
|
||||
}
|
||||
|
||||
/** Mise à jour interne (statut, htmlPath, errorMessage) */
|
||||
async updateInternal(id: string, fields: InternalUpdateFields): Promise<void> {
|
||||
await this.repo.update(id, fields);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const entity = await this.repo.findOne({ where: { id } });
|
||||
if (!entity) throw new NotFoundException(`API entry ${id} not found`);
|
||||
await this.repo.delete(id);
|
||||
}
|
||||
|
||||
async regenerate(id: string): Promise<ApiEntry> {
|
||||
await this.findOneOrThrow(id);
|
||||
await this.updateInternal(id, { status: 'PENDING', errorMessage: null });
|
||||
this.docsService.generate(id).catch(console.error);
|
||||
return this.findOneOrThrow(id);
|
||||
}
|
||||
}
|
||||
|
||||
function toApiEntry(entity: ApiEntryEntity): ApiEntry {
|
||||
return {
|
||||
id: entity.id,
|
||||
title: entity.title,
|
||||
version: entity.version,
|
||||
description: entity.description,
|
||||
type: entity.type,
|
||||
yamlContent: entity.yamlContent,
|
||||
htmlPath: entity.htmlPath,
|
||||
status: entity.status,
|
||||
errorMessage: entity.errorMessage,
|
||||
categoryId: entity.categoryId,
|
||||
createdAt: entity.createdAt.toISOString(),
|
||||
updatedAt: entity.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem {
|
||||
return {
|
||||
id: entity.id,
|
||||
title: entity.title,
|
||||
version: entity.version,
|
||||
description: entity.description,
|
||||
type: entity.type,
|
||||
status: entity.status,
|
||||
categoryId: entity.categoryId,
|
||||
createdAt: entity.createdAt.toISOString(),
|
||||
updatedAt: entity.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
27
back/src/modules/apis/dto/create-api-entry.dto.ts
Normal file
27
back/src/modules/apis/dto/create-api-entry.dto.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { IsString, IsNotEmpty, IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
import { ApiType } from '@datacat/shared';
|
||||
|
||||
export class CreateApiEntryDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
version!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsIn(['ASYNCAPI', 'OPENAPI'])
|
||||
type!: ApiType;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
yamlContent!: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
categoryId?: string;
|
||||
}
|
||||
19
back/src/modules/apis/dto/update-api-entry.dto.ts
Normal file
19
back/src/modules/apis/dto/update-api-entry.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { IsString, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class UpdateApiEntryDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
title?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
version?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
categoryId?: string | null;
|
||||
}
|
||||
55
back/src/modules/categories/categories.controller.ts
Normal file
55
back/src/modules/categories/categories.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
HttpCode,
|
||||
} from '@nestjs/common';
|
||||
import { CategoriesService } from './categories.service';
|
||||
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||
import { UpdateCategoryDto } from './dto/update-category.dto';
|
||||
|
||||
@Controller('categories')
|
||||
export class CategoriesController {
|
||||
constructor(private readonly categoriesService: CategoriesService) {}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.categoriesService.findAll();
|
||||
}
|
||||
|
||||
/* Déclarés AVANT :id pour éviter que NestJS interprète "browse" comme un UUID */
|
||||
@Get('browse')
|
||||
browseRoot() {
|
||||
return this.categoriesService.browse(null);
|
||||
}
|
||||
|
||||
@Get('browse/:id')
|
||||
browseCategory(@Param('id') id: string) {
|
||||
return this.categoriesService.browse(id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.categoriesService.findOneOrThrow(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateCategoryDto) {
|
||||
return this.categoriesService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateCategoryDto) {
|
||||
return this.categoriesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
remove(@Param('id') id: string) {
|
||||
return this.categoriesService.remove(id);
|
||||
}
|
||||
}
|
||||
14
back/src/modules/categories/categories.module.ts
Normal file
14
back/src/modules/categories/categories.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CategoryEntity } from './category.entity';
|
||||
import { ApiEntryEntity } from '../apis/api-entry.entity';
|
||||
import { CategoriesService } from './categories.service';
|
||||
import { CategoriesController } from './categories.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([CategoryEntity, ApiEntryEntity])],
|
||||
providers: [CategoriesService],
|
||||
controllers: [CategoriesController],
|
||||
exports: [CategoriesService],
|
||||
})
|
||||
export class CategoriesModule {}
|
||||
155
back/src/modules/categories/categories.service.ts
Normal file
155
back/src/modules/categories/categories.service.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, IsNull } from 'typeorm';
|
||||
import { CategoryEntity } from './category.entity';
|
||||
import { ApiEntryEntity } from '../apis/api-entry.entity';
|
||||
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||
import { UpdateCategoryDto } from './dto/update-category.dto';
|
||||
import {
|
||||
Category,
|
||||
CategoryWithCounts,
|
||||
CategoryBrowseResponse,
|
||||
CategoryListResponse,
|
||||
ApiEntryListItem,
|
||||
ApiType,
|
||||
ApiStatus,
|
||||
} from '@datacat/shared';
|
||||
|
||||
@Injectable()
|
||||
export class CategoriesService {
|
||||
constructor(
|
||||
@InjectRepository(CategoryEntity)
|
||||
private readonly categoryRepo: Repository<CategoryEntity>,
|
||||
@InjectRepository(ApiEntryEntity)
|
||||
private readonly apiRepo: Repository<ApiEntryEntity>,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<CategoryListResponse> {
|
||||
const entities = await this.categoryRepo.find({
|
||||
order: { orderIndex: 'ASC', name: 'ASC' },
|
||||
});
|
||||
return { items: entities.map(toCategory) };
|
||||
}
|
||||
|
||||
async findOneOrThrow(id: string): Promise<Category> {
|
||||
const entity = await this.categoryRepo.findOne({ where: { id } });
|
||||
if (!entity) throw new NotFoundException(`Category ${id} not found`);
|
||||
return toCategory(entity);
|
||||
}
|
||||
|
||||
async create(dto: CreateCategoryDto): Promise<Category> {
|
||||
const entity = this.categoryRepo.create({
|
||||
name: dto.name,
|
||||
description: dto.description ?? null,
|
||||
parentId: dto.parentId ?? null,
|
||||
orderIndex: dto.orderIndex ?? 0,
|
||||
});
|
||||
const saved = await this.categoryRepo.save(entity);
|
||||
return toCategory(saved);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateCategoryDto): Promise<Category> {
|
||||
await this.findOneOrThrow(id);
|
||||
const fields: Partial<CategoryEntity> = {};
|
||||
if (dto.name !== undefined) fields.name = dto.name;
|
||||
if (dto.description !== undefined) fields.description = dto.description;
|
||||
if (dto.parentId !== undefined) fields.parentId = dto.parentId ?? null;
|
||||
if (dto.orderIndex !== undefined) fields.orderIndex = dto.orderIndex;
|
||||
await this.categoryRepo.update(id, fields);
|
||||
return this.findOneOrThrow(id);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findOneOrThrow(id);
|
||||
/* Désassocier les APIs enfants */
|
||||
await this.apiRepo
|
||||
.createQueryBuilder()
|
||||
.update(ApiEntryEntity)
|
||||
.set({ categoryId: null })
|
||||
.where('category_id = :id', { id })
|
||||
.execute();
|
||||
/* Détacher les sous-catégories enfants */
|
||||
await this.categoryRepo
|
||||
.createQueryBuilder()
|
||||
.update(CategoryEntity)
|
||||
.set({ parentId: null })
|
||||
.where('parent_id = :id', { id })
|
||||
.execute();
|
||||
await this.categoryRepo.delete(id);
|
||||
}
|
||||
|
||||
async browse(categoryId: string | null): Promise<CategoryBrowseResponse> {
|
||||
/* 1. Charge la catégorie courante */
|
||||
let currentCategory: CategoryEntity | null = null;
|
||||
if (categoryId) {
|
||||
currentCategory = await this.categoryRepo.findOne({ where: { id: categoryId } });
|
||||
if (!currentCategory) throw new NotFoundException(`Category ${categoryId} not found`);
|
||||
}
|
||||
|
||||
/* 2. Construit le fil d'Ariane (racine → courant) */
|
||||
const breadcrumb: Category[] = [];
|
||||
if (currentCategory) {
|
||||
breadcrumb.push(toCategory(currentCategory));
|
||||
let node = currentCategory;
|
||||
while (node.parentId) {
|
||||
const parent = await this.categoryRepo.findOne({ where: { id: node.parentId } });
|
||||
if (!parent) break;
|
||||
breadcrumb.unshift(toCategory(parent));
|
||||
node = parent;
|
||||
}
|
||||
}
|
||||
|
||||
/* 3. Sous-catégories directes avec comptages */
|
||||
const directChildren = await this.categoryRepo.find({
|
||||
where: { parentId: categoryId ?? IsNull() },
|
||||
order: { orderIndex: 'ASC', name: 'ASC' },
|
||||
});
|
||||
|
||||
const subcategories: CategoryWithCounts[] = await Promise.all(
|
||||
directChildren.map(async (child) => {
|
||||
const subcategoryCount = await this.categoryRepo.count({ where: { parentId: child.id } });
|
||||
const apiCount = await this.apiRepo.count({ where: { categoryId: child.id } });
|
||||
return { ...toCategory(child), subcategoryCount, apiCount };
|
||||
}),
|
||||
);
|
||||
|
||||
/* 4. APIs directement dans cette catégorie */
|
||||
const apiEntities = await this.apiRepo.find({
|
||||
where: { categoryId: categoryId ?? IsNull() },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
return {
|
||||
category: currentCategory ? toCategory(currentCategory) : null,
|
||||
breadcrumb,
|
||||
subcategories,
|
||||
apis: apiEntities.map(toApiEntryListItem),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function toCategory(entity: CategoryEntity): Category {
|
||||
return {
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
description: entity.description,
|
||||
parentId: entity.parentId,
|
||||
orderIndex: entity.orderIndex,
|
||||
createdAt: entity.createdAt.toISOString(),
|
||||
updatedAt: entity.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem {
|
||||
return {
|
||||
id: entity.id,
|
||||
title: entity.title,
|
||||
version: entity.version,
|
||||
description: entity.description,
|
||||
type: entity.type as ApiType,
|
||||
status: entity.status as ApiStatus,
|
||||
categoryId: entity.categoryId,
|
||||
createdAt: entity.createdAt.toISOString(),
|
||||
updatedAt: entity.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
25
back/src/modules/categories/category.entity.ts
Normal file
25
back/src/modules/categories/category.entity.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('categories')
|
||||
export class CategoryEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
name!: string;
|
||||
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
description!: string | null;
|
||||
|
||||
@Column({ nullable: true, type: 'varchar', length: 36, name: 'parent_id' })
|
||||
parentId!: string | null;
|
||||
|
||||
@Column({ type: 'int', default: 0, name: 'order_index' })
|
||||
orderIndex!: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
21
back/src/modules/categories/dto/create-category.dto.ts
Normal file
21
back/src/modules/categories/dto/create-category.dto.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsUUID, IsInt } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class CreateCategoryDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
parentId?: string;
|
||||
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
orderIndex?: number;
|
||||
}
|
||||
21
back/src/modules/categories/dto/update-category.dto.ts
Normal file
21
back/src/modules/categories/dto/update-category.dto.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { IsString, IsOptional, IsUUID, IsInt } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class UpdateCategoryDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
parentId?: string | null;
|
||||
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
orderIndex?: number;
|
||||
}
|
||||
0
back/src/modules/docs-generation/.gitkeep
Normal file
0
back/src/modules/docs-generation/.gitkeep
Normal file
10
back/src/modules/docs-generation/docs-generation.module.ts
Normal file
10
back/src/modules/docs-generation/docs-generation.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { DocsGenerationService } from './docs-generation.service';
|
||||
import { ApisModule } from '../apis/apis.module';
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => ApisModule)],
|
||||
providers: [DocsGenerationService],
|
||||
exports: [DocsGenerationService],
|
||||
})
|
||||
export class DocsGenerationModule {}
|
||||
64
back/src/modules/docs-generation/docs-generation.service.ts
Normal file
64
back/src/modules/docs-generation/docs-generation.service.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Injectable, Inject, forwardRef } from '@nestjs/common';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { ApisService } from '../apis/apis.service';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@Injectable()
|
||||
export class DocsGenerationService {
|
||||
private readonly outputDir = process.env.DOCS_OUTPUT_DIR ?? '/app/docs-output';
|
||||
|
||||
constructor(
|
||||
@Inject(forwardRef(() => ApisService))
|
||||
private readonly apisService: ApisService,
|
||||
) {}
|
||||
|
||||
async generate(entryId: string): Promise<void> {
|
||||
const entry = await this.apisService.findOneOrThrow(entryId);
|
||||
const entryDir = path.join(this.outputDir, entryId);
|
||||
|
||||
await fs.mkdir(entryDir, { recursive: true });
|
||||
|
||||
const yamlPath = path.join(entryDir, 'input.yaml');
|
||||
await fs.writeFile(yamlPath, entry.yamlContent, 'utf-8');
|
||||
|
||||
try {
|
||||
if (entry.type === 'ASYNCAPI') {
|
||||
await this.generateAsyncApi(yamlPath, entryDir);
|
||||
} else {
|
||||
await this.generateOpenApi(yamlPath, entryDir);
|
||||
}
|
||||
|
||||
const htmlPath = path.join(entryDir, 'index.html');
|
||||
await this.apisService.updateInternal(entryId, { htmlPath, status: 'GENERATED' });
|
||||
} catch (error) {
|
||||
await this.apisService.updateInternal(entryId, {
|
||||
status: 'ERROR',
|
||||
errorMessage: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async generateAsyncApi(yamlPath: string, outputDir: string): Promise<void> {
|
||||
await execFileAsync('asyncapi', [
|
||||
'generate', 'fromTemplate',
|
||||
yamlPath,
|
||||
'@asyncapi/html-template',
|
||||
'--output', outputDir,
|
||||
'--no-interactive',
|
||||
'--install',
|
||||
'--force-write',
|
||||
]);
|
||||
}
|
||||
|
||||
private async generateOpenApi(yamlPath: string, outputDir: string): Promise<void> {
|
||||
await execFileAsync('redocly', [
|
||||
'build-docs',
|
||||
yamlPath,
|
||||
'--output', path.join(outputDir, 'index.html'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
17
back/src/modules/seeder/seeder.module.ts
Normal file
17
back/src/modules/seeder/seeder.module.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ApiEntryEntity } from '../apis/api-entry.entity';
|
||||
import { CategoryEntity } from '../categories/category.entity';
|
||||
import { SeederService } from './seeder.service';
|
||||
import { ApisModule } from '../apis/apis.module';
|
||||
import { DocsGenerationModule } from '../docs-generation/docs-generation.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ApiEntryEntity, CategoryEntity]),
|
||||
ApisModule,
|
||||
DocsGenerationModule,
|
||||
],
|
||||
providers: [SeederService],
|
||||
})
|
||||
export class SeederModule {}
|
||||
730
back/src/modules/seeder/seeder.service.ts
Normal file
730
back/src/modules/seeder/seeder.service.ts
Normal file
@@ -0,0 +1,730 @@
|
||||
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ApiEntryEntity } from '../apis/api-entry.entity';
|
||||
import { CategoryEntity } from '../categories/category.entity';
|
||||
import { DocsGenerationService } from '../docs-generation/docs-generation.service';
|
||||
|
||||
/* ─── YAML demo data ─────────────────────────────────────────────────────── */
|
||||
|
||||
const PETSTORE_YAML = `openapi: "3.0.0"
|
||||
info:
|
||||
title: Swagger Petstore
|
||||
description: A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.
|
||||
version: "3.0.4"
|
||||
license:
|
||||
name: Apache 2.0
|
||||
servers:
|
||||
- url: https://petstore.swagger.io/v3
|
||||
paths:
|
||||
/pets:
|
||||
get:
|
||||
summary: List all pets
|
||||
operationId: listPets
|
||||
tags: [pets]
|
||||
parameters:
|
||||
- name: limit
|
||||
in: query
|
||||
description: How many items to return at one time (max 100)
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
format: int32
|
||||
maximum: 100
|
||||
responses:
|
||||
"200":
|
||||
description: A list of pets
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Pets"
|
||||
default:
|
||||
description: unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
post:
|
||||
summary: Create a pet
|
||||
operationId: createPets
|
||||
tags: [pets]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/NewPet"
|
||||
responses:
|
||||
"201":
|
||||
description: Null response
|
||||
default:
|
||||
description: unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
/pets/{petId}:
|
||||
get:
|
||||
summary: Info for a specific pet
|
||||
operationId: showPetById
|
||||
tags: [pets]
|
||||
parameters:
|
||||
- name: petId
|
||||
in: path
|
||||
required: true
|
||||
description: The id of the pet to retrieve
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Expected response to a valid request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Pet"
|
||||
default:
|
||||
description: unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
components:
|
||||
schemas:
|
||||
Pet:
|
||||
type: object
|
||||
required: [id, name]
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
format: int64
|
||||
name:
|
||||
type: string
|
||||
tag:
|
||||
type: string
|
||||
NewPet:
|
||||
type: object
|
||||
required: [name]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
tag:
|
||||
type: string
|
||||
Pets:
|
||||
type: array
|
||||
maxItems: 100
|
||||
items:
|
||||
$ref: "#/components/schemas/Pet"
|
||||
Error:
|
||||
type: object
|
||||
required: [code, message]
|
||||
properties:
|
||||
code:
|
||||
type: integer
|
||||
format: int32
|
||||
message:
|
||||
type: string
|
||||
`;
|
||||
|
||||
const STREETLIGHTS_YAML = `asyncapi: "3.0.0"
|
||||
info:
|
||||
title: Streetlights Kafka API
|
||||
version: "1.0.0"
|
||||
description: The Smartylighting Streetlights API allows you to remotely manage the city lights.
|
||||
license:
|
||||
name: Apache 2.0
|
||||
servers:
|
||||
scram-connections:
|
||||
host: test.mykafkacluster.org:18092
|
||||
protocol: kafka-secure
|
||||
description: Test broker (SCRAM-SHA-512 security)
|
||||
channels:
|
||||
lightingMeasured:
|
||||
address: "smartylighting.streetlights.1.0.event.{streetlightId}.lighting.measured"
|
||||
messages:
|
||||
lightMeasured:
|
||||
$ref: "#/components/messages/LightMeasured"
|
||||
parameters:
|
||||
streetlightId:
|
||||
$ref: "#/components/parameters/streetlightId"
|
||||
lightTurnOn:
|
||||
address: "smartylighting.streetlights.1.0.action.{streetlightId}.turn.on"
|
||||
messages:
|
||||
turnOn:
|
||||
$ref: "#/components/messages/TurnOn"
|
||||
parameters:
|
||||
streetlightId:
|
||||
$ref: "#/components/parameters/streetlightId"
|
||||
lightTurnOff:
|
||||
address: "smartylighting.streetlights.1.0.action.{streetlightId}.turn.off"
|
||||
messages:
|
||||
turnOff:
|
||||
$ref: "#/components/messages/TurnOff"
|
||||
parameters:
|
||||
streetlightId:
|
||||
$ref: "#/components/parameters/streetlightId"
|
||||
operations:
|
||||
receiveLightMeasurement:
|
||||
action: receive
|
||||
channel:
|
||||
$ref: "#/channels/lightingMeasured"
|
||||
messages:
|
||||
- $ref: "#/channels/lightingMeasured/messages/lightMeasured"
|
||||
turnOn:
|
||||
action: send
|
||||
channel:
|
||||
$ref: "#/channels/lightTurnOn"
|
||||
messages:
|
||||
- $ref: "#/channels/lightTurnOn/messages/turnOn"
|
||||
turnOff:
|
||||
action: send
|
||||
channel:
|
||||
$ref: "#/channels/lightTurnOff"
|
||||
messages:
|
||||
- $ref: "#/channels/lightTurnOff/messages/turnOff"
|
||||
components:
|
||||
messages:
|
||||
LightMeasured:
|
||||
name: LightMeasured
|
||||
title: Light measured
|
||||
summary: Inform about environmental lighting conditions.
|
||||
payload:
|
||||
$ref: "#/components/schemas/LightMeasuredPayload"
|
||||
TurnOn:
|
||||
name: TurnOn
|
||||
title: Turn on
|
||||
summary: Command a specific streetlight to turn on.
|
||||
payload:
|
||||
$ref: "#/components/schemas/TurnOnPayload"
|
||||
TurnOff:
|
||||
name: TurnOff
|
||||
title: Turn off
|
||||
summary: Command a specific streetlight to turn off.
|
||||
payload:
|
||||
$ref: "#/components/schemas/TurnOffPayload"
|
||||
parameters:
|
||||
streetlightId:
|
||||
description: The ID of the streetlight.
|
||||
schemas:
|
||||
LightMeasuredPayload:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: ID of the streetlight.
|
||||
lumens:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: Light intensity measured in lumens.
|
||||
sentAt:
|
||||
type: string
|
||||
format: date-time
|
||||
TurnOnPayload:
|
||||
type: object
|
||||
properties:
|
||||
command:
|
||||
type: string
|
||||
enum: [on]
|
||||
sentAt:
|
||||
type: string
|
||||
format: date-time
|
||||
TurnOffPayload:
|
||||
type: object
|
||||
properties:
|
||||
command:
|
||||
type: string
|
||||
enum: [off]
|
||||
sentAt:
|
||||
type: string
|
||||
format: date-time
|
||||
`;
|
||||
|
||||
const ACCOUNT_SERVICE_YAML = `asyncapi: "3.0.0"
|
||||
info:
|
||||
title: Account Service
|
||||
version: "1.0.0"
|
||||
description: This service manages user accounts and publishes events on user lifecycle changes.
|
||||
channels:
|
||||
userSignedUp:
|
||||
address: user/signedup
|
||||
messages:
|
||||
userSignedUp:
|
||||
$ref: "#/components/messages/UserSignedUp"
|
||||
userPasswordChanged:
|
||||
address: user/passwordchanged
|
||||
messages:
|
||||
userPasswordChanged:
|
||||
$ref: "#/components/messages/UserPasswordChanged"
|
||||
operations:
|
||||
sendUserSignedUp:
|
||||
action: send
|
||||
channel:
|
||||
$ref: "#/channels/userSignedUp"
|
||||
messages:
|
||||
- $ref: "#/channels/userSignedUp/messages/userSignedUp"
|
||||
sendUserPasswordChanged:
|
||||
action: send
|
||||
channel:
|
||||
$ref: "#/channels/userPasswordChanged"
|
||||
messages:
|
||||
- $ref: "#/channels/userPasswordChanged/messages/userPasswordChanged"
|
||||
components:
|
||||
messages:
|
||||
UserSignedUp:
|
||||
payload:
|
||||
$ref: "#/components/schemas/UserSignedUpPayload"
|
||||
UserPasswordChanged:
|
||||
payload:
|
||||
$ref: "#/components/schemas/UserPasswordChangedPayload"
|
||||
schemas:
|
||||
UserSignedUpPayload:
|
||||
type: object
|
||||
properties:
|
||||
displayName:
|
||||
type: string
|
||||
description: Name of the user
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
description: Email of the user
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
UserPasswordChangedPayload:
|
||||
type: object
|
||||
properties:
|
||||
userId:
|
||||
type: string
|
||||
changedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
`;
|
||||
|
||||
const ORDERS_YAML = `openapi: "3.0.0"
|
||||
info:
|
||||
title: Orders API
|
||||
version: "1.0.0"
|
||||
description: API de gestion des commandes clients.
|
||||
servers:
|
||||
- url: https://api.example.com/v1
|
||||
paths:
|
||||
/orders:
|
||||
get:
|
||||
summary: Lister les commandes
|
||||
operationId: listOrders
|
||||
tags: [orders]
|
||||
parameters:
|
||||
- name: status
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [pending, confirmed, shipped, delivered, cancelled]
|
||||
- name: page
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 1
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 20
|
||||
responses:
|
||||
"200":
|
||||
description: Liste des commandes
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/OrderList"
|
||||
post:
|
||||
summary: Créer une commande
|
||||
operationId: createOrder
|
||||
tags: [orders]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateOrder"
|
||||
responses:
|
||||
"201":
|
||||
description: Commande créée
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Order"
|
||||
/orders/{orderId}:
|
||||
get:
|
||||
summary: Obtenir une commande
|
||||
operationId: getOrder
|
||||
tags: [orders]
|
||||
parameters:
|
||||
- name: orderId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Détails de la commande
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Order"
|
||||
"404":
|
||||
description: Commande non trouvée
|
||||
patch:
|
||||
summary: Mettre à jour le statut
|
||||
operationId: updateOrderStatus
|
||||
tags: [orders]
|
||||
parameters:
|
||||
- name: orderId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [confirmed, shipped, delivered, cancelled]
|
||||
responses:
|
||||
"200":
|
||||
description: Commande mise à jour
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Order"
|
||||
components:
|
||||
schemas:
|
||||
Order:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
customerId:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
enum: [pending, confirmed, shipped, delivered, cancelled]
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/OrderItem"
|
||||
totalAmount:
|
||||
type: number
|
||||
format: float
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
OrderItem:
|
||||
type: object
|
||||
properties:
|
||||
productId:
|
||||
type: string
|
||||
quantity:
|
||||
type: integer
|
||||
minimum: 1
|
||||
price:
|
||||
type: number
|
||||
format: float
|
||||
CreateOrder:
|
||||
type: object
|
||||
required: [customerId, items]
|
||||
properties:
|
||||
customerId:
|
||||
type: string
|
||||
items:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
$ref: "#/components/schemas/OrderItem"
|
||||
OrderList:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Order"
|
||||
total:
|
||||
type: integer
|
||||
page:
|
||||
type: integer
|
||||
limit:
|
||||
type: integer
|
||||
`;
|
||||
|
||||
const AUTH_YAML = `openapi: "3.0.0"
|
||||
info:
|
||||
title: Auth API
|
||||
version: "1.0.0"
|
||||
description: Service d'authentification et d'autorisation avec JWT.
|
||||
servers:
|
||||
- url: https://auth.example.com/v1
|
||||
paths:
|
||||
/auth/login:
|
||||
post:
|
||||
summary: Connexion
|
||||
operationId: login
|
||||
tags: [auth]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/LoginRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Authentification réussie
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TokenResponse"
|
||||
"401":
|
||||
description: Identifiants invalides
|
||||
/auth/refresh:
|
||||
post:
|
||||
summary: Rafraîchir le token
|
||||
operationId: refreshToken
|
||||
tags: [auth]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [refreshToken]
|
||||
properties:
|
||||
refreshToken:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Nouveau token émis
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TokenResponse"
|
||||
"401":
|
||||
description: Refresh token invalide ou expiré
|
||||
/auth/logout:
|
||||
post:
|
||||
summary: Déconnexion
|
||||
operationId: logout
|
||||
tags: [auth]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"204":
|
||||
description: Déconnecté avec succès
|
||||
/users/me:
|
||||
get:
|
||||
summary: Profil utilisateur courant
|
||||
operationId: getCurrentUser
|
||||
tags: [users]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: Profil de l'utilisateur connecté
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/User"
|
||||
"401":
|
||||
description: Non authentifié
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
schemas:
|
||||
LoginRequest:
|
||||
type: object
|
||||
required: [email, password]
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
password:
|
||||
type: string
|
||||
format: password
|
||||
TokenResponse:
|
||||
type: object
|
||||
properties:
|
||||
accessToken:
|
||||
type: string
|
||||
refreshToken:
|
||||
type: string
|
||||
expiresIn:
|
||||
type: integer
|
||||
description: Durée de validité en secondes
|
||||
User:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
displayName:
|
||||
type: string
|
||||
roles:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
`;
|
||||
|
||||
/* ─── Seeder ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
@Injectable()
|
||||
export class SeederService implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
@InjectRepository(ApiEntryEntity)
|
||||
private readonly apiRepo: Repository<ApiEntryEntity>,
|
||||
@InjectRepository(CategoryEntity)
|
||||
private readonly categoryRepo: Repository<CategoryEntity>,
|
||||
private readonly docsService: DocsGenerationService,
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
const count = await this.apiRepo.count();
|
||||
if (count > 0) return; /* Idempotent — ne ré-exécute pas si des données existent */
|
||||
await this.seed();
|
||||
}
|
||||
|
||||
private async seed(): Promise<void> {
|
||||
/* Catégories racines */
|
||||
const ecommerce = await this.categoryRepo.save(
|
||||
this.categoryRepo.create({
|
||||
name: 'E-commerce',
|
||||
description: 'APIs liées au commerce électronique',
|
||||
parentId: null,
|
||||
orderIndex: 0,
|
||||
}),
|
||||
);
|
||||
const infrastructure = await this.categoryRepo.save(
|
||||
this.categoryRepo.create({
|
||||
name: 'Infrastructure',
|
||||
description: "APIs d'infrastructure et événements système",
|
||||
parentId: null,
|
||||
orderIndex: 1,
|
||||
}),
|
||||
);
|
||||
const identite = await this.categoryRepo.save(
|
||||
this.categoryRepo.create({
|
||||
name: 'Identité & Accès',
|
||||
description: "APIs d'authentification et contrôle d'accès",
|
||||
parentId: null,
|
||||
orderIndex: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
/* Catégories feuilles */
|
||||
const produits = await this.categoryRepo.save(
|
||||
this.categoryRepo.create({
|
||||
name: 'Produits',
|
||||
description: 'Catalogue de produits',
|
||||
parentId: ecommerce.id,
|
||||
orderIndex: 0,
|
||||
}),
|
||||
);
|
||||
const commandes = await this.categoryRepo.save(
|
||||
this.categoryRepo.create({
|
||||
name: 'Commandes',
|
||||
description: 'Gestion des commandes',
|
||||
parentId: ecommerce.id,
|
||||
orderIndex: 1,
|
||||
}),
|
||||
);
|
||||
const notifications = await this.categoryRepo.save(
|
||||
this.categoryRepo.create({
|
||||
name: 'Notifications',
|
||||
description: 'Événements et notifications utilisateur',
|
||||
parentId: ecommerce.id,
|
||||
orderIndex: 2,
|
||||
}),
|
||||
);
|
||||
const evenements = await this.categoryRepo.save(
|
||||
this.categoryRepo.create({
|
||||
name: 'Événements',
|
||||
description: "Événements d'infrastructure temps réel",
|
||||
parentId: infrastructure.id,
|
||||
orderIndex: 0,
|
||||
}),
|
||||
);
|
||||
const auth = await this.categoryRepo.save(
|
||||
this.categoryRepo.create({
|
||||
name: 'Authentification',
|
||||
description: 'Authentification et autorisation',
|
||||
parentId: identite.id,
|
||||
orderIndex: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
/* APIs de démo */
|
||||
const entries = await this.apiRepo.save([
|
||||
this.apiRepo.create({
|
||||
title: 'Swagger Petstore',
|
||||
version: '3.0.4',
|
||||
description:
|
||||
'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.',
|
||||
type: 'OPENAPI',
|
||||
yamlContent: PETSTORE_YAML,
|
||||
status: 'PENDING',
|
||||
categoryId: produits.id,
|
||||
}),
|
||||
this.apiRepo.create({
|
||||
title: 'Streetlights Kafka API',
|
||||
version: '1.0.0',
|
||||
description:
|
||||
'The Smartylighting Streetlights API allows you to remotely manage the city lights using Kafka messaging.',
|
||||
type: 'ASYNCAPI',
|
||||
yamlContent: STREETLIGHTS_YAML,
|
||||
status: 'PENDING',
|
||||
categoryId: evenements.id,
|
||||
}),
|
||||
this.apiRepo.create({
|
||||
title: 'Account Service',
|
||||
version: '1.0.0',
|
||||
description: "Service de gestion des comptes utilisateurs avec publication d'événements.",
|
||||
type: 'ASYNCAPI',
|
||||
yamlContent: ACCOUNT_SERVICE_YAML,
|
||||
status: 'PENDING',
|
||||
categoryId: notifications.id,
|
||||
}),
|
||||
this.apiRepo.create({
|
||||
title: 'Orders API',
|
||||
version: '1.0.0',
|
||||
description: 'API de gestion des commandes clients.',
|
||||
type: 'OPENAPI',
|
||||
yamlContent: ORDERS_YAML,
|
||||
status: 'PENDING',
|
||||
categoryId: commandes.id,
|
||||
}),
|
||||
this.apiRepo.create({
|
||||
title: 'Auth API',
|
||||
version: '1.0.0',
|
||||
description: "Service d'authentification et d'autorisation.",
|
||||
type: 'OPENAPI',
|
||||
yamlContent: AUTH_YAML,
|
||||
status: 'PENDING',
|
||||
categoryId: auth.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
console.log('[Seeder] 8 catégories et 5 APIs de démo créées — génération docs en cours...');
|
||||
|
||||
/* Déclenche la génération de docs en fire-and-forget pour chaque API */
|
||||
for (const entry of entries) {
|
||||
this.docsService.generate(entry.id).catch(console.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
0
back/src/modules/uploads/.gitkeep
Normal file
0
back/src/modules/uploads/.gitkeep
Normal file
147
back/src/modules/uploads/uploads.controller.ts
Normal file
147
back/src/modules/uploads/uploads.controller.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
UseInterceptors,
|
||||
UploadedFiles,
|
||||
Body,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
import * as fs from 'fs';
|
||||
import * as fsPromises from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ApisService } from '../apis/apis.service';
|
||||
import { DocsGenerationService } from '../docs-generation/docs-generation.service';
|
||||
import { ApiType } from '@datacat/shared';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@Controller('uploads')
|
||||
export class UploadsController {
|
||||
constructor(
|
||||
private readonly apisService: ApisService,
|
||||
private readonly docsService: DocsGenerationService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(
|
||||
FilesInterceptor('files', 20, {
|
||||
storage: diskStorage({ destination: '/tmp/uploads' }),
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!file.originalname.match(/\.(yaml|yml)$/i)) {
|
||||
return cb(new BadRequestException('Only YAML files are allowed'), false);
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
}),
|
||||
)
|
||||
async upload(
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@Body()
|
||||
body: {
|
||||
title: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
type?: ApiType;
|
||||
categoryId?: string;
|
||||
},
|
||||
) {
|
||||
if (!files || files.length === 0) throw new BadRequestException('YAML file is required');
|
||||
if (!body.title) throw new BadRequestException('title is required');
|
||||
if (!body.version) throw new BadRequestException('version is required');
|
||||
|
||||
/* Crée le répertoire si absent */
|
||||
if (!fs.existsSync('/tmp/uploads')) {
|
||||
fs.mkdirSync('/tmp/uploads', { recursive: true });
|
||||
}
|
||||
|
||||
let yamlContent: string;
|
||||
let detectedType: ApiType | null = null;
|
||||
|
||||
if (files.length === 1) {
|
||||
/* Cas simple : un seul fichier, comportement identique à l'ancien */
|
||||
yamlContent = fs.readFileSync(files[0].path, 'utf-8');
|
||||
detectedType = this.detectTypeFromContent(yamlContent);
|
||||
} else {
|
||||
/* Cas multi-fichiers : détecter le fichier principal et bundler */
|
||||
const { mainFile, type } = this.detectMainFile(files);
|
||||
detectedType = type;
|
||||
yamlContent = await this.bundleFiles(files, mainFile, type);
|
||||
}
|
||||
|
||||
/* Utiliser le type détecté ou le fallback du body */
|
||||
const apiType = detectedType ?? body.type;
|
||||
if (!apiType || !['ASYNCAPI', 'OPENAPI'].includes(apiType)) {
|
||||
throw new BadRequestException('type must be ASYNCAPI or OPENAPI');
|
||||
}
|
||||
|
||||
const entry = await this.apisService.create({
|
||||
title: body.title,
|
||||
version: body.version,
|
||||
description: body.description,
|
||||
type: apiType,
|
||||
yamlContent,
|
||||
categoryId: body.categoryId || undefined,
|
||||
});
|
||||
|
||||
/* Fire-and-forget : génération asynchrone */
|
||||
this.docsService.generate(entry.id).catch(console.error);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
private detectTypeFromContent(content: string): ApiType | null {
|
||||
/* Cherche la clé racine openapi: ou asyncapi: sans dépendance à js-yaml */
|
||||
if (/^openapi\s*:/m.test(content)) return 'OPENAPI';
|
||||
if (/^asyncapi\s*:/m.test(content)) return 'ASYNCAPI';
|
||||
return null;
|
||||
}
|
||||
|
||||
private detectMainFile(files: Express.Multer.File[]): { mainFile: Express.Multer.File; type: ApiType } {
|
||||
for (const file of files) {
|
||||
const content = fs.readFileSync(file.path, 'utf-8');
|
||||
const type = this.detectTypeFromContent(content);
|
||||
if (type) {
|
||||
return { mainFile: file, type };
|
||||
}
|
||||
}
|
||||
throw new BadRequestException(
|
||||
'Aucun fichier principal trouvé (clé openapi: ou asyncapi: manquante)',
|
||||
);
|
||||
}
|
||||
|
||||
private async bundleFiles(
|
||||
files: Express.Multer.File[],
|
||||
mainFile: Express.Multer.File,
|
||||
type: ApiType,
|
||||
): Promise<string> {
|
||||
const tempDir = `/tmp/uploads/${randomUUID()}`;
|
||||
const bundledPath = path.join(tempDir, 'bundled.yaml');
|
||||
|
||||
try {
|
||||
await fsPromises.mkdir(tempDir, { recursive: true });
|
||||
|
||||
/* Copier tous les fichiers dans le répertoire temp avec leurs noms d'origine */
|
||||
await Promise.all(
|
||||
files.map((f) => fsPromises.copyFile(f.path, path.join(tempDir, f.originalname))),
|
||||
);
|
||||
|
||||
const mainPath = path.join(tempDir, mainFile.originalname);
|
||||
|
||||
if (type === 'OPENAPI') {
|
||||
await execFileAsync('redocly', ['bundle', mainPath, '--output', bundledPath]);
|
||||
} else {
|
||||
await execFileAsync('asyncapi', ['bundle', mainPath, '--output', bundledPath]);
|
||||
}
|
||||
|
||||
return await fsPromises.readFile(bundledPath, 'utf-8');
|
||||
} finally {
|
||||
/* Nettoyage du répertoire temp même en cas d'erreur */
|
||||
await fsPromises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
back/src/modules/uploads/uploads.module.ts
Normal file
10
back/src/modules/uploads/uploads.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UploadsController } from './uploads.controller';
|
||||
import { ApisModule } from '../apis/apis.module';
|
||||
import { DocsGenerationModule } from '../docs-generation/docs-generation.module';
|
||||
|
||||
@Module({
|
||||
imports: [ApisModule, DocsGenerationModule],
|
||||
controllers: [UploadsController],
|
||||
})
|
||||
export class UploadsModule {}
|
||||
4
back/tsconfig.build.json
Normal file
4
back/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
19
back/tsconfig.json
Normal file
19
back/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@datacat/shared": ["../shared/src/index.ts"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user