- name/provider : seuil 0.4 (textes courts, scores plus précis) - description : seuil 0.5 (textes longs, évite les faux positifs) - "auten" trouve désormais "Authentification JWT" via fuzzy sur le nom Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
236 lines
8.7 KiB
TypeScript
236 lines
8.7 KiB
TypeScript
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<ApiEntryEntity>,
|
|
@Inject(forwardRef(() => DocsGenerationService))
|
|
private readonly docsService: DocsGenerationService,
|
|
) {}
|
|
|
|
async create(dto: CreateApiEntryDto): Promise<ApiEntry> {
|
|
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<ApiListResponse> {
|
|
const page = Math.max(1, Number(query.page ?? 1));
|
|
const limit = Math.min(100, Math.max(1, Number(query.limit ?? 20)));
|
|
const offset = (page - 1) * limit;
|
|
|
|
const qb = this.repo.createQueryBuilder('api');
|
|
|
|
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.4
|
|
OR word_similarity(:text, api.provider) > 0.4
|
|
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<string, ApiType> = {
|
|
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<ApiEntry> {
|
|
const entity = await this.repo.findOne({ where: { id } });
|
|
if (!entity) throw new NotFoundException(`API entry ${id} not found`);
|
|
return toApiEntry(entity);
|
|
}
|
|
|
|
/** Mise à jour publique (champs éditables par l'utilisateur) */
|
|
async update(id: string, dto: UpdateApiEntryDto): Promise<ApiEntry> {
|
|
await this.repo.update(id, dto);
|
|
return this.findOneOrThrow(id);
|
|
}
|
|
|
|
/** Mise à jour interne (statut, htmlPath, errorMessage) */
|
|
async updateInternal(id: string, fields: InternalUpdateFields): Promise<void> {
|
|
await this.repo.update(id, fields);
|
|
}
|
|
|
|
async remove(id: string): Promise<void> {
|
|
const entity = await this.repo.findOne({ where: { id } });
|
|
if (!entity) throw new NotFoundException(`API entry ${id} not found`);
|
|
await this.repo.delete(id);
|
|
}
|
|
|
|
async findVersionsOf(id: string): Promise<ApiEntryListItem[]> {
|
|
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<ApiEntry> {
|
|
await this.findOneOrThrow(id);
|
|
await this.updateInternal(id, { status: 'PENDING', errorMessage: null });
|
|
this.docsService.generate(id).catch(console.error);
|
|
return this.findOneOrThrow(id);
|
|
}
|
|
}
|
|
|
|
function 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(),
|
|
};
|
|
}
|
|
|