Compare commits
4 Commits
main
...
34a83233f2
| Author | SHA1 | Date | |
|---|---|---|---|
| 34a83233f2 | |||
| f7f706e1e1 | |||
| 38f6f6ab52 | |||
| 558bf763e7 |
@@ -7,19 +7,20 @@ import { UploadsModule } from './modules/uploads/uploads.module';
|
|||||||
import { DocsGenerationModule } from './modules/docs-generation/docs-generation.module';
|
import { DocsGenerationModule } from './modules/docs-generation/docs-generation.module';
|
||||||
import { CategoriesModule } from './modules/categories/categories.module';
|
import { CategoriesModule } from './modules/categories/categories.module';
|
||||||
import { SeederModule } from './modules/seeder/seeder.module';
|
import { SeederModule } from './modules/seeder/seeder.module';
|
||||||
|
import { validateEnv } from './config/env.validation';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true, validate: validateEnv }),
|
||||||
TypeOrmModule.forRootAsync({
|
TypeOrmModule.forRootAsync({
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (config: ConfigService) => ({
|
useFactory: (config: ConfigService) => ({
|
||||||
type: 'postgres',
|
type: 'postgres',
|
||||||
host: config.get('DATABASE_HOST', 'localhost'),
|
host: config.getOrThrow<string>('DATABASE_HOST'),
|
||||||
port: config.get<number>('DATABASE_PORT', 5432),
|
port: config.getOrThrow<number>('DATABASE_PORT'),
|
||||||
username: config.get('DATABASE_USER', 'datacat'),
|
username: config.getOrThrow<string>('DATABASE_USER'),
|
||||||
password: config.get('DATABASE_PASSWORD', 'datacat'),
|
password: config.getOrThrow<string>('DATABASE_PASSWORD'),
|
||||||
database: config.get('DATABASE_NAME', 'datacat'),
|
database: config.getOrThrow<string>('DATABASE_NAME'),
|
||||||
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||||
synchronize: config.get('NODE_ENV') !== 'production',
|
synchronize: config.get('NODE_ENV') !== 'production',
|
||||||
logging: config.get('NODE_ENV') === 'development',
|
logging: config.get('NODE_ENV') === 'development',
|
||||||
|
|||||||
76
back/src/config/env.validation.ts
Normal file
76
back/src/config/env.validation.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { plainToInstance, Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
validateSync,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export enum NodeEnv {
|
||||||
|
Development = 'development',
|
||||||
|
Production = 'production',
|
||||||
|
Test = 'test',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schéma des variables d'environnement. Validé au démarrage via `validateEnv`
|
||||||
|
* (branché sur `ConfigModule.forRoot({ validate })`) : l'app refuse de démarrer
|
||||||
|
* si une variable requise manque ou est mal typée, plutôt que d'utiliser
|
||||||
|
* silencieusement des valeurs par défaut (ex. credentials DB).
|
||||||
|
*/
|
||||||
|
export class EnvironmentVariables {
|
||||||
|
@IsEnum(NodeEnv)
|
||||||
|
@IsOptional()
|
||||||
|
NODE_ENV: NodeEnv = NodeEnv.Development;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
DATABASE_HOST!: string;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(65535)
|
||||||
|
DATABASE_PORT!: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
DATABASE_USER!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
DATABASE_PASSWORD!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
DATABASE_NAME!: string;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(65535)
|
||||||
|
@IsOptional()
|
||||||
|
PORT = 3000;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
DOCS_OUTPUT_DIR?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateEnv(config: Record<string, unknown>): EnvironmentVariables {
|
||||||
|
const validated = plainToInstance(EnvironmentVariables, config, {
|
||||||
|
enableImplicitConversion: true,
|
||||||
|
});
|
||||||
|
const errors = validateSync(validated, { skipMissingProperties: false });
|
||||||
|
if (errors.length > 0) {
|
||||||
|
const details = errors
|
||||||
|
.map((e) => Object.values(e.constraints ?? {}).join(', '))
|
||||||
|
.join('\n - ');
|
||||||
|
throw new Error(`Configuration d'environnement invalide:\n - ${details}`);
|
||||||
|
}
|
||||||
|
return validated;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { ValidationPipe, RequestMethod } from '@nestjs/common';
|
import { Logger, ValidationPipe, RequestMethod } from '@nestjs/common';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ async function bootstrap() {
|
|||||||
|
|
||||||
const port = process.env.PORT ?? 3000;
|
const port = process.env.PORT ?? 3000;
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
console.log(`Datacat API running on port ${port}`);
|
Logger.log(`Datacat API running on port ${port}`, 'Bootstrap');
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@@ -170,11 +170,17 @@ const mockRepo = {
|
|||||||
|
|
||||||
const mockDocsService = { generate: vi.fn() };
|
const mockDocsService = { generate: vi.fn() };
|
||||||
|
|
||||||
|
const mockDataSource = {
|
||||||
|
transaction: vi.fn(async (cb: (m: unknown) => unknown) =>
|
||||||
|
cb({ getRepository: () => mockRepo }),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
let service: ApisService;
|
let service: ApisService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
service = new ApisService(mockRepo as any, mockDocsService as any);
|
service = new ApisService(mockRepo as any, mockDataSource as any, mockDocsService as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
function makeEntity(overrides: Record<string, unknown> = {}) {
|
function makeEntity(overrides: Record<string, unknown> = {}) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, NotFoundException, ConflictException, Inject, forwardRef } from '@nestjs/common';
|
import { Injectable, NotFoundException, ConflictException, Inject, forwardRef, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { DataSource, Repository } from 'typeorm';
|
||||||
import { ApiEntryEntity } from './api-entry.entity';
|
import { ApiEntryEntity } from './api-entry.entity';
|
||||||
import { CreateApiEntryDto } from './dto/create-api-entry.dto';
|
import { CreateApiEntryDto } from './dto/create-api-entry.dto';
|
||||||
import { UpdateApiEntryDto } from './dto/update-api-entry.dto';
|
import { UpdateApiEntryDto } from './dto/update-api-entry.dto';
|
||||||
@@ -17,9 +17,12 @@ interface InternalUpdateFields {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApisService {
|
export class ApisService {
|
||||||
|
private readonly logger = new Logger(ApisService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(ApiEntryEntity)
|
@InjectRepository(ApiEntryEntity)
|
||||||
private readonly repo: Repository<ApiEntryEntity>,
|
private readonly repo: Repository<ApiEntryEntity>,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
@Inject(forwardRef(() => DocsGenerationService))
|
@Inject(forwardRef(() => DocsGenerationService))
|
||||||
private readonly docsService: DocsGenerationService,
|
private readonly docsService: DocsGenerationService,
|
||||||
) {}
|
) {}
|
||||||
@@ -213,20 +216,24 @@ export class ApisService {
|
|||||||
|
|
||||||
async setCurrent(id: string): Promise<ApiEntry> {
|
async setCurrent(id: string): Promise<ApiEntry> {
|
||||||
const entry = await this.findOneOrThrow(id);
|
const entry = await this.findOneOrThrow(id);
|
||||||
// Mettre tous les siblings à false
|
// Transaction : éviter une fenêtre où aucun (ou deux) sibling n'est courant.
|
||||||
await this.repo.update(
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const repo = manager.getRepository(ApiEntryEntity);
|
||||||
|
await repo.update(
|
||||||
{ name: entry.name, convention: entry.convention },
|
{ name: entry.name, convention: entry.convention },
|
||||||
{ isCurrent: false },
|
{ isCurrent: false },
|
||||||
);
|
);
|
||||||
// Mettre cet entry à true
|
await repo.update({ id }, { isCurrent: true });
|
||||||
await this.repo.update({ id }, { isCurrent: true });
|
});
|
||||||
return this.findOneOrThrow(id);
|
return this.findOneOrThrow(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async regenerate(id: string): Promise<ApiEntry> {
|
async regenerate(id: string): Promise<ApiEntry> {
|
||||||
await this.findOneOrThrow(id);
|
await this.findOneOrThrow(id);
|
||||||
await this.updateInternal(id, { status: 'PENDING', errorMessage: null });
|
await this.updateInternal(id, { status: 'PENDING', errorMessage: null });
|
||||||
this.docsService.generate(id).catch(console.error);
|
this.docsService
|
||||||
|
.generate(id)
|
||||||
|
.catch((err) => this.logger.error(`Génération docs échouée pour ${id}`, err));
|
||||||
return this.findOneOrThrow(id);
|
return this.findOneOrThrow(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { IsString, IsNotEmpty, IsIn, IsOptional, IsUUID, IsInt, Min, IsEmail } from 'class-validator';
|
import { IsString, IsNotEmpty, IsIn, IsOptional, IsUUID, IsInt, Min, IsEmail } from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { ApiType, ApiConvention } from '@datacat/shared';
|
import { ApiType, ApiConvention, API_CONVENTIONS, API_TYPES } from '@datacat/shared';
|
||||||
|
|
||||||
export class CreateApiEntryDto {
|
export class CreateApiEntryDto {
|
||||||
@IsIn(['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'])
|
@IsIn([...API_CONVENTIONS])
|
||||||
convention!: ApiConvention;
|
convention!: ApiConvention;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -33,7 +33,7 @@ export class CreateApiEntryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@IsIn(['ASYNCAPI', 'OPENAPI'])
|
@IsIn([...API_TYPES])
|
||||||
type!: ApiType;
|
type!: ApiType;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { IsString, IsOptional, IsUUID, IsIn, IsInt, Min, IsEmail } from 'class-validator';
|
import { IsString, IsOptional, IsUUID, IsIn, IsInt, Min, IsEmail } from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { ApiConvention } from '@datacat/shared';
|
import { ApiConvention, API_CONVENTIONS } from '@datacat/shared';
|
||||||
|
|
||||||
export class UpdateApiEntryDto {
|
export class UpdateApiEntryDto {
|
||||||
@IsIn(['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'])
|
@IsIn([...API_CONVENTIONS])
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
convention?: ApiConvention;
|
convention?: ApiConvention;
|
||||||
|
|
||||||
|
|||||||
@@ -98,11 +98,51 @@ export class CategoriesService {
|
|||||||
await this.categoryRepo.delete(id);
|
await this.categoryRepo.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async countApisRecursively(categoryId: string): Promise<number> {
|
/**
|
||||||
const direct = await this.apiRepo.count({ where: { categoryId } });
|
* Comptages pour TOUTES les catégories en 2 requêtes (au lieu d'un N+1
|
||||||
const children = await this.categoryRepo.find({ where: { parentId: categoryId }, select: ['id'] });
|
* récursif par enfant) : la hiérarchie et les APIs directes sont chargées en
|
||||||
const childCounts = await Promise.all(children.map((c) => this.countApisRecursively(c.id)));
|
* une fois, puis les compteurs récursifs sont calculés en mémoire.
|
||||||
return direct + childCounts.reduce((sum, n) => sum + n, 0);
|
*/
|
||||||
|
private async computeCategoryCounts(): Promise<{
|
||||||
|
subcategoryCount: Map<string, number>;
|
||||||
|
recursiveApiCount: Map<string, number>;
|
||||||
|
}> {
|
||||||
|
const categories = await this.categoryRepo.find({ select: ['id', 'parentId'] });
|
||||||
|
const directRows = await this.apiRepo
|
||||||
|
.createQueryBuilder('api')
|
||||||
|
.select('api.categoryId', 'categoryId')
|
||||||
|
.addSelect('COUNT(*)', 'count')
|
||||||
|
.where('api.categoryId IS NOT NULL')
|
||||||
|
.groupBy('api.categoryId')
|
||||||
|
.getRawMany<{ categoryId: string; count: string }>();
|
||||||
|
|
||||||
|
const directApiCount = new Map(directRows.map((r) => [r.categoryId, Number(r.count)]));
|
||||||
|
const childrenOf = new Map<string, string[]>();
|
||||||
|
for (const c of categories) {
|
||||||
|
if (c.parentId) {
|
||||||
|
const siblings = childrenOf.get(c.parentId) ?? [];
|
||||||
|
siblings.push(c.id);
|
||||||
|
childrenOf.set(c.parentId, siblings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const recursiveApiCount = new Map<string, number>();
|
||||||
|
const computeRecursive = (id: string): number => {
|
||||||
|
const cached = recursiveApiCount.get(id);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
let total = directApiCount.get(id) ?? 0;
|
||||||
|
for (const childId of childrenOf.get(id) ?? []) total += computeRecursive(childId);
|
||||||
|
recursiveApiCount.set(id, total);
|
||||||
|
return total;
|
||||||
|
};
|
||||||
|
|
||||||
|
const subcategoryCount = new Map<string, number>();
|
||||||
|
for (const c of categories) {
|
||||||
|
subcategoryCount.set(c.id, (childrenOf.get(c.id) ?? []).length);
|
||||||
|
computeRecursive(c.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { subcategoryCount, recursiveApiCount };
|
||||||
}
|
}
|
||||||
|
|
||||||
async browse(categoryId: string | null): Promise<CategoryBrowseResponse> {
|
async browse(categoryId: string | null): Promise<CategoryBrowseResponse> {
|
||||||
@@ -132,13 +172,12 @@ export class CategoriesService {
|
|||||||
order: { orderIndex: 'ASC', name: 'ASC' },
|
order: { orderIndex: 'ASC', name: 'ASC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const subcategories: CategoryWithCounts[] = await Promise.all(
|
const { subcategoryCount, recursiveApiCount } = await this.computeCategoryCounts();
|
||||||
directChildren.map(async (child) => {
|
const subcategories: CategoryWithCounts[] = directChildren.map((child) => ({
|
||||||
const subcategoryCount = await this.categoryRepo.count({ where: { parentId: child.id } });
|
...toCategory(child),
|
||||||
const apiCount = await this.countApisRecursively(child.id);
|
subcategoryCount: subcategoryCount.get(child.id) ?? 0,
|
||||||
return { ...toCategory(child), subcategoryCount, apiCount };
|
apiCount: recursiveApiCount.get(child.id) ?? 0,
|
||||||
}),
|
}));
|
||||||
);
|
|
||||||
|
|
||||||
/* 4. APIs directement dans cette catégorie */
|
/* 4. APIs directement dans cette catégorie */
|
||||||
const apiEntities = await this.apiRepo.find({
|
const apiEntities = await this.apiRepo.find({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, DataSource } from 'typeorm';
|
import { Repository, DataSource } from 'typeorm';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
@@ -587,6 +587,8 @@ components:
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SeederService implements OnApplicationBootstrap {
|
export class SeederService implements OnApplicationBootstrap {
|
||||||
|
private readonly logger = new Logger(SeederService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(ApiEntryEntity)
|
@InjectRepository(ApiEntryEntity)
|
||||||
private readonly apiRepo: Repository<ApiEntryEntity>,
|
private readonly apiRepo: Repository<ApiEntryEntity>,
|
||||||
@@ -648,7 +650,7 @@ export class SeederService implements OnApplicationBootstrap {
|
|||||||
const docsDir = process.env.DOCS_OUTPUT_DIR ?? '/app/docs-output';
|
const docsDir = process.env.DOCS_OUTPUT_DIR ?? '/app/docs-output';
|
||||||
await fs.rm(docsDir, { recursive: true, force: true });
|
await fs.rm(docsDir, { recursive: true, force: true });
|
||||||
await fs.mkdir(docsDir, { recursive: true });
|
await fs.mkdir(docsDir, { recursive: true });
|
||||||
console.log('[Seeder] Reset complet — données supprimées, docs nettoyées.');
|
this.logger.log('Reset complet — données supprimées, docs nettoyées.');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async seed(): Promise<void> {
|
private async seed(): Promise<void> {
|
||||||
@@ -864,11 +866,13 @@ export class SeederService implements OnApplicationBootstrap {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
console.log('[Seeder] 8 catégories et 7 APIs de démo créées — génération docs en cours...');
|
this.logger.log('8 catégories et 7 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 */
|
/* Déclenche la génération de docs en fire-and-forget pour chaque API */
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
this.docsService.generate(entry.id).catch(console.error);
|
this.docsService
|
||||||
|
.generate(entry.id)
|
||||||
|
.catch((err) => this.logger.error(`Génération docs échouée pour ${entry.id}`, err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||||
import { diskStorage } from 'multer';
|
import { diskStorage } from 'multer';
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as fsPromises from 'fs/promises';
|
import * as fsPromises from 'fs/promises';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
@@ -69,11 +68,13 @@ export class UploadsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Lire tous les contenus en mémoire avant toute suppression */
|
/* Lire tous les contenus en mémoire avant toute suppression */
|
||||||
const fileContents = files.map((file) => ({
|
const fileContents = await Promise.all(
|
||||||
|
files.map(async (file) => ({
|
||||||
name: file.originalname,
|
name: file.originalname,
|
||||||
path: file.path,
|
path: file.path,
|
||||||
content: fs.readFileSync(file.path, 'utf-8'),
|
content: await fsPromises.readFile(file.path, 'utf-8'),
|
||||||
}));
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
/* Nettoyage immédiat des fichiers temporaires */
|
/* Nettoyage immédiat des fichiers temporaires */
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
@@ -197,11 +198,9 @@ export class UploadsController {
|
|||||||
if (!body.name) throw new BadRequestException('name is required');
|
if (!body.name) throw new BadRequestException('name is required');
|
||||||
if (!body.convention) throw new BadRequestException('convention is required');
|
if (!body.convention) throw new BadRequestException('convention is required');
|
||||||
|
|
||||||
/* Crée le répertoire si absent */
|
/* Crée le répertoire si absent (mkdir recursive est idempotent) */
|
||||||
const uploadDir = path.join(os.tmpdir(), 'datacat-uploads');
|
const uploadDir = path.join(os.tmpdir(), 'datacat-uploads');
|
||||||
if (!fs.existsSync(uploadDir)) {
|
await fsPromises.mkdir(uploadDir, { recursive: true });
|
||||||
fs.mkdirSync(uploadDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
let yamlContent: string;
|
let yamlContent: string;
|
||||||
let detectedType: ApiType | null = null;
|
let detectedType: ApiType | null = null;
|
||||||
@@ -211,7 +210,7 @@ export class UploadsController {
|
|||||||
yamlContent = '';
|
yamlContent = '';
|
||||||
} else if (files.length === 1) {
|
} else if (files.length === 1) {
|
||||||
/* Cas simple : un seul fichier */
|
/* Cas simple : un seul fichier */
|
||||||
yamlContent = fs.readFileSync(files[0].path, 'utf-8');
|
yamlContent = await fsPromises.readFile(files[0].path, 'utf-8');
|
||||||
|
|
||||||
/* Validation syntaxique YAML */
|
/* Validation syntaxique YAML */
|
||||||
this.validateYamlSyntax(yamlContent, files[0].originalname);
|
this.validateYamlSyntax(yamlContent, files[0].originalname);
|
||||||
@@ -219,7 +218,7 @@ export class UploadsController {
|
|||||||
detectedType = this.detectTypeFromContent(yamlContent);
|
detectedType = this.detectTypeFromContent(yamlContent);
|
||||||
} else {
|
} else {
|
||||||
/* Cas multi-fichiers : détecter le fichier principal et bundler */
|
/* Cas multi-fichiers : détecter le fichier principal et bundler */
|
||||||
const { mainFile, type } = this.detectMainFile(files);
|
const { mainFile, type } = await this.detectMainFile(files);
|
||||||
detectedType = type;
|
detectedType = type;
|
||||||
|
|
||||||
this.logger.log(`Multi-file upload: main=${mainFile.originalname}, type=${type}, total=${files.length} files`);
|
this.logger.log(`Multi-file upload: main=${mainFile.originalname}, type=${type}, total=${files.length} files`);
|
||||||
@@ -253,7 +252,9 @@ export class UploadsController {
|
|||||||
|
|
||||||
/* Fire-and-forget : génération asynchrone (seulement si YAML fourni) */
|
/* Fire-and-forget : génération asynchrone (seulement si YAML fourni) */
|
||||||
if (yamlContent) {
|
if (yamlContent) {
|
||||||
this.docsService.generate(entry.id).catch(console.error);
|
this.docsService
|
||||||
|
.generate(entry.id)
|
||||||
|
.catch((err) => this.logger.error(`Génération docs échouée pour ${entry.id}`, err));
|
||||||
}
|
}
|
||||||
|
|
||||||
return entry;
|
return entry;
|
||||||
@@ -308,9 +309,11 @@ export class UploadsController {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private detectMainFile(files: Express.Multer.File[]): { mainFile: Express.Multer.File; type: ApiType } {
|
private async detectMainFile(
|
||||||
|
files: Express.Multer.File[],
|
||||||
|
): Promise<{ mainFile: Express.Multer.File; type: ApiType }> {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const content = fs.readFileSync(file.path, 'utf-8');
|
const content = await fsPromises.readFile(file.path, 'utf-8');
|
||||||
const type = this.detectTypeFromContent(content);
|
const type = this.detectTypeFromContent(content);
|
||||||
if (type) {
|
if (type) {
|
||||||
return { mainFile: file, type };
|
return { mainFile: file, type };
|
||||||
|
|||||||
@@ -57,7 +57,13 @@
|
|||||||
{ "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" },
|
{ "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" },
|
||||||
{ "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" }
|
{ "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" }
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all",
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.prod.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"optimization": false,
|
"optimization": false,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
"lint": "ng lint"
|
"lint": "ng lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@datacat/shared": "workspace:*",
|
||||||
|
"@gouvfr/dsfr": "^1.13.0",
|
||||||
"@angular/animations": "^19.0.0",
|
"@angular/animations": "^19.0.0",
|
||||||
"@angular/common": "^19.0.0",
|
"@angular/common": "^19.0.0",
|
||||||
"@angular/compiler": "^19.0.0",
|
"@angular/compiler": "^19.0.0",
|
||||||
@@ -27,7 +29,6 @@
|
|||||||
"@angular/cli": "^19.0.0",
|
"@angular/cli": "^19.0.0",
|
||||||
"@angular/compiler-cli": "^19.0.0",
|
"@angular/compiler-cli": "^19.0.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@gouvfr/dsfr": "^1.13.0",
|
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"sass": "^1.83.0"
|
"sass": "^1.83.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||||
import { provideRouter, withComponentInputBinding } from '@angular/router';
|
import { provideRouter, withComponentInputBinding } from '@angular/router';
|
||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
|
import { errorInterceptor } from './core/error.interceptor';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||||
provideRouter(routes, withComponentInputBinding()),
|
provideRouter(routes, withComponentInputBinding()),
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptors([errorInterceptor])),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { ApiEntry, ApiEntryListItem, ApiListResponse, ApiListQuery } from '@datacat/shared';
|
import { ApiEntry, ApiEntryListItem, ApiListResponse, ApiListQuery, ApiType } from '@datacat/shared';
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ApiService {
|
export class ApiService {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
private baseUrl = '/api/apis';
|
private baseUrl = `${environment.apiBaseUrl}/apis`;
|
||||||
|
private uploadsUrl = `${environment.apiBaseUrl}/uploads`;
|
||||||
|
|
||||||
list(query?: ApiListQuery): Observable<ApiListResponse> {
|
list(query?: ApiListQuery): Observable<ApiListResponse> {
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
@@ -58,12 +60,18 @@ export class ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
upload(formData: FormData): Observable<ApiEntry> {
|
upload(formData: FormData): Observable<ApiEntry> {
|
||||||
return this.http.post<ApiEntry>('/api/uploads', formData);
|
return this.http.post<ApiEntry>(this.uploadsUrl, formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
validate(formData: FormData): Observable<{
|
validate(formData: FormData): Observable<ValidateResponse> {
|
||||||
|
return this.http.post<ValidateResponse>(`${this.uploadsUrl}/validate`, formData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Réponse de l'endpoint de validation d'upload (POST /uploads/validate). */
|
||||||
|
export interface ValidateResponse {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
type: 'ASYNCAPI' | 'OPENAPI' | null;
|
type: ApiType | null;
|
||||||
mainFile: string | null;
|
mainFile: string | null;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
versionMajor: number | null;
|
versionMajor: number | null;
|
||||||
@@ -71,17 +79,4 @@ export class ApiService {
|
|||||||
versionPatch: number | null;
|
versionPatch: number | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
errors: Array<{ file: string; message: string }>;
|
errors: Array<{ file: string; message: string }>;
|
||||||
}> {
|
|
||||||
return this.http.post<{
|
|
||||||
valid: boolean;
|
|
||||||
type: 'ASYNCAPI' | 'OPENAPI' | null;
|
|
||||||
mainFile: string | null;
|
|
||||||
name: string | null;
|
|
||||||
versionMajor: number | null;
|
|
||||||
versionMinor: number | null;
|
|
||||||
versionPatch: number | null;
|
|
||||||
description: string | null;
|
|
||||||
errors: Array<{ file: string; message: string }>;
|
|
||||||
}>('/api/uploads/validate', formData);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import {
|
|||||||
CategoryListResponse,
|
CategoryListResponse,
|
||||||
CategoryBrowseResponse,
|
CategoryBrowseResponse,
|
||||||
} from '@datacat/shared';
|
} from '@datacat/shared';
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class CategoryService {
|
export class CategoryService {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
private baseUrl = '/api/categories';
|
private baseUrl = `${environment.apiBaseUrl}/categories`;
|
||||||
|
|
||||||
list(search?: string): Observable<CategoryListResponse> {
|
list(search?: string): Observable<CategoryListResponse> {
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
|
|||||||
18
front-public/src/app/core/error.interceptor.ts
Normal file
18
front-public/src/app/core/error.interceptor.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
|
||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { ErrorService } from './error.service';
|
||||||
|
import { catchError, throwError } from 'rxjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interceptor HTTP fonctionnel : journalise et normalise les erreurs réseau en
|
||||||
|
* un message lisible (via ErrorService), puis relaie l'erreur aux appelants.
|
||||||
|
*/
|
||||||
|
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
|
const errorService = inject(ErrorService);
|
||||||
|
return next(req).pipe(
|
||||||
|
catchError((error: HttpErrorResponse) => {
|
||||||
|
errorService.log(req.method, req.url, error);
|
||||||
|
return throwError(() => error);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
32
front-public/src/app/core/error.service.ts
Normal file
32
front-public/src/app/core/error.service.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralise la traduction d'une erreur HTTP en message lisible et son
|
||||||
|
* journal. Les composants appellent `toMessage(err)` plutôt que de
|
||||||
|
* réimplémenter chacun leur extraction de message.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ErrorService {
|
||||||
|
/** Extrait un message lisible depuis une erreur HTTP (ou autre). */
|
||||||
|
toMessage(error: unknown, fallback = 'Une erreur est survenue.'): string {
|
||||||
|
if (error instanceof HttpErrorResponse) {
|
||||||
|
const body = error.error;
|
||||||
|
if (typeof body === 'string' && body.trim()) return body;
|
||||||
|
if (body && typeof body === 'object') {
|
||||||
|
const message = (body as { message?: unknown }).message;
|
||||||
|
if (typeof message === 'string' && message) return message;
|
||||||
|
if (Array.isArray(message) && message.length) return message.join(', ');
|
||||||
|
}
|
||||||
|
if (error.status === 0) return 'Serveur injoignable. Vérifiez votre connexion.';
|
||||||
|
return error.message || fallback;
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.message) return error.message;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Journalise une erreur réseau (appelé par l'interceptor). */
|
||||||
|
log(method: string, url: string, error: HttpErrorResponse): void {
|
||||||
|
console.error(`[HTTP ${error.status}] ${method} ${url} — ${this.toMessage(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -560,7 +560,7 @@ export class UploadComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/* Convertit valueChanges en signal pour que les computed() trackent les changements du formulaire */
|
/* Convertit valueChanges en signal pour que les computed() trackent les changements du formulaire */
|
||||||
private readonly formValues = toSignal(this.metaForm.valueChanges, { initialValue: this.metaForm.value });
|
protected readonly formValues = toSignal(this.metaForm.valueChanges, { initialValue: this.metaForm.value });
|
||||||
|
|
||||||
previewTitle = computed(() => {
|
previewTitle = computed(() => {
|
||||||
const values = this.formValues();
|
const values = this.formValues();
|
||||||
|
|||||||
4
front-public/src/environments/environment.prod.ts
Normal file
4
front-public/src/environments/environment.prod.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: true,
|
||||||
|
apiBaseUrl: '/api',
|
||||||
|
};
|
||||||
5
front-public/src/environments/environment.ts
Normal file
5
front-public/src/environments/environment.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: false,
|
||||||
|
// Préfixe des appels API (proxifié vers le back en dev, cf. proxy.conf*.json).
|
||||||
|
apiBaseUrl: '/api',
|
||||||
|
};
|
||||||
31
shared/README.md
Normal file
31
shared/README.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# @datacat/shared
|
||||||
|
|
||||||
|
Types et constantes partagés entre le **back** (NestJS) et le **front** (Angular).
|
||||||
|
|
||||||
|
## Contenu
|
||||||
|
|
||||||
|
- `src/types/` — interfaces et types (`ApiEntry`, `Category`, réponses d'API…).
|
||||||
|
- `src/schemas/api-entry.schema.ts` — **source de vérité** des énumérations
|
||||||
|
(`API_TYPES`, `API_STATUSES`, `API_CONVENTIONS`). Les types `ApiType` /
|
||||||
|
`ApiStatus` / `ApiConvention` en sont **dérivés** (`typeof X[number]`), et la
|
||||||
|
validation back (`@IsIn`) réutilise ces mêmes constantes. Ajouter une valeur
|
||||||
|
dans le tableau suffit donc à propager type + validation.
|
||||||
|
|
||||||
|
## Double build (piège récurrent)
|
||||||
|
|
||||||
|
Ce paquet est consommé de **deux façons différentes** :
|
||||||
|
|
||||||
|
| Consommateur | Mécanisme | Config |
|
||||||
|
|--------------|-----------|--------|
|
||||||
|
| **front** (Angular) | alias TypeScript vers la **source** | `tsconfig.json` (ES2022) + `paths` dans `front-public/tsconfig.json` |
|
||||||
|
| **back** (NestJS) | paquet **compilé** (`dist/`) en CommonJS | `tsconfig.build.json` (CommonJS) → `dist/` |
|
||||||
|
|
||||||
|
⚠️ Le back lit `dist/`, pas la source. Après toute modification de `shared`, il
|
||||||
|
faut **rebuild** avant que le back voie le changement :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm --filter @datacat/shared build
|
||||||
|
# (le docker-compose le fait déjà : voir infra/docker-compose.yml, étape tsc)
|
||||||
|
```
|
||||||
|
|
||||||
|
Le front, lui, voit la source directement (pas de rebuild nécessaire).
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "@datacat/shared",
|
"name": "@datacat/shared",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"description": "Types et constantes partagés entre le back (NestJS) et le front (Angular) de Datacat.",
|
||||||
|
"license": "UNLICENSED",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --project tsconfig.build.json"
|
"build": "tsc --project tsconfig.build.json"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
// Source de vérité unique pour les énumérations d'API.
|
||||||
|
// Les types (`ApiType`, `ApiStatus`, `ApiConvention`) sont dérivés de ces
|
||||||
|
// constantes dans `../types/api-entry.types.ts`, et la validation back
|
||||||
|
// (class-validator `@IsIn`) les réutilise — pas de duplication.
|
||||||
export const API_TYPES = ['ASYNCAPI', 'OPENAPI'] as const;
|
export const API_TYPES = ['ASYNCAPI', 'OPENAPI'] as const;
|
||||||
export const API_STATUSES = ['PENDING', 'GENERATED', 'ERROR'] as const;
|
export const API_STATUSES = ['PENDING', 'GENERATED', 'ERROR', 'NO_YAML'] as const;
|
||||||
export const API_CONVENTIONS = ['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'] as const;
|
export const API_CONVENTIONS = ['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'] as const;
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
export type ApiType = 'ASYNCAPI' | 'OPENAPI';
|
import { API_TYPES, API_STATUSES, API_CONVENTIONS } from '../schemas/api-entry.schema';
|
||||||
|
|
||||||
export type ApiStatus = 'PENDING' | 'GENERATED' | 'ERROR' | 'NO_YAML';
|
// Types dérivés des constantes (source de vérité dans api-entry.schema.ts) :
|
||||||
|
// ajouter une valeur dans le tableau suffit à mettre à jour le type.
|
||||||
|
export type ApiType = (typeof API_TYPES)[number];
|
||||||
|
|
||||||
export type ApiConvention = 'CONSULTER' | 'ENREGISTRER' | 'ETRE_NOTIFIE';
|
export type ApiStatus = (typeof API_STATUSES)[number];
|
||||||
|
|
||||||
|
export type ApiConvention = (typeof API_CONVENTIONS)[number];
|
||||||
|
|
||||||
export const API_CONVENTION_LABELS: Record<ApiConvention, string> = {
|
export const API_CONVENTION_LABELS: Record<ApiConvention, string> = {
|
||||||
CONSULTER: 'Consulter',
|
CONSULTER: 'Consulter',
|
||||||
|
|||||||
Reference in New Issue
Block a user