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, ApiConvention, API_CONVENTION_LABELS } from '@datacat/shared'; import { toApiEntryListItem } from './api-entry.mapper'; import { DocsGenerationService } from '../docs-generation/docs-generation.service'; /** Champs internes mis à jour par DocsGenerationService */ interface InternalUpdateFields { htmlPath?: string | null; status?: ApiStatus; errorMessage?: string | null; } @Injectable() export class ApisService { constructor( @InjectRepository(ApiEntryEntity) private readonly repo: Repository, @Inject(forwardRef(() => DocsGenerationService)) private readonly docsService: DocsGenerationService, ) {} async create(dto: CreateApiEntryDto): Promise { const entity = this.repo.create({ convention: dto.convention, name: dto.name, provider: dto.provider, versionMajor: dto.versionMajor, versionMinor: dto.versionMinor, versionPatch: dto.versionPatch, description: dto.description ?? null, type: dto.type, yamlContent: dto.yamlContent, status: 'PENDING', categoryId: dto.categoryId ?? null, contactFunctionalName: dto.contactFunctionalName, contactFunctionalEntity: dto.contactFunctionalEntity, contactFunctionalEmail: dto.contactFunctionalEmail, contactTechnicalName: dto.contactTechnicalName, contactTechnicalEntity: dto.contactTechnicalEntity, contactTechnicalEmail: dto.contactTechnicalEmail, }); const saved = await this.repo.save(entity); return toApiEntry(saved); } async findAll(query: ApiListQuery): Promise { 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'); let effectiveType = query.type as ApiType | undefined; let version: string | undefined; let latestOnly = false; if (query.search?.trim()) { const parsed = this.parseSearchTokens(query.search); // Le type détecté dans la saisie override le select UI if (parsed.type) effectiveType = parsed.type; version = parsed.version; latestOnly = parsed.latestOnly; if (parsed.text) { // Recherche fuzzy via pg_trgm + ILIKE pour les matches partiels courts qb.andWhere( `( api.name ILIKE :likeText OR api.provider ILIKE :likeText OR COALESCE(api.description, '') ILIKE :likeText OR api.contact_functional_name ILIKE :likeText OR api.contact_technical_name ILIKE :likeText OR word_similarity(:text, api.name) > 0.5 OR word_similarity(:text, api.provider) > 0.5 OR word_similarity(:text, COALESCE(api.description, '')) > 0.5 )`, { likeText: `%${parsed.text}%`, text: parsed.text }, ); } } if (effectiveType) { qb.andWhere('api.type = :type', { type: effectiveType }); } if (query.categoryId) { qb.andWhere('api.category_id = :categoryId', { categoryId: query.categoryId }); } // Filtre par version (partielle ou complète) if (version) { const parts = version.split('.').map(Number); if (parts.length >= 1 && !isNaN(parts[0])) qb.andWhere('api.version_major = :major', { major: parts[0] }); if (parts.length >= 2 && !isNaN(parts[1])) qb.andWhere('api.version_minor = :minor', { minor: parts[1] }); if (parts.length >= 3 && !isNaN(parts[2])) qb.andWhere('api.version_patch = :patch', { patch: parts[2] }); } // Latest-only : exclure les entrées dont il existe une version plus récente du même API if (latestOnly) { qb.andWhere(`NOT EXISTS ( SELECT 1 FROM api_entries e2 WHERE e2.name = api.name AND e2.convention = api.convention AND ( e2.version_major > api.version_major OR (e2.version_major = api.version_major AND e2.version_minor > api.version_minor) OR (e2.version_major = api.version_major AND e2.version_minor = api.version_minor AND e2.version_patch > api.version_patch) ) )`); } qb.orderBy('api.created_at', 'DESC').skip(offset).take(limit); const [entities, total] = await qb.getManyAndCount(); return { items: entities.map(toApiEntryListItem), total, page, limit }; } private parseSearchTokens(raw: string): { text: string; type?: ApiType; version?: string; latestOnly: boolean; } { const TYPE_MAP: Record = { async: 'ASYNCAPI', asyncapi: 'ASYNCAPI', openapi: 'OPENAPI', open: 'OPENAPI', }; const VERSION_RE = /^v?(\d+)(?:\.(\d+)(?:\.(\d+))?)?$/i; let type: ApiType | undefined; let version: string | undefined; let latestOnly = false; const remaining: string[] = []; for (const word of raw.trim().split(/\s+/).filter(Boolean)) { const lower = word.toLowerCase(); if (lower === 'latest') { latestOnly = true; } else if (TYPE_MAP[lower]) { type = TYPE_MAP[lower]; } else if (VERSION_RE.test(word)) { version = word.replace(/^v/i, ''); } else { remaining.push(word); } } const text = remaining.join(' '); // Pas de version explicite ET champ non vide → latest par défaut if (!latestOnly && raw.trim().length > 0 && !version) latestOnly = true; return { text, type, version, latestOnly }; } async findOneOrThrow(id: string): Promise { 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 { await this.repo.update(id, dto); return this.findOneOrThrow(id); } /** Mise à jour interne (statut, htmlPath, errorMessage) */ async updateInternal(id: string, fields: InternalUpdateFields): Promise { await this.repo.update(id, fields); } async remove(id: string): Promise { const entity = await this.repo.findOne({ where: { id } }); if (!entity) throw new NotFoundException(`API entry ${id} not found`); await this.repo.delete(id); } async findVersionsOf(id: string): Promise { const entry = await this.findOneOrThrow(id); const entities = await this.repo.find({ where: { name: entry.name, convention: entry.convention }, order: { versionMajor: 'DESC', versionMinor: 'DESC', versionPatch: 'DESC', }, }); return entities.map(toApiEntryListItem); } async regenerate(id: string): Promise { await this.findOneOrThrow(id); await this.updateInternal(id, { status: 'PENDING', errorMessage: null }); this.docsService.generate(id).catch(console.error); return this.findOneOrThrow(id); } } function buildTitle(convention: ApiConvention, name: string): string { return `${API_CONVENTION_LABELS[convention]} ${name}`; } function buildVersion(major: number, minor: number, patch: number): string { return `${major}.${minor}.${patch}`; } function toApiEntry(entity: ApiEntryEntity): ApiEntry { return { id: entity.id, title: buildTitle(entity.convention, entity.name), name: entity.name, convention: entity.convention, version: buildVersion(entity.versionMajor, entity.versionMinor, entity.versionPatch), versionMajor: entity.versionMajor, versionMinor: entity.versionMinor, versionPatch: entity.versionPatch, description: entity.description, type: entity.type, provider: entity.provider, yamlContent: entity.yamlContent, htmlPath: entity.htmlPath, status: entity.status, errorMessage: entity.errorMessage, categoryId: entity.categoryId, contactFunctionalName: entity.contactFunctionalName, contactFunctionalEntity: entity.contactFunctionalEntity, contactFunctionalEmail: entity.contactFunctionalEmail, contactTechnicalName: entity.contactTechnicalName, contactTechnicalEntity: entity.contactTechnicalEntity, contactTechnicalEmail: entity.contactTechnicalEmail, createdAt: entity.createdAt.toISOString(), updatedAt: entity.updatedAt.toISOString(), }; }