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:
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user