feat: refonte métadonnées API + UX dossiers et import

- Nouveau schéma api_entries : convention+name, version X.X.X, provider,
  contacts splitées (name/entity/email), suppression des anciens champs
- Formulaire upload étape 2 : convention+nom, version 3 champs, fournisseur,
  contacts métier et technique obligatoires ; type détecté auto → select désactivé
- Validation YAML : redocly.yaml (extends: spec), support noms avec espaces,
  extraction métadonnées (name, version, description) depuis info YAML
- Dossiers : modale création avec champ Emplacement pré-initialisé depuis
  le contexte de navigation, options └ avec indentation par profondeur
- Import depuis un dossier : pré-sélection de la catégorie via queryParam
- Recherche étendue : name, provider, description, contacts
- Résolution @datacat/shared via workspace:* + compilation CJS avant NestJS

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-06-11 13:38:14 +00:00
parent ef3884c68d
commit e85696079c
20 changed files with 911 additions and 519 deletions

View File

@@ -12,6 +12,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@datacat/shared": "workspace:*",
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",

3
back/redocly.yaml Normal file
View File

@@ -0,0 +1,3 @@
# Validation structurelle uniquement — pas de règles de style
extends:
- spec

View File

@@ -1,16 +1,16 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { ApiType, ApiStatus } from '@datacat/shared';
import { ApiType, ApiStatus, ApiConvention } from '@datacat/shared';
@Entity('api_entries')
export class ApiEntryEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
title!: string;
@Column({ type: 'varchar', length: 30, default: 'CONSULTER' })
convention!: ApiConvention;
@Column()
version!: string;
@Column({ type: 'varchar', length: 255, default: '' })
name!: string;
@Column({ nullable: true, type: 'text' })
description!: string | null;
@@ -18,6 +18,18 @@ export class ApiEntryEntity {
@Column({ type: 'varchar', length: 10 })
type!: ApiType;
@Column({ type: 'varchar', length: 255, default: '', name: 'provider' })
provider!: string;
@Column({ type: 'int', default: 0, name: 'version_major' })
versionMajor!: number;
@Column({ type: 'int', default: 0, name: 'version_minor' })
versionMinor!: number;
@Column({ type: 'int', default: 0, name: 'version_patch' })
versionPatch!: number;
@Column({ type: 'text', name: 'yaml_content' })
yamlContent!: string;
@@ -33,23 +45,23 @@ export class ApiEntryEntity {
@Column({ nullable: true, type: 'varchar', length: 36, name: 'category_id' })
categoryId!: string | null;
@Column({ nullable: true, type: 'text', name: 'functional_doc' })
functionalDoc!: string | null;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_name' })
contactFunctionalName!: string;
@Column({ nullable: true, type: 'text', name: 'technical_doc' })
technicalDoc!: string | null;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_entity' })
contactFunctionalEntity!: string;
@Column({ nullable: true, type: 'varchar', length: 500, name: 'external_doc_url' })
externalDocUrl!: string | null;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_email' })
contactFunctionalEmail!: string;
@Column({ nullable: true, type: 'varchar', length: 255, name: 'contact_functional' })
contactFunctional!: string | null;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_technical_name' })
contactTechnicalName!: string;
@Column({ nullable: true, type: 'varchar', length: 255, name: 'contact_technical' })
contactTechnical!: string | null;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_technical_entity' })
contactTechnicalEntity!: string;
@Column({ nullable: true, type: 'text', name: 'access_rights' })
accessRights!: string | null;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_technical_email' })
contactTechnicalEmail!: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;

View File

@@ -4,14 +4,11 @@ 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 { ApiListResponse, ApiListQuery, ApiEntry, ApiEntryListItem, ApiType, ApiStatus, ApiConvention, API_CONVENTION_LABELS } 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;
@@ -28,19 +25,23 @@ export class ApisService {
async create(dto: CreateApiEntryDto): Promise<ApiEntry> {
const entity = this.repo.create({
title: dto.title,
version: dto.version,
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,
functionalDoc: dto.functionalDoc ?? null,
technicalDoc: dto.technicalDoc ?? null,
externalDocUrl: dto.externalDocUrl ?? null,
contactFunctional: dto.contactFunctional ?? null,
contactTechnical: dto.contactTechnical ?? null,
accessRights: dto.accessRights ?? 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);
@@ -58,7 +59,11 @@ export class ApisService {
}
if (query.search) {
qb.andWhere(
'(LOWER(api.title) LIKE :search OR LOWER(api.description) LIKE :search)',
`(LOWER(api.name) LIKE :search
OR LOWER(api.provider) LIKE :search
OR LOWER(COALESCE(api.description, '')) LIKE :search
OR LOWER(api.contact_functional_name) LIKE :search
OR LOWER(api.contact_technical_name) LIKE :search)`,
{ search: `%${query.search.toLowerCase()}%` },
);
}
@@ -109,24 +114,38 @@ export class ApisService {
}
}
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: entity.title,
version: entity.version,
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,
functionalDoc: entity.functionalDoc,
technicalDoc: entity.technicalDoc,
externalDocUrl: entity.externalDocUrl,
contactFunctional: entity.contactFunctional,
contactTechnical: entity.contactTechnical,
accessRights: entity.accessRights,
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(),
};
@@ -135,10 +154,13 @@ function toApiEntry(entity: ApiEntryEntity): ApiEntry {
function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem {
return {
id: entity.id,
title: entity.title,
version: entity.version,
title: buildTitle(entity.convention, entity.name),
name: entity.name,
convention: entity.convention,
version: buildVersion(entity.versionMajor, entity.versionMinor, entity.versionPatch),
description: entity.description,
type: entity.type,
provider: entity.provider,
status: entity.status,
categoryId: entity.categoryId,
createdAt: entity.createdAt.toISOString(),

View File

@@ -1,14 +1,33 @@
import { IsString, IsNotEmpty, IsIn, IsOptional, IsUUID } from 'class-validator';
import { ApiType } from '@datacat/shared';
import { IsString, IsNotEmpty, IsIn, IsOptional, IsUUID, IsInt, Min, IsEmail } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiType, ApiConvention } from '@datacat/shared';
export class CreateApiEntryDto {
@IsString()
@IsNotEmpty()
title!: string;
@IsIn(['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'])
convention!: ApiConvention;
@IsString()
@IsNotEmpty()
version!: string;
name!: string;
@IsString()
@IsNotEmpty()
provider!: string;
@IsInt()
@Min(0)
@Type(() => Number)
versionMajor!: number;
@IsInt()
@Min(0)
@Type(() => Number)
versionMinor!: number;
@IsInt()
@Min(0)
@Type(() => Number)
versionPatch!: number;
@IsString()
@IsOptional()
@@ -26,26 +45,24 @@ export class CreateApiEntryDto {
categoryId?: string;
@IsString()
@IsOptional()
functionalDoc?: string;
@IsNotEmpty()
contactFunctionalName!: string;
@IsString()
@IsOptional()
technicalDoc?: string;
@IsNotEmpty()
contactFunctionalEntity!: string;
@IsEmail()
contactFunctionalEmail!: string;
@IsString()
@IsOptional()
externalDocUrl?: string;
@IsNotEmpty()
contactTechnicalName!: string;
@IsString()
@IsOptional()
contactFunctional?: string;
@IsNotEmpty()
contactTechnicalEntity!: string;
@IsString()
@IsOptional()
contactTechnical?: string;
@IsString()
@IsOptional()
accessRights?: string;
@IsEmail()
contactTechnicalEmail!: string;
}

View File

@@ -1,17 +1,41 @@
import { IsString, IsOptional, IsUUID } from 'class-validator';
import { IsString, IsOptional, IsUUID, IsIn, IsInt, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiConvention } from '@datacat/shared';
export class UpdateApiEntryDto {
@IsString()
@IsIn(['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'])
@IsOptional()
title?: string;
convention?: ApiConvention;
@IsString()
@IsOptional()
version?: string;
name?: string;
@IsString()
@IsOptional()
description?: string;
provider?: string;
@IsInt()
@Min(0)
@Type(() => Number)
@IsOptional()
versionMajor?: number;
@IsInt()
@Min(0)
@Type(() => Number)
@IsOptional()
versionMinor?: number;
@IsInt()
@Min(0)
@Type(() => Number)
@IsOptional()
versionPatch?: number;
@IsString()
@IsOptional()
description?: string | null;
@IsUUID()
@IsOptional()
@@ -19,25 +43,25 @@ export class UpdateApiEntryDto {
@IsString()
@IsOptional()
functionalDoc?: string | null;
contactFunctionalName?: string;
@IsString()
@IsOptional()
technicalDoc?: string | null;
contactFunctionalEntity?: string;
@IsString()
@IsOptional()
externalDocUrl?: string | null;
contactFunctionalEmail?: string;
@IsString()
@IsOptional()
contactFunctional?: string | null;
contactTechnicalName?: string;
@IsString()
@IsOptional()
contactTechnical?: string | null;
contactTechnicalEntity?: string;
@IsString()
@IsOptional()
accessRights?: string | null;
contactTechnicalEmail?: string;
}

View File

@@ -13,6 +13,7 @@ import {
ApiEntryListItem,
ApiType,
ApiStatus,
ApiConvention,
} from '@datacat/shared';
@Injectable()
@@ -150,13 +151,22 @@ function toCategory(entity: CategoryEntity): Category {
};
}
const CONVENTION_LABELS: Record<ApiConvention, string> = {
CONSULTER: 'Consulter',
ENREGISTRER: 'Enregistrer',
ETRE_NOTIFIE: 'Être notifié',
};
function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem {
return {
id: entity.id,
title: entity.title,
version: entity.version,
title: `${CONVENTION_LABELS[entity.convention] ?? entity.convention} ${entity.name}`,
name: entity.name,
convention: entity.convention,
version: `${entity.versionMajor}.${entity.versionMinor}.${entity.versionPatch}`,
description: entity.description,
type: entity.type as ApiType,
provider: entity.provider,
status: entity.status as ApiStatus,
categoryId: entity.categoryId,
createdAt: entity.createdAt.toISOString(),

View File

@@ -688,81 +688,101 @@ export class SeederService implements OnApplicationBootstrap {
/* APIs de démo */
const entries = await this.apiRepo.save([
this.apiRepo.create({
title: 'Swagger Petstore',
version: '3.0.4',
convention: 'CONSULTER',
name: 'Référentiel Produits',
versionMajor: 3,
versionMinor: 0,
versionPatch: 4,
description:
'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.',
type: 'OPENAPI',
yamlContent: PETSTORE_YAML,
status: 'PENDING',
categoryId: produits.id,
functionalDoc: 'Cas d\'usage : gestion du catalogue produits. Permet de lister, créer et consulter des animaux de compagnie. Usage typique : intégration e-commerce, démo API REST.',
technicalDoc: 'Authentification Bearer token requise. Pagination via paramètre `limit` (max 100). Réponses JSON selon schéma OpenAPI 3.0. Endpoint base : https://petstore.swagger.io/v3.',
externalDocUrl: null,
contactFunctional: 'Équipe Produit',
contactTechnical: 'Équipe API',
accessRights: 'API publique — aucune habilitation requise.',
provider: 'DINUM',
contactFunctionalName: 'Alice Martin',
contactFunctionalEntity: 'Équipe Produit',
contactFunctionalEmail: 'alice.martin@example.gouv.fr',
contactTechnicalName: 'Bob Dupont',
contactTechnicalEntity: 'Équipe API',
contactTechnicalEmail: 'bob.dupont@example.gouv.fr',
}),
this.apiRepo.create({
title: 'Streetlights Kafka API',
version: '1.0.0',
convention: 'ETRE_NOTIFIE',
name: 'Éclairage Urbain Kafka',
versionMajor: 1,
versionMinor: 0,
versionPatch: 0,
description:
'The Smartylighting Streetlights API allows you to remotely manage the city lights using Kafka messaging.',
type: 'ASYNCAPI',
yamlContent: STREETLIGHTS_YAML,
status: 'PENDING',
categoryId: evenements.id,
functionalDoc: 'Gestion de l\'éclairage urbain IoT. Permet de recevoir les mesures de luminosité des lampadaires et d\'envoyer des commandes d\'allumage/extinction à distance.',
technicalDoc: 'Broker Kafka sécurisé avec SCRAM-SHA-512. Topics : smartylighting.streetlights.1.0.*. Schémas de messages définis en AsyncAPI 3.0. Adresse broker : test.mykafkacluster.org:18092.',
externalDocUrl: null,
contactFunctional: 'Bureau Infrastructure',
contactTechnical: 'ops@example.com',
accessRights: 'Accès sur demande — habilitation DSI requise.',
provider: 'Bureau Infrastructure',
contactFunctionalName: 'Claire Leroy',
contactFunctionalEntity: 'Bureau Infrastructure',
contactFunctionalEmail: 'claire.leroy@example.gouv.fr',
contactTechnicalName: 'David Chen',
contactTechnicalEntity: 'Ops',
contactTechnicalEmail: 'ops@example.com',
}),
this.apiRepo.create({
title: 'Account Service',
version: '1.0.0',
convention: 'ETRE_NOTIFIE',
name: 'Comptes Utilisateurs',
versionMajor: 1,
versionMinor: 0,
versionPatch: 0,
description: "Service de gestion des comptes utilisateurs avec publication d'événements.",
type: 'ASYNCAPI',
yamlContent: ACCOUNT_SERVICE_YAML,
status: 'PENDING',
categoryId: notifications.id,
functionalDoc: 'Cycle de vie du compte utilisateur. Publie des événements lors de l\'inscription et du changement de mot de passe. Utilisé par les services de notification et d\'audit.',
technicalDoc: 'Consommer via broker AMQP ou Kafka. Canaux : user/signedup, user/passwordchanged. Payloads JSON avec horodatage ISO 8601. Spec AsyncAPI 3.0.',
externalDocUrl: null,
contactFunctional: 'Équipe Comptes',
contactTechnical: 'dev-core@example.com',
accessRights: 'Interne uniquement — accès réservé aux services du SI.',
provider: 'Équipe Comptes',
contactFunctionalName: 'Emma Roux',
contactFunctionalEntity: 'Équipe Comptes',
contactFunctionalEmail: 'emma.roux@example.gouv.fr',
contactTechnicalName: 'François Blanc',
contactTechnicalEntity: 'Dev Core',
contactTechnicalEmail: 'dev-core@example.com',
}),
this.apiRepo.create({
title: 'Orders API',
version: '1.0.0',
convention: 'ENREGISTRER',
name: 'Commandes Clients',
versionMajor: 1,
versionMinor: 0,
versionPatch: 0,
description: 'API de gestion des commandes clients.',
type: 'OPENAPI',
yamlContent: ORDERS_YAML,
status: 'PENDING',
categoryId: commandes.id,
functionalDoc: 'Gestion des commandes clients : création, consultation, mise à jour du statut. Supporte les webhooks pour les transitions de statut (confirmed, shipped, delivered, cancelled).',
technicalDoc: 'REST + JWT Bearer. Pagination via paramètres `page` et `limit`. Webhook de statut : POST vers URL configurée lors de la transition. Base URL : https://api.example.com/v1.',
externalDocUrl: null,
contactFunctional: 'Équipe Commerce',
contactTechnical: 'api-orders@example.com',
accessRights: 'Partenaires agréés uniquement — convention de partenariat requise.',
provider: 'Équipe Commerce',
contactFunctionalName: 'Gaëlle Moreau',
contactFunctionalEntity: 'Équipe Commerce',
contactFunctionalEmail: 'gaelle.moreau@example.gouv.fr',
contactTechnicalName: 'Hugo Simon',
contactTechnicalEntity: 'API Commerce',
contactTechnicalEmail: 'api-orders@example.com',
}),
this.apiRepo.create({
title: 'Auth API',
version: '1.0.0',
convention: 'CONSULTER',
name: 'Authentification JWT',
versionMajor: 1,
versionMinor: 0,
versionPatch: 0,
description: "Service d'authentification et d'autorisation.",
type: 'OPENAPI',
yamlContent: AUTH_YAML,
status: 'PENDING',
categoryId: auth.id,
functionalDoc: 'SSO centralisé avec tokens JWT. Gère la connexion, le rafraîchissement de token et la déconnexion. Exposé à tous les services du SI comme fournisseur d\'identité.',
technicalDoc: 'Bearer JWT (RS256). Endpoint principal : POST /auth/login. Refresh via POST /auth/refresh. Durée access token : 15 min. Durée refresh token : 7 jours. Base URL : https://auth.example.com/v1.',
externalDocUrl: null,
contactFunctional: 'Équipe Sécurité',
contactTechnical: 'securite@example.com',
accessRights: 'Restreint — habilitation RSSI requise avant intégration.',
provider: 'Équipe Sécurité',
contactFunctionalName: 'Isabelle Petit',
contactFunctionalEntity: 'Équipe Sécurité',
contactFunctionalEmail: 'isabelle.petit@example.gouv.fr',
contactTechnicalName: 'Julien Bernard',
contactTechnicalEntity: 'Sécurité SI',
contactTechnicalEmail: 'securite@example.com',
}),
]);

View File

@@ -57,13 +57,15 @@ export class UploadsController {
valid: boolean;
type: ApiType | null;
mainFile: string | null;
title: string | null;
version: string | null;
name: string | null;
versionMajor: number | null;
versionMinor: number | null;
versionPatch: number | null;
description: string | null;
errors: Array<{ file: string; message: string }>;
}> {
if (!files || files.length === 0) {
return { valid: false, type: null, mainFile: null, title: null, version: null, description: null, errors: [{ file: '', message: 'Aucun fichier fourni.' }] };
return { valid: false, type: null, mainFile: null, name: null, versionMajor: null, versionMinor: null, versionPatch: null, description: null, errors: [{ file: '', message: 'Aucun fichier fourni.' }] };
}
/* Lire tous les contenus en mémoire avant toute suppression */
@@ -94,7 +96,7 @@ export class UploadsController {
}
if (errors.length > 0) {
return { valid: false, type: null, mainFile: null, title: null, version: null, description: null, errors };
return { valid: false, type: null, mainFile: null, name: null, versionMajor: null, versionMinor: null, versionPatch: null, description: null, errors };
}
/* Détection du fichier principal */
@@ -112,7 +114,7 @@ export class UploadsController {
if (!mainFileName) {
return {
valid: false, type: null, mainFile: null, title: null, version: null, description: null,
valid: false, type: null, mainFile: null, name: null, versionMajor: null, versionMinor: null, versionPatch: null, description: null,
errors: [{ file: '', message: 'Aucun fichier principal trouvé (clé openapi: ou asyncapi: manquante).' }],
};
}
@@ -133,14 +135,14 @@ export class UploadsController {
if (detectedType === 'ASYNCAPI') {
await execFileAsync('asyncapi', ['validate', mainPath], { env: execEnv, timeout: 30000 });
} else {
await execFileAsync('redocly', ['lint', mainPath], { shell: true, env: execEnv, timeout: 30000 });
await execFileAsync('redocly', ['lint', mainPath], { env: execEnv, timeout: 30000 });
}
} catch (cliError) {
const err = cliError as { stdout?: string; stderr?: string };
const output = (err.stdout ?? err.stderr ?? String(cliError)).trim();
const output = (err.stdout || err.stderr || String(cliError)).trim();
return {
valid: false, type: detectedType, mainFile: mainFileName,
title: null, version: null, description: null,
name: null, versionMajor: null, versionMinor: null, versionPatch: null, description: null,
errors: [{ file: mainFileName, message: output }],
};
}
@@ -152,11 +154,22 @@ export class UploadsController {
const mainContent = fileContents.find((f) => f.name === mainFileName)!.content;
const parsed = yaml.load(mainContent) as Record<string, unknown>;
const info = parsed['info'] as Record<string, unknown> | undefined;
const title = typeof info?.['title'] === 'string' ? info['title'] : null;
const version = typeof info?.['version'] === 'string' ? String(info['version']) : null;
const name = typeof info?.['title'] === 'string' ? info['title'] : null;
const description = typeof info?.['description'] === 'string' ? info['description'] : null;
return { valid: true, type: detectedType, mainFile: mainFileName, title, version, description, errors: [] };
/* Décomposer la version en major.minor.patch */
let versionMajor: number | null = null;
let versionMinor: number | null = null;
let versionPatch: number | null = null;
const versionStr = typeof info?.['version'] === 'string' ? info['version'] : null;
if (versionStr) {
const parts = versionStr.split('.').map((p) => parseInt(p, 10));
if (parts.length >= 1 && !isNaN(parts[0])) versionMajor = parts[0];
if (parts.length >= 2 && !isNaN(parts[1])) versionMinor = parts[1];
if (parts.length >= 3 && !isNaN(parts[2])) versionPatch = parts[2];
}
return { valid: true, type: detectedType, mainFile: mainFileName, name, versionMajor, versionMinor, versionPatch, description, errors: [] };
}
@Post()
@@ -175,22 +188,26 @@ export class UploadsController {
@UploadedFiles() files: Express.Multer.File[],
@Body()
body: {
title: string;
version: string;
convention: string;
name: string;
provider: string;
versionMajor: string;
versionMinor: string;
versionPatch: string;
description?: string;
type?: ApiType;
categoryId?: string;
functionalDoc?: string;
technicalDoc?: string;
externalDocUrl?: string;
contactFunctional?: string;
contactTechnical?: string;
accessRights?: string;
contactFunctionalName: string;
contactFunctionalEntity: string;
contactFunctionalEmail: string;
contactTechnicalName: string;
contactTechnicalEntity: string;
contactTechnicalEmail: string;
},
) {
if (!files || files.length === 0) throw new BadRequestException('YAML file is required');
if (!body.title) throw new BadRequestException('title is required');
if (!body.version) throw new BadRequestException('version is required');
if (!body.name) throw new BadRequestException('name is required');
if (!body.convention) throw new BadRequestException('convention is required');
/* Crée le répertoire si absent */
const uploadDir = path.join(os.tmpdir(), 'datacat-uploads');
@@ -225,18 +242,22 @@ export class UploadsController {
}
const entry = await this.apisService.create({
title: body.title,
version: body.version,
convention: (body.convention as 'CONSULTER' | 'ENREGISTRER' | 'ETRE_NOTIFIE') ?? 'CONSULTER',
name: body.name,
provider: body.provider,
versionMajor: parseInt(body.versionMajor, 10) || 0,
versionMinor: parseInt(body.versionMinor, 10) || 0,
versionPatch: parseInt(body.versionPatch, 10) || 0,
description: body.description,
type: apiType,
yamlContent,
categoryId: body.categoryId || undefined,
functionalDoc: body.functionalDoc || undefined,
technicalDoc: body.technicalDoc || undefined,
externalDocUrl: body.externalDocUrl || undefined,
contactFunctional: body.contactFunctional || undefined,
contactTechnical: body.contactTechnical || undefined,
accessRights: body.accessRights || undefined,
contactFunctionalName: body.contactFunctionalName,
contactFunctionalEntity: body.contactFunctionalEntity,
contactFunctionalEmail: body.contactFunctionalEmail,
contactTechnicalName: body.contactTechnicalName,
contactTechnicalEntity: body.contactTechnicalEntity,
contactTechnicalEmail: body.contactTechnicalEmail,
});
/* Fire-and-forget : génération asynchrone */
@@ -300,7 +321,7 @@ export class UploadsController {
if (type === 'OPENAPI') {
/* redocly bundle : pas d'appel réseau, fonctionne en local */
await execFileAsync('redocly', ['bundle', mainPath, '--output', bundledPath], { shell: true, env: execEnv });
await execFileAsync('redocly', ['bundle', mainPath, '--output', bundledPath], { env: execEnv });
return await fsPromises.readFile(bundledPath, 'utf-8');
} else {
/* @asyncapi/bundler API programmatique : aucun appel réseau */

View File

@@ -10,10 +10,9 @@
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"rootDir": "../",
"baseUrl": "./",
"incremental": true,
"paths": {
"@datacat/shared": ["../shared/src/index.ts"]
}
}
"incremental": true
},
"include": ["src/**/*.ts"]
}