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:
@@ -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
3
back/redocly.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Validation structurelle uniquement — pas de règles de style
|
||||
extends:
|
||||
- spec
|
||||
@@ -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 */
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -23,16 +23,20 @@ export class ApiService {
|
||||
}
|
||||
|
||||
update(id: string, data: {
|
||||
title?: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
convention?: string;
|
||||
name?: string;
|
||||
provider?: string;
|
||||
versionMajor?: number;
|
||||
versionMinor?: number;
|
||||
versionPatch?: number;
|
||||
description?: string | null;
|
||||
categoryId?: string | null;
|
||||
functionalDoc?: string | null;
|
||||
technicalDoc?: string | null;
|
||||
externalDocUrl?: string | null;
|
||||
contactFunctional?: string | null;
|
||||
contactTechnical?: string | null;
|
||||
accessRights?: string | null;
|
||||
contactFunctionalName?: string;
|
||||
contactFunctionalEntity?: string;
|
||||
contactFunctionalEmail?: string;
|
||||
contactTechnicalName?: string;
|
||||
contactTechnicalEntity?: string;
|
||||
contactTechnicalEmail?: string;
|
||||
}): Observable<ApiEntry> {
|
||||
return this.http.patch<ApiEntry>(`${this.baseUrl}/${id}`, data);
|
||||
}
|
||||
@@ -53,8 +57,10 @@ export class ApiService {
|
||||
valid: boolean;
|
||||
type: 'ASYNCAPI' | 'OPENAPI' | 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 }>;
|
||||
}> {
|
||||
@@ -62,8 +68,10 @@ export class ApiService {
|
||||
valid: boolean;
|
||||
type: 'ASYNCAPI' | 'OPENAPI' | 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 }>;
|
||||
}>('/api/uploads/validate', formData);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { CategoryService } from '../../core/category.service';
|
||||
import { ApiEntry, Category } from '@datacat/shared';
|
||||
import { ApiEntry, Category, ApiConvention } from '@datacat/shared';
|
||||
|
||||
@Component({
|
||||
selector: 'app-api-detail',
|
||||
@@ -81,10 +81,6 @@ import { ApiEntry, Category } from '@datacat/shared';
|
||||
<div class="fr-card__content">
|
||||
<h2 class="fr-h5 fr-mb-2w">Informations</h2>
|
||||
<dl class="fr-grid-row fr-grid-row--gutters">
|
||||
<div class="fr-col-12 fr-col-md-4">
|
||||
<dt class="fr-text--bold">Titre</dt>
|
||||
<dd>{{ entry.title }}</dd>
|
||||
</div>
|
||||
<div class="fr-col-12 fr-col-md-4">
|
||||
<dt class="fr-text--bold">Version</dt>
|
||||
<dd>{{ entry.version }}</dd>
|
||||
@@ -93,6 +89,10 @@ import { ApiEntry, Category } from '@datacat/shared';
|
||||
<dt class="fr-text--bold">Type</dt>
|
||||
<dd>{{ entry.type }}</dd>
|
||||
</div>
|
||||
<div class="fr-col-12 fr-col-md-4">
|
||||
<dt class="fr-text--bold">Fournisseur</dt>
|
||||
<dd>{{ entry.provider }}</dd>
|
||||
</div>
|
||||
@if (entry.description) {
|
||||
<div class="fr-col-12">
|
||||
<dt class="fr-text--bold">Description</dt>
|
||||
@@ -112,80 +112,28 @@ import { ApiEntry, Category } from '@datacat/shared';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Documentation fonctionnelle -->
|
||||
@if (entry.functionalDoc) {
|
||||
<div class="fr-card fr-mb-4w">
|
||||
<div class="fr-card__body">
|
||||
<div class="fr-card__content">
|
||||
<h2 class="fr-h5 fr-mb-2w">Documentation fonctionnelle</h2>
|
||||
<p style="white-space: pre-wrap">{{ entry.functionalDoc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Documentation technique -->
|
||||
@if (entry.technicalDoc) {
|
||||
<div class="fr-card fr-mb-4w">
|
||||
<div class="fr-card__body">
|
||||
<div class="fr-card__content">
|
||||
<h2 class="fr-h5 fr-mb-2w">Documentation technique</h2>
|
||||
<p style="white-space: pre-wrap">{{ entry.technicalDoc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Contacts -->
|
||||
@if (entry.contactFunctional || entry.contactTechnical) {
|
||||
<div class="fr-card fr-mb-4w">
|
||||
<div class="fr-card__body">
|
||||
<div class="fr-card__content">
|
||||
<h2 class="fr-h5 fr-mb-2w">Contacts</h2>
|
||||
<dl class="fr-grid-row fr-grid-row--gutters">
|
||||
@if (entry.contactFunctional) {
|
||||
<div class="fr-grid-row fr-grid-row--gutters">
|
||||
<div class="fr-col-12 fr-col-md-6">
|
||||
<dt class="fr-text--bold">Contact fonctionnel</dt>
|
||||
<dd>{{ entry.contactFunctional }}</dd>
|
||||
<p class="fr-text--bold fr-mb-1w">Contact métier</p>
|
||||
<p class="fr-mb-0">{{ entry.contactFunctionalName }}</p>
|
||||
<p class="fr-mb-0 fr-text--sm">{{ entry.contactFunctionalEntity }}</p>
|
||||
<p class="fr-mb-0 fr-text--sm">{{ entry.contactFunctionalEmail }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (entry.contactTechnical) {
|
||||
<div class="fr-col-12 fr-col-md-6">
|
||||
<dt class="fr-text--bold">Contact technique</dt>
|
||||
<dd>{{ entry.contactTechnical }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
<p class="fr-text--bold fr-mb-1w">Contact technique</p>
|
||||
<p class="fr-mb-0">{{ entry.contactTechnicalName }}</p>
|
||||
<p class="fr-mb-0 fr-text--sm">{{ entry.contactTechnicalEntity }}</p>
|
||||
<p class="fr-mb-0 fr-text--sm">{{ entry.contactTechnicalEmail }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Droits d'accès -->
|
||||
@if (entry.accessRights) {
|
||||
<div class="fr-card fr-mb-4w">
|
||||
<div class="fr-card__body">
|
||||
<div class="fr-card__content">
|
||||
<h2 class="fr-h5 fr-mb-2w">Droits d'accès</h2>
|
||||
<p style="white-space: pre-wrap">{{ entry.accessRights }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Documentation externe -->
|
||||
@if (entry.externalDocUrl) {
|
||||
<div class="fr-mb-4w">
|
||||
<a
|
||||
[href]="entry.externalDocUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="fr-btn fr-btn--secondary fr-icon-external-link-line fr-btn--icon-left"
|
||||
>
|
||||
Documentation externe
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="fr-btns-group fr-btns-group--inline-sm">
|
||||
@@ -229,7 +177,7 @@ import { ApiEntry, Category } from '@datacat/shared';
|
||||
@if (showEditModal()) {
|
||||
<div style="position:fixed;inset:0;z-index:9999;background:rgba(22,22,22,.64);display:flex;align-items:flex-start;justify-content:center;padding-top:5vh;overflow-y:auto"
|
||||
role="dialog" aria-modal="true" aria-labelledby="edit-modal-title">
|
||||
<div style="background:white;width:calc(100% - 2rem);max-width:600px;margin:0 1rem 2rem">
|
||||
<div style="background:white;width:calc(100% - 2rem);max-width:640px;margin:0 1rem 2rem">
|
||||
<div class="fr-p-4w">
|
||||
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
|
||||
<div class="fr-col">
|
||||
@@ -242,57 +190,144 @@ import { ApiEntry, Category } from '@datacat/shared';
|
||||
@if (editError()) {
|
||||
<div class="fr-alert fr-alert--error fr-mb-2w"><p>{{ editError() }}</p></div>
|
||||
}
|
||||
<div class="fr-input-group fr-mb-2w" [class.fr-input-group--error]="editTitleError()">
|
||||
<label class="fr-label" for="edit-title">Titre <span style="color:var(--text-default-error)">*</span></label>
|
||||
<input id="edit-title" class="fr-input" [class.fr-input--error]="editTitleError()" type="text" [value]="editTitle()"
|
||||
(input)="editTitle.set($any($event.target).value); editTitleError.set(false)"
|
||||
(blur)="editTitleError.set(!editTitle().trim())" />
|
||||
@if (editTitleError()) { <p class="fr-error-text">Le titre est obligatoire.</p> }
|
||||
|
||||
<!-- Convention + Nom -->
|
||||
<p class="fr-label fr-mb-1w">Titre <span style="color:var(--text-default-error)">*</span></p>
|
||||
<div class="fr-grid-row fr-grid-row--gutters fr-mb-1w">
|
||||
<div class="fr-col-12 fr-col-md-4">
|
||||
<div class="fr-select-group">
|
||||
<label class="fr-label" for="edit-convention">Convention</label>
|
||||
<select id="edit-convention" class="fr-select" [value]="editConvention()"
|
||||
(change)="editConvention.set($any($event.target).value)">
|
||||
<option value="CONSULTER">Consulter</option>
|
||||
<option value="ENREGISTRER">Enregistrer</option>
|
||||
<option value="ETRE_NOTIFIE">Être notifié</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fr-input-group fr-mb-2w" [class.fr-input-group--error]="editVersionError()">
|
||||
<label class="fr-label" for="edit-version">Version <span style="color:var(--text-default-error)">*</span></label>
|
||||
<input id="edit-version" class="fr-input" [class.fr-input--error]="editVersionError()" type="text" [value]="editVersion()"
|
||||
(input)="editVersion.set($any($event.target).value); editVersionError.set(false)"
|
||||
(blur)="editVersionError.set(!editVersion().trim())" />
|
||||
@if (editVersionError()) { <p class="fr-error-text">La version est obligatoire.</p> }
|
||||
</div>
|
||||
<div class="fr-col-12 fr-col-md-8">
|
||||
<div class="fr-input-group" [class.fr-input-group--error]="editNameError()">
|
||||
<label class="fr-label" for="edit-name">Nom de l'API</label>
|
||||
<input id="edit-name" class="fr-input" [class.fr-input--error]="editNameError()" type="text"
|
||||
[value]="editName()"
|
||||
(input)="editName.set($any($event.target).value); editNameError.set(false)"
|
||||
(blur)="editNameError.set(!editName().trim())" />
|
||||
@if (editNameError()) { <p class="fr-error-text">Le nom est obligatoire.</p> }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="fr-hint-text fr-mb-2w">Titre : <strong>{{ editPreviewTitle() }}</strong></p>
|
||||
|
||||
<!-- Catégorie -->
|
||||
<div class="fr-select-group fr-mb-2w">
|
||||
<label class="fr-label" for="edit-category">Catégorie</label>
|
||||
<select id="edit-category" class="fr-select" [value]="editCategoryId()" (change)="editCategoryId.set($any($event.target).value || null)">
|
||||
<select id="edit-category" class="fr-select" [value]="editCategoryId()"
|
||||
(change)="editCategoryId.set($any($event.target).value || null)">
|
||||
<option value="">— Sans catégorie —</option>
|
||||
@for (cat of categories(); track cat.id) {
|
||||
<option [value]="cat.id">{{ cat.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Fournisseur -->
|
||||
<div class="fr-input-group fr-mb-2w" [class.fr-input-group--error]="editProviderError()">
|
||||
<label class="fr-label" for="edit-provider">Fournisseur <span style="color:var(--text-default-error)">*</span></label>
|
||||
<input id="edit-provider" class="fr-input" [class.fr-input--error]="editProviderError()" type="text"
|
||||
[value]="editProvider()"
|
||||
(input)="editProvider.set($any($event.target).value); editProviderError.set(false)"
|
||||
(blur)="editProviderError.set(!editProvider().trim())" />
|
||||
@if (editProviderError()) { <p class="fr-error-text">Le fournisseur est obligatoire.</p> }
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="fr-input-group fr-mb-2w">
|
||||
<label class="fr-label" for="edit-description">Description</label>
|
||||
<textarea id="edit-description" class="fr-input" rows="3" [value]="editDescription()" (input)="editDescription.set($any($event.target).value)"></textarea>
|
||||
<textarea id="edit-description" class="fr-input" rows="3" [value]="editDescription()"
|
||||
(input)="editDescription.set($any($event.target).value)"></textarea>
|
||||
</div>
|
||||
<div class="fr-input-group fr-mb-2w">
|
||||
<label class="fr-label" for="edit-func-doc">Documentation fonctionnelle</label>
|
||||
<textarea id="edit-func-doc" class="fr-input" rows="3" [value]="editFunctionalDoc()" (input)="editFunctionalDoc.set($any($event.target).value)"></textarea>
|
||||
|
||||
<!-- Version -->
|
||||
<p class="fr-label fr-mb-1w">Version <span style="color:var(--text-default-error)">*</span></p>
|
||||
<div class="fr-grid-row fr-grid-row--gutters fr-mb-2w">
|
||||
<div class="fr-col-4">
|
||||
<div class="fr-input-group">
|
||||
<label class="fr-label" for="edit-v-major">Majeure (X)</label>
|
||||
<input id="edit-v-major" class="fr-input" type="number" min="0"
|
||||
[value]="editVersionMajor()"
|
||||
(input)="editVersionMajor.set(+$any($event.target).value)" />
|
||||
</div>
|
||||
<div class="fr-input-group fr-mb-2w">
|
||||
<label class="fr-label" for="edit-tech-doc">Documentation technique</label>
|
||||
<textarea id="edit-tech-doc" class="fr-input" rows="3" [value]="editTechnicalDoc()" (input)="editTechnicalDoc.set($any($event.target).value)"></textarea>
|
||||
</div>
|
||||
<div class="fr-input-group fr-mb-2w">
|
||||
<label class="fr-label" for="edit-ext-url">URL documentation externe</label>
|
||||
<input id="edit-ext-url" class="fr-input" type="text" [value]="editExternalDocUrl()" (input)="editExternalDocUrl.set($any($event.target).value)" />
|
||||
<div class="fr-col-4">
|
||||
<div class="fr-input-group">
|
||||
<label class="fr-label" for="edit-v-minor">Mineure (Y)</label>
|
||||
<input id="edit-v-minor" class="fr-input" type="number" min="0"
|
||||
[value]="editVersionMinor()"
|
||||
(input)="editVersionMinor.set(+$any($event.target).value)" />
|
||||
</div>
|
||||
<div class="fr-input-group fr-mb-2w">
|
||||
<label class="fr-label" for="edit-contact-func">Contact fonctionnel</label>
|
||||
<input id="edit-contact-func" class="fr-input" type="text" [value]="editContactFunctional()" (input)="editContactFunctional.set($any($event.target).value)" />
|
||||
</div>
|
||||
<div class="fr-input-group fr-mb-2w">
|
||||
<label class="fr-label" for="edit-contact-tech">Contact technique</label>
|
||||
<input id="edit-contact-tech" class="fr-input" type="text" [value]="editContactTechnical()" (input)="editContactTechnical.set($any($event.target).value)" />
|
||||
<div class="fr-col-4">
|
||||
<div class="fr-input-group">
|
||||
<label class="fr-label" for="edit-v-patch">Correctif (Z)</label>
|
||||
<input id="edit-v-patch" class="fr-input" type="number" min="0"
|
||||
[value]="editVersionPatch()"
|
||||
(input)="editVersionPatch.set(+$any($event.target).value)" />
|
||||
</div>
|
||||
<div class="fr-input-group fr-mb-3w">
|
||||
<label class="fr-label" for="edit-access">Droits d'accès</label>
|
||||
<textarea id="edit-access" class="fr-input" rows="3" [value]="editAccessRights()" (input)="editAccessRights.set($any($event.target).value)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact métier -->
|
||||
<p class="fr-label fr-mb-1w">Contact métier <span style="color:var(--text-default-error)">*</span></p>
|
||||
<div class="fr-grid-row fr-grid-row--gutters fr-mb-2w">
|
||||
<div class="fr-col-12 fr-col-md-4">
|
||||
<div class="fr-input-group">
|
||||
<label class="fr-label" for="edit-cf-name">Nom et prénom</label>
|
||||
<input id="edit-cf-name" class="fr-input" type="text" [value]="editContactFunctionalName()"
|
||||
(input)="editContactFunctionalName.set($any($event.target).value)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="fr-col-12 fr-col-md-4">
|
||||
<div class="fr-input-group">
|
||||
<label class="fr-label" for="edit-cf-entity">Entité</label>
|
||||
<input id="edit-cf-entity" class="fr-input" type="text" [value]="editContactFunctionalEntity()"
|
||||
(input)="editContactFunctionalEntity.set($any($event.target).value)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="fr-col-12 fr-col-md-4">
|
||||
<div class="fr-input-group">
|
||||
<label class="fr-label" for="edit-cf-email">Email</label>
|
||||
<input id="edit-cf-email" class="fr-input" type="email" [value]="editContactFunctionalEmail()"
|
||||
(input)="editContactFunctionalEmail.set($any($event.target).value)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact technique -->
|
||||
<p class="fr-label fr-mb-1w">Contact technique <span style="color:var(--text-default-error)">*</span></p>
|
||||
<div class="fr-grid-row fr-grid-row--gutters fr-mb-3w">
|
||||
<div class="fr-col-12 fr-col-md-4">
|
||||
<div class="fr-input-group">
|
||||
<label class="fr-label" for="edit-ct-name">Nom et prénom</label>
|
||||
<input id="edit-ct-name" class="fr-input" type="text" [value]="editContactTechnicalName()"
|
||||
(input)="editContactTechnicalName.set($any($event.target).value)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="fr-col-12 fr-col-md-4">
|
||||
<div class="fr-input-group">
|
||||
<label class="fr-label" for="edit-ct-entity">Entité</label>
|
||||
<input id="edit-ct-entity" class="fr-input" type="text" [value]="editContactTechnicalEntity()"
|
||||
(input)="editContactTechnicalEntity.set($any($event.target).value)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="fr-col-12 fr-col-md-4">
|
||||
<div class="fr-input-group">
|
||||
<label class="fr-label" for="edit-ct-email">Email</label>
|
||||
<input id="edit-ct-email" class="fr-input" type="email" [value]="editContactTechnicalEmail()"
|
||||
(input)="editContactTechnicalEmail.set($any($event.target).value)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fr-btns-group fr-btns-group--right fr-btns-group--inline">
|
||||
<button class="fr-btn fr-btn--secondary" (click)="closeEdit()">Annuler</button>
|
||||
<button class="fr-btn" (click)="submitEdit()" [disabled]="editLoading()">
|
||||
@@ -321,19 +356,28 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
|
||||
showEditModal = signal(false);
|
||||
editLoading = signal(false);
|
||||
editError = signal<string | null>(null);
|
||||
editTitleError = signal(false);
|
||||
editVersionError = signal(false);
|
||||
editNameError = signal(false);
|
||||
editProviderError = signal(false);
|
||||
categories = signal<Category[]>([]);
|
||||
editTitle = signal('');
|
||||
editVersion = signal('');
|
||||
editConvention = signal<ApiConvention>('CONSULTER');
|
||||
editName = signal('');
|
||||
editProvider = signal('');
|
||||
editVersionMajor = signal(0);
|
||||
editVersionMinor = signal(0);
|
||||
editVersionPatch = signal(0);
|
||||
editCategoryId = signal<string | null>(null);
|
||||
editDescription = signal('');
|
||||
editFunctionalDoc = signal('');
|
||||
editTechnicalDoc = signal('');
|
||||
editExternalDocUrl = signal('');
|
||||
editContactFunctional = signal('');
|
||||
editContactTechnical = signal('');
|
||||
editAccessRights = signal('');
|
||||
editContactFunctionalName = signal('');
|
||||
editContactFunctionalEntity = signal('');
|
||||
editContactFunctionalEmail = signal('');
|
||||
editContactTechnicalName = signal('');
|
||||
editContactTechnicalEntity = signal('');
|
||||
editContactTechnicalEmail = signal('');
|
||||
|
||||
editPreviewTitle() {
|
||||
const labels: Record<ApiConvention, string> = { CONSULTER: 'Consulter', ENREGISTRER: 'Enregistrer', ETRE_NOTIFIE: 'Être notifié' };
|
||||
return `${labels[this.editConvention()]} ${this.editName()}`.trim();
|
||||
}
|
||||
|
||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private id = '';
|
||||
@@ -362,19 +406,23 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
|
||||
openEdit() {
|
||||
const entry = this.api();
|
||||
if (!entry) return;
|
||||
this.editTitle.set(entry.title);
|
||||
this.editVersion.set(entry.version);
|
||||
this.editConvention.set(entry.convention);
|
||||
this.editName.set(entry.name);
|
||||
this.editProvider.set(entry.provider);
|
||||
this.editVersionMajor.set(entry.versionMajor);
|
||||
this.editVersionMinor.set(entry.versionMinor);
|
||||
this.editVersionPatch.set(entry.versionPatch);
|
||||
this.editCategoryId.set(entry.categoryId ?? null);
|
||||
this.editDescription.set(entry.description ?? '');
|
||||
this.editFunctionalDoc.set(entry.functionalDoc ?? '');
|
||||
this.editTechnicalDoc.set(entry.technicalDoc ?? '');
|
||||
this.editExternalDocUrl.set(entry.externalDocUrl ?? '');
|
||||
this.editContactFunctional.set(entry.contactFunctional ?? '');
|
||||
this.editContactTechnical.set(entry.contactTechnical ?? '');
|
||||
this.editAccessRights.set(entry.accessRights ?? '');
|
||||
this.editContactFunctionalName.set(entry.contactFunctionalName);
|
||||
this.editContactFunctionalEntity.set(entry.contactFunctionalEntity);
|
||||
this.editContactFunctionalEmail.set(entry.contactFunctionalEmail);
|
||||
this.editContactTechnicalName.set(entry.contactTechnicalName);
|
||||
this.editContactTechnicalEntity.set(entry.contactTechnicalEntity);
|
||||
this.editContactTechnicalEmail.set(entry.contactTechnicalEmail);
|
||||
this.editError.set(null);
|
||||
this.editTitleError.set(false);
|
||||
this.editVersionError.set(false);
|
||||
this.editNameError.set(false);
|
||||
this.editProviderError.set(false);
|
||||
this.categoryService.list().subscribe((res) => this.categories.set(res.items));
|
||||
this.showEditModal.set(true);
|
||||
}
|
||||
@@ -384,21 +432,25 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
submitEdit() {
|
||||
this.editTitleError.set(!this.editTitle().trim());
|
||||
this.editVersionError.set(!this.editVersion().trim());
|
||||
if (!this.editTitle().trim() || !this.editVersion().trim()) return;
|
||||
this.editNameError.set(!this.editName().trim());
|
||||
this.editProviderError.set(!this.editProvider().trim());
|
||||
if (!this.editName().trim() || !this.editProvider().trim()) return;
|
||||
this.editLoading.set(true);
|
||||
this.apiService.update(this.id, {
|
||||
title: this.editTitle().trim(),
|
||||
version: this.editVersion().trim(),
|
||||
convention: this.editConvention(),
|
||||
name: this.editName().trim(),
|
||||
provider: this.editProvider().trim(),
|
||||
versionMajor: this.editVersionMajor(),
|
||||
versionMinor: this.editVersionMinor(),
|
||||
versionPatch: this.editVersionPatch(),
|
||||
categoryId: this.editCategoryId() || null,
|
||||
description: this.editDescription().trim() || undefined,
|
||||
functionalDoc: this.editFunctionalDoc().trim() || null,
|
||||
technicalDoc: this.editTechnicalDoc().trim() || null,
|
||||
externalDocUrl: this.editExternalDocUrl().trim() || null,
|
||||
contactFunctional: this.editContactFunctional().trim() || null,
|
||||
contactTechnical: this.editContactTechnical().trim() || null,
|
||||
accessRights: this.editAccessRights().trim() || null,
|
||||
description: this.editDescription().trim() || null,
|
||||
contactFunctionalName: this.editContactFunctionalName().trim(),
|
||||
contactFunctionalEntity: this.editContactFunctionalEntity().trim(),
|
||||
contactFunctionalEmail: this.editContactFunctionalEmail().trim(),
|
||||
contactTechnicalName: this.editContactTechnicalName().trim(),
|
||||
contactTechnicalEntity: this.editContactTechnicalEntity().trim(),
|
||||
contactTechnicalEmail: this.editContactTechnicalEmail().trim(),
|
||||
}).subscribe({
|
||||
next: (entry) => {
|
||||
this.api.set(entry);
|
||||
|
||||
@@ -47,7 +47,12 @@ import { forkJoin } from 'rxjs';
|
||||
<div class="fr-col">
|
||||
<h1 class="fr-h2 fr-mb-0">{{ browseData()?.category?.name ?? 'Catalogue' }}</h1>
|
||||
</div>
|
||||
<div class="fr-col-auto">
|
||||
<div class="fr-col-auto" style="display:flex;gap:0.5rem">
|
||||
<a class="fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-upload-2-line"
|
||||
[routerLink]="['/upload']"
|
||||
[queryParams]="categoryId() ? { categoryId: categoryId() } : {}">
|
||||
Importer une API
|
||||
</a>
|
||||
<button class="fr-btn fr-btn--icon-left fr-icon-folder-2-fill" (click)="openCreate()">
|
||||
Nouveau dossier
|
||||
</button>
|
||||
@@ -246,6 +251,18 @@ import { forkJoin } from 'rxjs';
|
||||
<p>{{ formError() }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (formMode() === 'create') {
|
||||
<div class="fr-select-group fr-mb-2w">
|
||||
<label class="fr-label" for="form-parent">Emplacement</label>
|
||||
<select id="form-parent" class="fr-select"
|
||||
(change)="formParentId.set($any($event.target).value || null)">
|
||||
<option value="" [selected]="!formParentId()"></option>
|
||||
@for (opt of categoriesForSelect(); track opt.id) {
|
||||
<option [value]="opt.id" [selected]="opt.id === formParentId()">{{ opt.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
<div class="fr-input-group fr-mb-2w" [class.fr-input-group--error]="formNameError()">
|
||||
<label class="fr-label" for="form-name">
|
||||
Nom <span style="color:var(--text-default-error)">*</span>
|
||||
@@ -308,9 +325,11 @@ export class BrowseComponent implements OnInit {
|
||||
editingCategoryId = signal<string | null>(null);
|
||||
formName = signal('');
|
||||
formDescription = signal('');
|
||||
formParentId = signal<string | null>(null);
|
||||
formError = signal<string | null>(null);
|
||||
formNameError = signal(false);
|
||||
formLoading = signal(false);
|
||||
allCategories = signal<Category[]>([]);
|
||||
|
||||
/* Ancêtres = breadcrumb sans le nœud courant (déjà dans le h1) */
|
||||
ancestors = computed<Category[]>(() => {
|
||||
@@ -319,6 +338,39 @@ export class BrowseComponent implements OnInit {
|
||||
return d.breadcrumb.slice(0, -1);
|
||||
});
|
||||
|
||||
/* Options du select "Emplacement" avec indentation type └ similaire au formulaire d'import */
|
||||
categoriesForSelect = computed<{ id: string; label: string }[]>(() => {
|
||||
const cats = this.allCategories();
|
||||
const map = new Map(cats.map((c) => [c.id, c]));
|
||||
|
||||
/* Chemin complet pour le tri */
|
||||
const getSortPath = (id: string, depth = 0): string => {
|
||||
if (depth > 10) return '';
|
||||
const cat = map.get(id);
|
||||
if (!cat) return '';
|
||||
if (!cat.parentId) return cat.name;
|
||||
return `${getSortPath(cat.parentId, depth + 1)} / ${cat.name}`;
|
||||
};
|
||||
|
||||
/* Profondeur pour l'indentation */
|
||||
const getDepth = (id: string, depth = 0): number => {
|
||||
if (depth > 10) return depth;
|
||||
const cat = map.get(id);
|
||||
if (!cat?.parentId) return depth;
|
||||
return getDepth(cat.parentId, depth + 1);
|
||||
};
|
||||
|
||||
return cats
|
||||
.map((c) => {
|
||||
const depth = getDepth(c.id);
|
||||
const indent = '\u00a0\u00a0'.repeat(depth);
|
||||
const prefix = depth > 0 ? `${indent}└\u00a0` : '';
|
||||
return { id: c.id, label: `${prefix}${c.name}`, _sort: getSortPath(c.id) };
|
||||
})
|
||||
.sort((a, b) => a._sort.localeCompare(b._sort, 'fr'))
|
||||
.map(({ id, label }) => ({ id, label }));
|
||||
});
|
||||
|
||||
ngOnInit() {
|
||||
this.route.paramMap
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
@@ -348,9 +400,20 @@ export class BrowseComponent implements OnInit {
|
||||
this.editingCategoryId.set(null);
|
||||
this.formName.set('');
|
||||
this.formDescription.set('');
|
||||
this.formParentId.set(this.categoryId());
|
||||
this.formError.set(null);
|
||||
this.formNameError.set(false);
|
||||
/* Charger les catégories d'abord, puis afficher la modale pour que la pré-sélection fonctionne */
|
||||
this.categoryService.list().subscribe({
|
||||
next: (res) => {
|
||||
this.allCategories.set(res.items);
|
||||
this.showFormModal.set(true);
|
||||
},
|
||||
error: () => {
|
||||
this.allCategories.set([]);
|
||||
this.showFormModal.set(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openEdit(cat: CategoryWithCounts) {
|
||||
@@ -382,7 +445,7 @@ export class BrowseComponent implements OnInit {
|
||||
? this.categoryService.create({
|
||||
name,
|
||||
description,
|
||||
parentId: this.categoryId() ?? undefined,
|
||||
parentId: this.formParentId() ?? undefined,
|
||||
})
|
||||
: this.categoryService.update(this.editingCategoryId()!, { name, description });
|
||||
|
||||
|
||||
@@ -6,12 +6,13 @@ import {
|
||||
OnInit,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { Router, RouterLink, ActivatedRoute } from '@angular/router';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { CategoryService } from '../../core/category.service';
|
||||
import { Category } from '@datacat/shared';
|
||||
import { Category, API_CONVENTION_LABELS, ApiConvention } from '@datacat/shared';
|
||||
|
||||
@Component({
|
||||
selector: 'app-upload',
|
||||
@@ -122,58 +123,52 @@ import { Category } from '@datacat/shared';
|
||||
<!-- Étape 2 : Métadonnées -->
|
||||
@if (step() === 2) {
|
||||
<form [formGroup]="metaForm" (ngSubmit)="goToStep3()" novalidate>
|
||||
<div class="fr-input-group fr-mb-3w" [class.fr-input-group--error]="titleInvalid()">
|
||||
<label class="fr-label" for="title-input">
|
||||
Titre <span style="color:var(--text-default-error)">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="title-input"
|
||||
class="fr-input"
|
||||
[class.fr-input--error]="titleInvalid()"
|
||||
formControlName="title"
|
||||
type="text"
|
||||
placeholder="Nom de l'API"
|
||||
/>
|
||||
@if (titleInvalid()) {
|
||||
<p class="fr-error-text">Le titre est obligatoire.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="fr-input-group fr-mb-3w" [class.fr-input-group--error]="versionInvalid()">
|
||||
<label class="fr-label" for="version-input">
|
||||
Version <span style="color:var(--text-default-error)">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="version-input"
|
||||
class="fr-input"
|
||||
[class.fr-input--error]="versionInvalid()"
|
||||
formControlName="version"
|
||||
type="text"
|
||||
placeholder="1.0.0"
|
||||
/>
|
||||
@if (versionInvalid()) {
|
||||
<p class="fr-error-text">La version est obligatoire.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="fr-mb-3w">
|
||||
<p class="fr-label fr-mb-1w">Type <span style="color:var(--text-default-error)">*</span></p>
|
||||
@if (detectedType()) {
|
||||
<span class="fr-tag">{{ detectedType() }}</span>
|
||||
<span class="fr-hint-text fr-mt-1w">Détecté automatiquement depuis le fichier YAML</span>
|
||||
} @else {
|
||||
<div class="fr-select-group">
|
||||
<select id="type-input" class="fr-select" formControlName="type">
|
||||
<option value="ASYNCAPI">AsyncAPI</option>
|
||||
<option value="OPENAPI">OpenAPI</option>
|
||||
<!-- Titre : convention + nom -->
|
||||
<p class="fr-label fr-mb-1w">Titre <span style="color:var(--text-default-error)">*</span></p>
|
||||
<div class="fr-grid-row fr-grid-row--gutters fr-mb-1w">
|
||||
<div class="fr-col-12 fr-col-md-4">
|
||||
<div class="fr-select-group" [class.fr-select-group--error]="conventionInvalid()">
|
||||
<label class="fr-label" for="convention-input">Convention</label>
|
||||
<select
|
||||
id="convention-input"
|
||||
class="fr-select"
|
||||
[class.fr-select--error]="conventionInvalid()"
|
||||
formControlName="convention"
|
||||
>
|
||||
<option value="CONSULTER">Consulter</option>
|
||||
<option value="ENREGISTRER">Enregistrer</option>
|
||||
<option value="ETRE_NOTIFIE">Être notifié</option>
|
||||
</select>
|
||||
</div>
|
||||
@if (conventionInvalid()) {
|
||||
<p class="fr-error-text">La convention est obligatoire.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="fr-col-12 fr-col-md-8">
|
||||
<div class="fr-input-group" [class.fr-input-group--error]="nameInvalid()">
|
||||
<label class="fr-label" for="name-input">Nom de l'API</label>
|
||||
<input
|
||||
id="name-input"
|
||||
class="fr-input"
|
||||
[class.fr-input--error]="nameInvalid()"
|
||||
formControlName="name"
|
||||
type="text"
|
||||
placeholder="Référentiel Adresses"
|
||||
/>
|
||||
@if (nameInvalid()) {
|
||||
<p class="fr-error-text">Le nom est obligatoire.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="fr-hint-text fr-mb-3w">Titre complet : <strong>{{ previewTitle() }}</strong></p>
|
||||
|
||||
<!-- Catégorie -->
|
||||
<div class="fr-select-group fr-mb-3w">
|
||||
<label class="fr-label" for="category-input">
|
||||
Catégorie </label>
|
||||
Catégorie
|
||||
</label>
|
||||
<select id="category-input" class="fr-select" formControlName="categoryId">
|
||||
<option value="">— Sans catégorie —</option>
|
||||
@for (cat of categories(); track cat.id) {
|
||||
@@ -182,9 +177,43 @@ import { Category } from '@datacat/shared';
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Fournisseur -->
|
||||
<div class="fr-input-group fr-mb-3w" [class.fr-input-group--error]="providerInvalid()">
|
||||
<label class="fr-label" for="provider-input">
|
||||
Fournisseur <span style="color:var(--text-default-error)">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="provider-input"
|
||||
class="fr-input"
|
||||
[class.fr-input--error]="providerInvalid()"
|
||||
formControlName="provider"
|
||||
type="text"
|
||||
placeholder="DINUM, Direction XYZ..."
|
||||
/>
|
||||
@if (providerInvalid()) {
|
||||
<p class="fr-error-text">Le fournisseur est obligatoire.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Type -->
|
||||
<div class="fr-select-group fr-mb-3w">
|
||||
<label class="fr-label" for="type-input">
|
||||
Type <span style="color:var(--text-default-error)">*</span>
|
||||
</label>
|
||||
<select id="type-input" class="fr-select" formControlName="type">
|
||||
<option value="ASYNCAPI">AsyncAPI</option>
|
||||
<option value="OPENAPI">OpenAPI</option>
|
||||
</select>
|
||||
@if (detectedType()) {
|
||||
<p class="fr-hint-text">Détecté automatiquement depuis le fichier YAML</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="fr-input-group fr-mb-3w">
|
||||
<label class="fr-label" for="description-input">
|
||||
Description </label>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description-input"
|
||||
class="fr-input"
|
||||
@@ -194,76 +223,127 @@ import { Category } from '@datacat/shared';
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="fr-input-group fr-mb-3w">
|
||||
<label class="fr-label" for="functional-doc-input">
|
||||
Documentation fonctionnelle </label>
|
||||
<textarea
|
||||
id="functional-doc-input"
|
||||
class="fr-input"
|
||||
formControlName="functionalDoc"
|
||||
rows="4"
|
||||
placeholder="Cas d'usage, contexte métier..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="fr-input-group fr-mb-3w">
|
||||
<label class="fr-label" for="technical-doc-input">
|
||||
Documentation technique </label>
|
||||
<textarea
|
||||
id="technical-doc-input"
|
||||
class="fr-input"
|
||||
formControlName="technicalDoc"
|
||||
rows="4"
|
||||
placeholder="Guide d'intégration technique..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="fr-input-group fr-mb-3w">
|
||||
<label class="fr-label" for="external-doc-url-input">
|
||||
URL documentation externe </label>
|
||||
<!-- Version X.Y.Z -->
|
||||
<p class="fr-label fr-mb-1w">Version <span style="color:var(--text-default-error)">*</span></p>
|
||||
<div class="fr-grid-row fr-grid-row--gutters fr-mb-3w">
|
||||
<div class="fr-col-4">
|
||||
<div class="fr-input-group" [class.fr-input-group--error]="versionMajorInvalid()">
|
||||
<label class="fr-label" for="version-major-input">Majeure (X)</label>
|
||||
<input
|
||||
id="external-doc-url-input"
|
||||
id="version-major-input"
|
||||
class="fr-input"
|
||||
formControlName="externalDocUrl"
|
||||
type="text"
|
||||
placeholder="https://..."
|
||||
[class.fr-input--error]="versionMajorInvalid()"
|
||||
formControlName="versionMajor"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="fr-input-group fr-mb-3w">
|
||||
<label class="fr-label" for="contact-functional-input">
|
||||
Contact fonctionnel </label>
|
||||
</div>
|
||||
<div class="fr-col-4">
|
||||
<div class="fr-input-group" [class.fr-input-group--error]="versionMinorInvalid()">
|
||||
<label class="fr-label" for="version-minor-input">Mineure (Y)</label>
|
||||
<input
|
||||
id="contact-functional-input"
|
||||
id="version-minor-input"
|
||||
class="fr-input"
|
||||
formControlName="contactFunctional"
|
||||
type="text"
|
||||
placeholder="Nom, email, téléphone..."
|
||||
[class.fr-input--error]="versionMinorInvalid()"
|
||||
formControlName="versionMinor"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="fr-input-group fr-mb-3w">
|
||||
<label class="fr-label" for="contact-technical-input">
|
||||
Contact technique </label>
|
||||
</div>
|
||||
<div class="fr-col-4">
|
||||
<div class="fr-input-group" [class.fr-input-group--error]="versionPatchInvalid()">
|
||||
<label class="fr-label" for="version-patch-input">Correctif (Z)</label>
|
||||
<input
|
||||
id="contact-technical-input"
|
||||
id="version-patch-input"
|
||||
class="fr-input"
|
||||
formControlName="contactTechnical"
|
||||
type="text"
|
||||
placeholder="Nom, email, téléphone..."
|
||||
[class.fr-input--error]="versionPatchInvalid()"
|
||||
formControlName="versionPatch"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fr-input-group fr-mb-4w">
|
||||
<label class="fr-label" for="access-rights-input">
|
||||
Droits d'accès </label>
|
||||
<textarea
|
||||
id="access-rights-input"
|
||||
class="fr-input"
|
||||
formControlName="accessRights"
|
||||
rows="3"
|
||||
placeholder="Processus d'habilitation, conditions d'accès..."
|
||||
></textarea>
|
||||
<!-- Contact métier -->
|
||||
<p class="fr-label fr-mb-1w">Contact métier <span style="color:var(--text-default-error)">*</span></p>
|
||||
<div class="fr-grid-row fr-grid-row--gutters fr-mb-3w">
|
||||
<div class="fr-col-12 fr-col-md-4">
|
||||
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactFunctionalName')">
|
||||
<label class="fr-label" for="cf-name-input">Nom et prénom</label>
|
||||
<input id="cf-name-input" class="fr-input"
|
||||
[class.fr-input--error]="fieldInvalid('contactFunctionalName')"
|
||||
formControlName="contactFunctionalName" type="text" placeholder="Prénom Nom" />
|
||||
@if (fieldInvalid('contactFunctionalName')) {
|
||||
<p class="fr-error-text">Obligatoire.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="fr-col-12 fr-col-md-4">
|
||||
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactFunctionalEntity')">
|
||||
<label class="fr-label" for="cf-entity-input">Entité</label>
|
||||
<input id="cf-entity-input" class="fr-input"
|
||||
[class.fr-input--error]="fieldInvalid('contactFunctionalEntity')"
|
||||
formControlName="contactFunctionalEntity" type="text" placeholder="Direction XYZ" />
|
||||
@if (fieldInvalid('contactFunctionalEntity')) {
|
||||
<p class="fr-error-text">Obligatoire.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="fr-col-12 fr-col-md-4">
|
||||
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactFunctionalEmail')">
|
||||
<label class="fr-label" for="cf-email-input">Email</label>
|
||||
<input id="cf-email-input" class="fr-input"
|
||||
[class.fr-input--error]="fieldInvalid('contactFunctionalEmail')"
|
||||
formControlName="contactFunctionalEmail" type="email" placeholder="prenom.nom@example.fr" />
|
||||
@if (fieldInvalid('contactFunctionalEmail')) {
|
||||
<p class="fr-error-text">Email valide requis.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact technique -->
|
||||
<p class="fr-label fr-mb-1w">Contact technique <span style="color:var(--text-default-error)">*</span></p>
|
||||
<div class="fr-grid-row fr-grid-row--gutters fr-mb-4w">
|
||||
<div class="fr-col-12 fr-col-md-4">
|
||||
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactTechnicalName')">
|
||||
<label class="fr-label" for="ct-name-input">Nom et prénom</label>
|
||||
<input id="ct-name-input" class="fr-input"
|
||||
[class.fr-input--error]="fieldInvalid('contactTechnicalName')"
|
||||
formControlName="contactTechnicalName" type="text" placeholder="Prénom Nom" />
|
||||
@if (fieldInvalid('contactTechnicalName')) {
|
||||
<p class="fr-error-text">Obligatoire.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="fr-col-12 fr-col-md-4">
|
||||
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactTechnicalEntity')">
|
||||
<label class="fr-label" for="ct-entity-input">Entité</label>
|
||||
<input id="ct-entity-input" class="fr-input"
|
||||
[class.fr-input--error]="fieldInvalid('contactTechnicalEntity')"
|
||||
formControlName="contactTechnicalEntity" type="text" placeholder="Équipe Technique" />
|
||||
@if (fieldInvalid('contactTechnicalEntity')) {
|
||||
<p class="fr-error-text">Obligatoire.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="fr-col-12 fr-col-md-4">
|
||||
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactTechnicalEmail')">
|
||||
<label class="fr-label" for="ct-email-input">Email</label>
|
||||
<input id="ct-email-input" class="fr-input"
|
||||
[class.fr-input--error]="fieldInvalid('contactTechnicalEmail')"
|
||||
formControlName="contactTechnicalEmail" type="email" placeholder="tech@example.fr" />
|
||||
@if (fieldInvalid('contactTechnicalEmail')) {
|
||||
<p class="fr-error-text">Email valide requis.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fr-btns-group fr-btns-group--inline-sm">
|
||||
@@ -303,13 +383,17 @@ import { Category } from '@datacat/shared';
|
||||
<dt class="fr-text--bold">Type</dt>
|
||||
<dd>{{ metaForm.value.type }}</dd>
|
||||
</div>
|
||||
<div class="fr-col-12 fr-col-md-6">
|
||||
<div class="fr-col-12">
|
||||
<dt class="fr-text--bold">Titre</dt>
|
||||
<dd>{{ metaForm.value.title }}</dd>
|
||||
<dd>{{ previewTitle() }}</dd>
|
||||
</div>
|
||||
<div class="fr-col-12 fr-col-md-6">
|
||||
<dt class="fr-text--bold">Version</dt>
|
||||
<dd>{{ metaForm.value.version }}</dd>
|
||||
<dd>{{ metaForm.value.versionMajor }}.{{ metaForm.value.versionMinor }}.{{ metaForm.value.versionPatch }}</dd>
|
||||
</div>
|
||||
<div class="fr-col-12 fr-col-md-6">
|
||||
<dt class="fr-text--bold">Fournisseur</dt>
|
||||
<dd>{{ metaForm.value.provider }}</dd>
|
||||
</div>
|
||||
@if (metaForm.value.categoryId) {
|
||||
<div class="fr-col-12 fr-col-md-6">
|
||||
@@ -356,6 +440,7 @@ export class UploadComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
private categoryService = inject(CategoryService);
|
||||
private router = inject(Router);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
step = signal(1);
|
||||
selectedFiles = signal<File[]>([]);
|
||||
@@ -376,30 +461,57 @@ export class UploadComponent implements OnInit {
|
||||
];
|
||||
|
||||
metaForm = this.fb.group({
|
||||
title: ['', Validators.required],
|
||||
version: ['', Validators.required],
|
||||
convention: ['CONSULTER', Validators.required],
|
||||
name: ['', Validators.required],
|
||||
provider: ['', Validators.required],
|
||||
versionMajor: [0, [Validators.required, Validators.min(0)]],
|
||||
versionMinor: [0, [Validators.required, Validators.min(0)]],
|
||||
versionPatch: [0, [Validators.required, Validators.min(0)]],
|
||||
type: ['ASYNCAPI', Validators.required],
|
||||
categoryId: [''],
|
||||
description: [''],
|
||||
functionalDoc: [''],
|
||||
technicalDoc: [''],
|
||||
externalDocUrl: [''],
|
||||
contactFunctional: [''],
|
||||
contactTechnical: [''],
|
||||
accessRights: [''],
|
||||
contactFunctionalName: ['', Validators.required],
|
||||
contactFunctionalEntity: ['', Validators.required],
|
||||
contactFunctionalEmail: ['', [Validators.required, Validators.email]],
|
||||
contactTechnicalName: ['', Validators.required],
|
||||
contactTechnicalEntity: ['', Validators.required],
|
||||
contactTechnicalEmail: ['', [Validators.required, Validators.email]],
|
||||
});
|
||||
|
||||
titleInvalid = computed(
|
||||
() => !!(this.metaForm.get('title')?.invalid && this.metaForm.get('title')?.touched),
|
||||
);
|
||||
versionInvalid = computed(
|
||||
() => !!(this.metaForm.get('version')?.invalid && this.metaForm.get('version')?.touched),
|
||||
);
|
||||
/* Convertit valueChanges en signal pour que les computed() trackent les changements du formulaire */
|
||||
private readonly formValues = toSignal(this.metaForm.valueChanges, { initialValue: this.metaForm.value });
|
||||
|
||||
previewTitle = computed(() => {
|
||||
const values = this.formValues();
|
||||
const convention = (values.convention ?? 'CONSULTER') as ApiConvention;
|
||||
const name = values.name ?? '';
|
||||
return `${API_CONVENTION_LABELS[convention]} ${name}`.trim();
|
||||
});
|
||||
selectedCategoryName = computed(() => {
|
||||
const id = this.metaForm.value.categoryId;
|
||||
const id = this.formValues().categoryId;
|
||||
if (!id) return '';
|
||||
return this.categories().find((c) => c.id === id)?.name ?? '';
|
||||
});
|
||||
|
||||
/* Méthodes de validation (pas de computed — la re-exécution est déclenchée par les événements DOM blur/input) */
|
||||
conventionInvalid(): boolean {
|
||||
return !!(this.metaForm.get('convention')?.invalid && this.metaForm.get('convention')?.touched);
|
||||
}
|
||||
nameInvalid(): boolean {
|
||||
return !!(this.metaForm.get('name')?.invalid && this.metaForm.get('name')?.touched);
|
||||
}
|
||||
providerInvalid(): boolean {
|
||||
return !!(this.metaForm.get('provider')?.invalid && this.metaForm.get('provider')?.touched);
|
||||
}
|
||||
versionMajorInvalid(): boolean {
|
||||
return !!(this.metaForm.get('versionMajor')?.invalid && this.metaForm.get('versionMajor')?.touched);
|
||||
}
|
||||
versionMinorInvalid(): boolean {
|
||||
return !!(this.metaForm.get('versionMinor')?.invalid && this.metaForm.get('versionMinor')?.touched);
|
||||
}
|
||||
versionPatchInvalid(): boolean {
|
||||
return !!(this.metaForm.get('versionPatch')?.invalid && this.metaForm.get('versionPatch')?.touched);
|
||||
}
|
||||
canProceedFromStep1 = computed(() => {
|
||||
/* Sans fichier, on peut toujours passer à l'étape 2 */
|
||||
if (this.selectedFiles().length === 0) return true;
|
||||
@@ -408,9 +520,20 @@ export class UploadComponent implements OnInit {
|
||||
return this.validationDone() && this.validationErrors().length === 0;
|
||||
});
|
||||
|
||||
fieldInvalid(name: string): boolean {
|
||||
const ctrl = this.metaForm.get(name);
|
||||
return !!(ctrl?.invalid && ctrl?.touched);
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
const preselectedCategoryId = this.route.snapshot.queryParamMap.get('categoryId');
|
||||
this.categoryService.list().subscribe({
|
||||
next: (res) => this.categories.set(res.items),
|
||||
next: (res) => {
|
||||
this.categories.set(res.items);
|
||||
if (preselectedCategoryId) {
|
||||
this.metaForm.patchValue({ categoryId: preselectedCategoryId });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -426,6 +549,7 @@ export class UploadComponent implements OnInit {
|
||||
this.detectedMainFilename.set(null);
|
||||
this.validationErrors.set([]);
|
||||
this.validationDone.set(false);
|
||||
this.metaForm.get('type')?.enable();
|
||||
|
||||
const input = event.target as HTMLInputElement;
|
||||
const fileList = input.files;
|
||||
@@ -471,18 +595,22 @@ export class UploadComponent implements OnInit {
|
||||
this.validationDone.set(true);
|
||||
if (!result.valid) {
|
||||
this.validationErrors.set(result.errors);
|
||||
this.metaForm.get('type')?.enable();
|
||||
} else {
|
||||
this.validationErrors.set([]);
|
||||
if (result.type) {
|
||||
this.detectedType.set(result.type);
|
||||
this.metaForm.patchValue({ type: result.type });
|
||||
this.metaForm.get('type')?.disable();
|
||||
}
|
||||
if (result.mainFile) {
|
||||
this.detectedMainFilename.set(result.mainFile);
|
||||
}
|
||||
/* Pré-remplissage du formulaire depuis les métadonnées du fichier */
|
||||
if (result.title) this.metaForm.patchValue({ title: result.title });
|
||||
if (result.version) this.metaForm.patchValue({ version: String(result.version) });
|
||||
if (result.name) this.metaForm.patchValue({ name: result.name });
|
||||
if (result.versionMajor !== null) this.metaForm.patchValue({ versionMajor: result.versionMajor });
|
||||
if (result.versionMinor !== null) this.metaForm.patchValue({ versionMinor: result.versionMinor });
|
||||
if (result.versionPatch !== null) this.metaForm.patchValue({ versionPatch: result.versionPatch });
|
||||
if (result.description) this.metaForm.patchValue({ description: result.description });
|
||||
}
|
||||
},
|
||||
@@ -509,37 +637,30 @@ export class UploadComponent implements OnInit {
|
||||
this.uploading.set(true);
|
||||
this.uploadError.set(null);
|
||||
|
||||
const raw = this.metaForm.getRawValue();
|
||||
const formData = new FormData();
|
||||
for (const f of files) {
|
||||
formData.append('files', f);
|
||||
}
|
||||
formData.append('title', this.metaForm.value.title ?? '');
|
||||
formData.append('version', this.metaForm.value.version ?? '');
|
||||
formData.append('type', this.metaForm.value.type ?? 'ASYNCAPI');
|
||||
if (this.metaForm.value.categoryId) {
|
||||
formData.append('categoryId', this.metaForm.value.categoryId);
|
||||
formData.append('convention', raw.convention ?? 'CONSULTER');
|
||||
formData.append('name', raw.name ?? '');
|
||||
formData.append('provider', raw.provider ?? '');
|
||||
formData.append('versionMajor', String(raw.versionMajor ?? 0));
|
||||
formData.append('versionMinor', String(raw.versionMinor ?? 0));
|
||||
formData.append('versionPatch', String(raw.versionPatch ?? 0));
|
||||
formData.append('type', raw.type ?? 'ASYNCAPI');
|
||||
if (raw.categoryId) {
|
||||
formData.append('categoryId', raw.categoryId);
|
||||
}
|
||||
if (this.metaForm.value.description) {
|
||||
formData.append('description', this.metaForm.value.description);
|
||||
}
|
||||
if (this.metaForm.value.functionalDoc) {
|
||||
formData.append('functionalDoc', this.metaForm.value.functionalDoc);
|
||||
}
|
||||
if (this.metaForm.value.technicalDoc) {
|
||||
formData.append('technicalDoc', this.metaForm.value.technicalDoc);
|
||||
}
|
||||
if (this.metaForm.value.externalDocUrl) {
|
||||
formData.append('externalDocUrl', this.metaForm.value.externalDocUrl);
|
||||
}
|
||||
if (this.metaForm.value.contactFunctional) {
|
||||
formData.append('contactFunctional', this.metaForm.value.contactFunctional);
|
||||
}
|
||||
if (this.metaForm.value.contactTechnical) {
|
||||
formData.append('contactTechnical', this.metaForm.value.contactTechnical);
|
||||
}
|
||||
if (this.metaForm.value.accessRights) {
|
||||
formData.append('accessRights', this.metaForm.value.accessRights);
|
||||
if (raw.description) {
|
||||
formData.append('description', raw.description);
|
||||
}
|
||||
formData.append('contactFunctionalName', raw.contactFunctionalName ?? '');
|
||||
formData.append('contactFunctionalEntity', raw.contactFunctionalEntity ?? '');
|
||||
formData.append('contactFunctionalEmail', raw.contactFunctionalEmail ?? '');
|
||||
formData.append('contactTechnicalName', raw.contactTechnicalName ?? '');
|
||||
formData.append('contactTechnicalEntity', raw.contactTechnicalEntity ?? '');
|
||||
formData.append('contactTechnicalEmail', raw.contactTechnicalEmail ?? '');
|
||||
|
||||
this.apiService.upload(formData).subscribe({
|
||||
next: (entry) => {
|
||||
|
||||
@@ -21,7 +21,7 @@ services:
|
||||
NODE_ENV: development
|
||||
DOCS_OUTPUT_DIR: /app/docs-output
|
||||
working_dir: /workspace/back
|
||||
command: sh -c "cd /workspace && pnpm install && cd /workspace/back && pnpm run start:dev"
|
||||
command: sh -c "cd /workspace && pnpm install && /workspace/back/node_modules/.bin/tsc --project /workspace/shared/tsconfig.build.json && cd /workspace/back && pnpm run start:dev"
|
||||
networks:
|
||||
- network
|
||||
depends_on:
|
||||
|
||||
51
pnpm-lock.yaml
generated
51
pnpm-lock.yaml
generated
@@ -22,6 +22,9 @@ importers:
|
||||
'@asyncapi/html-template':
|
||||
specifier: 3.5.6
|
||||
version: 3.5.6(@noble/hashes@1.8.0)(encoding@0.1.13)(typescript@5.9.3)
|
||||
'@datacat/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../shared
|
||||
'@nestjs/common':
|
||||
specifier: ^11.0.0
|
||||
version: 11.1.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@@ -10189,7 +10192,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
'@angular-devkit/architect': 0.1902.26(chokidar@4.0.3)
|
||||
'@angular-devkit/build-webpack': 0.1902.26(chokidar@4.0.3)(webpack-dev-server@5.2.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12)))(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))
|
||||
'@angular-devkit/build-webpack': 0.1902.26(chokidar@4.0.3)(webpack-dev-server@5.2.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12)))(webpack@5.105.0(postcss@8.5.12))
|
||||
'@angular-devkit/core': 19.2.26(chokidar@4.0.3)
|
||||
'@angular/build': 19.2.26(@angular/compiler-cli@19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3))(@angular/compiler@19.2.22)(@types/node@22.19.19)(chokidar@4.0.3)(jiti@1.21.7)(less@4.2.2)(postcss@8.5.12)(tailwindcss@3.3.3(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.8.3)))(terser@5.39.0)(typescript@5.8.3)(yaml@2.9.0)
|
||||
'@angular/compiler-cli': 19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3)
|
||||
@@ -10203,14 +10206,14 @@ snapshots:
|
||||
'@babel/preset-env': 7.26.9(@babel/core@7.26.10)
|
||||
'@babel/runtime': 7.26.10
|
||||
'@discoveryjs/json-ext': 0.6.3
|
||||
'@ngtools/webpack': 19.2.26(@angular/compiler-cli@19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))
|
||||
'@ngtools/webpack': 19.2.26(@angular/compiler-cli@19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.105.0(postcss@8.5.12))
|
||||
'@vitejs/plugin-basic-ssl': 1.2.0(vite@7.3.3(@types/node@22.19.19)(jiti@1.21.7)(less@4.2.2)(sass@1.99.0)(terser@5.39.0)(yaml@2.9.0))
|
||||
ansi-colors: 4.1.3
|
||||
autoprefixer: 10.4.20(postcss@8.5.12)
|
||||
babel-loader: 9.2.1(@babel/core@7.26.10)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))
|
||||
babel-loader: 9.2.1(@babel/core@7.26.10)(webpack@5.105.0(postcss@8.5.12))
|
||||
browserslist: 4.28.2
|
||||
copy-webpack-plugin: 12.0.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))
|
||||
css-loader: 7.1.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))
|
||||
copy-webpack-plugin: 12.0.2(webpack@5.105.0(postcss@8.5.12))
|
||||
css-loader: 7.1.2(webpack@5.105.0(postcss@8.5.12))
|
||||
esbuild-wasm: 0.28.0
|
||||
fast-glob: 3.3.3
|
||||
http-proxy-middleware: 3.0.5
|
||||
@@ -10218,22 +10221,22 @@ snapshots:
|
||||
jsonc-parser: 3.3.1
|
||||
karma-source-map-support: 1.4.0
|
||||
less: 4.2.2
|
||||
less-loader: 12.2.0(less@4.2.2)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))
|
||||
license-webpack-plugin: 4.0.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))
|
||||
less-loader: 12.2.0(less@4.2.2)(webpack@5.105.0(postcss@8.5.12))
|
||||
license-webpack-plugin: 4.0.2(webpack@5.105.0(postcss@8.5.12))
|
||||
loader-utils: 3.3.1
|
||||
mini-css-extract-plugin: 2.9.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))
|
||||
mini-css-extract-plugin: 2.9.2(webpack@5.105.0(postcss@8.5.12))
|
||||
open: 10.1.0
|
||||
ora: 5.4.1
|
||||
picomatch: 4.0.4
|
||||
piscina: 4.8.0
|
||||
postcss: 8.5.12
|
||||
postcss-loader: 8.1.1(postcss@8.5.12)(typescript@5.8.3)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))
|
||||
postcss-loader: 8.1.1(postcss@8.5.12)(typescript@5.8.3)(webpack@5.105.0(postcss@8.5.12))
|
||||
resolve-url-loader: 5.0.0
|
||||
rxjs: 7.8.1
|
||||
sass: 1.85.0
|
||||
sass-loader: 16.0.5(sass@1.85.0)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))
|
||||
sass-loader: 16.0.5(sass@1.85.0)(webpack@5.105.0(postcss@8.5.12))
|
||||
semver: 7.7.1
|
||||
source-map-loader: 5.0.0(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))
|
||||
source-map-loader: 5.0.0(webpack@5.105.0(postcss@8.5.12))
|
||||
source-map-support: 0.5.21
|
||||
terser: 5.39.0
|
||||
tree-kill: 1.2.2
|
||||
@@ -10243,7 +10246,7 @@ snapshots:
|
||||
webpack-dev-middleware: 7.4.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12))
|
||||
webpack-dev-server: 5.2.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12))
|
||||
webpack-merge: 6.0.1
|
||||
webpack-subresource-integrity: 5.1.0(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))
|
||||
webpack-subresource-integrity: 5.1.0(webpack@5.105.0(postcss@8.5.12))
|
||||
optionalDependencies:
|
||||
esbuild: 0.28.0
|
||||
tailwindcss: 3.3.3(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.8.3))
|
||||
@@ -10277,7 +10280,7 @@ snapshots:
|
||||
- webpack-cli
|
||||
- yaml
|
||||
|
||||
'@angular-devkit/build-webpack@0.1902.26(chokidar@4.0.3)(webpack-dev-server@5.2.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12)))(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))':
|
||||
'@angular-devkit/build-webpack@0.1902.26(chokidar@4.0.3)(webpack-dev-server@5.2.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12)))(webpack@5.105.0(postcss@8.5.12))':
|
||||
dependencies:
|
||||
'@angular-devkit/architect': 0.1902.26(chokidar@4.0.3)
|
||||
rxjs: 7.8.1
|
||||
@@ -13785,7 +13788,7 @@ snapshots:
|
||||
'@next/swc-win32-x64-msvc@14.2.33':
|
||||
optional: true
|
||||
|
||||
'@ngtools/webpack@19.2.26(@angular/compiler-cli@19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))':
|
||||
'@ngtools/webpack@19.2.26(@angular/compiler-cli@19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.105.0(postcss@8.5.12))':
|
||||
dependencies:
|
||||
'@angular/compiler-cli': 19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3)
|
||||
typescript: 5.8.3
|
||||
@@ -16051,7 +16054,7 @@ snapshots:
|
||||
|
||||
b4a@1.8.1: {}
|
||||
|
||||
babel-loader@9.2.1(@babel/core@7.26.10)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)):
|
||||
babel-loader@9.2.1(@babel/core@7.26.10)(webpack@5.105.0(postcss@8.5.12)):
|
||||
dependencies:
|
||||
'@babel/core': 7.26.10
|
||||
find-cache-dir: 4.0.0
|
||||
@@ -16729,7 +16732,7 @@ snapshots:
|
||||
dependencies:
|
||||
is-what: 3.14.1
|
||||
|
||||
copy-webpack-plugin@12.0.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)):
|
||||
copy-webpack-plugin@12.0.2(webpack@5.105.0(postcss@8.5.12)):
|
||||
dependencies:
|
||||
fast-glob: 3.3.3
|
||||
glob-parent: 6.0.2
|
||||
@@ -16804,7 +16807,7 @@ snapshots:
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
css-loader@7.1.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)):
|
||||
css-loader@7.1.2(webpack@5.105.0(postcss@8.5.12)):
|
||||
dependencies:
|
||||
icss-utils: 5.1.0(postcss@8.5.12)
|
||||
postcss: 8.5.12
|
||||
@@ -18750,7 +18753,7 @@ snapshots:
|
||||
dependencies:
|
||||
readable-stream: 2.3.8
|
||||
|
||||
less-loader@12.2.0(less@4.2.2)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)):
|
||||
less-loader@12.2.0(less@4.2.2)(webpack@5.105.0(postcss@8.5.12)):
|
||||
dependencies:
|
||||
less: 4.2.2
|
||||
optionalDependencies:
|
||||
@@ -18776,7 +18779,7 @@ snapshots:
|
||||
|
||||
libphonenumber-js@1.13.2: {}
|
||||
|
||||
license-webpack-plugin@4.0.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)):
|
||||
license-webpack-plugin@4.0.2(webpack@5.105.0(postcss@8.5.12)):
|
||||
dependencies:
|
||||
webpack-sources: 3.4.1
|
||||
optionalDependencies:
|
||||
@@ -19068,7 +19071,7 @@ snapshots:
|
||||
|
||||
mimic-response@4.0.0: {}
|
||||
|
||||
mini-css-extract-plugin@2.9.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)):
|
||||
mini-css-extract-plugin@2.9.2(webpack@5.105.0(postcss@8.5.12)):
|
||||
dependencies:
|
||||
schema-utils: 4.3.3
|
||||
tapable: 2.3.3
|
||||
@@ -20167,7 +20170,7 @@ snapshots:
|
||||
postcss: 8.5.15
|
||||
ts-node: 10.9.2(@types/node@22.19.19)(typescript@5.9.3)
|
||||
|
||||
postcss-loader@8.1.1(postcss@8.5.12)(typescript@5.8.3)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)):
|
||||
postcss-loader@8.1.1(postcss@8.5.12)(typescript@5.8.3)(webpack@5.105.0(postcss@8.5.12)):
|
||||
dependencies:
|
||||
cosmiconfig: 9.0.1(typescript@5.8.3)
|
||||
jiti: 1.21.7
|
||||
@@ -20863,7 +20866,7 @@ snapshots:
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
sass-loader@16.0.5(sass@1.85.0)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)):
|
||||
sass-loader@16.0.5(sass@1.85.0)(webpack@5.105.0(postcss@8.5.12)):
|
||||
dependencies:
|
||||
neo-async: 2.6.2
|
||||
optionalDependencies:
|
||||
@@ -21241,7 +21244,7 @@ snapshots:
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
source-map-loader@5.0.0(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)):
|
||||
source-map-loader@5.0.0(webpack@5.105.0(postcss@8.5.12)):
|
||||
dependencies:
|
||||
iconv-lite: 0.6.3
|
||||
source-map-js: 1.2.1
|
||||
@@ -22344,7 +22347,7 @@ snapshots:
|
||||
|
||||
webpack-sources@3.4.1: {}
|
||||
|
||||
webpack-subresource-integrity@5.1.0(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)):
|
||||
webpack-subresource-integrity@5.1.0(webpack@5.105.0(postcss@8.5.12)):
|
||||
dependencies:
|
||||
typed-assert: 1.0.9
|
||||
webpack: 5.105.0(esbuild@0.28.0)(postcss@8.5.12)
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
"name": "@datacat/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts"
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,3 @@
|
||||
// Zod schemas for validation (used in backend)
|
||||
// Install zod in back/ if needed: pnpm add zod
|
||||
|
||||
export const API_TYPES = ['ASYNCAPI', 'OPENAPI'] as const;
|
||||
export const API_STATUSES = ['PENDING', 'GENERATED', 'ERROR'] as const;
|
||||
|
||||
export interface CreateApiEntryDto {
|
||||
title: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
type: 'ASYNCAPI' | 'OPENAPI';
|
||||
}
|
||||
|
||||
export interface UpdateApiEntryDto {
|
||||
title?: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
categoryId?: string | null;
|
||||
}
|
||||
export const API_CONVENTIONS = ['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'] as const;
|
||||
|
||||
@@ -2,23 +2,37 @@ export type ApiType = 'ASYNCAPI' | 'OPENAPI';
|
||||
|
||||
export type ApiStatus = 'PENDING' | 'GENERATED' | 'ERROR';
|
||||
|
||||
export type ApiConvention = 'CONSULTER' | 'ENREGISTRER' | 'ETRE_NOTIFIE';
|
||||
|
||||
export const API_CONVENTION_LABELS: Record<ApiConvention, string> = {
|
||||
CONSULTER: 'Consulter',
|
||||
ENREGISTRER: 'Enregistrer',
|
||||
ETRE_NOTIFIE: 'Être notifié',
|
||||
};
|
||||
|
||||
export interface ApiEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
name: string;
|
||||
convention: ApiConvention;
|
||||
version: string;
|
||||
versionMajor: number;
|
||||
versionMinor: number;
|
||||
versionPatch: number;
|
||||
description: string | null;
|
||||
type: ApiType;
|
||||
provider: string;
|
||||
yamlContent: string;
|
||||
htmlPath: string | null;
|
||||
status: ApiStatus;
|
||||
errorMessage: string | null;
|
||||
categoryId: string | null;
|
||||
functionalDoc: string | null;
|
||||
technicalDoc: string | null;
|
||||
externalDocUrl: string | null;
|
||||
contactFunctional: string | null;
|
||||
contactTechnical: string | null;
|
||||
accessRights: string | null;
|
||||
contactFunctionalName: string;
|
||||
contactFunctionalEntity: string;
|
||||
contactFunctionalEmail: string;
|
||||
contactTechnicalName: string;
|
||||
contactTechnicalEntity: string;
|
||||
contactTechnicalEmail: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -26,9 +40,12 @@ export interface ApiEntry {
|
||||
export interface ApiEntryListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
name: string;
|
||||
convention: ApiConvention;
|
||||
version: string;
|
||||
description: string | null;
|
||||
type: ApiType;
|
||||
provider: string;
|
||||
status: ApiStatus;
|
||||
categoryId: string | null;
|
||||
createdAt: string;
|
||||
|
||||
12
shared/tsconfig.build.json
Normal file
12
shared/tsconfig.build.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "./dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user