Phase 1: Test/Prod Database Configurations - Migration 010: test_* columns on databases table, environment column on request_logs - DatabasePoolManager: dual-pool strategy (prod + test), getPool(id, env) with fallback - SqlExecutor, ScriptExecutor, IsolatedScriptExecutor, AqlExecutor: environment param threaded through all execution paths - dynamicApiController: X-Environment header detection, environment in logging - databaseManagementController: CRUD for test credentials, testConnection with ?env=test - Frontend: test env form in DatabaseModal, env toggle in EndpointEditor test panel Phase 2: Endpoint Versioning - Migration 011: endpoint_versions table with full snapshots, backfill v1 for existing endpoints - VersionService: createVersion, saveDraft, publishVersion, rollbackToVersion, getHistory - endpointController: auto-versioning on update, 6 new version management handlers - dynamicApiController: draft serving when environment=test and draft exists - Frontend: Save Draft button, version history panel with publish/rollback actions Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
import { Response } from 'express';
|
|
import { ApiKeyRequest } from '../middleware/apiKey';
|
|
import { mainPool } from '../config/database';
|
|
import { sqlExecutor } from '../services/SqlExecutor';
|
|
import { scriptExecutor } from '../services/ScriptExecutor';
|
|
import { EndpointParameter, ScriptQuery, ScriptExecutionError, Environment } from '../types';
|
|
|
|
export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response) => {
|
|
const startTime = Date.now();
|
|
let shouldLog = false;
|
|
let endpointId: string | null = null;
|
|
|
|
try {
|
|
const environment: Environment =
|
|
(req.headers['x-environment'] as string)?.toLowerCase() === 'test' ? 'test' : 'prod';
|
|
|
|
// Extract the path from the request (remove /api/v1 prefix)
|
|
const requestPath = req.path; // This already has the path without /api/v1
|
|
const requestMethod = req.method.toUpperCase();
|
|
|
|
// Fetch endpoint configuration by path and method
|
|
const endpointResult = await mainPool.query(
|
|
'SELECT * FROM endpoints WHERE path = $1 AND method = $2',
|
|
[requestPath, requestMethod]
|
|
);
|
|
|
|
if (endpointResult.rows.length === 0) {
|
|
return res.status(404).json({
|
|
error: 'Endpoint not found',
|
|
path: requestPath,
|
|
method: requestMethod
|
|
});
|
|
}
|
|
|
|
let endpoint = endpointResult.rows[0];
|
|
endpointId = endpoint.id;
|
|
|
|
// If test environment requested and a draft exists, serve draft content
|
|
if (environment === 'test' && endpoint.draft_version_id) {
|
|
const draftResult = await mainPool.query(
|
|
`SELECT * FROM endpoint_versions WHERE id = $1 AND status = 'draft'`,
|
|
[endpoint.draft_version_id]
|
|
);
|
|
if (draftResult.rows.length > 0) {
|
|
const draft = draftResult.rows[0];
|
|
endpoint = { ...endpoint, ...draft, id: endpoint.id, folder_id: endpoint.folder_id, user_id: endpoint.user_id };
|
|
}
|
|
}
|
|
|
|
// Check if logging is enabled (on endpoint OR on API key, but log only once)
|
|
const endpointLogging = endpoint.enable_logging || false;
|
|
const apiKeyLogging = req.apiKey?.enable_logging || false;
|
|
shouldLog = endpointLogging || apiKeyLogging;
|
|
|
|
// Check if endpoint is public or if API key has permission
|
|
if (!endpoint.is_public) {
|
|
if (!req.apiKey) {
|
|
return res.status(401).json({ error: 'API key required for this endpoint' });
|
|
}
|
|
|
|
let hasPermission = req.apiKey.permissions.includes(endpointId!) ||
|
|
req.apiKey.permissions.includes('*');
|
|
|
|
// If no direct permission, check folder permissions
|
|
if (!hasPermission && endpoint.folder_id) {
|
|
// Check if this folder or any parent folder has permission
|
|
let currentFolderId: string | null = endpoint.folder_id;
|
|
|
|
while (currentFolderId && !hasPermission) {
|
|
if (req.apiKey.permissions.includes(`folder:${currentFolderId}`)) {
|
|
hasPermission = true;
|
|
break;
|
|
}
|
|
|
|
// Get parent folder
|
|
const folderResult = await mainPool.query(
|
|
'SELECT parent_id FROM folders WHERE id = $1',
|
|
[currentFolderId]
|
|
);
|
|
|
|
currentFolderId = folderResult.rows.length > 0 ? folderResult.rows[0].parent_id : null;
|
|
}
|
|
}
|
|
|
|
if (!hasPermission) {
|
|
return res.status(403).json({ error: 'Access denied to this endpoint' });
|
|
}
|
|
}
|
|
|
|
// Parse parameters - PostgreSQL может вернуть как JSON строку, так и уже распарсенный объект
|
|
let parameters: EndpointParameter[] = [];
|
|
if (endpoint.parameters) {
|
|
if (typeof endpoint.parameters === 'string') {
|
|
try {
|
|
parameters = JSON.parse(endpoint.parameters);
|
|
} catch (e) {
|
|
parameters = [];
|
|
}
|
|
} else if (Array.isArray(endpoint.parameters)) {
|
|
parameters = endpoint.parameters;
|
|
}
|
|
}
|
|
// Build request parameters object
|
|
const requestParams: Record<string, any> = {};
|
|
|
|
// Extract and validate parameters from request
|
|
for (const param of parameters) {
|
|
let value;
|
|
|
|
if (param.in === 'query') {
|
|
value = req.query[param.name];
|
|
} else if (param.in === 'body') {
|
|
value = req.body[param.name];
|
|
} else if (param.in === 'path') {
|
|
value = req.params[param.name];
|
|
}
|
|
|
|
// Use default value if not provided
|
|
if (value === undefined || value === null) {
|
|
if (param.required) {
|
|
return res.status(400).json({
|
|
error: `Missing required parameter: ${param.name}`,
|
|
});
|
|
}
|
|
value = param.default_value;
|
|
}
|
|
|
|
// Type conversion
|
|
if (value !== undefined && value !== null) {
|
|
switch (param.type) {
|
|
case 'number':
|
|
value = Number(value);
|
|
if (isNaN(value)) {
|
|
return res.status(400).json({
|
|
error: `Parameter ${param.name} must be a number`,
|
|
});
|
|
}
|
|
break;
|
|
case 'boolean':
|
|
value = value === 'true' || value === true;
|
|
break;
|
|
case 'date':
|
|
value = new Date(value);
|
|
if (isNaN(value.getTime())) {
|
|
return res.status(400).json({
|
|
error: `Parameter ${param.name} must be a valid date`,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
requestParams[param.name] = value;
|
|
}
|
|
|
|
let result;
|
|
const executionType = endpoint.execution_type || 'sql';
|
|
|
|
if (executionType === 'aql') {
|
|
// Execute AQL query
|
|
const aqlMethod = endpoint.aql_method;
|
|
const aqlEndpoint = endpoint.aql_endpoint;
|
|
const aqlBody = endpoint.aql_body;
|
|
|
|
let aqlQueryParams: Record<string, string> = {};
|
|
if (endpoint.aql_query_params) {
|
|
if (typeof endpoint.aql_query_params === 'string') {
|
|
try {
|
|
aqlQueryParams = JSON.parse(endpoint.aql_query_params);
|
|
} catch (e) {
|
|
aqlQueryParams = {};
|
|
}
|
|
} else if (typeof endpoint.aql_query_params === 'object') {
|
|
aqlQueryParams = endpoint.aql_query_params;
|
|
}
|
|
}
|
|
|
|
if (!aqlMethod || !aqlEndpoint) {
|
|
return res.status(500).json({ error: 'AQL configuration is incomplete' });
|
|
}
|
|
|
|
const { aqlExecutor } = require('../services/AqlExecutor');
|
|
result = await aqlExecutor.executeAqlQuery(endpoint.database_id, {
|
|
method: aqlMethod,
|
|
endpoint: aqlEndpoint,
|
|
body: aqlBody,
|
|
queryParams: aqlQueryParams,
|
|
parameters: requestParams,
|
|
}, environment);
|
|
} else if (executionType === 'script') {
|
|
// Execute script
|
|
const scriptLanguage = endpoint.script_language;
|
|
const scriptCode = endpoint.script_code;
|
|
|
|
let scriptQueries: ScriptQuery[] = [];
|
|
if (endpoint.script_queries) {
|
|
if (typeof endpoint.script_queries === 'string') {
|
|
try {
|
|
scriptQueries = JSON.parse(endpoint.script_queries);
|
|
} catch (e) {
|
|
scriptQueries = [];
|
|
}
|
|
} else if (Array.isArray(endpoint.script_queries)) {
|
|
scriptQueries = endpoint.script_queries;
|
|
}
|
|
}
|
|
|
|
if (!scriptLanguage || !scriptCode) {
|
|
return res.status(500).json({ error: 'Script configuration is incomplete' });
|
|
}
|
|
|
|
const scriptResult = await scriptExecutor.execute(scriptLanguage, scriptCode, {
|
|
databaseId: endpoint.database_id,
|
|
scriptQueries,
|
|
requestParams,
|
|
endpointParameters: parameters,
|
|
environment,
|
|
});
|
|
|
|
result = {
|
|
rows: scriptResult.result,
|
|
rowCount: 0,
|
|
executionTime: 0,
|
|
scriptLogs: scriptResult.logs,
|
|
scriptQueries: scriptResult.queries,
|
|
};
|
|
} else {
|
|
// Execute SQL query
|
|
const queryParams: any[] = [];
|
|
|
|
parameters.forEach((param) => {
|
|
queryParams.push(requestParams[param.name]);
|
|
});
|
|
|
|
// Преобразуем именованные параметры ($paramName) в позиционные ($1, $2, $3...)
|
|
let processedQuery = endpoint.sql_query;
|
|
|
|
parameters.forEach((param, index) => {
|
|
const paramName = param.name;
|
|
const position = index + 1;
|
|
|
|
// Заменяем все вхождения $paramName на $position
|
|
const regex = new RegExp(`\\$${paramName}\\b`, 'g');
|
|
processedQuery = processedQuery.replace(regex, `$${position}`);
|
|
});
|
|
|
|
result = await sqlExecutor.executeQuery(
|
|
endpoint.database_id,
|
|
processedQuery,
|
|
queryParams,
|
|
environment
|
|
);
|
|
}
|
|
|
|
// Build response based on detailed_response flag
|
|
const detailedResponse = endpoint.detailed_response || false;
|
|
const responseData = detailedResponse
|
|
? {
|
|
success: true,
|
|
data: result.rows,
|
|
rowCount: result.rowCount,
|
|
executionTime: result.executionTime,
|
|
...(result.scriptLogs && result.scriptLogs.length > 0 ? { logs: result.scriptLogs } : {}),
|
|
...(result.scriptQueries && result.scriptQueries.length > 0 ? { queries: result.scriptQueries } : {}),
|
|
}
|
|
: result.rows;
|
|
|
|
// Log if needed
|
|
if (shouldLog && endpointId) {
|
|
const executionTime = Date.now() - startTime;
|
|
await logRequest({
|
|
endpoint_id: endpointId,
|
|
api_key_id: req.apiKey?.id || null,
|
|
method: req.method,
|
|
path: req.path,
|
|
request_params: req.query || {},
|
|
request_body: req.body || {},
|
|
response_status: 200,
|
|
response_data: responseData,
|
|
execution_time: executionTime,
|
|
error_message: null,
|
|
ip_address: req.ip || req.socket.remoteAddress || 'unknown',
|
|
user_agent: req.headers['user-agent'] || 'unknown',
|
|
environment,
|
|
});
|
|
}
|
|
|
|
res.json(responseData);
|
|
} catch (error: any) {
|
|
console.error('Dynamic API execution error:', error);
|
|
|
|
const isScriptError = error instanceof ScriptExecutionError;
|
|
const errorResponse: any = {
|
|
success: false,
|
|
error: error.message,
|
|
...(isScriptError && error.logs.length > 0 ? { logs: error.logs } : {}),
|
|
...(isScriptError && error.queries.length > 0 ? { queries: error.queries } : {}),
|
|
};
|
|
|
|
// Log error if needed
|
|
if (shouldLog && endpointId) {
|
|
const executionTime = Date.now() - startTime;
|
|
await logRequest({
|
|
endpoint_id: endpointId,
|
|
api_key_id: req.apiKey?.id || null,
|
|
method: req.method,
|
|
path: req.path,
|
|
request_params: req.query || {},
|
|
request_body: req.body || {},
|
|
response_status: 500,
|
|
response_data: errorResponse,
|
|
execution_time: executionTime,
|
|
error_message: error.message,
|
|
ip_address: req.ip || req.socket.remoteAddress || 'unknown',
|
|
user_agent: req.headers['user-agent'] || 'unknown',
|
|
environment: (req.headers['x-environment'] as string)?.toLowerCase() === 'test' ? 'test' : 'prod',
|
|
});
|
|
}
|
|
|
|
res.status(500).json(errorResponse);
|
|
}
|
|
};
|
|
|
|
async function logRequest(data: {
|
|
endpoint_id: string;
|
|
api_key_id: string | null;
|
|
method: string;
|
|
path: string;
|
|
request_params: any;
|
|
request_body: any;
|
|
response_status: number;
|
|
response_data: any;
|
|
execution_time: number;
|
|
error_message: string | null;
|
|
ip_address: string;
|
|
user_agent: string;
|
|
environment?: string;
|
|
}) {
|
|
try {
|
|
await mainPool.query(
|
|
`INSERT INTO request_logs (
|
|
endpoint_id, api_key_id, method, path,
|
|
request_params, request_body, response_status,
|
|
response_data, execution_time, error_message,
|
|
ip_address, user_agent, environment
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
|
|
[
|
|
data.endpoint_id,
|
|
data.api_key_id,
|
|
data.method,
|
|
data.path,
|
|
JSON.stringify(data.request_params),
|
|
JSON.stringify(data.request_body),
|
|
data.response_status,
|
|
JSON.stringify(data.response_data),
|
|
data.execution_time,
|
|
data.error_message,
|
|
data.ip_address,
|
|
data.user_agent,
|
|
data.environment || 'prod',
|
|
]
|
|
);
|
|
} catch (error) {
|
|
console.error('Failed to log request:', error);
|
|
}
|
|
}
|