Compare commits

...

4 Commits

Author SHA1 Message Date
z3n
34a83233f2 front: config env, error interceptor fonctionnel, DSFR en deps, fix build
- src/environments/{environment,environment.prod}.ts + fileReplacements :
  baseUrl API configurable au lieu de '/api' hardcodé dans les services
- ErrorService + errorInterceptor (HttpInterceptorFn) ; app.config passe à
  withInterceptors([...]) (remplace withInterceptorsFromDi legacy)
- @gouvfr/dsfr déplacé en dependencies (chargé au runtime)
- api.service: extrait ValidateResponse (dédup du type inline)
- fix: formValues passe en protected (utilisé dans le template -> build NG1
  cassé, le conteneur servait un ancien bundle)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:43:06 +00:00
z3n
f7f706e1e1 back(uploads): I/O fichiers en async (non bloquant)
readFileSync/existsSync/mkdirSync -> fs/promises (readFile, mkdir recursive
idempotent). detectMainFile devient async. Retire l'import fs sync inutilisé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:02:13 +00:00
z3n
38f6f6ab52 back: validation d'env, transaction setCurrent, fix N+1 catégories, logging
- ConfigModule.forRoot({ validate }) : schéma class-validator des variables
  d'env, l'app refuse de démarrer si une var manque/mal typée ; suppression
  des fallbacks de credentials DB (getOrThrow)
- setCurrent() enveloppé dans une transaction (plus de fenêtre incohérente
  sur isCurrent entre les 2 updates)
- browse() : compteurs de toutes les catégories en 2 requêtes + calcul en
  mémoire au lieu d'un N+1 récursif par enfant
- console.* -> Logger Nest partout (apis, uploads, seeder, bootstrap) ;
  .catch(console.error) -> logger.error avec contexte

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:00:13 +00:00
z3n
558bf763e7 shared: source de vérité unique pour les énums d'API
- API_STATUSES inclut 'NO_YAML' (était manquant alors que le type et le
  code l'utilisent)
- ApiType/ApiStatus/ApiConvention dérivés des constantes (typeof X[number])
  -> plus de double maintenance type <-> tableau
- DTO back réutilisent API_CONVENTIONS/API_TYPES dans @IsIn (fin de la
  triple duplication des valeurs)
- front: déclare la dépendance @datacat/shared (workspace:*)
- shared: exports map + métadonnées + README (double build front/back)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 11:54:27 +00:00
24 changed files with 336 additions and 90 deletions

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -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,30 +60,23 @@ 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> {
valid: boolean; return this.http.post<ValidateResponse>(`${this.uploadsUrl}/validate`, formData);
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 }>;
}> {
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);
} }
} }
/** Réponse de l'endpoint de validation d'upload (POST /uploads/validate). */
export interface ValidateResponse {
valid: boolean;
type: ApiType | 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 }>;
}

View File

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

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

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

View File

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

View File

@@ -0,0 +1,4 @@
export const environment = {
production: true,
apiBaseUrl: '/api',
};

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

View File

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

View File

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

View File

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