Add test/prod environments and endpoint versioning
Phase 1: Test/Prod Database Configurations - Migration 010: test_* columns on databases table, environment column on request_logs - DatabasePoolManager: dual-pool strategy (prod + test), getPool(id, env) with fallback - SqlExecutor, ScriptExecutor, IsolatedScriptExecutor, AqlExecutor: environment param threaded through all execution paths - dynamicApiController: X-Environment header detection, environment in logging - databaseManagementController: CRUD for test credentials, testConnection with ?env=test - Frontend: test env form in DatabaseModal, env toggle in EndpointEditor test panel Phase 2: Endpoint Versioning - Migration 011: endpoint_versions table with full snapshots, backfill v1 for existing endpoints - VersionService: createVersion, saveDraft, publishVersion, rollbackToVersion, getHistory - endpointController: auto-versioning on update, 6 new version management handlers - dynamicApiController: draft serving when environment=test and draft exists - Frontend: Save Draft button, version history panel with publish/rollback actions Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
72
CLAUDE.md
Normal file
72
CLAUDE.md
Normal file
@@ -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:<id>`), 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
|
||||||
|
```
|
||||||
@@ -14,6 +14,7 @@ export const getDatabases = async (req: Request, res: Response) => {
|
|||||||
host: db.host,
|
host: db.host,
|
||||||
port: db.port,
|
port: db.port,
|
||||||
database: db.database_name,
|
database: db.database_name,
|
||||||
|
has_test_env: db.has_test_env || false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
res.json(sanitized);
|
res.json(sanitized);
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ import { AuthRequest } from '../middleware/auth';
|
|||||||
import { mainPool } from '../config/database';
|
import { mainPool } from '../config/database';
|
||||||
import { databasePoolManager } from '../services/DatabasePoolManager';
|
import { databasePoolManager } from '../services/DatabasePoolManager';
|
||||||
import { generateSchemaForDatabase } from './schemaController';
|
import { generateSchemaForDatabase } from './schemaController';
|
||||||
|
import { Environment } from '../types';
|
||||||
|
|
||||||
// Только админы могут управлять базами данных
|
|
||||||
export const getDatabases = async (req: AuthRequest, res: Response) => {
|
export const getDatabases = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const result = await mainPool.query(
|
const result = await mainPool.query(
|
||||||
`SELECT id, name, type, host, port, database_name, username, ssl, is_active,
|
`SELECT id, name, type, host, port, database_name, username, 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_ssl,
|
||||||
|
test_aql_base_url, test_aql_headers,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM databases ORDER BY name`
|
FROM databases ORDER BY name`
|
||||||
);
|
);
|
||||||
@@ -28,6 +30,8 @@ export const getDatabase = async (req: AuthRequest, res: Response) => {
|
|||||||
const result = await mainPool.query(
|
const result = await mainPool.query(
|
||||||
`SELECT id, name, type, host, port, database_name, username, ssl, is_active,
|
`SELECT id, name, type, host, port, database_name, username, 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_ssl,
|
||||||
|
test_aql_base_url, test_aql_headers,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM databases WHERE id = $1`,
|
FROM databases WHERE id = $1`,
|
||||||
[id]
|
[id]
|
||||||
@@ -48,18 +52,18 @@ export const createDatabase = async (req: AuthRequest, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
name, type, host, port, database_name, username, password, ssl,
|
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;
|
} = req.body;
|
||||||
|
|
||||||
const dbType = type || 'postgresql';
|
const dbType = type || 'postgresql';
|
||||||
|
|
||||||
// Валидация для обычных БД
|
|
||||||
if (dbType !== 'aql') {
|
if (dbType !== 'aql') {
|
||||||
if (!name || !host || !port || !database_name || !username || !password) {
|
if (!name || !host || !port || !database_name || !username || !password) {
|
||||||
return res.status(400).json({ error: 'Не заполнены обязательные поля' });
|
return res.status(400).json({ error: 'Не заполнены обязательные поля' });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Валидация для AQL
|
|
||||||
if (!name || !aql_base_url) {
|
if (!name || !aql_base_url) {
|
||||||
return res.status(400).json({ error: 'Не заполнены обязательные поля для AQL базы' });
|
return res.status(400).json({ error: 'Не заполнены обязательные поля для AQL базы' });
|
||||||
}
|
}
|
||||||
@@ -68,9 +72,11 @@ export const createDatabase = async (req: AuthRequest, res: Response) => {
|
|||||||
const result = await mainPool.query(
|
const result = await mainPool.query(
|
||||||
`INSERT INTO databases (
|
`INSERT INTO databases (
|
||||||
name, type, host, port, database_name, username, password, ssl, is_active,
|
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 *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
name,
|
name,
|
||||||
@@ -84,17 +90,25 @@ export const createDatabase = async (req: AuthRequest, res: Response) => {
|
|||||||
aql_base_url || null,
|
aql_base_url || null,
|
||||||
aql_auth_type || null,
|
aql_auth_type || null,
|
||||||
aql_auth_value || 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];
|
const newDb = result.rows[0];
|
||||||
|
|
||||||
// Добавить пул подключений (только для не-AQL баз)
|
|
||||||
if (dbType !== 'aql') {
|
if (dbType !== 'aql') {
|
||||||
await databasePoolManager.reloadPool(newDb.id);
|
await databasePoolManager.reloadPool(newDb.id);
|
||||||
|
|
||||||
// Generate schema in background for PostgreSQL databases
|
|
||||||
if (dbType === 'postgresql') {
|
if (dbType === 'postgresql') {
|
||||||
generateSchemaForDatabase(newDb.id).catch(err => {
|
generateSchemaForDatabase(newDb.id).catch(err => {
|
||||||
console.error('Background schema generation failed:', err.message);
|
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.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);
|
res.status(201).json(newDb);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -121,62 +136,67 @@ export const updateDatabase = async (req: AuthRequest, res: Response) => {
|
|||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const {
|
const {
|
||||||
name, type, host, port, database_name, username, password, ssl, is_active,
|
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;
|
} = req.body;
|
||||||
|
|
||||||
// Если пароль/auth не передан, не обновляем его
|
// Build SET clauses and params dynamically to handle optional password fields
|
||||||
let query;
|
const setClauses: string[] = [];
|
||||||
let params;
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
if (password || aql_auth_value) {
|
const addField = (clause: string, value: any) => {
|
||||||
query = `
|
setClauses.push(`${clause} = $${idx}`);
|
||||||
UPDATE databases
|
params.push(value);
|
||||||
SET name = COALESCE($1, name),
|
idx++;
|
||||||
type = COALESCE($2, type),
|
};
|
||||||
host = COALESCE($3, host),
|
|
||||||
port = COALESCE($4, port),
|
const addCoalesce = (field: string, value: any) => {
|
||||||
database_name = COALESCE($5, database_name),
|
setClauses.push(`${field} = COALESCE($${idx}, ${field})`);
|
||||||
username = COALESCE($6, username),
|
params.push(value);
|
||||||
password = COALESCE($7, password),
|
idx++;
|
||||||
ssl = COALESCE($8, ssl),
|
};
|
||||||
is_active = COALESCE($9, is_active),
|
|
||||||
aql_base_url = COALESCE($10, aql_base_url),
|
addCoalesce('name', name);
|
||||||
aql_auth_type = COALESCE($11, aql_auth_type),
|
addCoalesce('type', type);
|
||||||
aql_auth_value = COALESCE($12, aql_auth_value),
|
addCoalesce('host', host);
|
||||||
aql_headers = COALESCE($13, aql_headers),
|
addCoalesce('port', port);
|
||||||
updated_at = CURRENT_TIMESTAMP
|
addCoalesce('database_name', database_name);
|
||||||
WHERE id = $14
|
addCoalesce('username', username);
|
||||||
RETURNING id, name, type, host, port, database_name, username, ssl, is_active,
|
if (password) { addField('password', password); }
|
||||||
aql_base_url, aql_auth_type, aql_headers, created_at, updated_at
|
addCoalesce('ssl', ssl);
|
||||||
`;
|
addCoalesce('is_active', is_active);
|
||||||
params = [
|
addCoalesce('aql_base_url', aql_base_url);
|
||||||
name, type, host, port, database_name, username, password || aql_auth_value, ssl, is_active,
|
addCoalesce('aql_auth_type', aql_auth_type);
|
||||||
aql_base_url, aql_auth_type, aql_auth_value, aql_headers ? JSON.stringify(aql_headers) : null, id
|
if (aql_auth_value) { addField('aql_auth_value', aql_auth_value); }
|
||||||
];
|
addCoalesce('aql_headers', aql_headers ? JSON.stringify(aql_headers) : null);
|
||||||
} else {
|
addCoalesce('has_test_env', has_test_env);
|
||||||
query = `
|
// Test fields: use direct SET (nullable — user may want to clear them)
|
||||||
UPDATE databases
|
addField('test_host', test_host || null);
|
||||||
SET name = COALESCE($1, name),
|
addField('test_port', test_port || null);
|
||||||
type = COALESCE($2, type),
|
addField('test_database_name', test_database_name || null);
|
||||||
host = COALESCE($3, host),
|
addField('test_username', test_username || null);
|
||||||
port = COALESCE($4, port),
|
if (test_password) { addField('test_password', test_password); }
|
||||||
database_name = COALESCE($5, database_name),
|
addField('test_ssl', test_ssl !== undefined ? test_ssl : null);
|
||||||
username = COALESCE($6, username),
|
addField('test_aql_base_url', test_aql_base_url || null);
|
||||||
ssl = COALESCE($7, ssl),
|
if (test_aql_auth_value) { addField('test_aql_auth_value', test_aql_auth_value); }
|
||||||
is_active = COALESCE($8, is_active),
|
addField('test_aql_headers', test_aql_headers ? JSON.stringify(test_aql_headers) : null);
|
||||||
aql_base_url = COALESCE($9, aql_base_url),
|
|
||||||
aql_auth_type = COALESCE($10, aql_auth_type),
|
setClauses.push('updated_at = CURRENT_TIMESTAMP');
|
||||||
aql_headers = COALESCE($11, aql_headers),
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
params.push(id);
|
||||||
WHERE id = $12
|
const whereIdx = idx;
|
||||||
RETURNING id, name, type, host, port, database_name, username, ssl, is_active,
|
|
||||||
aql_base_url, aql_auth_type, aql_headers, created_at, updated_at
|
const query = `
|
||||||
`;
|
UPDATE databases
|
||||||
params = [
|
SET ${setClauses.join(', ')}
|
||||||
name, type, host, port, database_name, username, ssl, is_active,
|
WHERE id = $${whereIdx}
|
||||||
aql_base_url, aql_auth_type, aql_headers ? JSON.stringify(aql_headers) : null, id
|
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);
|
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: 'База данных не найдена' });
|
return res.status(404).json({ error: 'База данных не найдена' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Перезагрузить пул (только для не-AQL баз)
|
|
||||||
if (result.rows[0].type !== 'aql') {
|
if (result.rows[0].type !== 'aql') {
|
||||||
await databasePoolManager.reloadPool(id);
|
await databasePoolManager.reloadPool(id);
|
||||||
}
|
}
|
||||||
@@ -203,7 +222,6 @@ export const deleteDatabase = async (req: AuthRequest, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
// Проверяем, используется ли база данных в эндпоинтах
|
|
||||||
const endpointCheck = await mainPool.query(
|
const endpointCheck = await mainPool.query(
|
||||||
'SELECT COUNT(*) FROM endpoints WHERE database_id = $1',
|
'SELECT COUNT(*) FROM endpoints WHERE database_id = $1',
|
||||||
[id]
|
[id]
|
||||||
@@ -224,7 +242,6 @@ export const deleteDatabase = async (req: AuthRequest, res: Response) => {
|
|||||||
return res.status(404).json({ error: 'База данных не найдена' });
|
return res.status(404).json({ error: 'База данных не найдена' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Удалить пул
|
|
||||||
databasePoolManager.removePool(id);
|
databasePoolManager.removePool(id);
|
||||||
|
|
||||||
res.json({ message: 'База данных удалена успешно' });
|
res.json({ message: 'База данных удалена успешно' });
|
||||||
@@ -237,8 +254,8 @@ export const deleteDatabase = async (req: AuthRequest, res: Response) => {
|
|||||||
export const testDatabaseConnection = async (req: AuthRequest, res: Response) => {
|
export const testDatabaseConnection = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
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]);
|
const dbResult = await mainPool.query('SELECT type FROM databases WHERE id = $1', [id]);
|
||||||
|
|
||||||
if (dbResult.rows.length === 0) {
|
if (dbResult.rows.length === 0) {
|
||||||
@@ -248,17 +265,15 @@ export const testDatabaseConnection = async (req: AuthRequest, res: Response) =>
|
|||||||
const dbType = dbResult.rows[0].type;
|
const dbType = dbResult.rows[0].type;
|
||||||
|
|
||||||
if (dbType === 'aql') {
|
if (dbType === 'aql') {
|
||||||
// Для AQL используем aqlExecutor
|
|
||||||
const { aqlExecutor } = require('../services/AqlExecutor');
|
const { aqlExecutor } = require('../services/AqlExecutor');
|
||||||
const result = await aqlExecutor.testConnection(id);
|
const result = await aqlExecutor.testConnection(id, env);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: result.success,
|
success: result.success,
|
||||||
message: result.success ? 'Подключение успешно' : result.error || 'Ошибка подключения',
|
message: result.success ? 'Подключение успешно' : result.error || 'Ошибка подключения',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Для обычных БД используем databasePoolManager
|
const isConnected = await databasePoolManager.testConnection(id, env);
|
||||||
const isConnected = await databasePoolManager.testConnection(id);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: isConnected,
|
success: isConnected,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ApiKeyRequest } from '../middleware/apiKey';
|
|||||||
import { mainPool } from '../config/database';
|
import { mainPool } from '../config/database';
|
||||||
import { sqlExecutor } from '../services/SqlExecutor';
|
import { sqlExecutor } from '../services/SqlExecutor';
|
||||||
import { scriptExecutor } from '../services/ScriptExecutor';
|
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) => {
|
export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response) => {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
@@ -11,6 +11,9 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
|
|||||||
let endpointId: string | null = null;
|
let endpointId: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const environment: Environment =
|
||||||
|
(req.headers['x-environment'] as string)?.toLowerCase() === 'test' ? 'test' : 'prod';
|
||||||
|
|
||||||
// Extract the path from the request (remove /api/v1 prefix)
|
// Extract the path from the request (remove /api/v1 prefix)
|
||||||
const requestPath = req.path; // This already has the path without /api/v1
|
const requestPath = req.path; // This already has the path without /api/v1
|
||||||
const requestMethod = req.method.toUpperCase();
|
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;
|
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)
|
// Check if logging is enabled (on endpoint OR on API key, but log only once)
|
||||||
const endpointLogging = endpoint.enable_logging || false;
|
const endpointLogging = endpoint.enable_logging || false;
|
||||||
const apiKeyLogging = req.apiKey?.enable_logging || false;
|
const apiKeyLogging = req.apiKey?.enable_logging || false;
|
||||||
@@ -171,7 +186,7 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
|
|||||||
body: aqlBody,
|
body: aqlBody,
|
||||||
queryParams: aqlQueryParams,
|
queryParams: aqlQueryParams,
|
||||||
parameters: requestParams,
|
parameters: requestParams,
|
||||||
});
|
}, environment);
|
||||||
} else if (executionType === 'script') {
|
} else if (executionType === 'script') {
|
||||||
// Execute script
|
// Execute script
|
||||||
const scriptLanguage = endpoint.script_language;
|
const scriptLanguage = endpoint.script_language;
|
||||||
@@ -199,6 +214,7 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
|
|||||||
scriptQueries,
|
scriptQueries,
|
||||||
requestParams,
|
requestParams,
|
||||||
endpointParameters: parameters,
|
endpointParameters: parameters,
|
||||||
|
environment,
|
||||||
});
|
});
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
@@ -231,7 +247,8 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
|
|||||||
result = await sqlExecutor.executeQuery(
|
result = await sqlExecutor.executeQuery(
|
||||||
endpoint.database_id,
|
endpoint.database_id,
|
||||||
processedQuery,
|
processedQuery,
|
||||||
queryParams
|
queryParams,
|
||||||
|
environment
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +281,7 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
|
|||||||
error_message: null,
|
error_message: null,
|
||||||
ip_address: req.ip || req.socket.remoteAddress || 'unknown',
|
ip_address: req.ip || req.socket.remoteAddress || 'unknown',
|
||||||
user_agent: req.headers['user-agent'] || '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,
|
error_message: error.message,
|
||||||
ip_address: req.ip || req.socket.remoteAddress || 'unknown',
|
ip_address: req.ip || req.socket.remoteAddress || 'unknown',
|
||||||
user_agent: req.headers['user-agent'] || '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;
|
error_message: string | null;
|
||||||
ip_address: string;
|
ip_address: string;
|
||||||
user_agent: string;
|
user_agent: string;
|
||||||
|
environment?: string;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
await mainPool.query(
|
await mainPool.query(
|
||||||
@@ -322,8 +342,8 @@ async function logRequest(data: {
|
|||||||
endpoint_id, api_key_id, method, path,
|
endpoint_id, api_key_id, method, path,
|
||||||
request_params, request_body, response_status,
|
request_params, request_body, response_status,
|
||||||
response_data, execution_time, error_message,
|
response_data, execution_time, error_message,
|
||||||
ip_address, user_agent
|
ip_address, user_agent, environment
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
|
||||||
[
|
[
|
||||||
data.endpoint_id,
|
data.endpoint_id,
|
||||||
data.api_key_id,
|
data.api_key_id,
|
||||||
@@ -337,6 +357,7 @@ async function logRequest(data: {
|
|||||||
data.error_message,
|
data.error_message,
|
||||||
data.ip_address,
|
data.ip_address,
|
||||||
data.user_agent,
|
data.user_agent,
|
||||||
|
data.environment || 'prod',
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { Response } from 'express';
|
|||||||
import { AuthRequest } from '../middleware/auth';
|
import { AuthRequest } from '../middleware/auth';
|
||||||
import { mainPool } from '../config/database';
|
import { mainPool } from '../config/database';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
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 { encryptEndpointData, decryptEndpointData } from '../services/endpointCrypto';
|
||||||
|
import { versionService } from '../services/VersionService';
|
||||||
|
|
||||||
export const getEndpoints = async (req: AuthRequest, res: Response) => {
|
export const getEndpoints = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -242,6 +243,15 @@ export const updateEndpoint = async (req: AuthRequest, res: Response) => {
|
|||||||
return res.status(404).json({ error: 'Endpoint not found' });
|
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]);
|
res.json(result.rows[0]);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Update endpoint error:', error);
|
console.error('Update endpoint error:', error);
|
||||||
@@ -287,8 +297,10 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => {
|
|||||||
aql_endpoint,
|
aql_endpoint,
|
||||||
aql_body,
|
aql_body,
|
||||||
aql_query_params,
|
aql_query_params,
|
||||||
|
environment: reqEnv,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
|
const environment: Environment = reqEnv === 'prod' ? 'prod' : 'test';
|
||||||
const execType = execution_type || 'sql';
|
const execType = execution_type || 'sql';
|
||||||
|
|
||||||
if (execType === 'sql') {
|
if (execType === 'sql') {
|
||||||
@@ -314,7 +326,7 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { sqlExecutor } = require('../services/SqlExecutor');
|
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -346,6 +358,7 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => {
|
|||||||
scriptQueries: script_queries || [],
|
scriptQueries: script_queries || [],
|
||||||
requestParams,
|
requestParams,
|
||||||
endpointParameters: endpoint_parameters || [],
|
endpointParameters: endpoint_parameters || [],
|
||||||
|
environment,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -377,7 +390,7 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => {
|
|||||||
body: aql_body,
|
body: aql_body,
|
||||||
queryParams: aql_query_params,
|
queryParams: aql_query_params,
|
||||||
parameters: requestParams,
|
parameters: requestParams,
|
||||||
});
|
}, environment);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -744,3 +757,73 @@ export const importEndpoint = async (req: AuthRequest, res: Response) => {
|
|||||||
res.status(500).json({ error: 'Internal server error' });
|
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' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
18
backend/src/migrations/010_add_test_environments.sql
Normal file
18
backend/src/migrations/010_add_test_environments.sql
Normal file
@@ -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';
|
||||||
78
backend/src/migrations/011_add_endpoint_versions.sql
Normal file
78
backend/src/migrations/011_add_endpoint_versions.sql
Normal file
@@ -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;
|
||||||
@@ -10,6 +10,12 @@ import {
|
|||||||
exportEndpoint,
|
exportEndpoint,
|
||||||
importPreview,
|
importPreview,
|
||||||
importEndpoint,
|
importEndpoint,
|
||||||
|
getVersionHistory,
|
||||||
|
getVersion,
|
||||||
|
publishVersion,
|
||||||
|
rollbackVersion,
|
||||||
|
saveDraftVersion,
|
||||||
|
getDraftVersion,
|
||||||
} from '../controllers/endpointController';
|
} from '../controllers/endpointController';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -117,6 +123,14 @@ router.get('/:id', getEndpoint);
|
|||||||
*/
|
*/
|
||||||
router.get('/:id/export', exportEndpoint);
|
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
|
* @swagger
|
||||||
* /api/endpoints/{id}:
|
* /api/endpoints/{id}:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { QueryResult, DatabaseConfig } from '../types';
|
import { QueryResult, DatabaseConfig, Environment } from '../types';
|
||||||
import { mainPool } from '../config/database';
|
import { mainPool } from '../config/database';
|
||||||
|
import { databasePoolManager } from './DatabasePoolManager';
|
||||||
|
|
||||||
interface AqlRequestConfig {
|
interface AqlRequestConfig {
|
||||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||||
@@ -73,19 +74,30 @@ export class AqlExecutor {
|
|||||||
*/
|
*/
|
||||||
async executeAqlQuery(
|
async executeAqlQuery(
|
||||||
databaseId: string,
|
databaseId: string,
|
||||||
config: AqlRequestConfig
|
config: AqlRequestConfig,
|
||||||
|
environment: Environment = 'prod'
|
||||||
): Promise<QueryResult> {
|
): Promise<QueryResult> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Получаем конфигурацию БД
|
const dbConfig = await databasePoolManager.getDatabaseConfig(databaseId);
|
||||||
const dbConfig = await this.getDatabaseConfig(databaseId);
|
|
||||||
|
|
||||||
if (!dbConfig) {
|
if (!dbConfig) {
|
||||||
throw new Error(`AQL database with id ${databaseId} not found or not configured`);
|
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}`);
|
throw new Error(`AQL base URL not configured for database ${databaseId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +112,7 @@ export class AqlExecutor {
|
|||||||
: '';
|
: '';
|
||||||
|
|
||||||
// Формируем полный URL
|
// Формируем полный URL
|
||||||
const fullUrl = `${dbConfig.aql_base_url}${processedEndpoint}${queryString}`;
|
const fullUrl = `${baseUrl}${processedEndpoint}${queryString}`;
|
||||||
|
|
||||||
// Обрабатываем body с параметрами
|
// Обрабатываем body с параметрами
|
||||||
let processedBody: string | undefined;
|
let processedBody: string | undefined;
|
||||||
@@ -115,19 +127,19 @@ export class AqlExecutor {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Добавляем аутентификацию
|
// Добавляем аутентификацию
|
||||||
if (dbConfig.aql_auth_type === 'basic' && dbConfig.aql_auth_value) {
|
if (dbConfig.aql_auth_type === 'basic' && authValue) {
|
||||||
headers['Authorization'] = `Basic ${dbConfig.aql_auth_value}`;
|
headers['Authorization'] = `Basic ${authValue}`;
|
||||||
} else if (dbConfig.aql_auth_type === 'bearer' && dbConfig.aql_auth_value) {
|
} else if (dbConfig.aql_auth_type === 'bearer' && authValue) {
|
||||||
headers['Authorization'] = `Bearer ${dbConfig.aql_auth_value}`;
|
headers['Authorization'] = `Bearer ${authValue}`;
|
||||||
} else if (dbConfig.aql_auth_type === 'custom' && dbConfig.aql_auth_value) {
|
} else if (dbConfig.aql_auth_type === 'custom' && authValue) {
|
||||||
headers['Authorization'] = dbConfig.aql_auth_value;
|
headers['Authorization'] = authValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавляем кастомные заголовки из конфигурации БД
|
// Добавляем кастомные заголовки из конфигурации БД
|
||||||
if (dbConfig.aql_headers) {
|
if (headers_config) {
|
||||||
const customHeaders = typeof dbConfig.aql_headers === 'string'
|
const customHeaders = typeof headers_config === 'string'
|
||||||
? JSON.parse(dbConfig.aql_headers)
|
? JSON.parse(headers_config)
|
||||||
: dbConfig.aql_headers;
|
: headers_config;
|
||||||
|
|
||||||
Object.assign(headers, customHeaders);
|
Object.assign(headers, customHeaders);
|
||||||
}
|
}
|
||||||
@@ -234,28 +246,29 @@ export class AqlExecutor {
|
|||||||
/**
|
/**
|
||||||
* Тестирует подключение к AQL базе
|
* Тестирует подключение к AQL базе
|
||||||
*/
|
*/
|
||||||
async testConnection(databaseId: string): Promise<{ success: boolean; error?: string }> {
|
async testConnection(databaseId: string, environment: Environment = 'prod'): Promise<{ success: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const dbConfig = await this.getDatabaseConfig(databaseId);
|
const dbConfig = await databasePoolManager.getDatabaseConfig(databaseId);
|
||||||
|
|
||||||
if (!dbConfig) {
|
if (!dbConfig) {
|
||||||
return { success: false, error: 'Database not found' };
|
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' };
|
return { success: false, error: 'AQL base URL not configured' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пробуем выполнить простой запрос для проверки соединения
|
const response = await fetch(testUrl, {
|
||||||
const response = await fetch(dbConfig.aql_base_url, {
|
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: { 'Accept': 'application/json' },
|
||||||
'Accept': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok || response.status === 404) {
|
if (response.ok || response.status === 404) {
|
||||||
// 404 тоже OK - это значит что сервер доступен
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} else {
|
} else {
|
||||||
return { success: false, error: `HTTP ${response.status}` };
|
return { success: false, error: `HTTP ${response.status}` };
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
import { DatabaseConfig } from '../types';
|
import { DatabaseConfig, Environment } from '../types';
|
||||||
import { mainPool } from '../config/database';
|
import { mainPool } from '../config/database';
|
||||||
|
|
||||||
|
interface PoolEntry {
|
||||||
|
prod: Pool;
|
||||||
|
test?: Pool;
|
||||||
|
}
|
||||||
|
|
||||||
class DatabasePoolManager {
|
class DatabasePoolManager {
|
||||||
private pools: Map<string, Pool> = new Map();
|
private pools: Map<string, PoolEntry> = new Map();
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
// Load databases from DB instead of env
|
|
||||||
await this.loadDatabasesFromDB();
|
await this.loadDatabasesFromDB();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,67 +21,112 @@ class DatabasePoolManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const row of result.rows) {
|
for (const row of result.rows) {
|
||||||
const dbConfig: DatabaseConfig = {
|
const dbConfig = this.rowToConfig(row);
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.addPool(dbConfig);
|
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) {
|
} 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) {
|
addPool(dbConfig: DatabaseConfig) {
|
||||||
if (this.pools.has(dbConfig.id)) {
|
if (this.pools.has(dbConfig.id)) {
|
||||||
console.warn(`Pool with id ${dbConfig.id} already exists. Skipping.`);
|
console.warn(`Pool with id ${dbConfig.id} already exists. Skipping.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pool = new Pool({
|
const prodPool = this.createPool(
|
||||||
host: dbConfig.host,
|
dbConfig.host, dbConfig.port, dbConfig.database_name,
|
||||||
port: dbConfig.port,
|
dbConfig.username, dbConfig.password, dbConfig.ssl
|
||||||
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 секунд
|
|
||||||
});
|
|
||||||
|
|
||||||
pool.on('error', (err) => {
|
const entry: PoolEntry = { prod: prodPool };
|
||||||
console.error(`Database pool error for ${dbConfig.id}:`, err);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.pools.set(dbConfig.id, pool);
|
if (dbConfig.has_test_env && dbConfig.test_host) {
|
||||||
console.log(`✅ Pool created for database: ${dbConfig.name} (${dbConfig.id})`);
|
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) {
|
removePool(databaseId: string) {
|
||||||
const pool = this.pools.get(databaseId);
|
const entry = this.pools.get(databaseId);
|
||||||
if (pool) {
|
if (entry) {
|
||||||
pool.end();
|
entry.prod.end();
|
||||||
|
if (entry.test) entry.test.end();
|
||||||
this.pools.delete(databaseId);
|
this.pools.delete(databaseId);
|
||||||
console.log(`Pool removed for database: ${databaseId}`);
|
console.log(`Pool removed for database: ${databaseId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getPool(databaseId: string): Pool | undefined {
|
getPool(databaseId: string, environment: Environment = 'prod'): Pool | undefined {
|
||||||
return this.pools.get(databaseId);
|
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<DatabaseConfig | null> {
|
async getDatabaseConfig(databaseId: string): Promise<DatabaseConfig | null> {
|
||||||
@@ -86,26 +135,8 @@ class DatabasePoolManager {
|
|||||||
'SELECT * FROM databases WHERE id = $1',
|
'SELECT * FROM databases WHERE id = $1',
|
||||||
[databaseId]
|
[databaseId]
|
||||||
);
|
);
|
||||||
if (result.rows.length === 0) {
|
if (result.rows.length === 0) return null;
|
||||||
return null;
|
return this.rowToConfig(result.rows[0]);
|
||||||
}
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching database config:', error);
|
console.error('Error fetching database config:', error);
|
||||||
return null;
|
return null;
|
||||||
@@ -115,7 +146,7 @@ class DatabasePoolManager {
|
|||||||
async getAllDatabaseConfigs(): Promise<DatabaseConfig[]> {
|
async getAllDatabaseConfigs(): Promise<DatabaseConfig[]> {
|
||||||
try {
|
try {
|
||||||
const result = await mainPool.query(
|
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;
|
return result.rows;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -124,53 +155,40 @@ class DatabasePoolManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async testConnection(databaseId: string): Promise<boolean> {
|
async testConnection(databaseId: string, environment: Environment = 'prod'): Promise<boolean> {
|
||||||
const pool = this.getPool(databaseId);
|
const pool = this.getPool(databaseId, environment);
|
||||||
if (!pool) {
|
if (!pool) return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
client.release();
|
client.release();
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Connection test failed for ${databaseId}:`, error);
|
console.error(`Connection test failed for ${databaseId} (${environment}):`, error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async reloadPool(databaseId: string) {
|
async reloadPool(databaseId: string) {
|
||||||
// Remove old pool
|
|
||||||
this.removePool(databaseId);
|
this.removePool(databaseId);
|
||||||
|
|
||||||
// Load new config from DB
|
|
||||||
const result = await mainPool.query(
|
const result = await mainPool.query(
|
||||||
'SELECT * FROM databases WHERE id = $1 AND is_active = true',
|
'SELECT * FROM databases WHERE id = $1 AND is_active = true',
|
||||||
[databaseId]
|
[databaseId]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.rows.length > 0) {
|
if (result.rows.length > 0) {
|
||||||
const row = result.rows[0];
|
const dbConfig = this.rowToConfig(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,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.addPool(dbConfig);
|
this.addPool(dbConfig);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async closeAll() {
|
async closeAll() {
|
||||||
const promises = Array.from(this.pools.values()).map((pool) => pool.end());
|
const promises: Promise<void>[] = [];
|
||||||
|
for (const entry of this.pools.values()) {
|
||||||
|
promises.push(entry.prod.end());
|
||||||
|
if (entry.test) promises.push(entry.test.end());
|
||||||
|
}
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
this.pools.clear();
|
this.pools.clear();
|
||||||
console.log('All database pools closed');
|
console.log('All database pools closed');
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as vm from 'vm';
|
import * as vm from 'vm';
|
||||||
import { sqlExecutor } from './SqlExecutor';
|
import { sqlExecutor } from './SqlExecutor';
|
||||||
import { aqlExecutor } from './AqlExecutor';
|
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 { databasePoolManager } from './DatabasePoolManager';
|
||||||
|
|
||||||
interface IsolatedScriptContext {
|
interface IsolatedScriptContext {
|
||||||
@@ -9,6 +9,7 @@ interface IsolatedScriptContext {
|
|||||||
scriptQueries: ScriptQuery[];
|
scriptQueries: ScriptQuery[];
|
||||||
requestParams: Record<string, any>;
|
requestParams: Record<string, any>;
|
||||||
endpointParameters: EndpointParameter[];
|
endpointParameters: EndpointParameter[];
|
||||||
|
environment?: Environment;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class IsolatedScriptExecutor {
|
export class IsolatedScriptExecutor {
|
||||||
@@ -74,7 +75,7 @@ export class IsolatedScriptExecutor {
|
|||||||
body: query.aql_body || '',
|
body: query.aql_body || '',
|
||||||
queryParams: query.aql_query_params || {},
|
queryParams: query.aql_query_params || {},
|
||||||
parameters: allParams,
|
parameters: allParams,
|
||||||
});
|
}, context.environment);
|
||||||
|
|
||||||
queries.push({
|
queries.push({
|
||||||
name: queryName,
|
name: queryName,
|
||||||
@@ -118,7 +119,7 @@ export class IsolatedScriptExecutor {
|
|||||||
paramValues.push(value !== undefined ? value : null);
|
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({
|
queries.push({
|
||||||
name: queryName,
|
name: queryName,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { sqlExecutor } from './SqlExecutor';
|
import { sqlExecutor } from './SqlExecutor';
|
||||||
import { aqlExecutor } from './AqlExecutor';
|
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 { databasePoolManager } from './DatabasePoolManager';
|
||||||
import { isolatedScriptExecutor } from './IsolatedScriptExecutor';
|
import { isolatedScriptExecutor } from './IsolatedScriptExecutor';
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ interface ScriptContext {
|
|||||||
scriptQueries: ScriptQuery[];
|
scriptQueries: ScriptQuery[];
|
||||||
requestParams: Record<string, any>;
|
requestParams: Record<string, any>;
|
||||||
endpointParameters: EndpointParameter[];
|
endpointParameters: EndpointParameter[];
|
||||||
|
environment?: Environment;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ScriptExecutor {
|
export class ScriptExecutor {
|
||||||
@@ -135,7 +136,7 @@ print(json.dumps(result))
|
|||||||
body: query.aql_body || '',
|
body: query.aql_body || '',
|
||||||
queryParams: query.aql_query_params || {},
|
queryParams: query.aql_query_params || {},
|
||||||
parameters: allParams,
|
parameters: allParams,
|
||||||
});
|
}, context.environment);
|
||||||
|
|
||||||
queries.push({
|
queries.push({
|
||||||
name: request.query_name,
|
name: request.query_name,
|
||||||
@@ -195,7 +196,8 @@ print(json.dumps(result))
|
|||||||
const result = await sqlExecutor.executeQuery(
|
const result = await sqlExecutor.executeQuery(
|
||||||
dbId,
|
dbId,
|
||||||
processedQuery,
|
processedQuery,
|
||||||
paramValues
|
paramValues,
|
||||||
|
context.environment
|
||||||
);
|
);
|
||||||
|
|
||||||
queries.push({
|
queries.push({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { QueryResult } from '../types';
|
import { QueryResult, Environment } from '../types';
|
||||||
import { databasePoolManager } from './DatabasePoolManager';
|
import { databasePoolManager } from './DatabasePoolManager';
|
||||||
|
|
||||||
export class SqlExecutor {
|
export class SqlExecutor {
|
||||||
@@ -32,9 +32,10 @@ export class SqlExecutor {
|
|||||||
async executeQuery(
|
async executeQuery(
|
||||||
databaseId: string,
|
databaseId: string,
|
||||||
sqlQuery: string,
|
sqlQuery: string,
|
||||||
params: any[] = []
|
params: any[] = [],
|
||||||
|
environment: Environment = 'prod'
|
||||||
): Promise<QueryResult> {
|
): Promise<QueryResult> {
|
||||||
const pool = databasePoolManager.getPool(databaseId);
|
const pool = databasePoolManager.getPool(databaseId, environment);
|
||||||
|
|
||||||
if (!pool) {
|
if (!pool) {
|
||||||
throw new Error(`Database with id ${databaseId} not found or not configured`);
|
throw new Error(`Database with id ${databaseId} not found or not configured`);
|
||||||
|
|||||||
306
backend/src/services/VersionService.ts
Normal file
306
backend/src/services/VersionService.ts
Normal file
@@ -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, any>): string {
|
||||||
|
const relevant: Record<string, any> = {};
|
||||||
|
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<EndpointVersion> {
|
||||||
|
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<string, any>,
|
||||||
|
userId: string,
|
||||||
|
changeMessage?: string
|
||||||
|
): Promise<EndpointVersion> {
|
||||||
|
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<void> {
|
||||||
|
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<EndpointVersion> {
|
||||||
|
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<EndpointVersion[]> {
|
||||||
|
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<EndpointVersion | null> {
|
||||||
|
const result = await mainPool.query(
|
||||||
|
'SELECT * FROM endpoint_versions WHERE id = $1',
|
||||||
|
[versionId]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDraft(endpointId: string): Promise<EndpointVersion | null> {
|
||||||
|
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();
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export type Environment = 'prod' | 'test';
|
||||||
|
|
||||||
export interface DatabaseConfig {
|
export interface DatabaseConfig {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -14,6 +16,17 @@ export interface DatabaseConfig {
|
|||||||
aql_auth_type?: 'basic' | 'bearer' | 'custom';
|
aql_auth_type?: 'basic' | 'bearer' | 'custom';
|
||||||
aql_auth_value?: string;
|
aql_auth_value?: string;
|
||||||
aql_headers?: Record<string, string>;
|
aql_headers?: Record<string, string>;
|
||||||
|
// 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<string, string>;
|
||||||
created_at?: Date;
|
created_at?: Date;
|
||||||
updated_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<string, string>;
|
||||||
|
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 {
|
export interface SwaggerEndpoint {
|
||||||
tags: string[];
|
tags: string[];
|
||||||
summary: string;
|
summary: string;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { endpointsApi, foldersApi, databasesApi } from '@/services/api';
|
import { endpointsApi, foldersApi, databasesApi, versionsApi } from '@/services/api';
|
||||||
import { EndpointParameter, QueryTestResult, LogEntry, QueryExecution } from '@/types';
|
import { EndpointParameter, QueryTestResult, LogEntry, QueryExecution, EndpointVersion } from '@/types';
|
||||||
import { Plus, Trash2, Play, Edit2, ChevronDown, ChevronUp, ArrowLeft, CheckCircle, XCircle, Clock, Copy, X, Terminal } from 'lucide-react';
|
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 toast from 'react-hot-toast';
|
||||||
import SqlEditor from '@/components/SqlEditor';
|
import SqlEditor from '@/components/SqlEditor';
|
||||||
import CodeEditor from '@/components/CodeEditor';
|
import CodeEditor from '@/components/CodeEditor';
|
||||||
@@ -93,6 +93,7 @@ export default function EndpointEditor() {
|
|||||||
const [testResult, setTestResult] = useState<QueryTestResult | null>(null);
|
const [testResult, setTestResult] = useState<QueryTestResult | null>(null);
|
||||||
const [activeResultTab, setActiveResultTab] = useState<'data' | 'logs' | 'queries'>('data');
|
const [activeResultTab, setActiveResultTab] = useState<'data' | 'logs' | 'queries'>('data');
|
||||||
const [curlApiKey, setCurlApiKey] = useState('');
|
const [curlApiKey, setCurlApiKey] = useState('');
|
||||||
|
const [testEnvironment, setTestEnvironment] = useState<'test' | 'prod'>('test');
|
||||||
|
|
||||||
const selectedDatabase = databases?.find(db => db.id === formData.database_id);
|
const selectedDatabase = databases?.find(db => db.id === formData.database_id);
|
||||||
const isAqlDatabase = selectedDatabase?.type === 'aql';
|
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 || {},
|
aql_query_params: typeof formData.aql_query_params === 'string' ? {} : formData.aql_query_params || {},
|
||||||
parameters: paramValues,
|
parameters: paramValues,
|
||||||
endpoint_parameters: formData.parameters,
|
endpoint_parameters: formData.parameters,
|
||||||
|
environment: testEnvironment,
|
||||||
} as any);
|
} as any);
|
||||||
} else if (formData.execution_type === 'script') {
|
} else if (formData.execution_type === 'script') {
|
||||||
const scriptQueries = formData.script_queries || [];
|
const scriptQueries = formData.script_queries || [];
|
||||||
@@ -201,6 +203,7 @@ export default function EndpointEditor() {
|
|||||||
script_queries: scriptQueries,
|
script_queries: scriptQueries,
|
||||||
parameters: paramValues,
|
parameters: paramValues,
|
||||||
endpoint_parameters: formData.parameters,
|
endpoint_parameters: formData.parameters,
|
||||||
|
environment: testEnvironment,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return endpointsApi.test({
|
return endpointsApi.test({
|
||||||
@@ -209,6 +212,7 @@ export default function EndpointEditor() {
|
|||||||
sql_query: formData.sql_query || '',
|
sql_query: formData.sql_query || '',
|
||||||
parameters: paramValues,
|
parameters: paramValues,
|
||||||
endpoint_parameters: formData.parameters,
|
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) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
let parsedSchema = null;
|
let parsedSchema = null;
|
||||||
@@ -841,13 +882,23 @@ export default function EndpointEditor() {
|
|||||||
<button type="button" onClick={() => navigate(-1)} className="btn btn-secondary">
|
<button type="button" onClick={() => navigate(-1)} className="btn btn-secondary">
|
||||||
Отмена
|
Отмена
|
||||||
</button>
|
</button>
|
||||||
|
{isEditing && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={draftMutation?.isPending}
|
||||||
|
className="btn bg-orange-50 text-orange-700 border border-orange-200 hover:bg-orange-100"
|
||||||
|
onClick={() => draftMutation?.mutate()}
|
||||||
|
>
|
||||||
|
{draftMutation?.isPending ? 'Сохранение...' : 'Save Draft'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saveMutation.isPending}
|
disabled={saveMutation.isPending}
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={() => setSaveAndStay(true)}
|
onClick={() => setSaveAndStay(true)}
|
||||||
>
|
>
|
||||||
{saveMutation.isPending && saveAndStay ? 'Сохранение...' : 'Сохранить'}
|
{saveMutation.isPending && saveAndStay ? 'Публикация...' : 'Publish'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -855,7 +906,7 @@ export default function EndpointEditor() {
|
|||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => setSaveAndStay(false)}
|
onClick={() => setSaveAndStay(false)}
|
||||||
>
|
>
|
||||||
{saveMutation.isPending && !saveAndStay ? 'Сохранение...' : 'Сохранить и выйти'}
|
{saveMutation.isPending && !saveAndStay ? 'Публикация...' : 'Publish & Exit'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -901,8 +952,38 @@ export default function EndpointEditor() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Test button */}
|
{/* Environment toggle + Test button */}
|
||||||
<div className="card p-4">
|
<div className="card p-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium text-gray-500">Среда:</span>
|
||||||
|
<div className="flex rounded-lg overflow-hidden border border-gray-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTestEnvironment('test')}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||||
|
testEnvironment === 'test'
|
||||||
|
? 'bg-orange-500 text-white'
|
||||||
|
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Test
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTestEnvironment('prod')}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||||
|
testEnvironment === 'prod'
|
||||||
|
? 'bg-green-600 text-white'
|
||||||
|
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Prod
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{selectedDatabase && !selectedDatabase.has_test_env && testEnvironment === 'test' && (
|
||||||
|
<span className="text-xs text-amber-600">Test env не настроена — fallback на prod</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => testMutation.mutate()}
|
onClick={() => testMutation.mutate()}
|
||||||
@@ -915,10 +996,14 @@ export default function EndpointEditor() {
|
|||||||
: !formData.script_code
|
: !formData.script_code
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="btn btn-primary w-full flex items-center justify-center gap-2"
|
className={`btn w-full flex items-center justify-center gap-2 ${
|
||||||
|
testEnvironment === 'test'
|
||||||
|
? 'bg-orange-500 hover:bg-orange-600 text-white'
|
||||||
|
: 'btn-primary'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Play size={18} />
|
<Play size={18} />
|
||||||
{testMutation.isPending ? 'Тестирование...' : 'Тест запроса'}
|
{testMutation.isPending ? 'Тестирование...' : `Тест (${testEnvironment})`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -952,6 +1037,68 @@ export default function EndpointEditor() {
|
|||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Version History */}
|
||||||
|
{isEditing && (
|
||||||
|
<div className="card p-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowVersionHistory(!showVersionHistory)}
|
||||||
|
className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 w-full"
|
||||||
|
>
|
||||||
|
<History size={16} />
|
||||||
|
<span>Version History</span>
|
||||||
|
<span className="text-xs text-gray-400 ml-1">({versions?.length || 0})</span>
|
||||||
|
<span className="ml-auto">{showVersionHistory ? <ChevronUp size={14} /> : <ChevronDown size={14} />}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showVersionHistory && versions && (
|
||||||
|
<div className="mt-3 space-y-2 max-h-80 overflow-y-auto">
|
||||||
|
{versions.map((v: EndpointVersion) => (
|
||||||
|
<div key={v.id} className="flex items-center gap-2 text-xs border border-gray-100 rounded-lg p-2">
|
||||||
|
<span className="font-mono font-bold text-gray-700">v{v.version_number}</span>
|
||||||
|
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
|
||||||
|
v.status === 'published' ? 'bg-green-100 text-green-700' :
|
||||||
|
v.status === 'draft' ? 'bg-orange-100 text-orange-700' :
|
||||||
|
'bg-gray-100 text-gray-500'
|
||||||
|
}`}>
|
||||||
|
{v.status}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400 truncate flex-1" title={v.change_message || ''}>
|
||||||
|
{v.change_message || '-'}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400 whitespace-nowrap">
|
||||||
|
{new Date(v.created_at).toLocaleDateString('ru-RU')}
|
||||||
|
</span>
|
||||||
|
{v.status === 'draft' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => publishVersionMutation.mutate(v.id)}
|
||||||
|
className="p-1 hover:bg-green-50 rounded text-green-600"
|
||||||
|
title="Publish"
|
||||||
|
>
|
||||||
|
<Upload size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{v.status !== 'draft' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => rollbackMutation.mutate(v.id)}
|
||||||
|
className="p-1 hover:bg-blue-50 rounded text-blue-600"
|
||||||
|
title="Rollback to this version"
|
||||||
|
>
|
||||||
|
<RotateCcw size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{versions.length === 0 && (
|
||||||
|
<p className="text-xs text-gray-400 text-center py-2">No versions yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Test results */}
|
{/* Test results */}
|
||||||
{testResult && (
|
{testResult && (
|
||||||
<div className="card overflow-hidden">
|
<div className="card overflow-hidden">
|
||||||
|
|||||||
@@ -284,13 +284,13 @@ function DatabasesSubTab() {
|
|||||||
onError: () => toast.error('Не удалось удалить базу данных'),
|
onError: () => toast.error('Не удалось удалить базу данных'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const testConnection = async (databaseId: string) => {
|
const testConnection = async (databaseId: string, env?: 'prod' | 'test') => {
|
||||||
try {
|
try {
|
||||||
const { data } = await dbManagementApi.test(databaseId);
|
const { data } = await dbManagementApi.test(databaseId, env);
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
toast.success('Подключение успешно!');
|
toast.success(env === 'test' ? 'Test Env: подключение успешно!' : 'Подключение успешно!');
|
||||||
} else {
|
} else {
|
||||||
toast.error('Ошибка подключения');
|
toast.error(env === 'test' ? 'Test Env: ошибка подключения' : 'Ошибка подключения');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Ошибка тестирования подключения');
|
toast.error('Ошибка тестирования подключения');
|
||||||
@@ -350,6 +350,9 @@ function DatabasesSubTab() {
|
|||||||
{db.is_active && (
|
{db.is_active && (
|
||||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">Активна</span>
|
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">Активна</span>
|
||||||
)}
|
)}
|
||||||
|
{db.has_test_env && (
|
||||||
|
<span className="text-xs bg-orange-100 text-orange-700 px-2 py-1 rounded">Test Env</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm text-gray-600 ml-8">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm text-gray-600 ml-8">
|
||||||
{db.type === 'aql' ? (
|
{db.type === 'aql' ? (
|
||||||
@@ -375,6 +378,14 @@ function DatabasesSubTab() {
|
|||||||
>
|
>
|
||||||
Тест
|
Тест
|
||||||
</button>
|
</button>
|
||||||
|
{db.has_test_env && (
|
||||||
|
<button
|
||||||
|
onClick={() => testConnection(db.id, 'test')}
|
||||||
|
className="btn text-sm bg-orange-50 text-orange-700 border border-orange-200 hover:bg-orange-100"
|
||||||
|
>
|
||||||
|
Тест (Test Env)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(db)}
|
onClick={() => handleEdit(db)}
|
||||||
className="btn btn-secondary text-sm flex items-center gap-1"
|
className="btn btn-secondary text-sm flex items-center gap-1"
|
||||||
@@ -431,6 +442,7 @@ function DatabaseModal({
|
|||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showTestEnv, setShowTestEnv] = useState(database?.has_test_env || false);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: database?.name || '',
|
name: database?.name || '',
|
||||||
type: database?.type || 'postgresql',
|
type: database?.type || 'postgresql',
|
||||||
@@ -446,18 +458,30 @@ function DatabaseModal({
|
|||||||
aql_auth_type: database?.aql_auth_type || 'basic',
|
aql_auth_type: database?.aql_auth_type || 'basic',
|
||||||
aql_auth_value: database?.aql_auth_value || '',
|
aql_auth_value: database?.aql_auth_value || '',
|
||||||
aql_headers: database?.aql_headers || {},
|
aql_headers: database?.aql_headers || {},
|
||||||
|
// Test environment
|
||||||
|
has_test_env: database?.has_test_env || false,
|
||||||
|
test_host: database?.test_host || '',
|
||||||
|
test_port: database?.test_port || 5432,
|
||||||
|
test_database_name: database?.test_database_name || '',
|
||||||
|
test_username: database?.test_username || '',
|
||||||
|
test_password: '',
|
||||||
|
test_ssl: database?.test_ssl || false,
|
||||||
|
test_aql_base_url: database?.test_aql_base_url || '',
|
||||||
|
test_aql_auth_value: '',
|
||||||
|
test_aql_headers: database?.test_aql_headers || {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (data: any) => {
|
mutationFn: (data: any) => {
|
||||||
const payload = { ...data };
|
const payload = { ...data };
|
||||||
// Если редактируем и пароль пустой, удаляем его из payload
|
if (database && !payload.password) delete payload.password;
|
||||||
if (database && !payload.password) {
|
if (database && data.type === 'aql' && !payload.aql_auth_value) delete payload.aql_auth_value;
|
||||||
delete payload.password;
|
if (database && !payload.test_password) delete payload.test_password;
|
||||||
}
|
if (database && !payload.test_aql_auth_value) delete payload.test_aql_auth_value;
|
||||||
// Для AQL: если редактируем и auth_value пустой, удаляем его
|
if (!payload.has_test_env) {
|
||||||
if (database && data.type === 'aql' && !payload.aql_auth_value) {
|
delete payload.test_host; delete payload.test_port; delete payload.test_database_name;
|
||||||
delete payload.aql_auth_value;
|
delete payload.test_username; delete payload.test_password; delete payload.test_ssl;
|
||||||
|
delete payload.test_aql_base_url; delete payload.test_aql_auth_value; delete payload.test_aql_headers;
|
||||||
}
|
}
|
||||||
return database ? dbManagementApi.update(database.id, payload) : dbManagementApi.create(payload);
|
return database ? dbManagementApi.update(database.id, payload) : dbManagementApi.create(payload);
|
||||||
},
|
},
|
||||||
@@ -693,6 +717,123 @@ function DatabaseModal({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Test Environment Section */}
|
||||||
|
<div className="border-t border-gray-200 pt-4 mt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const next = !showTestEnv;
|
||||||
|
setShowTestEnv(next);
|
||||||
|
setFormData({ ...formData, has_test_env: next });
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
<span className={`transform transition-transform ${showTestEnv ? 'rotate-90' : ''}`}>▶</span>
|
||||||
|
Тестовая среда
|
||||||
|
{formData.has_test_env && (
|
||||||
|
<span className="text-xs bg-orange-100 text-orange-700 px-2 py-0.5 rounded">Включена</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showTestEnv && (
|
||||||
|
<div className="mt-4 space-y-4 pl-4 border-l-2 border-orange-200">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Альтернативные креды для тестирования endpoint-ов. При тестировании запросы пойдут на эту БД вместо продовой.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{formData.type === 'aql' ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Test AQL Base URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.test_aql_base_url}
|
||||||
|
onChange={(e) => setFormData({ ...formData, test_aql_base_url: e.target.value })}
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="http://test-api.example.com/api/rest/v1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Test Auth Value</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={formData.test_aql_auth_value}
|
||||||
|
onChange={(e) => setFormData({ ...formData, test_aql_auth_value: e.target.value })}
|
||||||
|
className="input w-full"
|
||||||
|
placeholder={database ? '(не менять)' : 'Значение'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Test Host</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.test_host}
|
||||||
|
onChange={(e) => setFormData({ ...formData, test_host: e.target.value })}
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="test-db-host"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Test Port</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.test_port}
|
||||||
|
onChange={(e) => setFormData({ ...formData, test_port: parseInt(e.target.value) })}
|
||||||
|
className="input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Test DB Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.test_database_name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, test_database_name: e.target.value })}
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="test_database"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Test User</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.test_username}
|
||||||
|
onChange={(e) => setFormData({ ...formData, test_username: e.target.value })}
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="test_user"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Test Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={formData.test_password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, test_password: e.target.value })}
|
||||||
|
className="input w-full"
|
||||||
|
placeholder={database ? '(не менять)' : 'Пароль'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.test_ssl}
|
||||||
|
onChange={(e) => setFormData({ ...formData, test_ssl: e.target.checked })}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Test SSL</span>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3 pt-4 border-t border-gray-200">
|
<div className="flex gap-3 pt-4 border-t border-gray-200">
|
||||||
<button type="button" onClick={onClose} className="btn btn-secondary">
|
<button type="button" onClick={onClose} className="btn btn-secondary">
|
||||||
Отмена
|
Отмена
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { AuthResponse, User, Endpoint, Folder, ApiKey, Database, QueryTestResult, ImportPreviewResponse } from '@/types';
|
import { AuthResponse, User, Endpoint, Folder, ApiKey, Database, QueryTestResult, ImportPreviewResponse, EndpointVersion } from '@/types';
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
@@ -86,8 +86,8 @@ export const dbManagementApi = {
|
|||||||
delete: (id: string) =>
|
delete: (id: string) =>
|
||||||
api.delete(`/db-management/${id}`),
|
api.delete(`/db-management/${id}`),
|
||||||
|
|
||||||
test: (id: string) =>
|
test: (id: string, env?: 'prod' | 'test') =>
|
||||||
api.get<{ success: boolean; message: string }>(`/db-management/${id}/test`),
|
api.get<{ success: boolean; message: string }>(`/db-management/${id}/test${env === 'test' ? '?env=test' : ''}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Endpoints API
|
// Endpoints API
|
||||||
@@ -120,6 +120,7 @@ export const endpointsApi = {
|
|||||||
aql_endpoint?: string;
|
aql_endpoint?: string;
|
||||||
aql_body?: string;
|
aql_body?: string;
|
||||||
aql_query_params?: Record<string, string>;
|
aql_query_params?: Record<string, string>;
|
||||||
|
environment?: 'prod' | 'test';
|
||||||
}) =>
|
}) =>
|
||||||
api.post<QueryTestResult>('/endpoints/test', data),
|
api.post<QueryTestResult>('/endpoints/test', data),
|
||||||
|
|
||||||
@@ -142,6 +143,27 @@ export const endpointsApi = {
|
|||||||
api.post<Endpoint>('/endpoints/import', data),
|
api.post<Endpoint>('/endpoints/import', data),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Versions API
|
||||||
|
export const versionsApi = {
|
||||||
|
getHistory: (endpointId: string) =>
|
||||||
|
api.get<EndpointVersion[]>(`/endpoints/${endpointId}/versions`),
|
||||||
|
|
||||||
|
getVersion: (endpointId: string, versionId: string) =>
|
||||||
|
api.get<EndpointVersion>(`/endpoints/${endpointId}/versions/${versionId}`),
|
||||||
|
|
||||||
|
publish: (endpointId: string, versionId: string) =>
|
||||||
|
api.post(`/endpoints/${endpointId}/versions/${versionId}/publish`),
|
||||||
|
|
||||||
|
rollback: (endpointId: string, versionId: string) =>
|
||||||
|
api.post(`/endpoints/${endpointId}/versions/${versionId}/rollback`),
|
||||||
|
|
||||||
|
saveDraft: (endpointId: string, data: any) =>
|
||||||
|
api.post<EndpointVersion>(`/endpoints/${endpointId}/draft`, data),
|
||||||
|
|
||||||
|
getDraft: (endpointId: string) =>
|
||||||
|
api.get<EndpointVersion>(`/endpoints/${endpointId}/draft`),
|
||||||
|
};
|
||||||
|
|
||||||
// Folders API
|
// Folders API
|
||||||
export const foldersApi = {
|
export const foldersApi = {
|
||||||
getAll: () =>
|
getAll: () =>
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ export interface Database {
|
|||||||
aql_auth_type?: 'basic' | 'bearer' | 'custom';
|
aql_auth_type?: 'basic' | 'bearer' | 'custom';
|
||||||
aql_auth_value?: string;
|
aql_auth_value?: string;
|
||||||
aql_headers?: Record<string, string>;
|
aql_headers?: Record<string, string>;
|
||||||
|
// Test environment
|
||||||
|
has_test_env?: boolean;
|
||||||
|
test_host?: string;
|
||||||
|
test_port?: number;
|
||||||
|
test_database_name?: string;
|
||||||
|
test_username?: string;
|
||||||
|
test_ssl?: boolean;
|
||||||
|
test_aql_base_url?: string;
|
||||||
|
test_aql_headers?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Folder {
|
export interface Folder {
|
||||||
@@ -125,6 +134,39 @@ export interface QueryTestResult {
|
|||||||
processedQuery?: string;
|
processedQuery?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<string, string>;
|
||||||
|
is_public: boolean;
|
||||||
|
enable_logging: boolean;
|
||||||
|
detailed_response: boolean;
|
||||||
|
response_schema?: object | null;
|
||||||
|
status: VersionStatus;
|
||||||
|
change_message: string | null;
|
||||||
|
user_id: string;
|
||||||
|
user_name?: string;
|
||||||
|
content_hash: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ImportPreviewDatabase {
|
export interface ImportPreviewDatabase {
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user