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..2949cb4 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(); @@ -29,9 +32,21 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response) }); } - const endpoint = endpointResult.rows[0]; + 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; @@ -171,7 +186,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 +214,7 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response) scriptQueries, requestParams, endpointParameters: parameters, + environment, }); result = { @@ -231,7 +247,8 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response) result = await sqlExecutor.executeQuery( endpoint.database_id, processedQuery, - queryParams + queryParams, + environment ); } @@ -264,6 +281,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 +313,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 +334,7 @@ async function logRequest(data: { error_message: string | null; ip_address: string; user_agent: string; + environment?: string; }) { try { await mainPool.query( @@ -322,8 +342,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 +357,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..d2a3dea 100644 --- a/backend/src/controllers/endpointController.ts +++ b/backend/src/controllers/endpointController.ts @@ -2,8 +2,9 @@ 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'; +import { versionService } from '../services/VersionService'; export const getEndpoints = async (req: AuthRequest, res: Response) => { try { @@ -242,6 +243,15 @@ export const updateEndpoint = async (req: AuthRequest, res: Response) => { return res.status(404).json({ error: 'Endpoint not found' }); } + // Auto-create published version + try { + await versionService.createVersionFromEndpoint( + id, req.user!.id, req.body.change_message || 'Updated', 'published' + ); + } catch (vErr) { + console.error('Auto-version creation failed:', vErr); + } + res.json(result.rows[0]); } catch (error: any) { console.error('Update endpoint error:', error); @@ -287,8 +297,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 +326,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 +358,7 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => { scriptQueries: script_queries || [], requestParams, endpointParameters: endpoint_parameters || [], + environment, }); res.json({ @@ -377,7 +390,7 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => { body: aql_body, queryParams: aql_query_params, parameters: requestParams, - }); + }, environment); res.json({ success: true, @@ -744,3 +757,73 @@ export const importEndpoint = async (req: AuthRequest, res: Response) => { res.status(500).json({ error: 'Internal server error' }); } }; + +// Version management handlers + +export const getVersionHistory = async (req: AuthRequest, res: Response) => { + try { + const { id } = req.params; + const versions = await versionService.getVersionHistory(id); + res.json(versions); + } catch (error) { + console.error('Get version history error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; + +export const getVersion = async (req: AuthRequest, res: Response) => { + try { + const { versionId } = req.params; + const version = await versionService.getVersion(versionId); + if (!version) return res.status(404).json({ error: 'Version not found' }); + res.json(version); + } catch (error) { + console.error('Get version error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; + +export const publishVersion = async (req: AuthRequest, res: Response) => { + try { + const { versionId } = req.params; + await versionService.publishVersion(versionId, req.user!.id); + res.json({ message: 'Version published' }); + } catch (error: any) { + console.error('Publish version error:', error); + res.status(500).json({ error: error.message || 'Internal server error' }); + } +}; + +export const rollbackVersion = async (req: AuthRequest, res: Response) => { + try { + const { id, versionId } = req.params; + const newVersion = await versionService.rollbackToVersion(id, versionId, req.user!.id); + res.json(newVersion); + } catch (error: any) { + console.error('Rollback version error:', error); + res.status(500).json({ error: error.message || 'Internal server error' }); + } +}; + +export const saveDraftVersion = async (req: AuthRequest, res: Response) => { + try { + const { id } = req.params; + const draft = await versionService.saveDraft(id, req.body, req.user!.id, req.body.change_message); + res.json(draft); + } catch (error: any) { + console.error('Save draft error:', error); + res.status(500).json({ error: error.message || 'Internal server error' }); + } +}; + +export const getDraftVersion = async (req: AuthRequest, res: Response) => { + try { + const { id } = req.params; + const draft = await versionService.getDraft(id); + if (!draft) return res.status(404).json({ error: 'No draft found' }); + res.json(draft); + } catch (error) { + console.error('Get draft error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; 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/migrations/011_add_endpoint_versions.sql b/backend/src/migrations/011_add_endpoint_versions.sql new file mode 100644 index 0000000..66d33c7 --- /dev/null +++ b/backend/src/migrations/011_add_endpoint_versions.sql @@ -0,0 +1,78 @@ +-- Endpoint versioning +CREATE TABLE IF NOT EXISTS endpoint_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + endpoint_id UUID NOT NULL REFERENCES endpoints(id) ON DELETE CASCADE, + version_number INTEGER NOT NULL, + + -- Full snapshot of endpoint content + name VARCHAR(255) NOT NULL, + description TEXT, + method VARCHAR(10) NOT NULL, + path VARCHAR(500) NOT NULL, + database_id UUID REFERENCES databases(id) ON DELETE RESTRICT, + sql_query TEXT, + parameters JSONB DEFAULT '[]'::jsonb, + execution_type VARCHAR(50) DEFAULT 'sql', + script_language VARCHAR(50), + script_code TEXT, + script_queries JSONB DEFAULT '[]'::jsonb, + aql_method VARCHAR(10), + aql_endpoint TEXT, + aql_body TEXT, + aql_query_params JSONB DEFAULT '{}'::jsonb, + is_public BOOLEAN DEFAULT false, + enable_logging BOOLEAN DEFAULT false, + detailed_response BOOLEAN DEFAULT false, + response_schema JSONB, + + -- Version metadata + status VARCHAR(20) NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft', 'published', 'archived')), + change_message TEXT, + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + content_hash VARCHAR(64), + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(endpoint_id, version_number) +); + +CREATE INDEX IF NOT EXISTS idx_endpoint_versions_endpoint_id + ON endpoint_versions(endpoint_id); +CREATE INDEX IF NOT EXISTS idx_endpoint_versions_status + ON endpoint_versions(endpoint_id, status); + +-- Version tracking on endpoints table +ALTER TABLE endpoints ADD COLUMN IF NOT EXISTS published_version_id UUID + REFERENCES endpoint_versions(id) ON DELETE SET NULL; +ALTER TABLE endpoints ADD COLUMN IF NOT EXISTS draft_version_id UUID + REFERENCES endpoint_versions(id) ON DELETE SET NULL; +ALTER TABLE endpoints ADD COLUMN IF NOT EXISTS current_version INTEGER DEFAULT 0; + +-- Backfill: create version 1 (published) for all existing endpoints +INSERT INTO endpoint_versions ( + endpoint_id, version_number, name, description, method, path, + database_id, sql_query, parameters, execution_type, script_language, + script_code, script_queries, aql_method, aql_endpoint, aql_body, + aql_query_params, is_public, enable_logging, detailed_response, + response_schema, status, change_message, user_id, created_at +) +SELECT + id, 1, name, description, method, path, + database_id, sql_query, parameters, execution_type, script_language, + script_code, script_queries, aql_method, aql_endpoint, aql_body, + aql_query_params, is_public, enable_logging, detailed_response, + response_schema, 'published', 'Initial version (migrated)', user_id, created_at +FROM endpoints +WHERE NOT EXISTS ( + SELECT 1 FROM endpoint_versions WHERE endpoint_versions.endpoint_id = endpoints.id +); + +-- Set current_version and published_version_id +UPDATE endpoints SET current_version = 1 +WHERE current_version IS NULL OR current_version = 0; + +UPDATE endpoints e SET published_version_id = v.id +FROM endpoint_versions v +WHERE v.endpoint_id = e.id AND v.version_number = 1 AND v.status = 'published' +AND e.published_version_id IS NULL; diff --git a/backend/src/routes/endpoints.ts b/backend/src/routes/endpoints.ts index a6ac76c..dc643ad 100644 --- a/backend/src/routes/endpoints.ts +++ b/backend/src/routes/endpoints.ts @@ -10,6 +10,12 @@ import { exportEndpoint, importPreview, importEndpoint, + getVersionHistory, + getVersion, + publishVersion, + rollbackVersion, + saveDraftVersion, + getDraftVersion, } from '../controllers/endpointController'; const router = express.Router(); @@ -117,6 +123,14 @@ router.get('/:id', getEndpoint); */ router.get('/:id/export', exportEndpoint); +// Version management +router.get('/:id/versions', getVersionHistory); +router.get('/:id/versions/:versionId', getVersion); +router.post('/:id/versions/:versionId/publish', publishVersion); +router.post('/:id/versions/:versionId/rollback', rollbackVersion); +router.post('/:id/draft', saveDraftVersion); +router.get('/:id/draft', getDraftVersion); + /** * @swagger * /api/endpoints/{id}: 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/services/VersionService.ts b/backend/src/services/VersionService.ts new file mode 100644 index 0000000..c6a09d7 --- /dev/null +++ b/backend/src/services/VersionService.ts @@ -0,0 +1,306 @@ +import { mainPool } from '../config/database'; +import { EndpointVersion, VersionStatus } from '../types'; +import * as crypto from 'crypto'; + +const SNAPSHOT_FIELDS = [ + 'name', 'description', 'method', 'path', 'database_id', 'sql_query', + 'parameters', 'execution_type', 'script_language', 'script_code', + 'script_queries', 'aql_method', 'aql_endpoint', 'aql_body', + 'aql_query_params', 'is_public', 'enable_logging', 'detailed_response', + 'response_schema' +] as const; + +function computeHash(data: Record): string { + const relevant: Record = {}; + for (const key of SNAPSHOT_FIELDS) { + relevant[key] = data[key] ?? null; + } + return crypto.createHash('sha256').update(JSON.stringify(relevant)).digest('hex'); +} + +class VersionService { + async createVersionFromEndpoint( + endpointId: string, + userId: string, + changeMessage?: string, + status: VersionStatus = 'published' + ): Promise { + const epResult = await mainPool.query('SELECT * FROM endpoints WHERE id = $1', [endpointId]); + if (epResult.rows.length === 0) throw new Error('Endpoint not found'); + const ep = epResult.rows[0]; + + const nextVersion = (ep.current_version || 0) + 1; + const hash = computeHash(ep); + + const result = await mainPool.query( + `INSERT INTO endpoint_versions ( + endpoint_id, version_number, name, description, method, path, + database_id, sql_query, parameters, execution_type, script_language, + script_code, script_queries, aql_method, aql_endpoint, aql_body, + aql_query_params, is_public, enable_logging, detailed_response, + response_schema, status, change_message, user_id, content_hash + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25) + RETURNING *`, + [ + endpointId, nextVersion, ep.name, ep.description, ep.method, ep.path, + ep.database_id, ep.sql_query, JSON.stringify(ep.parameters || []), + ep.execution_type, ep.script_language, ep.script_code, + JSON.stringify(ep.script_queries || []), ep.aql_method, ep.aql_endpoint, + ep.aql_body, JSON.stringify(ep.aql_query_params || {}), + ep.is_public, ep.enable_logging, ep.detailed_response, + ep.response_schema ? JSON.stringify(ep.response_schema) : null, + status, changeMessage || null, userId, hash + ] + ); + + const version = result.rows[0]; + + const updateFields: string[] = ['current_version = $1']; + const updateParams: any[] = [nextVersion]; + let idx = 2; + + if (status === 'published') { + // Archive previous published + await mainPool.query( + `UPDATE endpoint_versions SET status = 'archived' + WHERE endpoint_id = $1 AND status = 'published' AND id != $2`, + [endpointId, version.id] + ); + updateFields.push(`published_version_id = $${idx}`); + updateParams.push(version.id); + idx++; + } else if (status === 'draft') { + updateFields.push(`draft_version_id = $${idx}`); + updateParams.push(version.id); + idx++; + } + + updateParams.push(endpointId); + await mainPool.query( + `UPDATE endpoints SET ${updateFields.join(', ')} WHERE id = $${idx}`, + updateParams + ); + + return version; + } + + async saveDraft( + endpointId: string, + data: Record, + userId: string, + changeMessage?: string + ): Promise { + const epResult = await mainPool.query( + 'SELECT current_version, draft_version_id FROM endpoints WHERE id = $1', + [endpointId] + ); + if (epResult.rows.length === 0) throw new Error('Endpoint not found'); + + const ep = epResult.rows[0]; + const nextVersion = (ep.current_version || 0) + 1; + const hash = computeHash(data); + + // If there's an existing draft, delete it + if (ep.draft_version_id) { + await mainPool.query( + 'DELETE FROM endpoint_versions WHERE id = $1 AND status = $2', + [ep.draft_version_id, 'draft'] + ); + } + + const result = await mainPool.query( + `INSERT INTO endpoint_versions ( + endpoint_id, version_number, name, description, method, path, + database_id, sql_query, parameters, execution_type, script_language, + script_code, script_queries, aql_method, aql_endpoint, aql_body, + aql_query_params, is_public, enable_logging, detailed_response, + response_schema, status, change_message, user_id, content_hash + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25) + RETURNING *`, + [ + endpointId, nextVersion, + data.name, data.description || null, data.method, data.path, + data.database_id || null, data.sql_query || null, + JSON.stringify(data.parameters || []), + data.execution_type || 'sql', data.script_language || null, + data.script_code || null, JSON.stringify(data.script_queries || []), + data.aql_method || null, data.aql_endpoint || null, + data.aql_body || null, JSON.stringify(data.aql_query_params || {}), + data.is_public || false, data.enable_logging || false, + data.detailed_response || false, + data.response_schema ? JSON.stringify(data.response_schema) : null, + 'draft', changeMessage || null, userId, hash + ] + ); + + const version = result.rows[0]; + + await mainPool.query( + 'UPDATE endpoints SET current_version = $1, draft_version_id = $2 WHERE id = $3', + [nextVersion, version.id, endpointId] + ); + + return version; + } + + async publishVersion(versionId: string, userId: string): Promise { + const vResult = await mainPool.query( + 'SELECT * FROM endpoint_versions WHERE id = $1', + [versionId] + ); + if (vResult.rows.length === 0) throw new Error('Version not found'); + const version = vResult.rows[0]; + + // Archive previous published versions + await mainPool.query( + `UPDATE endpoint_versions SET status = 'archived' + WHERE endpoint_id = $1 AND status = 'published'`, + [version.endpoint_id] + ); + + // Mark this version as published + await mainPool.query( + `UPDATE endpoint_versions SET status = 'published' WHERE id = $1`, + [versionId] + ); + + // Copy version content to endpoints table + await mainPool.query( + `UPDATE endpoints SET + name = $1, description = $2, method = $3, path = $4, + database_id = $5, sql_query = $6, parameters = $7, + execution_type = $8, script_language = $9, script_code = $10, + script_queries = $11, aql_method = $12, aql_endpoint = $13, + aql_body = $14, aql_query_params = $15, is_public = $16, + enable_logging = $17, detailed_response = $18, response_schema = $19, + published_version_id = $20, draft_version_id = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE id = $21`, + [ + version.name, version.description, version.method, version.path, + version.database_id, version.sql_query, JSON.stringify(version.parameters || []), + version.execution_type, version.script_language, version.script_code, + JSON.stringify(version.script_queries || []), version.aql_method, + version.aql_endpoint, version.aql_body, + JSON.stringify(version.aql_query_params || {}), + version.is_public, version.enable_logging, version.detailed_response, + version.response_schema ? JSON.stringify(version.response_schema) : null, + versionId, version.endpoint_id + ] + ); + } + + async rollbackToVersion( + endpointId: string, + versionId: string, + userId: string + ): Promise { + const vResult = await mainPool.query( + 'SELECT * FROM endpoint_versions WHERE id = $1 AND endpoint_id = $2', + [versionId, endpointId] + ); + if (vResult.rows.length === 0) throw new Error('Version not found'); + const oldVersion = vResult.rows[0]; + + const epResult = await mainPool.query( + 'SELECT current_version FROM endpoints WHERE id = $1', + [endpointId] + ); + const nextVersionNum = (epResult.rows[0]?.current_version || 0) + 1; + + // Archive current published + await mainPool.query( + `UPDATE endpoint_versions SET status = 'archived' + WHERE endpoint_id = $1 AND status = 'published'`, + [endpointId] + ); + + // Create new version from old snapshot + const hash = computeHash(oldVersion); + const result = await mainPool.query( + `INSERT INTO endpoint_versions ( + endpoint_id, version_number, name, description, method, path, + database_id, sql_query, parameters, execution_type, script_language, + script_code, script_queries, aql_method, aql_endpoint, aql_body, + aql_query_params, is_public, enable_logging, detailed_response, + response_schema, status, change_message, user_id, content_hash + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25) + RETURNING *`, + [ + endpointId, nextVersionNum, oldVersion.name, oldVersion.description, + oldVersion.method, oldVersion.path, oldVersion.database_id, + oldVersion.sql_query, JSON.stringify(oldVersion.parameters || []), + oldVersion.execution_type, oldVersion.script_language, + oldVersion.script_code, JSON.stringify(oldVersion.script_queries || []), + oldVersion.aql_method, oldVersion.aql_endpoint, oldVersion.aql_body, + JSON.stringify(oldVersion.aql_query_params || {}), + oldVersion.is_public, oldVersion.enable_logging, oldVersion.detailed_response, + oldVersion.response_schema ? JSON.stringify(oldVersion.response_schema) : null, + 'published', `Rollback to v${oldVersion.version_number}`, + userId, hash + ] + ); + + const newVersion = result.rows[0]; + + // Update endpoints table with rolled-back content + await mainPool.query( + `UPDATE endpoints SET + name = $1, description = $2, method = $3, path = $4, + database_id = $5, sql_query = $6, parameters = $7, + execution_type = $8, script_language = $9, script_code = $10, + script_queries = $11, aql_method = $12, aql_endpoint = $13, + aql_body = $14, aql_query_params = $15, is_public = $16, + enable_logging = $17, detailed_response = $18, response_schema = $19, + published_version_id = $20, draft_version_id = NULL, + current_version = $21, updated_at = CURRENT_TIMESTAMP + WHERE id = $22`, + [ + oldVersion.name, oldVersion.description, oldVersion.method, oldVersion.path, + oldVersion.database_id, oldVersion.sql_query, + JSON.stringify(oldVersion.parameters || []), + oldVersion.execution_type, oldVersion.script_language, + oldVersion.script_code, JSON.stringify(oldVersion.script_queries || []), + oldVersion.aql_method, oldVersion.aql_endpoint, oldVersion.aql_body, + JSON.stringify(oldVersion.aql_query_params || {}), + oldVersion.is_public, oldVersion.enable_logging, oldVersion.detailed_response, + oldVersion.response_schema ? JSON.stringify(oldVersion.response_schema) : null, + newVersion.id, nextVersionNum, endpointId + ] + ); + + return newVersion; + } + + async getVersionHistory(endpointId: string): Promise { + const result = await mainPool.query( + `SELECT ev.*, u.username as user_name + FROM endpoint_versions ev + LEFT JOIN users u ON ev.user_id = u.id + WHERE ev.endpoint_id = $1 + ORDER BY ev.version_number DESC`, + [endpointId] + ); + return result.rows; + } + + async getVersion(versionId: string): Promise { + const result = await mainPool.query( + 'SELECT * FROM endpoint_versions WHERE id = $1', + [versionId] + ); + return result.rows[0] || null; + } + + async getDraft(endpointId: string): Promise { + const result = await mainPool.query( + `SELECT * FROM endpoint_versions + WHERE endpoint_id = $1 AND status = 'draft' + ORDER BY version_number DESC LIMIT 1`, + [endpointId] + ); + return result.rows[0] || null; + } +} + +export const versionService = new VersionService(); diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index 5ee25ef..4c10c5a 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; } @@ -137,6 +150,38 @@ export class ScriptExecutionError extends Error { } } +export type VersionStatus = 'draft' | 'published' | 'archived'; + +export interface EndpointVersion { + id: string; + endpoint_id: string; + version_number: number; + name: string; + description: string; + method: string; + path: string; + database_id: string; + sql_query: string; + parameters: EndpointParameter[]; + execution_type: string; + script_language?: string; + script_code?: string; + script_queries?: ScriptQuery[]; + aql_method?: string; + aql_endpoint?: string; + aql_body?: string; + aql_query_params?: Record; + is_public: boolean; + enable_logging: boolean; + detailed_response: boolean; + response_schema?: object | null; + status: VersionStatus; + change_message: string | null; + user_id: string; + content_hash: string | null; + created_at: Date; +} + export interface SwaggerEndpoint { tags: string[]; summary: string; diff --git a/frontend/src/pages/EndpointEditor.tsx b/frontend/src/pages/EndpointEditor.tsx index a6faedc..6e809a0 100644 --- a/frontend/src/pages/EndpointEditor.tsx +++ b/frontend/src/pages/EndpointEditor.tsx @@ -1,9 +1,9 @@ import { useState, useEffect, useMemo } from 'react'; import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { endpointsApi, foldersApi, databasesApi } from '@/services/api'; -import { EndpointParameter, QueryTestResult, LogEntry, QueryExecution } from '@/types'; -import { Plus, Trash2, Play, Edit2, ChevronDown, ChevronUp, ArrowLeft, CheckCircle, XCircle, Clock, Copy, X, Terminal } from 'lucide-react'; +import { endpointsApi, foldersApi, databasesApi, versionsApi } from '@/services/api'; +import { EndpointParameter, QueryTestResult, LogEntry, QueryExecution, EndpointVersion } from '@/types'; +import { Plus, Trash2, Play, Edit2, ChevronDown, ChevronUp, ArrowLeft, CheckCircle, XCircle, Clock, Copy, X, Terminal, History, RotateCcw, Upload } from 'lucide-react'; import toast from 'react-hot-toast'; import SqlEditor from '@/components/SqlEditor'; import CodeEditor from '@/components/CodeEditor'; @@ -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, }); } }, @@ -230,6 +234,43 @@ export default function EndpointEditor() { }, }); + // Version history + const { data: versions, refetch: refetchVersions } = useQuery({ + queryKey: ['versions', id], + queryFn: () => versionsApi.getHistory(id!).then(res => res.data), + enabled: isEditing, + }); + const [showVersionHistory, setShowVersionHistory] = useState(false); + + const draftMutation = useMutation({ + mutationFn: () => versionsApi.saveDraft(id!, { ...formData, change_message: undefined }), + onSuccess: () => { + toast.success('Draft saved'); + refetchVersions(); + }, + onError: () => toast.error('Failed to save draft'), + }); + + const publishVersionMutation = useMutation({ + mutationFn: (versionId: string) => versionsApi.publish(id!, versionId), + onSuccess: () => { + toast.success('Version published'); + queryClient.invalidateQueries({ queryKey: ['endpoint', id] }); + refetchVersions(); + }, + onError: () => toast.error('Failed to publish version'), + }); + + const rollbackMutation = useMutation({ + mutationFn: (versionId: string) => versionsApi.rollback(id!, versionId), + onSuccess: () => { + toast.success('Rolled back'); + queryClient.invalidateQueries({ queryKey: ['endpoint', id] }); + refetchVersions(); + }, + onError: () => toast.error('Failed to rollback'), + }); + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); let parsedSchema = null; @@ -841,13 +882,23 @@ export default function EndpointEditor() { + {isEditing && ( + + )} @@ -901,8 +952,38 @@ export default function EndpointEditor() { )} - {/* Test button */} -
+ {/* Environment toggle + Test button */} +
+
+ Среда: +
+ + +
+ {selectedDatabase && !selectedDatabase.has_test_env && testEnvironment === 'test' && ( + Test env не настроена — fallback на prod + )} +
@@ -952,6 +1037,68 @@ export default function EndpointEditor() {
+ {/* Version History */} + {isEditing && ( +
+ + + {showVersionHistory && versions && ( +
+ {versions.map((v: EndpointVersion) => ( +
+ v{v.version_number} + + {v.status} + + + {v.change_message || '-'} + + + {new Date(v.created_at).toLocaleDateString('ru-RU')} + + {v.status === 'draft' && ( + + )} + {v.status !== 'draft' && ( + + )} +
+ ))} + {versions.length === 0 && ( +

No versions yet

+ )} +
+ )} +
+ )} + {/* Test results */} {testResult && (
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 ? '(не менять)' : 'Пароль'} + /> +
+
+ + + )} +
+ )} +
+