@mulutime/plugin-sdk
Version:
SDK for developing MuluTime booking platform plugins
512 lines • 18.1 kB
JavaScript
"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