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:
z3n
2026-05-22 08:42:54 +00:00
commit 921a6e652b
87 changed files with 16719 additions and 0 deletions

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