UNPKG

@mulutime/plugin-sdk

Version:

SDK for developing MuluTime booking platform plugins

512 lines 18.1 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.APIActionBuilder = exports.APIActionHandler = void 0; exports.Get = Get; exports.Post = Post; exports.Put = Put; exports.Delete = Delete; exports.Patch = Patch; exports.createAPIAction = createAPIAction; exports.extractAPIActions = extractAPIActions; const plugin_types_1 = require("@mulutime/plugin-types"); const ajv_1 = __importDefault(require("ajv")); class APIActionHandler { constructor(options = {}) { this.actions = new Map(); this.executionLogs = []; this.options = { enableValidation: true, enablePermissionCheck: true, timeout: 30000, cors: { enabled: true, origins: ['*'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], headers: ['Content-Type', 'Authorization'] }, ...options }; this.ajv = new ajv_1.default(); } /** * Register an API action */ register(action) { const key = this.getActionKey(action.method, action.path); this.actions.set(key, action); } /** * Register multiple API actions */ registerMany(actions) { actions.forEach(action => this.register(action)); } /** * Unregister an API action */ unregister(method, path) { const key = this.getActionKey(method, path); this.actions.delete(key); } /** * Handle an incoming API request */ async handleRequest(method, path, request, context) { const startTime = Date.now(); const executionLog = { id: `api-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, path, method, timestamp: new Date(), duration: 0, statusCode: 500, success: false, userId: request.userId, organizationId: request.organizationId }; try { // Handle CORS preflight if (method === 'OPTIONS' && this.options.cors?.enabled) { executionLog.statusCode = 200; executionLog.success = true; executionLog.duration = Date.now() - startTime; this.logExecution(executionLog); return { status: 200, headers: this.getCorsHeaders() }; } // Find matching action const action = this.findMatchingAction(method, path); if (!action) { executionLog.statusCode = 404; executionLog.error = 'API endpoint not found'; executionLog.duration = Date.now() - startTime; this.logExecution(executionLog); return { status: 404, error: 'API endpoint not found', headers: this.getCorsHeaders() }; } // Check access scope (plugin, user, world) if (action.access) { const allowed = Array.isArray(action.access) ? action.access : [action.access]; let isAllowed = false; for (const scope of allowed) { if (scope === plugin_types_1.APIAccessScope.WORLD) { isAllowed = true; break; } if ((scope === plugin_types_1.APIAccessScope.USER) && request.userId) { isAllowed = true; break; } if ((scope === plugin_types_1.APIAccessScope.PLUGIN) && context.pluginId && !request.userId) { isAllowed = true; break; } } if (!isAllowed) { executionLog.statusCode = 403; executionLog.error = 'Access denied: not allowed by access scope'; executionLog.duration = Date.now() - startTime; this.logExecution(executionLog); return { status: 403, error: 'Access denied: not allowed by access scope', headers: this.getCorsHeaders() }; } } // Check permissions if (this.options.enablePermissionCheck && action.requiredPermissions) { const permissionError = this.checkPermissions(action, context); if (permissionError) { executionLog.statusCode = 403; executionLog.error = permissionError; executionLog.duration = Date.now() - startTime; this.logExecution(executionLog); return { status: 403, error: permissionError, headers: this.getCorsHeaders() }; } } // Validate request if (this.options.enableValidation && action.validation) { const validationError = this.validateRequest(action, request); if (validationError) { executionLog.statusCode = 400; executionLog.error = validationError; executionLog.duration = Date.now() - startTime; this.logExecution(executionLog); return { status: 400, error: validationError, headers: this.getCorsHeaders() }; } } // Execute the action with timeout const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('API action timeout')), this.options.timeout); }); const actionPromise = action.handler(request, context); const response = await Promise.race([actionPromise, timeoutPromise]); // Ensure response has required fields const normalizedResponse = { status: response.status || 200, data: response.data, error: response.error, headers: { ...this.getCorsHeaders(), ...response.headers } }; executionLog.statusCode = normalizedResponse.status; executionLog.success = normalizedResponse.status < 400; executionLog.duration = Date.now() - startTime; this.logExecution(executionLog); context.logger.debug(`API action executed`, { method, path, statusCode: normalizedResponse.status, duration: executionLog.duration, executionId: executionLog.id }); return normalizedResponse; } catch (error) { executionLog.statusCode = 500; executionLog.error = error instanceof Error ? error.message : String(error); executionLog.duration = Date.now() - startTime; this.logExecution(executionLog); context.logger.error(`API action failed`, error, { method, path, duration: executionLog.duration, executionId: executionLog.id }); return { status: 500, error: 'Internal server error', headers: this.getCorsHeaders() }; } } /** * Find a matching action for the given method and path */ findMatchingAction(method, path) { // First try exact match const exactKey = this.getActionKey(method, path); const exactMatch = this.actions.get(exactKey); if (exactMatch) { return exactMatch; } // Try pattern matching for parameterized paths for (const [key, action] of this.actions.entries()) { if (this.matchesPattern(method, path, action)) { return action; } } return null; } /** * Check if a request matches an action pattern */ matchesPattern(method, path, action) { if (action.method !== method) { return false; } // Convert action path to regex pattern const pattern = action.path .replace(/:[^/]+/g, '([^/]+)') // Replace :param with regex group .replace(/\//g, '\\/'); // Escape forward slashes const regex = new RegExp(`^${pattern}$`); return regex.test(path); } /** * Extract parameters from a parameterized path */ extractParams(actionPath, requestPath) { const actionParts = actionPath.split('/'); const requestParts = requestPath.split('/'); const params = {}; if (actionParts.length !== requestParts.length) { return params; } for (let i = 0; i < actionParts.length; i++) { if (actionParts[i].startsWith(':')) { const paramName = actionParts[i].substring(1); params[paramName] = requestParts[i]; } } return params; } /** * Check if the user has required permissions */ checkPermissions(action, context) { if (!action.requiredPermissions || action.requiredPermissions.length === 0) { return null; } for (const permission of action.requiredPermissions) { if (!context.permissions.includes(permission)) { return `Missing required permission: ${permission}`; } } return null; } /** * Validate the request against the action's validation schema */ validateRequest(action, request) { if (!action.validation) { return null; } const errors = []; // Validate query parameters if (action.validation.query) { const queryValidation = this.validateSchema(request.query, action.validation.query); if (!queryValidation.valid) { errors.push(`Query validation failed: ${queryValidation.errors?.join(', ')}`); } } // Validate request body if (action.validation.body) { const bodyValidation = this.validateSchema(request.body, action.validation.body); if (!bodyValidation.valid) { errors.push(`Body validation failed: ${bodyValidation.errors?.join(', ')}`); } } // Validate path parameters if (action.validation.params) { const paramsValidation = this.validateSchema(request.params, action.validation.params); if (!paramsValidation.valid) { errors.push(`Parameters validation failed: ${paramsValidation.errors?.join(', ')}`); } } return errors.length > 0 ? errors.join('; ') : null; } /** * Validate data against JSON schema */ validateSchema(data, schema) { const validate = this.ajv.compile(schema); const valid = validate(data); return { valid, errors: valid ? undefined : validate.errors?.map(err => err.message || 'Validation error') }; } /** * Get CORS headers */ getCorsHeaders() { if (!this.options.cors?.enabled) { return {}; } const cors = this.options.cors; return { 'Access-Control-Allow-Origin': cors.origins?.join(', ') || '*', 'Access-Control-Allow-Methods': cors.methods?.join(', ') || 'GET, POST, PUT, DELETE, PATCH', 'Access-Control-Allow-Headers': cors.headers?.join(', ') || 'Content-Type, Authorization', 'Access-Control-Max-Age': '86400' }; } /** * Generate action key for storage */ getActionKey(method, path) { return `${method.toUpperCase()}:${path}`; } /** * Log API execution */ logExecution(log) { this.executionLogs.push(log); // Keep only last 1000 logs if (this.executionLogs.length > 1000) { this.executionLogs = this.executionLogs.slice(-1000); } } /** * Get all registered actions */ getRegisteredActions() { return Array.from(this.actions.values()); } /** * Get execution logs */ getExecutionLogs(limit = 100) { return this.executionLogs .sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()) .slice(0, limit); } /** * Get API statistics */ getStats() { const totalActions = this.actions.size; const totalExecutions = this.executionLogs.length; const successfulExecutions = this.executionLogs.filter(log => log.success).length; const failedExecutions = totalExecutions - successfulExecutions; const averageResponseTime = totalExecutions > 0 ? this.executionLogs.reduce((sum, log) => sum + log.duration, 0) / totalExecutions : 0; const responseTimeByStatus = {}; const statusGroups = {}; this.executionLogs.forEach(log => { const statusGroup = Math.floor(log.statusCode / 100) * 100; const key = `${statusGroup}xx`; if (!statusGroups[key]) { statusGroups[key] = []; } statusGroups[key].push(log); }); Object.entries(statusGroups).forEach(([status, logs]) => { responseTimeByStatus[status] = logs.reduce((sum, log) => sum + log.duration, 0) / logs.length; }); return { totalActions, totalExecutions, successfulExecutions, failedExecutions, averageResponseTime, responseTimeByStatus }; } /** * Clear all registered actions and logs */ clear() { this.actions.clear(); this.executionLogs = []; } } exports.APIActionHandler = APIActionHandler; // ============================================================================= // API ACTION BUILDER // ============================================================================= class APIActionBuilder { constructor() { this.action = {}; } access(access) { this.action.access = access; return this; } static create() { return new APIActionBuilder(); } path(path) { this.action.path = path; return this; } method(method) { this.action.method = method; return this; } handler(handler) { this.action.handler = handler; return this; } requirePermissions(permissions) { this.action.requiredPermissions = permissions; return this; } validateQuery(schema) { if (!this.action.validation) { this.action.validation = {}; } this.action.validation.query = schema; return this; } validateBody(schema) { if (!this.action.validation) { this.action.validation = {}; } this.action.validation.body = schema; return this; } validateParams(schema) { if (!this.action.validation) { this.action.validation = {}; } this.action.validation.params = schema; return this; } build() { if (!this.action.path || !this.action.method || !this.action.handler) { throw new Error('Path, method, and handler are required'); } return { path: this.action.path, method: this.action.method, handler: this.action.handler, requiredPermissions: this.action.requiredPermissions, validation: this.action.validation, access: this.action.access }; } } exports.APIActionBuilder = APIActionBuilder; // ============================================================================= // API ACTION DECORATORS // ============================================================================= function Get(path, options) { return createAPIDecorator('GET', path, options); } function Post(path, options) { return createAPIDecorator('POST', path, options); } function Put(path, options) { return createAPIDecorator('PUT', path, options); } function Delete(path, options) { return createAPIDecorator('DELETE', path, options); } function Patch(path, options) { return createAPIDecorator('PATCH', path, options); } function createAPIDecorator(method, path, options) { return function (target, propertyKey, descriptor) { const originalMethod = descriptor.value; // Add metadata to the method if (!target.constructor.__apiActions) { target.constructor.__apiActions = []; } target.constructor.__apiActions.push({ path, method, handler: originalMethod, requiredPermissions: options?.requiredPermissions, validation: options?.validation, access: options?.access }); return descriptor; }; } // ============================================================================= // HELPER FUNCTIONS // ============================================================================= function createAPIAction(method, path, handler, options) { return { path, method, handler, requiredPermissions: options?.requiredPermissions, validation: options?.validation }; } function extractAPIActions(pluginClass) { return pluginClass.__apiActions || []; } //# sourceMappingURL=api-handler.js.map