Files
api_builder/backend/src/controllers/endpointController.ts
eshmeshek e9032001bd Add test/prod environments for databases
- Migration 010: test_* columns on databases table, environment column on request_logs
- DatabasePoolManager: dual-pool strategy (prod + test), getPool(id, env) with fallback
- All executors (SQL, Script, AQL): environment param threaded through 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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 08:28:28 +03:00

750 lines
24 KiB
TypeScript

import { Response } from 'express';
import { AuthRequest } from '../middleware/auth';
import { mainPool } from '../config/database';
import { v4 as uuidv4 } from 'uuid';
import { ExportedEndpoint, ExportedScriptQuery, ScriptExecutionError, Environment } from '../types';
import { encryptEndpointData, decryptEndpointData } from '../services/endpointCrypto';
export const getEndpoints = async (req: AuthRequest, res: Response) => {
try {
const { search, folder_id } = req.query;
let query = `
SELECT e.*, f.name as folder_name
FROM endpoints e
LEFT JOIN folders f ON e.folder_id = f.id
WHERE 1=1
`;
const params: any[] = [];
if (folder_id) {
query += ` AND e.folder_id = $${params.length + 1}`;
params.push(folder_id);
}
if (search) {
const searchIndex = params.length + 1;
query += ` AND (
e.name ILIKE $${searchIndex} OR
e.description ILIKE $${searchIndex} OR
e.sql_query ILIKE $${searchIndex} OR
e.path ILIKE $${searchIndex}
)`;
params.push(`%${search}%`);
}
query += ` ORDER BY e.created_at DESC`;
const result = await mainPool.query(query, params);
res.json(result.rows);
} catch (error) {
console.error('Get endpoints error:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
export const getEndpoint = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const result = await mainPool.query(
`SELECT e.*, f.name as folder_name
FROM endpoints e
LEFT JOIN folders f ON e.folder_id = f.id
WHERE e.id = $1`,
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Endpoint not found' });
}
res.json(result.rows[0]);
} catch (error) {
console.error('Get endpoint error:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
export const createEndpoint = async (req: AuthRequest, res: Response) => {
try {
const {
name,
description,
method,
path,
database_id,
sql_query,
parameters,
folder_id,
is_public,
enable_logging,
execution_type,
script_language,
script_code,
script_queries,
aql_method,
aql_endpoint,
aql_body,
aql_query_params,
detailed_response,
response_schema,
} = req.body;
if (!name || !method || !path) {
return res.status(400).json({ error: 'Missing required fields' });
}
const execType = execution_type || 'sql';
// Валидация для типа SQL
if (execType === 'sql') {
if (!database_id || !sql_query) {
return res.status(400).json({ error: 'Database ID and SQL query are required for SQL execution type' });
}
}
// Валидация для типа Script
if (execType === 'script') {
if (!script_language || !script_code || !script_queries) {
return res.status(400).json({ error: 'Script language, code, and queries are required for script execution type' });
}
}
// Валидация для типа AQL
if (execType === 'aql') {
if (!database_id || !aql_method || !aql_endpoint) {
return res.status(400).json({ error: 'Database ID, AQL method, and AQL endpoint are required for AQL execution type' });
}
}
const result = await mainPool.query(
`INSERT INTO endpoints (
name, description, method, path, database_id, sql_query, parameters,
folder_id, user_id, is_public, enable_logging,
execution_type, script_language, script_code, script_queries,
aql_method, aql_endpoint, aql_body, aql_query_params, detailed_response, response_schema
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
RETURNING *`,
[
name,
description || '',
method,
path,
database_id || null,
sql_query || '',
JSON.stringify(parameters || []),
folder_id || null,
req.user!.id,
is_public || false,
enable_logging || false,
execType,
script_language || null,
script_code || null,
JSON.stringify(script_queries || []),
aql_method || null,
aql_endpoint || null,
aql_body || null,
JSON.stringify(aql_query_params || {}),
detailed_response || false,
response_schema ? JSON.stringify(response_schema) : null,
]
);
res.status(201).json(result.rows[0]);
} catch (error: any) {
console.error('Create endpoint error:', error);
if (error.code === '23505') {
return res.status(400).json({ error: 'Endpoint path already exists' });
}
res.status(500).json({ error: 'Internal server error' });
}
};
export const updateEndpoint = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const {
name,
description,
method,
path,
database_id,
sql_query,
parameters,
folder_id,
is_public,
enable_logging,
execution_type,
script_language,
script_code,
script_queries,
aql_method,
aql_endpoint,
aql_body,
aql_query_params,
detailed_response,
response_schema,
} = req.body;
const result = await mainPool.query(
`UPDATE endpoints
SET name = $1,
description = $2,
method = $3,
path = $4,
database_id = $5,
sql_query = $6,
parameters = $7,
folder_id = $8,
is_public = $9,
enable_logging = $10,
execution_type = $11,
script_language = $12,
script_code = $13,
script_queries = $14,
aql_method = $15,
aql_endpoint = $16,
aql_body = $17,
aql_query_params = $18,
detailed_response = $19,
response_schema = $20,
updated_at = CURRENT_TIMESTAMP
WHERE id = $21
RETURNING *`,
[
name,
description,
method,
path,
database_id || null,
sql_query,
parameters ? JSON.stringify(parameters) : null,
folder_id || null,
is_public,
enable_logging,
execution_type,
script_language || null,
script_code || null,
script_queries ? JSON.stringify(script_queries) : null,
aql_method || null,
aql_endpoint || null,
aql_body || null,
aql_query_params ? JSON.stringify(aql_query_params) : null,
detailed_response || false,
response_schema ? JSON.stringify(response_schema) : null,
id,
]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Endpoint not found' });
}
res.json(result.rows[0]);
} catch (error: any) {
console.error('Update endpoint error:', error);
if (error.code === '23505') {
return res.status(400).json({ error: 'Endpoint path already exists' });
}
res.status(500).json({ error: 'Internal server error' });
}
};
export const deleteEndpoint = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const result = await mainPool.query(
'DELETE FROM endpoints WHERE id = $1 RETURNING id',
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Endpoint not found' });
}
res.json({ message: 'Endpoint deleted successfully' });
} catch (error) {
console.error('Delete endpoint error:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
export const testEndpoint = async (req: AuthRequest, res: Response) => {
try {
const {
database_id,
sql_query,
parameters,
endpoint_parameters,
execution_type,
script_language,
script_code,
script_queries,
aql_method,
aql_endpoint,
aql_body,
aql_query_params,
environment: reqEnv,
} = req.body;
const environment: Environment = reqEnv === 'prod' ? 'prod' : 'test';
const execType = execution_type || 'sql';
if (execType === 'sql') {
if (!database_id) {
return res.status(400).json({ error: 'Missing database_id for SQL execution' });
}
if (!sql_query) {
return res.status(400).json({ error: 'Missing sql_query' });
}
// Преобразуем именованные параметры ($paramName) в позиционные ($1, $2, $3...)
let processedQuery = sql_query;
if (endpoint_parameters && Array.isArray(endpoint_parameters)) {
endpoint_parameters.forEach((param: any, index: number) => {
const paramName = param.name;
const position = index + 1;
// Заменяем все вхождения $paramName на $position
const regex = new RegExp(`\\$${paramName}\\b`, 'g');
processedQuery = processedQuery.replace(regex, `$${position}`);
});
}
const { sqlExecutor } = require('../services/SqlExecutor');
const result = await sqlExecutor.executeQuery(database_id, processedQuery, parameters || [], environment);
res.json({
success: true,
data: result.rows,
rowCount: result.rowCount,
executionTime: result.executionTime,
logs: [
{ type: 'info', message: `Query executed in ${result.executionTime}ms, returned ${result.rowCount} rows`, timestamp: Date.now() },
],
queries: [],
processedQuery,
});
} else if (execType === 'script') {
if (!script_language || !script_code) {
return res.status(400).json({ error: 'Missing script_language or script_code' });
}
// Собираем параметры из тестовых значений
const requestParams: Record<string, any> = {};
if (endpoint_parameters && Array.isArray(endpoint_parameters) && parameters && Array.isArray(parameters)) {
endpoint_parameters.forEach((param: any, index: number) => {
requestParams[param.name] = parameters[index];
});
}
const { scriptExecutor } = require('../services/ScriptExecutor');
const scriptResult = await scriptExecutor.execute(script_language, script_code, {
databaseId: database_id,
scriptQueries: script_queries || [],
requestParams,
endpointParameters: endpoint_parameters || [],
environment,
});
res.json({
success: true,
data: scriptResult.result,
logs: scriptResult.logs,
queries: scriptResult.queries,
});
} else if (execType === 'aql') {
if (!database_id) {
return res.status(400).json({ error: 'Missing database_id for AQL execution' });
}
if (!aql_method || !aql_endpoint) {
return res.status(400).json({ error: 'Missing aql_method or aql_endpoint' });
}
// Собираем параметры из тестовых значений
const requestParams: Record<string, any> = {};
if (endpoint_parameters && Array.isArray(endpoint_parameters) && parameters && Array.isArray(parameters)) {
endpoint_parameters.forEach((param: any, index: number) => {
requestParams[param.name] = parameters[index];
});
}
const { aqlExecutor } = require('../services/AqlExecutor');
const result = await aqlExecutor.executeAqlQuery(database_id, {
method: aql_method,
endpoint: aql_endpoint,
body: aql_body,
queryParams: aql_query_params,
parameters: requestParams,
}, environment);
res.json({
success: true,
data: result.rows,
rowCount: result.rowCount,
executionTime: result.executionTime,
logs: [
{ type: 'info', message: `AQL ${aql_method} ${aql_endpoint} executed in ${result.executionTime}ms`, timestamp: Date.now() },
],
queries: [],
});
} else {
return res.status(400).json({ error: 'Invalid execution_type' });
}
} catch (error: any) {
const isScriptError = error instanceof ScriptExecutionError;
res.status(400).json({
success: false,
error: error.message,
detail: error.detail || undefined,
hint: error.hint || undefined,
logs: isScriptError ? error.logs : [],
queries: isScriptError ? error.queries : [],
});
}
};
export const exportEndpoint = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const endpointResult = await mainPool.query(
'SELECT * FROM endpoints WHERE id = $1',
[id]
);
if (endpointResult.rows.length === 0) {
return res.status(404).json({ error: 'Endpoint not found' });
}
const endpoint = endpointResult.rows[0];
// Resolve database_id -> name & type
let databaseName: string | null = null;
let databaseType: string | null = null;
if (endpoint.database_id) {
const dbResult = await mainPool.query(
'SELECT name, type FROM databases WHERE id = $1',
[endpoint.database_id]
);
if (dbResult.rows.length > 0) {
databaseName = dbResult.rows[0].name;
databaseType = dbResult.rows[0].type;
}
}
// Resolve folder_id -> name
let folderName: string | null = null;
if (endpoint.folder_id) {
const folderResult = await mainPool.query(
'SELECT name FROM folders WHERE id = $1',
[endpoint.folder_id]
);
if (folderResult.rows.length > 0) {
folderName = folderResult.rows[0].name;
}
}
// Resolve database_ids in script_queries
const scriptQueries = endpoint.script_queries || [];
const exportedScriptQueries: ExportedScriptQuery[] = [];
for (const sq of scriptQueries) {
let sqDbName: string | undefined;
let sqDbType: string | undefined;
if (sq.database_id) {
const sqDbResult = await mainPool.query(
'SELECT name, type FROM databases WHERE id = $1',
[sq.database_id]
);
if (sqDbResult.rows.length > 0) {
sqDbName = sqDbResult.rows[0].name;
sqDbType = sqDbResult.rows[0].type;
}
}
exportedScriptQueries.push({
name: sq.name,
sql: sq.sql,
database_name: sqDbName,
database_type: sqDbType,
aql_method: sq.aql_method,
aql_endpoint: sq.aql_endpoint,
aql_body: sq.aql_body,
aql_query_params: sq.aql_query_params,
});
}
const exportData: ExportedEndpoint = {
_format: 'kabe_v1',
name: endpoint.name,
description: endpoint.description || '',
method: endpoint.method,
path: endpoint.path,
execution_type: endpoint.execution_type || 'sql',
database_name: databaseName,
database_type: databaseType,
sql_query: endpoint.sql_query || '',
parameters: endpoint.parameters || [],
script_language: endpoint.script_language || null,
script_code: endpoint.script_code || null,
script_queries: exportedScriptQueries,
aql_method: endpoint.aql_method || null,
aql_endpoint: endpoint.aql_endpoint || null,
aql_body: endpoint.aql_body || null,
aql_query_params: endpoint.aql_query_params || null,
is_public: endpoint.is_public || false,
enable_logging: endpoint.enable_logging || false,
detailed_response: endpoint.detailed_response || false,
response_schema: endpoint.response_schema || null,
folder_name: folderName,
};
const encrypted = encryptEndpointData(exportData);
const safeFileName = endpoint.name.replace(/[^a-zA-Z0-9_\-]/g, '_');
const encodedFileName = encodeURIComponent(endpoint.name.replace(/[\/\\:*?"<>|]/g, '_')) + '.kabe';
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${safeFileName}.kabe"; filename*=UTF-8''${encodedFileName}`);
res.send(encrypted);
} catch (error) {
console.error('Export endpoint error:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
export const importPreview = async (req: AuthRequest, res: Response) => {
try {
const buffer = req.body as Buffer;
if (!buffer || buffer.length === 0) {
return res.status(400).json({ error: 'No file uploaded' });
}
let exportData: ExportedEndpoint;
try {
exportData = decryptEndpointData(buffer) as ExportedEndpoint;
} catch (err) {
return res.status(400).json({ error: 'Invalid or corrupted .kabe file' });
}
if (exportData._format !== 'kabe_v1') {
return res.status(400).json({ error: 'Unsupported file format version' });
}
// Collect all referenced database names
const referencedDatabases: { name: string; type: string }[] = [];
if (exportData.database_name) {
referencedDatabases.push({
name: exportData.database_name,
type: exportData.database_type || 'unknown',
});
}
for (const sq of exportData.script_queries || []) {
if (sq.database_name && !referencedDatabases.find(d => d.name === sq.database_name)) {
referencedDatabases.push({
name: sq.database_name,
type: sq.database_type || 'unknown',
});
}
}
// Check which databases exist locally
const localDatabases = await mainPool.query(
'SELECT id, name, type FROM databases WHERE is_active = true'
);
const databaseMapping = referencedDatabases.map(ref => {
const found = localDatabases.rows.find(
(db: any) => db.name === ref.name && db.type === ref.type
);
return {
name: ref.name,
type: ref.type,
found: !!found,
local_id: found?.id || null,
};
});
// Check folder
let folder: { name: string; found: boolean; local_id: string | null } | null = null;
if (exportData.folder_name) {
const folderResult = await mainPool.query(
'SELECT id FROM folders WHERE name = $1',
[exportData.folder_name]
);
folder = {
name: exportData.folder_name,
found: folderResult.rows.length > 0,
local_id: folderResult.rows.length > 0 ? folderResult.rows[0].id : null,
};
}
// Check if path already exists
const pathCheck = await mainPool.query(
'SELECT id FROM endpoints WHERE path = $1',
[exportData.path]
);
res.json({
endpoint: {
name: exportData.name,
description: exportData.description,
method: exportData.method,
path: exportData.path,
execution_type: exportData.execution_type,
is_public: exportData.is_public,
enable_logging: exportData.enable_logging,
detailed_response: exportData.detailed_response,
folder_name: exportData.folder_name,
},
databases: databaseMapping,
all_databases_found: databaseMapping.every(d => d.found),
local_databases: localDatabases.rows.map((db: any) => ({
id: db.id,
name: db.name,
type: db.type,
})),
folder,
path_exists: pathCheck.rows.length > 0,
});
} catch (error) {
console.error('Import preview error:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
export const importEndpoint = async (req: AuthRequest, res: Response) => {
try {
const {
file_data,
database_mapping,
folder_id,
override_path,
} = req.body;
if (!file_data) {
return res.status(400).json({ error: 'No file data provided' });
}
const buffer = Buffer.from(file_data, 'base64');
let exportData: ExportedEndpoint;
try {
exportData = decryptEndpointData(buffer) as ExportedEndpoint;
} catch (err) {
return res.status(400).json({ error: 'Invalid or corrupted .kabe file' });
}
// Resolve main database_id
let databaseId: string | null = null;
if (exportData.database_name) {
const mappedId = database_mapping?.[exportData.database_name];
if (mappedId) {
databaseId = mappedId;
} else {
const dbResult = await mainPool.query(
'SELECT id FROM databases WHERE name = $1 AND is_active = true',
[exportData.database_name]
);
if (dbResult.rows.length > 0) {
databaseId = dbResult.rows[0].id;
} else {
return res.status(400).json({
error: `Database "${exportData.database_name}" not found and no mapping provided`
});
}
}
}
// Resolve script_queries database_ids
const resolvedScriptQueries = [];
for (const sq of exportData.script_queries || []) {
let sqDatabaseId: string | undefined;
if (sq.database_name) {
const mappedId = database_mapping?.[sq.database_name];
if (mappedId) {
sqDatabaseId = mappedId;
} else {
const sqDbResult = await mainPool.query(
'SELECT id FROM databases WHERE name = $1 AND is_active = true',
[sq.database_name]
);
if (sqDbResult.rows.length > 0) {
sqDatabaseId = sqDbResult.rows[0].id;
} else {
return res.status(400).json({
error: `Database "${sq.database_name}" (script query "${sq.name}") not found and no mapping provided`
});
}
}
}
resolvedScriptQueries.push({
name: sq.name,
sql: sq.sql,
database_id: sqDatabaseId,
aql_method: sq.aql_method,
aql_endpoint: sq.aql_endpoint,
aql_body: sq.aql_body,
aql_query_params: sq.aql_query_params,
});
}
// Resolve folder
let resolvedFolderId: string | null = folder_id || null;
if (!resolvedFolderId && exportData.folder_name) {
const folderResult = await mainPool.query(
'SELECT id FROM folders WHERE name = $1',
[exportData.folder_name]
);
if (folderResult.rows.length > 0) {
resolvedFolderId = folderResult.rows[0].id;
}
}
const finalPath = override_path || exportData.path;
const result = await mainPool.query(
`INSERT INTO endpoints (
name, description, method, path, database_id, sql_query, parameters,
folder_id, user_id, is_public, enable_logging,
execution_type, script_language, script_code, script_queries,
aql_method, aql_endpoint, aql_body, aql_query_params, detailed_response, response_schema
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
RETURNING *`,
[
exportData.name,
exportData.description || '',
exportData.method,
finalPath,
databaseId,
exportData.sql_query || '',
JSON.stringify(exportData.parameters || []),
resolvedFolderId,
req.user!.id,
exportData.is_public || false,
exportData.enable_logging || false,
exportData.execution_type || 'sql',
exportData.script_language || null,
exportData.script_code || null,
JSON.stringify(resolvedScriptQueries),
exportData.aql_method || null,
exportData.aql_endpoint || null,
exportData.aql_body || null,
JSON.stringify(exportData.aql_query_params || {}),
exportData.detailed_response || false,
exportData.response_schema ? JSON.stringify(exportData.response_schema) : null,
]
);
res.status(201).json(result.rows[0]);
} catch (error: any) {
console.error('Import endpoint error:', error);
if (error.code === '23505') {
return res.status(400).json({ error: 'Endpoint path already exists' });
}
res.status(500).json({ error: 'Internal server error' });
}
};