diff --git a/README.md b/README.md index f7e4857..82bab0d 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,65 @@ const result = await coreService.callPluginApi('notifications', 'stats', { perio const user = await coreService.callPluginApi('user-management', 'users/42'); ``` +#### `findPluginsByEvent(eventName)` + +Знайти плагіни, які оголосили певну подію у своєму `manifest.json`. + +```typescript +const listeners = await coreService.findPluginsByEvent('core.user.created'); +// listeners = [ { id: 'audit-logs', name: 'Audit Logs', ... } ] +``` + +#### `emitToPlugin(pluginId, pattern, payload)` + +Надіслати подію конкретному плагіну через RabbitMQ. Публікує в `system_events` з routing key `plugin..`. Цільовий плагін отримує подію через `@EventPattern()`. + +```typescript +// Надіслати подію тільки плагіну notifications +await coreService.emitToPlugin('notifications', 'send', { + userId: 1, + title: 'New deal', + message: 'Deal #42 created', +}); +``` + +Цільовий плагін обробляє через стандартний `@EventPattern`: + +```typescript +@EventPattern('plugin.notifications.send') +async handle(@Payload() data: any) { + const event = data.data || data; + // event.payload = { userId: 1, title: 'New deal', ... } +} +``` + +#### `requestPlugin(pluginId, pattern, payload, timeoutMs?)` + +RPC-виклик до іншого плагіна з очікуванням відповіді. Використовує NestJS RabbitMQ RPC (`send` / `@MessagePattern`). + +```typescript +const stats = await coreService.requestPlugin('notifications', 'getStats', { + period: 'week', +}); +// stats = { sent: 42, failed: 1 } +``` + +Цільовий плагін обробляє через `@MessagePattern`: + +```typescript +@MessagePattern('plugin.notifications.getStats') +async getStats(@Payload() data: { period: string }) { + // логіка підрахунку... + return { sent: 42, failed: 1 }; +} +``` + +Параметри: +- `pluginId` — ID цільового плагіна +- `pattern` — назва команди (без префікса `plugin..`, він додається автоматично) +- `payload` — тіло запиту +- `timeoutMs` — таймаут (за замовчуванням 5000 мс). Повертає `null` при перевищенні. + #### `discoverPlugins()` Отримати список усіх зареєстрованих плагінів з їхніми можливостями. Повертає масив маніфестів з полями: `id`, `name`, `label`, `icon`, `routePath`, `backendUrl`, `events`, `objectTypes`, `permissions`, `roles`, `database`, `menu`, `headerActions`. @@ -344,12 +403,22 @@ await coreClient.notifyCore('myplugin.event', { data: 'value' }); ```typescript import { getRabbitMQOptions } from '@erp/plugin-sdk'; +// Звичайне підключення — отримує всі події app.connectMicroservice(getRabbitMQOptions('plugin_my_queue')); + +// З підтримкою targeted events — також байндить plugin..* +const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf8')); +app.connectMicroservice(getRabbitMQOptions('plugin_my_queue', undefined, manifest.id)); ``` +Параметри: +- `queueName` — назва черги +- `url?` — RabbitMQ URL (за замовчуванням з `RABBITMQ_URL` або `amqp://localhost:5672`) +- `pluginId?` — (v1.4.0+) ID плагіна для додаткового binding `plugin..*` (targeted events) + Конфігурація: - Exchange: `system_events` (topic) -- Routing key: `#` (отримує всі події) +- Routing keys: `#` (всі події) + `plugin..*` (якщо передано `pluginId`) - Queue: вказана назва (durable) - Повідомлення: persistent @@ -619,19 +688,86 @@ Plugin A Core Backend Plugin B 2. **Визначає `backendUrl`** цільового плагіна з реєстру 3. **Проксує POST-запит** з автентифікацією та тілом -### Discover — дізнатись які плагіни доступні +### Усі способи комунікації + +| Метод | Тип | Канал | Адресат | +|-------|-----|-------|---------| +| `callPluginApi()` | Sync HTTP RPC | HTTP через core-proxy | Конкретний плагін | +| `emitToPlugin()` | Async event | RabbitMQ `plugin..` | Конкретний плагін | +| `requestPlugin()` | Sync RPC | RabbitMQ `send/@MessagePattern` | Конкретний плагін | +| `emit()` | Async event | RabbitMQ broadcast | Всі плагіни | + +### 1. Discovery ```typescript +// Всі зареєстровані плагіни const plugins = await coreService.discoverPlugins(); + +// Знайти плагіни за подією +const listeners = await coreService.findPluginsByEvent('core.user.created'); ``` Кожен плагін має: -- `id`, `name`, `label`, `backendUrl` — адреса для RPC +- `id`, `name`, `label`, `backendUrl` — адреса для HTTP RPC - `events: { name, description }[]` — які події оголошує - `objectTypes: string[]` — з якими об'єктами працює - `permissions`, `roles` — права доступу -### RPC — викликати API іншого плагіна +### 2. Targeted Events (RabbitMQ) + +Надіслати подію конкретному плагіну через RabbitMQ. Подія публікується в `system_events` з routing key `plugin..`. Цільовий плагін має додатковий binding `plugin..*` (якщо передано `pluginId` у `getRabbitMQOptions`). + +```typescript +// Відправник +await coreService.emitToPlugin('notifications', 'send', { + userId: 1, + title: 'New deal', + message: 'Deal #42 created', +}); + +// Отримувач (notifications plugin) +@EventPattern('plugin.notifications.send') +async handle(@Payload() data: any) { + const event = data.data || data; + console.log(event.payload.title); // 'New deal' +} +``` + +**Підключення отримувача** (в `main.ts`): +```typescript +const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf8')); +app.connectMicroservice( + getRabbitMQOptions('plugin_notifications_queue', undefined, manifest.id) +); +// Черга байндиться з routing keys: # (всі події) + plugin.notifications.* (targeted) +``` + +### 3. RPC Request/Response (RabbitMQ) + +Синхронний виклик до іншого плагіна з очікуванням відповіді. Використовує NestJS `send()` / `@MessagePattern()`. + +```typescript +// Відправник +const stats = await coreService.requestPlugin('notifications', 'getStats', { + period: 'week', +}); +console.log(stats); // { sent: 42, failed: 1 } + +// Отримувач (notifications plugin) +@MessagePattern('plugin.notifications.getStats') +async getStats(@Payload() data: { period: string }) { + const stats = await this.calcStats(data.period); + return stats; // повертається відправнику +} +``` + +Параметри `requestPlugin`: +- `pluginId` — ID цільового плагіна +- `pattern` — назва команди (без префікса) +- `payload` — тіло запиту +- `timeoutMs` — таймаут у мс (за замовчуванням 5000). `null` при перевищенні. + +### 4. HTTP RPC через core-proxy ```typescript const stats = await coreService.callPluginApi('notifications', 'stats'); @@ -643,32 +779,29 @@ const result = await coreService.callPluginApi('global-store', 'store/set', { ``` **Обмеження:** -- Тільки POST-метод (ядро проксує POST на `backendUrl` цільового плагіна). Для GET-ендпоінтів цільового плагіна — використовуйте `@Post()` контролер з потрібною логікою. -- Потребує JWT-токена (як і будь-який захищений API). Токен автоматично прокидyється з контексту запиту. -- Якщо плагін не зареєстрований або не має `backendUrl` — повертається 404. +- Тільки POST-метод +- Потребує JWT-токена (автоматично прокидyється з контексту) +- Якщо плагін не зареєстрований або без `backendUrl` — 404 -### Типовий сценарій використання +### Типовий сценарій ```typescript -// В сервісі плагіна A async function notifyAndLog() { - // 1. Discovery — знайти потрібний плагін - const plugins = await this.coreService.discoverPlugins(); - const notifPlugin = plugins.find(p => p.events?.some(e => e.name === 'notification.send')); + // 1. Discovery + const [notifPlugin] = await this.coreService.findPluginsByEvent('notification.send'); - // 2. RPC — викликати API плагіна B + // 2. Targeted event if (notifPlugin) { - await this.coreService.callPluginApi('notifications', 'send', { + await this.coreService.emitToPlugin('notifications', 'send', { userId: 1, title: 'Event occurred', message: 'Plugin A triggered a notification', }); } - // 3. Або викликати будь-який кастомний ендпоінт - const status = await this.coreService.callPluginApi('audit-logs', 'stats/daily'); + // 3. RPC запит до audit-logs + const status = await this.coreService.requestPlugin('audit-logs', 'dailyStats', {}); } -``` --- @@ -732,7 +865,7 @@ my-plugin/ ``` @erp/plugin-sdk -├── CoreService # Головний сервіс (fetchCore, emit, register, callPluginApi, discoverPlugins, ...) +├── CoreService # Головний сервіс (fetchCore, emit, register, callPluginApi, discoverPlugins, findPluginsByEvent, emitToPlugin, requestPlugin, ...) ├── CoreClientService # RabbitMQ RPC клієнт ├── AttributeAccessService # ABAC — керування атрибутами та перевірка доступу ├── getRabbitMQOptions() # NestJS Microservice options diff --git a/package.json b/package.json index 614b91c..401af0a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@erp/plugin-sdk", - "version": "1.3.0", + "version": "1.4.0", "main": "dist/index.js", "types": "dist/index.d.ts", "bin": { diff --git a/src/backend/config/rabbitmq.config.ts b/src/backend/config/rabbitmq.config.ts index 3db2abf..c4a4d3e 100644 --- a/src/backend/config/rabbitmq.config.ts +++ b/src/backend/config/rabbitmq.config.ts @@ -1,6 +1,10 @@ -import { Transport, MicroserviceOptions } from '@nestjs/microservices'; +import { Transport, MicroserviceOptions, RmqOptions } from '@nestjs/microservices'; -export const getRabbitMQOptions = (queueName: string, url?: string): MicroserviceOptions => { +export const getRabbitMQOptions = (queueName: string, url?: string, pluginId?: string): MicroserviceOptions => { + const routingKeys: string[] = ['#']; + if (pluginId) { + routingKeys.push(`plugin.${pluginId}.*`); + } return { transport: Transport.RMQ, options: { @@ -8,12 +12,13 @@ export const getRabbitMQOptions = (queueName: string, url?: string): Microservic queue: queueName, exchange: 'system_events', exchangeType: 'topic', - routingKey: '#', + routingKey: routingKeys, queueOptions: { - durable: true, // Черга не зникає при перезавантаженні RabbitMQ + durable: true, }, - persistent: true, // Повідомлення зберігаються на диску - }, + persistent: true, + noAck: true, + } as RmqOptions['options'] & { routingKey: string[] }, }; }; diff --git a/src/backend/services/core.service.ts b/src/backend/services/core.service.ts index 3ab499d..5d4240f 100644 --- a/src/backend/services/core.service.ts +++ b/src/backend/services/core.service.ts @@ -1,6 +1,6 @@ import { Injectable, Inject, Logger } from '@nestjs/common'; import { ClientProxy } from '@nestjs/microservices'; -import { firstValueFrom } from 'rxjs'; +import { firstValueFrom, timeout as rxTimeout } from 'rxjs'; import { SystemEvent } from '../interfaces/communication'; import { v4 as uuidv4 } from 'uuid'; import * as fs from 'fs'; @@ -144,6 +144,77 @@ export class CoreService { }, 30 * 1000); // 30 seconds } + /** + * Викликати API іншого плагіна через core RPC-проксі. + * Запит проксується ядром на backendUrl цільового плагіна методом POST. + */ + async callPluginApi(pluginId: string, path: string, body?: any): Promise { + return this.fetchCore(`/api/plugins/rpc/${pluginId}/${path.replace(/^\//, '')}`, { + method: 'POST', + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + } + + /** + * Отримати список усіх зареєстрованих плагінів з їхніми events, objectTypes, backendUrl тощо. + * Корисно для discovery — дізнатись, які плагіни доступні та що вони вміють. + */ + async discoverPlugins(): Promise { + const response = await this.fetchCore('/api/plugins/discovery'); + return response.data || response || []; + } + + /** + * Знайти плагіни, які оголосили певну подію у своєму manifest.json (поле events). + */ + async findPluginsByEvent(eventName: string): Promise { + const plugins = await this.discoverPlugins(); + return plugins.filter((p) => + p.events?.some((e: { name: string }) => e.name === eventName), + ); + } + + /** + * Надіслати подію конкретному плагіну через RabbitMQ. + * Публікує в system_events з routingKey: plugin... + * Цільовий плагін отримує подію через @EventPattern('plugin..') + * або більш загальний @EventPattern('plugin.*'). + * + * Інші плагіни теж отримують подію (через # binding), але за конвенцією + * мають ігнорувати події з префіксом plugin.<чужойId>. + */ + async emitToPlugin(pluginId: string, pattern: string, payload: any): Promise { + await this.emit(`plugin.${pluginId}.${pattern}`, payload); + } + + /** + * RPC-виклик до іншого плагіна з очікуванням відповіді. + * Використовує NestJS RabbitMQ RPC (send / @MessagePattern). + * Цільовий плагін має обробити запит через @MessagePattern('plugin..'). + * + * @param pluginId — ID цільового плагіна + * @param pattern — назва команди + * @param payload — тіло запиту + * @param timeoutMs — таймаут очікування (за замовчуванням 5000 мс) + * @returns відповідь від цільового плагіна або null по таймауту + */ + async requestPlugin( + pluginId: string, + pattern: string, + payload: any, + timeoutMs: number = 5000, + ): Promise { + try { + return await firstValueFrom( + this.client.send(`plugin.${pluginId}.${pattern}`, payload).pipe(rxTimeout(timeoutMs)), + { defaultValue: null }, + ); + } catch (err: any) { + this.logger.error(`[requestPlugin] failed for ${pluginId}/${pattern}: ${err.message}`); + return null; + } + } + async emit(pattern: string, payload: T): Promise { const event: SystemEvent = { eventId: uuidv4(),