From e9032001bd15a4a888eea0f64211b6c3b8d17b38 Mon Sep 17 00:00:00 2001 From: eshmeshek Date: Sat, 23 May 2026 21:04:11 +0300 Subject: [PATCH] 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) --- CLAUDE.md | 72 +++++++ backend/src/controllers/databaseController.ts | 1 + .../databaseManagementController.ts | 159 ++++++++------- .../src/controllers/dynamicApiController.ts | 19 +- backend/src/controllers/endpointController.ts | 9 +- .../migrations/010_add_test_environments.sql | 18 ++ backend/src/services/AqlExecutor.ts | 63 +++--- backend/src/services/DatabasePoolManager.ts | 188 ++++++++++-------- .../src/services/IsolatedScriptExecutor.ts | 7 +- backend/src/services/ScriptExecutor.ts | 8 +- backend/src/services/SqlExecutor.ts | 7 +- backend/src/types/index.ts | 13 ++ frontend/src/pages/EndpointEditor.tsx | 46 ++++- frontend/src/pages/Settings.tsx | 163 ++++++++++++++- frontend/src/services/api.ts | 5 +- frontend/src/types/index.ts | 9 + 16 files changed, 571 insertions(+), 216 deletions(-) create mode 100644 CLAUDE.md create mode 100644 backend/src/migrations/010_add_test_environments.sql diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4d647ff --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,72 @@ +# KIS API Builder + +Dynamic API construction system for KIS/PMU/CR_PPAK databases. Allows creating HTTP endpoints (SQL queries, JS/Python scripts, AQL HTTP calls) via web UI, serving them at `/api/v1/*`, and syncing via CLI (`kisync`). + +## Stack + +- **Backend:** Node.js, Express 4.18, TypeScript, PostgreSQL (metadata store), PM2 +- **Frontend:** React 18, TypeScript, Vite 5, Tailwind CSS, Zustand, Monaco Editor +- **CLI (`kisync`):** TypeScript, Commander.js, Node-Fetch +- **Target DBs:** PostgreSQL, MySQL, MSSQL, AQL (custom HTTP API) + +## Project structure + +``` +backend/ # Express API server + src/ + config/ # database.ts, environment.ts, dynamicSwagger.ts + controllers/ # endpointController, dynamicApiController, syncController + middleware/ # auth.ts (JWT), apiKey.ts + migrations/ # 001-009 sequential SQL migrations + routes/ # REST + dynamic wildcard router + services/ # SqlExecutor, ScriptExecutor, IsolatedScriptExecutor, AqlExecutor, DatabasePoolManager + types/ # index.ts — all interfaces +frontend/ # React SPA + src/ + pages/ # Endpoints, Folders, ApiKeys, DatabaseManagement, SqlWorkbench + components/ # Reusable UI components + services/ # api.ts (Axios client) + stores/ # Zustand stores (auth, endpoints, etc.) +cli/ # kisync CLI tool + src/ + commands/ # init, pull, push, status, update + api.ts # HTTP client to backend + files.ts # Endpoint ↔ filesystem serialization + hash.ts # SHA256 change detection +``` + +## Key concepts + +- **Endpoint execution types:** `sql` (direct query), `script` (JS in VM / Python subprocess), `aql` (HTTP proxy) +- **Dynamic routing:** `router.all('*')` at `/api/v1/` matches endpoints by path + method from DB +- **Auth:** JWT for web UI, API keys (`X-API-Key` header) for external consumers +- **Permissions:** endpoint-level or folder-level (`folder:`), with parent chain traversal +- **CLI sync:** hash-based change detection, conflict resolution via `_base_updated_at` +- **Query safety:** DDL/DML blocklist (DROP, TRUNCATE, ALTER, CREATE, DELETE FROM), single-statement only + +## Commands + +```bash +npm run dev # concurrently backend (nodemon) + frontend (vite) +npm run build # tsc + vite build +npm run migrate # run SQL migrations +npm run start:prod # PM2 production (port 10805) +``` + +## Production + +- PM2 fork mode, port 10805, max 500MB RAM +- Frontend served as static files from `frontend/dist` +- SPA fallback for non-API routes + +## Pipeline (agent workflow) + +``` +analyst -> spec.md +planner -> plan.md (reads spec) +developer -> code + testing-instructions.md (reads spec + plan) +tester-ui -> testing results (replay-loop with developer on FAIL) +code-reviewer -> review.md (APPROVE | REQUEST_CHANGES | BLOCK) +deployer -> deployment record (pre-step: safety-reviewer) +tech-writer -> updates docs + CLAUDE.md +``` diff --git a/backend/src/controllers/databaseController.ts b/backend/src/controllers/databaseController.ts index e30a9b0..caec9ba 100644 --- a/backend/src/controllers/databaseController.ts +++ b/backend/src/controllers/databaseController.ts @@ -14,6 +14,7 @@ export const getDatabases = async (req: Request, res: Response) => { host: db.host, port: db.port, database: db.database_name, + has_test_env: db.has_test_env || false, })); res.json(sanitized); diff --git a/backend/src/controllers/databaseManagementController.ts b/backend/src/controllers/databaseManagementController.ts index ab4a844..04fe50e 100644 --- a/backend/src/controllers/databaseManagementController.ts +++ b/backend/src/controllers/databaseManagementController.ts @@ -3,13 +3,15 @@ import { AuthRequest } from '../middleware/auth'; import { mainPool } from '../config/database'; import { databasePoolManager } from '../services/DatabasePoolManager'; import { generateSchemaForDatabase } from './schemaController'; +import { Environment } from '../types'; -// Только админы могут управлять базами данных export const getDatabases = async (req: AuthRequest, res: Response) => { try { const result = await mainPool.query( `SELECT id, name, type, host, port, database_name, username, ssl, is_active, aql_base_url, aql_auth_type, aql_auth_value, aql_headers, + has_test_env, test_host, test_port, test_database_name, test_username, test_ssl, + test_aql_base_url, test_aql_headers, created_at, updated_at FROM databases ORDER BY name` ); @@ -28,6 +30,8 @@ export const getDatabase = async (req: AuthRequest, res: Response) => { const result = await mainPool.query( `SELECT id, name, type, host, port, database_name, username, ssl, is_active, aql_base_url, aql_auth_type, aql_auth_value, aql_headers, + has_test_env, test_host, test_port, test_database_name, test_username, test_ssl, + test_aql_base_url, test_aql_headers, created_at, updated_at FROM databases WHERE id = $1`, [id] @@ -48,18 +52,18 @@ export const createDatabase = async (req: AuthRequest, res: Response) => { try { const { name, type, host, port, database_name, username, password, ssl, - aql_base_url, aql_auth_type, aql_auth_value, aql_headers + aql_base_url, aql_auth_type, aql_auth_value, aql_headers, + has_test_env, test_host, test_port, test_database_name, test_username, test_password, test_ssl, + test_aql_base_url, test_aql_auth_value, test_aql_headers } = req.body; const dbType = type || 'postgresql'; - // Валидация для обычных БД if (dbType !== 'aql') { if (!name || !host || !port || !database_name || !username || !password) { return res.status(400).json({ error: 'Не заполнены обязательные поля' }); } } else { - // Валидация для AQL if (!name || !aql_base_url) { return res.status(400).json({ error: 'Не заполнены обязательные поля для AQL базы' }); } @@ -68,9 +72,11 @@ export const createDatabase = async (req: AuthRequest, res: Response) => { const result = await mainPool.query( `INSERT INTO databases ( name, type, host, port, database_name, username, password, ssl, is_active, - aql_base_url, aql_auth_type, aql_auth_value, aql_headers + aql_base_url, aql_auth_type, aql_auth_value, aql_headers, + has_test_env, test_host, test_port, test_database_name, test_username, test_password, test_ssl, + test_aql_base_url, test_aql_auth_value, test_aql_headers ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, $9, $10, $11, $12) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22) RETURNING *`, [ name, @@ -84,17 +90,25 @@ export const createDatabase = async (req: AuthRequest, res: Response) => { aql_base_url || null, aql_auth_type || null, aql_auth_value || null, - aql_headers ? JSON.stringify(aql_headers) : null + aql_headers ? JSON.stringify(aql_headers) : null, + has_test_env || false, + test_host || null, + test_port || null, + test_database_name || null, + test_username || null, + test_password || null, + test_ssl || null, + test_aql_base_url || null, + test_aql_auth_value || null, + test_aql_headers ? JSON.stringify(test_aql_headers) : null, ] ); const newDb = result.rows[0]; - // Добавить пул подключений (только для не-AQL баз) if (dbType !== 'aql') { await databasePoolManager.reloadPool(newDb.id); - // Generate schema in background for PostgreSQL databases if (dbType === 'postgresql') { generateSchemaForDatabase(newDb.id).catch(err => { console.error('Background schema generation failed:', err.message); @@ -102,9 +116,10 @@ export const createDatabase = async (req: AuthRequest, res: Response) => { } } - // Не возвращаем пароль delete newDb.password; - delete newDb.aql_auth_value; // Также не возвращаем auth value + delete newDb.aql_auth_value; + delete newDb.test_password; + delete newDb.test_aql_auth_value; res.status(201).json(newDb); } catch (error: any) { @@ -121,62 +136,67 @@ export const updateDatabase = async (req: AuthRequest, res: Response) => { const { id } = req.params; const { name, type, host, port, database_name, username, password, ssl, is_active, - aql_base_url, aql_auth_type, aql_auth_value, aql_headers + aql_base_url, aql_auth_type, aql_auth_value, aql_headers, + has_test_env, test_host, test_port, test_database_name, test_username, test_password, test_ssl, + test_aql_base_url, test_aql_auth_value, test_aql_headers } = req.body; - // Если пароль/auth не передан, не обновляем его - let query; - let params; + // Build SET clauses and params dynamically to handle optional password fields + const setClauses: string[] = []; + const params: any[] = []; + let idx = 1; - if (password || aql_auth_value) { - query = ` - UPDATE databases - SET name = COALESCE($1, name), - type = COALESCE($2, type), - host = COALESCE($3, host), - port = COALESCE($4, port), - database_name = COALESCE($5, database_name), - username = COALESCE($6, username), - password = COALESCE($7, password), - ssl = COALESCE($8, ssl), - is_active = COALESCE($9, is_active), - aql_base_url = COALESCE($10, aql_base_url), - aql_auth_type = COALESCE($11, aql_auth_type), - aql_auth_value = COALESCE($12, aql_auth_value), - aql_headers = COALESCE($13, aql_headers), - updated_at = CURRENT_TIMESTAMP - WHERE id = $14 - RETURNING id, name, type, host, port, database_name, username, ssl, is_active, - aql_base_url, aql_auth_type, aql_headers, created_at, updated_at - `; - params = [ - name, type, host, port, database_name, username, password || aql_auth_value, ssl, is_active, - aql_base_url, aql_auth_type, aql_auth_value, aql_headers ? JSON.stringify(aql_headers) : null, id - ]; - } else { - query = ` - UPDATE databases - SET name = COALESCE($1, name), - type = COALESCE($2, type), - host = COALESCE($3, host), - port = COALESCE($4, port), - database_name = COALESCE($5, database_name), - username = COALESCE($6, username), - ssl = COALESCE($7, ssl), - is_active = COALESCE($8, is_active), - aql_base_url = COALESCE($9, aql_base_url), - aql_auth_type = COALESCE($10, aql_auth_type), - aql_headers = COALESCE($11, aql_headers), - updated_at = CURRENT_TIMESTAMP - WHERE id = $12 - RETURNING id, name, type, host, port, database_name, username, ssl, is_active, - aql_base_url, aql_auth_type, aql_headers, created_at, updated_at - `; - params = [ - name, type, host, port, database_name, username, ssl, is_active, - aql_base_url, aql_auth_type, aql_headers ? JSON.stringify(aql_headers) : null, id - ]; - } + const addField = (clause: string, value: any) => { + setClauses.push(`${clause} = $${idx}`); + params.push(value); + idx++; + }; + + const addCoalesce = (field: string, value: any) => { + setClauses.push(`${field} = COALESCE($${idx}, ${field})`); + params.push(value); + idx++; + }; + + addCoalesce('name', name); + addCoalesce('type', type); + addCoalesce('host', host); + addCoalesce('port', port); + addCoalesce('database_name', database_name); + addCoalesce('username', username); + if (password) { addField('password', password); } + addCoalesce('ssl', ssl); + addCoalesce('is_active', is_active); + addCoalesce('aql_base_url', aql_base_url); + addCoalesce('aql_auth_type', aql_auth_type); + if (aql_auth_value) { addField('aql_auth_value', aql_auth_value); } + addCoalesce('aql_headers', aql_headers ? JSON.stringify(aql_headers) : null); + addCoalesce('has_test_env', has_test_env); + // Test fields: use direct SET (nullable — user may want to clear them) + addField('test_host', test_host || null); + addField('test_port', test_port || null); + addField('test_database_name', test_database_name || null); + addField('test_username', test_username || null); + if (test_password) { addField('test_password', test_password); } + addField('test_ssl', test_ssl !== undefined ? test_ssl : null); + addField('test_aql_base_url', test_aql_base_url || null); + if (test_aql_auth_value) { addField('test_aql_auth_value', test_aql_auth_value); } + addField('test_aql_headers', test_aql_headers ? JSON.stringify(test_aql_headers) : null); + + setClauses.push('updated_at = CURRENT_TIMESTAMP'); + + params.push(id); + const whereIdx = idx; + + const query = ` + UPDATE databases + SET ${setClauses.join(', ')} + WHERE id = $${whereIdx} + RETURNING id, name, type, host, port, database_name, username, ssl, is_active, + aql_base_url, aql_auth_type, aql_headers, + has_test_env, test_host, test_port, test_database_name, test_username, test_ssl, + test_aql_base_url, test_aql_headers, + created_at, updated_at`; const result = await mainPool.query(query, params); @@ -184,7 +204,6 @@ export const updateDatabase = async (req: AuthRequest, res: Response) => { return res.status(404).json({ error: 'База данных не найдена' }); } - // Перезагрузить пул (только для не-AQL баз) if (result.rows[0].type !== 'aql') { await databasePoolManager.reloadPool(id); } @@ -203,7 +222,6 @@ export const deleteDatabase = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; - // Проверяем, используется ли база данных в эндпоинтах const endpointCheck = await mainPool.query( 'SELECT COUNT(*) FROM endpoints WHERE database_id = $1', [id] @@ -224,7 +242,6 @@ export const deleteDatabase = async (req: AuthRequest, res: Response) => { return res.status(404).json({ error: 'База данных не найдена' }); } - // Удалить пул databasePoolManager.removePool(id); res.json({ message: 'База данных удалена успешно' }); @@ -237,8 +254,8 @@ export const deleteDatabase = async (req: AuthRequest, res: Response) => { export const testDatabaseConnection = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; + const env = (req.query.env as string)?.toLowerCase() === 'test' ? 'test' : 'prod' as Environment; - // Получаем тип БД const dbResult = await mainPool.query('SELECT type FROM databases WHERE id = $1', [id]); if (dbResult.rows.length === 0) { @@ -248,17 +265,15 @@ export const testDatabaseConnection = async (req: AuthRequest, res: Response) => const dbType = dbResult.rows[0].type; if (dbType === 'aql') { - // Для AQL используем aqlExecutor const { aqlExecutor } = require('../services/AqlExecutor'); - const result = await aqlExecutor.testConnection(id); + const result = await aqlExecutor.testConnection(id, env); res.json({ success: result.success, message: result.success ? 'Подключение успешно' : result.error || 'Ошибка подключения', }); } else { - // Для обычных БД используем databasePoolManager - const isConnected = await databasePoolManager.testConnection(id); + const isConnected = await databasePoolManager.testConnection(id, env); res.json({ success: isConnected, diff --git a/backend/src/controllers/dynamicApiController.ts b/backend/src/controllers/dynamicApiController.ts index d937715..a00a42b 100644 --- a/backend/src/controllers/dynamicApiController.ts +++ b/backend/src/controllers/dynamicApiController.ts @@ -3,7 +3,7 @@ import { ApiKeyRequest } from '../middleware/apiKey'; import { mainPool } from '../config/database'; import { sqlExecutor } from '../services/SqlExecutor'; import { scriptExecutor } from '../services/ScriptExecutor'; -import { EndpointParameter, ScriptQuery, ScriptExecutionError } from '../types'; +import { EndpointParameter, ScriptQuery, ScriptExecutionError, Environment } from '../types'; export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response) => { const startTime = Date.now(); @@ -11,6 +11,9 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response) 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(); @@ -171,7 +174,7 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response) body: aqlBody, queryParams: aqlQueryParams, parameters: requestParams, - }); + }, environment); } else if (executionType === 'script') { // Execute script const scriptLanguage = endpoint.script_language; @@ -199,6 +202,7 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response) scriptQueries, requestParams, endpointParameters: parameters, + environment, }); result = { @@ -231,7 +235,8 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response) result = await sqlExecutor.executeQuery( endpoint.database_id, processedQuery, - queryParams + queryParams, + environment ); } @@ -264,6 +269,7 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response) error_message: null, ip_address: req.ip || req.socket.remoteAddress || 'unknown', user_agent: req.headers['user-agent'] || 'unknown', + environment, }); } @@ -295,6 +301,7 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response) 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', }); } @@ -315,6 +322,7 @@ async function logRequest(data: { error_message: string | null; ip_address: string; user_agent: string; + environment?: string; }) { try { await mainPool.query( @@ -322,8 +330,8 @@ async function logRequest(data: { endpoint_id, api_key_id, method, path, request_params, request_body, response_status, response_data, execution_time, error_message, - ip_address, user_agent - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, + 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, @@ -337,6 +345,7 @@ async function logRequest(data: { data.error_message, data.ip_address, data.user_agent, + data.environment || 'prod', ] ); } catch (error) { diff --git a/backend/src/controllers/endpointController.ts b/backend/src/controllers/endpointController.ts index 96baa85..d066cea 100644 --- a/backend/src/controllers/endpointController.ts +++ b/backend/src/controllers/endpointController.ts @@ -2,7 +2,7 @@ import { Response } from 'express'; import { AuthRequest } from '../middleware/auth'; import { mainPool } from '../config/database'; import { v4 as uuidv4 } from 'uuid'; -import { ExportedEndpoint, ExportedScriptQuery, ScriptExecutionError } from '../types'; +import { ExportedEndpoint, ExportedScriptQuery, ScriptExecutionError, Environment } from '../types'; import { encryptEndpointData, decryptEndpointData } from '../services/endpointCrypto'; export const getEndpoints = async (req: AuthRequest, res: Response) => { @@ -287,8 +287,10 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => { 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') { @@ -314,7 +316,7 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => { } const { sqlExecutor } = require('../services/SqlExecutor'); - const result = await sqlExecutor.executeQuery(database_id, processedQuery, parameters || []); + const result = await sqlExecutor.executeQuery(database_id, processedQuery, parameters || [], environment); res.json({ success: true, @@ -346,6 +348,7 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => { scriptQueries: script_queries || [], requestParams, endpointParameters: endpoint_parameters || [], + environment, }); res.json({ @@ -377,7 +380,7 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => { body: aql_body, queryParams: aql_query_params, parameters: requestParams, - }); + }, environment); res.json({ success: true, diff --git a/backend/src/migrations/010_add_test_environments.sql b/backend/src/migrations/010_add_test_environments.sql new file mode 100644 index 0000000..b308da2 --- /dev/null +++ b/backend/src/migrations/010_add_test_environments.sql @@ -0,0 +1,18 @@ +-- Test environment credentials for databases +ALTER TABLE databases ADD COLUMN IF NOT EXISTS test_host VARCHAR(255); +ALTER TABLE databases ADD COLUMN IF NOT EXISTS test_port INTEGER; +ALTER TABLE databases ADD COLUMN IF NOT EXISTS test_database_name VARCHAR(255); +ALTER TABLE databases ADD COLUMN IF NOT EXISTS test_username VARCHAR(255); +ALTER TABLE databases ADD COLUMN IF NOT EXISTS test_password VARCHAR(255); +ALTER TABLE databases ADD COLUMN IF NOT EXISTS test_ssl BOOLEAN; + +-- AQL test credentials +ALTER TABLE databases ADD COLUMN IF NOT EXISTS test_aql_base_url TEXT; +ALTER TABLE databases ADD COLUMN IF NOT EXISTS test_aql_auth_value TEXT; +ALTER TABLE databases ADD COLUMN IF NOT EXISTS test_aql_headers JSONB; + +-- Quick toggle +ALTER TABLE databases ADD COLUMN IF NOT EXISTS has_test_env BOOLEAN DEFAULT false; + +-- Track environment in request logs +ALTER TABLE request_logs ADD COLUMN IF NOT EXISTS environment VARCHAR(10) DEFAULT 'prod'; diff --git a/backend/src/services/AqlExecutor.ts b/backend/src/services/AqlExecutor.ts index b17f46f..7228294 100644 --- a/backend/src/services/AqlExecutor.ts +++ b/backend/src/services/AqlExecutor.ts @@ -1,5 +1,6 @@ -import { QueryResult, DatabaseConfig } from '../types'; +import { QueryResult, DatabaseConfig, Environment } from '../types'; import { mainPool } from '../config/database'; +import { databasePoolManager } from './DatabasePoolManager'; interface AqlRequestConfig { method: 'GET' | 'POST' | 'PUT' | 'DELETE'; @@ -73,19 +74,30 @@ export class AqlExecutor { */ async executeAqlQuery( databaseId: string, - config: AqlRequestConfig + config: AqlRequestConfig, + environment: Environment = 'prod' ): Promise { const startTime = Date.now(); try { - // Получаем конфигурацию БД - const dbConfig = await this.getDatabaseConfig(databaseId); + const dbConfig = await databasePoolManager.getDatabaseConfig(databaseId); if (!dbConfig) { throw new Error(`AQL database with id ${databaseId} not found or not configured`); } - if (!dbConfig.aql_base_url) { + // Use test credentials when environment is test and test env is configured + let baseUrl = dbConfig.aql_base_url; + let authValue = dbConfig.aql_auth_value; + let headers_config = dbConfig.aql_headers; + + if (environment === 'test' && dbConfig.has_test_env) { + if (dbConfig.test_aql_base_url) baseUrl = dbConfig.test_aql_base_url; + if (dbConfig.test_aql_auth_value) authValue = dbConfig.test_aql_auth_value; + if (dbConfig.test_aql_headers) headers_config = dbConfig.test_aql_headers; + } + + if (!baseUrl) { throw new Error(`AQL base URL not configured for database ${databaseId}`); } @@ -100,7 +112,7 @@ export class AqlExecutor { : ''; // Формируем полный URL - const fullUrl = `${dbConfig.aql_base_url}${processedEndpoint}${queryString}`; + const fullUrl = `${baseUrl}${processedEndpoint}${queryString}`; // Обрабатываем body с параметрами let processedBody: string | undefined; @@ -115,19 +127,19 @@ export class AqlExecutor { }; // Добавляем аутентификацию - if (dbConfig.aql_auth_type === 'basic' && dbConfig.aql_auth_value) { - headers['Authorization'] = `Basic ${dbConfig.aql_auth_value}`; - } else if (dbConfig.aql_auth_type === 'bearer' && dbConfig.aql_auth_value) { - headers['Authorization'] = `Bearer ${dbConfig.aql_auth_value}`; - } else if (dbConfig.aql_auth_type === 'custom' && dbConfig.aql_auth_value) { - headers['Authorization'] = dbConfig.aql_auth_value; + if (dbConfig.aql_auth_type === 'basic' && authValue) { + headers['Authorization'] = `Basic ${authValue}`; + } else if (dbConfig.aql_auth_type === 'bearer' && authValue) { + headers['Authorization'] = `Bearer ${authValue}`; + } else if (dbConfig.aql_auth_type === 'custom' && authValue) { + headers['Authorization'] = authValue; } // Добавляем кастомные заголовки из конфигурации БД - if (dbConfig.aql_headers) { - const customHeaders = typeof dbConfig.aql_headers === 'string' - ? JSON.parse(dbConfig.aql_headers) - : dbConfig.aql_headers; + if (headers_config) { + const customHeaders = typeof headers_config === 'string' + ? JSON.parse(headers_config) + : headers_config; Object.assign(headers, customHeaders); } @@ -234,28 +246,29 @@ export class AqlExecutor { /** * Тестирует подключение к AQL базе */ - async testConnection(databaseId: string): Promise<{ success: boolean; error?: string }> { + async testConnection(databaseId: string, environment: Environment = 'prod'): Promise<{ success: boolean; error?: string }> { try { - const dbConfig = await this.getDatabaseConfig(databaseId); + const dbConfig = await databasePoolManager.getDatabaseConfig(databaseId); if (!dbConfig) { return { success: false, error: 'Database not found' }; } - if (!dbConfig.aql_base_url) { + let testUrl = dbConfig.aql_base_url; + if (environment === 'test' && dbConfig.has_test_env && dbConfig.test_aql_base_url) { + testUrl = dbConfig.test_aql_base_url; + } + + if (!testUrl) { return { success: false, error: 'AQL base URL not configured' }; } - // Пробуем выполнить простой запрос для проверки соединения - const response = await fetch(dbConfig.aql_base_url, { + const response = await fetch(testUrl, { method: 'GET', - headers: { - 'Accept': 'application/json', - }, + headers: { 'Accept': 'application/json' }, }); if (response.ok || response.status === 404) { - // 404 тоже OK - это значит что сервер доступен return { success: true }; } else { return { success: false, error: `HTTP ${response.status}` }; diff --git a/backend/src/services/DatabasePoolManager.ts b/backend/src/services/DatabasePoolManager.ts index 26b6571..d0f21fe 100644 --- a/backend/src/services/DatabasePoolManager.ts +++ b/backend/src/services/DatabasePoolManager.ts @@ -1,12 +1,16 @@ import { Pool } from 'pg'; -import { DatabaseConfig } from '../types'; +import { DatabaseConfig, Environment } from '../types'; import { mainPool } from '../config/database'; +interface PoolEntry { + prod: Pool; + test?: Pool; +} + class DatabasePoolManager { - private pools: Map = new Map(); + private pools: Map = new Map(); async initialize() { - // Load databases from DB instead of env await this.loadDatabasesFromDB(); } @@ -17,67 +21,112 @@ class DatabasePoolManager { ); for (const row of result.rows) { - const dbConfig: DatabaseConfig = { - id: row.id, - name: row.name, - type: row.type, - host: row.host, - port: row.port, - database_name: row.database_name, - username: row.username, - password: row.password, - ssl: row.ssl, - is_active: row.is_active, - }; - + const dbConfig = this.rowToConfig(row); this.addPool(dbConfig); } - console.log(`✅ Loaded ${result.rows.length} database connection(s) from DB`); + console.log(`Loaded ${result.rows.length} database connection(s) from DB`); } catch (error) { - console.error('❌ Failed to load databases from DB:', error); + console.error('Failed to load databases from DB:', error); } } + private rowToConfig(row: any): DatabaseConfig { + return { + id: row.id, + name: row.name, + type: row.type, + host: row.host, + port: row.port, + database_name: row.database_name, + username: row.username, + password: row.password, + ssl: row.ssl, + is_active: row.is_active, + aql_base_url: row.aql_base_url, + aql_auth_type: row.aql_auth_type, + aql_auth_value: row.aql_auth_value, + aql_headers: row.aql_headers, + has_test_env: row.has_test_env, + test_host: row.test_host, + test_port: row.test_port, + test_database_name: row.test_database_name, + test_username: row.test_username, + test_password: row.test_password, + test_ssl: row.test_ssl, + test_aql_base_url: row.test_aql_base_url, + test_aql_auth_value: row.test_aql_auth_value, + test_aql_headers: row.test_aql_headers, + }; + } + + private createPool(host: string, port: number, database: string, user: string, password: string, ssl: boolean): Pool { + const pool = new Pool({ + host, + port, + database, + user, + password, + ssl: ssl ? { rejectUnauthorized: false } : false, + max: 20, + idleTimeoutMillis: 60000, + connectionTimeoutMillis: 10000, + keepAlive: true, + keepAliveInitialDelayMillis: 10000, + }); + + pool.on('error', (err) => { + console.error(`Database pool error:`, err); + }); + + return pool; + } + addPool(dbConfig: DatabaseConfig) { if (this.pools.has(dbConfig.id)) { console.warn(`Pool with id ${dbConfig.id} already exists. Skipping.`); return; } - const pool = new Pool({ - host: dbConfig.host, - port: dbConfig.port, - database: dbConfig.database_name, - user: dbConfig.username, - password: dbConfig.password, - ssl: dbConfig.ssl ? { rejectUnauthorized: false } : false, - max: 20, // Увеличено количество соединений - idleTimeoutMillis: 60000, // 60 секунд - connectionTimeoutMillis: 10000, // 10 секунд (было 2 секунды) - keepAlive: true, // Поддерживать соединения активными - keepAliveInitialDelayMillis: 10000, // Начать keepAlive через 10 секунд - }); + const prodPool = this.createPool( + dbConfig.host, dbConfig.port, dbConfig.database_name, + dbConfig.username, dbConfig.password, dbConfig.ssl + ); - pool.on('error', (err) => { - console.error(`Database pool error for ${dbConfig.id}:`, err); - }); + const entry: PoolEntry = { prod: prodPool }; - this.pools.set(dbConfig.id, pool); - console.log(`✅ Pool created for database: ${dbConfig.name} (${dbConfig.id})`); + if (dbConfig.has_test_env && dbConfig.test_host) { + entry.test = this.createPool( + dbConfig.test_host, + dbConfig.test_port || dbConfig.port, + dbConfig.test_database_name || dbConfig.database_name, + dbConfig.test_username || dbConfig.username, + dbConfig.test_password || dbConfig.password, + dbConfig.test_ssl !== undefined ? dbConfig.test_ssl : dbConfig.ssl + ); + console.log(`Pool created for database: ${dbConfig.name} (${dbConfig.id}) [prod + test]`); + } else { + console.log(`Pool created for database: ${dbConfig.name} (${dbConfig.id}) [prod]`); + } + + this.pools.set(dbConfig.id, entry); } removePool(databaseId: string) { - const pool = this.pools.get(databaseId); - if (pool) { - pool.end(); + const entry = this.pools.get(databaseId); + if (entry) { + entry.prod.end(); + if (entry.test) entry.test.end(); this.pools.delete(databaseId); console.log(`Pool removed for database: ${databaseId}`); } } - getPool(databaseId: string): Pool | undefined { - return this.pools.get(databaseId); + getPool(databaseId: string, environment: Environment = 'prod'): Pool | undefined { + const entry = this.pools.get(databaseId); + if (!entry) return undefined; + if (environment === 'test' && entry.test) return entry.test; + return entry.prod; } async getDatabaseConfig(databaseId: string): Promise { @@ -86,26 +135,8 @@ class DatabasePoolManager { 'SELECT * FROM databases WHERE id = $1', [databaseId] ); - if (result.rows.length === 0) { - return null; - } - const row = result.rows[0]; - return { - id: row.id, - name: row.name, - type: row.type, - host: row.host, - port: row.port, - database_name: row.database_name, - username: row.username, - password: row.password, - ssl: row.ssl, - is_active: row.is_active, - aql_base_url: row.aql_base_url, - aql_auth_type: row.aql_auth_type, - aql_auth_value: row.aql_auth_value, - aql_headers: row.aql_headers, - }; + if (result.rows.length === 0) return null; + return this.rowToConfig(result.rows[0]); } catch (error) { console.error('Error fetching database config:', error); return null; @@ -115,7 +146,7 @@ class DatabasePoolManager { async getAllDatabaseConfigs(): Promise { try { const result = await mainPool.query( - 'SELECT id, name, type, host, port, database_name, is_active FROM databases WHERE is_active = true' + 'SELECT id, name, type, host, port, database_name, is_active, has_test_env FROM databases WHERE is_active = true' ); return result.rows; } catch (error) { @@ -124,53 +155,40 @@ class DatabasePoolManager { } } - async testConnection(databaseId: string): Promise { - const pool = this.getPool(databaseId); - if (!pool) { - return false; - } + async testConnection(databaseId: string, environment: Environment = 'prod'): Promise { + const pool = this.getPool(databaseId, environment); + if (!pool) return false; try { const client = await pool.connect(); client.release(); return true; } catch (error) { - console.error(`Connection test failed for ${databaseId}:`, error); + console.error(`Connection test failed for ${databaseId} (${environment}):`, error); return false; } } async reloadPool(databaseId: string) { - // Remove old pool this.removePool(databaseId); - // Load new config from DB const result = await mainPool.query( 'SELECT * FROM databases WHERE id = $1 AND is_active = true', [databaseId] ); if (result.rows.length > 0) { - const row = result.rows[0]; - const dbConfig: DatabaseConfig = { - id: row.id, - name: row.name, - type: row.type, - host: row.host, - port: row.port, - database_name: row.database_name, - username: row.username, - password: row.password, - ssl: row.ssl, - is_active: row.is_active, - }; - + const dbConfig = this.rowToConfig(result.rows[0]); this.addPool(dbConfig); } } async closeAll() { - const promises = Array.from(this.pools.values()).map((pool) => pool.end()); + const promises: Promise[] = []; + for (const entry of this.pools.values()) { + promises.push(entry.prod.end()); + if (entry.test) promises.push(entry.test.end()); + } await Promise.all(promises); this.pools.clear(); console.log('All database pools closed'); diff --git a/backend/src/services/IsolatedScriptExecutor.ts b/backend/src/services/IsolatedScriptExecutor.ts index dba9c53..67e0f2f 100644 --- a/backend/src/services/IsolatedScriptExecutor.ts +++ b/backend/src/services/IsolatedScriptExecutor.ts @@ -1,7 +1,7 @@ import * as vm from 'vm'; import { sqlExecutor } from './SqlExecutor'; import { aqlExecutor } from './AqlExecutor'; -import { ScriptQuery, EndpointParameter, LogEntry, QueryExecution, IsolatedExecutionResult, ScriptExecutionError } from '../types'; +import { ScriptQuery, EndpointParameter, LogEntry, QueryExecution, IsolatedExecutionResult, ScriptExecutionError, Environment } from '../types'; import { databasePoolManager } from './DatabasePoolManager'; interface IsolatedScriptContext { @@ -9,6 +9,7 @@ interface IsolatedScriptContext { scriptQueries: ScriptQuery[]; requestParams: Record; endpointParameters: EndpointParameter[]; + environment?: Environment; } export class IsolatedScriptExecutor { @@ -74,7 +75,7 @@ export class IsolatedScriptExecutor { body: query.aql_body || '', queryParams: query.aql_query_params || {}, parameters: allParams, - }); + }, context.environment); queries.push({ name: queryName, @@ -118,7 +119,7 @@ export class IsolatedScriptExecutor { paramValues.push(value !== undefined ? value : null); }); - const result = await sqlExecutor.executeQuery(dbId, processedQuery, paramValues); + const result = await sqlExecutor.executeQuery(dbId, processedQuery, paramValues, context.environment); queries.push({ name: queryName, diff --git a/backend/src/services/ScriptExecutor.ts b/backend/src/services/ScriptExecutor.ts index 88938cb..265da03 100644 --- a/backend/src/services/ScriptExecutor.ts +++ b/backend/src/services/ScriptExecutor.ts @@ -1,7 +1,7 @@ import { spawn } from 'child_process'; import { sqlExecutor } from './SqlExecutor'; import { aqlExecutor } from './AqlExecutor'; -import { ScriptQuery, EndpointParameter, LogEntry, QueryExecution, IsolatedExecutionResult, ScriptExecutionError } from '../types'; +import { ScriptQuery, EndpointParameter, LogEntry, QueryExecution, IsolatedExecutionResult, ScriptExecutionError, Environment } from '../types'; import { databasePoolManager } from './DatabasePoolManager'; import { isolatedScriptExecutor } from './IsolatedScriptExecutor'; @@ -10,6 +10,7 @@ interface ScriptContext { scriptQueries: ScriptQuery[]; requestParams: Record; endpointParameters: EndpointParameter[]; + environment?: Environment; } export class ScriptExecutor { @@ -135,7 +136,7 @@ print(json.dumps(result)) body: query.aql_body || '', queryParams: query.aql_query_params || {}, parameters: allParams, - }); + }, context.environment); queries.push({ name: request.query_name, @@ -195,7 +196,8 @@ print(json.dumps(result)) const result = await sqlExecutor.executeQuery( dbId, processedQuery, - paramValues + paramValues, + context.environment ); queries.push({ diff --git a/backend/src/services/SqlExecutor.ts b/backend/src/services/SqlExecutor.ts index dd599cb..63d0562 100644 --- a/backend/src/services/SqlExecutor.ts +++ b/backend/src/services/SqlExecutor.ts @@ -1,4 +1,4 @@ -import { QueryResult } from '../types'; +import { QueryResult, Environment } from '../types'; import { databasePoolManager } from './DatabasePoolManager'; export class SqlExecutor { @@ -32,9 +32,10 @@ export class SqlExecutor { async executeQuery( databaseId: string, sqlQuery: string, - params: any[] = [] + params: any[] = [], + environment: Environment = 'prod' ): Promise { - const pool = databasePoolManager.getPool(databaseId); + const pool = databasePoolManager.getPool(databaseId, environment); if (!pool) { throw new Error(`Database with id ${databaseId} not found or not configured`); diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index 5ee25ef..3b94af6 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -1,3 +1,5 @@ +export type Environment = 'prod' | 'test'; + export interface DatabaseConfig { id: string; name: string; @@ -14,6 +16,17 @@ export interface DatabaseConfig { aql_auth_type?: 'basic' | 'bearer' | 'custom'; aql_auth_value?: string; aql_headers?: Record; + // Test environment fields + has_test_env?: boolean; + test_host?: string; + test_port?: number; + test_database_name?: string; + test_username?: string; + test_password?: string; + test_ssl?: boolean; + test_aql_base_url?: string; + test_aql_auth_value?: string; + test_aql_headers?: Record; created_at?: Date; updated_at?: Date; } diff --git a/frontend/src/pages/EndpointEditor.tsx b/frontend/src/pages/EndpointEditor.tsx index a6faedc..ceeb929 100644 --- a/frontend/src/pages/EndpointEditor.tsx +++ b/frontend/src/pages/EndpointEditor.tsx @@ -93,6 +93,7 @@ export default function EndpointEditor() { const [testResult, setTestResult] = useState(null); const [activeResultTab, setActiveResultTab] = useState<'data' | 'logs' | 'queries'>('data'); const [curlApiKey, setCurlApiKey] = useState(''); + const [testEnvironment, setTestEnvironment] = useState<'test' | 'prod'>('test'); const selectedDatabase = databases?.find(db => db.id === formData.database_id); const isAqlDatabase = selectedDatabase?.type === 'aql'; @@ -189,6 +190,7 @@ export default function EndpointEditor() { aql_query_params: typeof formData.aql_query_params === 'string' ? {} : formData.aql_query_params || {}, parameters: paramValues, endpoint_parameters: formData.parameters, + environment: testEnvironment, } as any); } else if (formData.execution_type === 'script') { const scriptQueries = formData.script_queries || []; @@ -201,6 +203,7 @@ export default function EndpointEditor() { script_queries: scriptQueries, parameters: paramValues, endpoint_parameters: formData.parameters, + environment: testEnvironment, }); } else { return endpointsApi.test({ @@ -209,6 +212,7 @@ export default function EndpointEditor() { sql_query: formData.sql_query || '', parameters: paramValues, endpoint_parameters: formData.parameters, + environment: testEnvironment, }); } }, @@ -901,8 +905,38 @@ export default function EndpointEditor() { )} - {/* Test button */} -
+ {/* Environment toggle + Test button */} +
+
+ Среда: +
+ + +
+ {selectedDatabase && !selectedDatabase.has_test_env && testEnvironment === 'test' && ( + Test env не настроена — fallback на prod + )} +
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index d3aa9ab..13ac5da 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -284,13 +284,13 @@ function DatabasesSubTab() { onError: () => toast.error('Не удалось удалить базу данных'), }); - const testConnection = async (databaseId: string) => { + const testConnection = async (databaseId: string, env?: 'prod' | 'test') => { try { - const { data } = await dbManagementApi.test(databaseId); + const { data } = await dbManagementApi.test(databaseId, env); if (data.success) { - toast.success('Подключение успешно!'); + toast.success(env === 'test' ? 'Test Env: подключение успешно!' : 'Подключение успешно!'); } else { - toast.error('Ошибка подключения'); + toast.error(env === 'test' ? 'Test Env: ошибка подключения' : 'Ошибка подключения'); } } catch (error) { toast.error('Ошибка тестирования подключения'); @@ -350,6 +350,9 @@ function DatabasesSubTab() { {db.is_active && ( Активна )} + {db.has_test_env && ( + Test Env + )}
{db.type === 'aql' ? ( @@ -375,6 +378,14 @@ function DatabasesSubTab() { > Тест + {db.has_test_env && ( + + )} + + {showTestEnv && ( +
+

+ Альтернативные креды для тестирования endpoint-ов. При тестировании запросы пойдут на эту БД вместо продовой. +

+ + {formData.type === 'aql' ? ( + <> +
+ + setFormData({ ...formData, test_aql_base_url: e.target.value })} + className="input w-full" + placeholder="http://test-api.example.com/api/rest/v1" + /> +
+
+ + setFormData({ ...formData, test_aql_auth_value: e.target.value })} + className="input w-full" + placeholder={database ? '(не менять)' : 'Значение'} + /> +
+ + ) : ( + <> +
+
+ + setFormData({ ...formData, test_host: e.target.value })} + className="input w-full" + placeholder="test-db-host" + /> +
+
+ + setFormData({ ...formData, test_port: parseInt(e.target.value) })} + className="input w-full" + /> +
+
+
+ + setFormData({ ...formData, test_database_name: e.target.value })} + className="input w-full" + placeholder="test_database" + /> +
+
+
+ + setFormData({ ...formData, test_username: e.target.value })} + className="input w-full" + placeholder="test_user" + /> +
+
+ + setFormData({ ...formData, test_password: e.target.value })} + className="input w-full" + placeholder={database ? '(не менять)' : 'Пароль'} + /> +
+
+ + + )} +
+ )} +
+