Refonte qualité (shared / back / front) #1

Open
z3n wants to merge 14 commits from chore/quality-refonte into main
8 changed files with 171 additions and 36 deletions
Showing only changes of commit 38f6f6ab52 - Show all commits

View File

@@ -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',

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

View File

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

View File

@@ -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> = {}) {

View File

@@ -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) => {
{ name: entry.name, convention: entry.convention }, const repo = manager.getRepository(ApiEntryEntity);
{ isCurrent: false }, await repo.update(
); { name: entry.name, convention: entry.convention },
// Mettre cet entry à true { isCurrent: false },
await this.repo.update({ id }, { isCurrent: true }); );
await 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);
} }
} }

View File

@@ -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({

View File

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

View File

@@ -253,7 +253,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;