feat: initial scaffold — MVP catalogue de données d'API
- Backend NestJS : CRUD api_entries + categories, upload YAML multi-fichiers, génération docs AsyncAPI (@asyncapi/cli@6.0.0) et OpenAPI (redocly) - Fix: route wildcard GET /api/apis/:id/*path pour servir les assets statiques (CSS/JS) générés par AsyncAPI HTML template (contournement bug path-to-regexp v8) - Frontend Angular 19 : pages catalog, browse, api-detail, doc-viewer, upload (DSFR) - Seeder de données de démo (idempotent) - Docker Compose dev + Dockerfiles + manifests K8s - Documentation : README, architecture, référence API REST 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>
This commit is contained in:
135
back/src/modules/apis/apis.service.ts
Normal file
135
back/src/modules/apis/apis.service.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
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 } from '@datacat/shared';
|
||||
import { DocsGenerationService } from '../docs-generation/docs-generation.service';
|
||||
|
||||
/** Champs internes mis à jour par DocsGenerationService */
|
||||
interface InternalUpdateFields {
|
||||
title?: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
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({
|
||||
title: dto.title,
|
||||
version: dto.version,
|
||||
description: dto.description ?? null,
|
||||
type: dto.type,
|
||||
yamlContent: dto.yamlContent,
|
||||
status: 'PENDING',
|
||||
categoryId: dto.categoryId ?? null,
|
||||
});
|
||||
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');
|
||||
|
||||
if (query.type) {
|
||||
qb.andWhere('api.type = :type', { type: query.type as ApiType });
|
||||
}
|
||||
if (query.search) {
|
||||
qb.andWhere(
|
||||
'(LOWER(api.title) LIKE :search OR LOWER(api.description) LIKE :search)',
|
||||
{ search: `%${query.search.toLowerCase()}%` },
|
||||
);
|
||||
}
|
||||
if (query.categoryId) {
|
||||
qb.andWhere('api.category_id = :categoryId', { categoryId: query.categoryId });
|
||||
}
|
||||
|
||||
qb.orderBy('api.created_at', 'DESC').skip(offset).take(limit);
|
||||
|
||||
const [entities, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
items: entities.map(toApiEntryListItem),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
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 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 toApiEntry(entity: ApiEntryEntity): ApiEntry {
|
||||
return {
|
||||
id: entity.id,
|
||||
title: entity.title,
|
||||
version: entity.version,
|
||||
description: entity.description,
|
||||
type: entity.type,
|
||||
yamlContent: entity.yamlContent,
|
||||
htmlPath: entity.htmlPath,
|
||||
status: entity.status,
|
||||
errorMessage: entity.errorMessage,
|
||||
categoryId: entity.categoryId,
|
||||
createdAt: entity.createdAt.toISOString(),
|
||||
updatedAt: entity.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem {
|
||||
return {
|
||||
id: entity.id,
|
||||
title: entity.title,
|
||||
version: entity.version,
|
||||
description: entity.description,
|
||||
type: entity.type,
|
||||
status: entity.status,
|
||||
categoryId: entity.categoryId,
|
||||
createdAt: entity.createdAt.toISOString(),
|
||||
updatedAt: entity.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user