Compare commits

12 Commits

Author SHA1 Message Date
c918f34595 modified: cli 2026-03-14 17:44:44 +03:00
c047319afe modified: cli 2026-03-14 17:42:54 +03:00
46396c6fc4 Fix TypeScript errors in syncController — cast Date arguments to String
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 17:11:36 +03:00
dcdd6fee6d modified: cli 2026-03-14 17:10:11 +03:00
4ae281209f modified: backend/src/server.ts 2026-03-14 16:33:30 +03:00
f0cbe99cb0 modified: frontend/src/pages/EndpointEditor.tsx
modified:   frontend/src/pages/Folders.tsx
2026-03-13 18:23:21 +03:00
727c6765f8 modified: backend/src/config/dynamicSwagger.ts
modified:   backend/src/controllers/endpointController.ts
	new file:   backend/src/migrations/009_add_response_schema.sql
	modified:   backend/src/types/index.ts
	modified:   frontend/src/pages/EndpointEditor.tsx
	modified:   frontend/src/types/index.ts
2026-03-13 15:22:32 +03:00
b6b7064a41 modified: backend/src/server.ts 2026-03-11 11:11:09 +03:00
49f262d8ae Fix script execution logs being lost
- Add ScriptExecutionError class that preserves captured logs/queries
- IsolatedScriptExecutor: throw ScriptExecutionError with accumulated
  logs instead of plain Error on script failure
- ScriptExecutor (Python): same fix for Python execution errors
- testEndpoint: return captured logs/queries on script errors
- dynamicApiController: correctly extract scriptResult.result instead
  of stuffing entire IsolatedExecutionResult into rows; include logs
  in detailed_response output

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 02:52:51 +03:00
3a3c87164d Fix export endpoint error with Cyrillic filenames
Content-Disposition header cannot contain non-ASCII characters.
Use RFC 5987 encoding (filename*=UTF-8'') for proper Cyrillic
filename support, with ASCII-only fallback in the base filename.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 02:25:11 +03:00
e8aab43a13 Merge branch 'main' of ssh://gitea.esh-service.ru:2222/public/api_builder 2026-03-01 19:32:37 +03:00
659ebf71ea modified: backend/src/types/index.ts
modified:   frontend/src/App.tsx
	deleted:    frontend/src/components/EndpointModal.tsx
	new file:   frontend/src/pages/EndpointEditor.tsx
	modified:   frontend/src/pages/Endpoints.tsx
	modified:   frontend/src/pages/Folders.tsx
	modified:   frontend/src/types/index.ts
2026-03-01 19:32:23 +03:00
17 changed files with 1984 additions and 1326 deletions

View File

@@ -44,6 +44,7 @@ export async function generateDynamicSwagger(): Promise<SwaggerSpec> {
e.path, e.path,
e.parameters, e.parameters,
e.is_public, e.is_public,
e.response_schema,
fp.full_path as folder_name fp.full_path as folder_name
FROM endpoints e FROM endpoints e
LEFT JOIN folder_path fp ON e.folder_id = fp.id LEFT JOIN folder_path fp ON e.folder_id = fp.id
@@ -136,15 +137,17 @@ export async function generateDynamicSwagger(): Promise<SwaggerSpec> {
description: 'Успешный ответ', description: 'Успешный ответ',
content: { content: {
'application/json': { 'application/json': {
schema: { schema: endpoint.response_schema
type: 'object', ? endpoint.response_schema
properties: { : {
success: { type: 'boolean' }, type: 'object',
data: { type: 'array', items: { type: 'object' } }, properties: {
rowCount: { type: 'number' }, success: { type: 'boolean' },
executionTime: { type: 'number' }, data: { type: 'array', items: { type: 'object' } },
}, rowCount: { type: 'number' },
}, executionTime: { type: 'number' },
},
},
}, },
}, },
}, },

View File

@@ -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 } from '../types'; import { EndpointParameter, ScriptQuery, ScriptExecutionError } 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();
@@ -202,9 +202,11 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
}); });
result = { result = {
rows: scriptResult, rows: scriptResult.result,
rowCount: 0, rowCount: 0,
executionTime: 0, executionTime: 0,
scriptLogs: scriptResult.logs,
scriptQueries: scriptResult.queries,
}; };
} else { } else {
// Execute SQL query // Execute SQL query
@@ -241,6 +243,8 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
data: result.rows, data: result.rows,
rowCount: result.rowCount, rowCount: result.rowCount,
executionTime: result.executionTime, executionTime: result.executionTime,
...(result.scriptLogs && result.scriptLogs.length > 0 ? { logs: result.scriptLogs } : {}),
...(result.scriptQueries && result.scriptQueries.length > 0 ? { queries: result.scriptQueries } : {}),
} }
: result.rows; : result.rows;
@@ -267,9 +271,12 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
} catch (error: any) { } catch (error: any) {
console.error('Dynamic API execution error:', error); console.error('Dynamic API execution error:', error);
const errorResponse = { const isScriptError = error instanceof ScriptExecutionError;
const errorResponse: any = {
success: false, success: false,
error: error.message, error: error.message,
...(isScriptError && error.logs.length > 0 ? { logs: error.logs } : {}),
...(isScriptError && error.queries.length > 0 ? { queries: error.queries } : {}),
}; };
// Log error if needed // Log error if needed

View File

@@ -2,7 +2,7 @@ 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 } from '../types'; import { ExportedEndpoint, ExportedScriptQuery, ScriptExecutionError } from '../types';
import { encryptEndpointData, decryptEndpointData } from '../services/endpointCrypto'; import { encryptEndpointData, decryptEndpointData } from '../services/endpointCrypto';
export const getEndpoints = async (req: AuthRequest, res: Response) => { export const getEndpoints = async (req: AuthRequest, res: Response) => {
@@ -88,6 +88,7 @@ export const createEndpoint = async (req: AuthRequest, res: Response) => {
aql_body, aql_body,
aql_query_params, aql_query_params,
detailed_response, detailed_response,
response_schema,
} = req.body; } = req.body;
if (!name || !method || !path) { if (!name || !method || !path) {
@@ -122,9 +123,9 @@ export const createEndpoint = async (req: AuthRequest, res: Response) => {
name, description, method, path, database_id, sql_query, parameters, name, description, method, path, database_id, sql_query, parameters,
folder_id, user_id, is_public, enable_logging, folder_id, user_id, is_public, enable_logging,
execution_type, script_language, script_code, script_queries, execution_type, script_language, script_code, script_queries,
aql_method, aql_endpoint, aql_body, aql_query_params, detailed_response aql_method, aql_endpoint, aql_body, aql_query_params, detailed_response, response_schema
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
RETURNING *`, RETURNING *`,
[ [
name, name,
@@ -147,6 +148,7 @@ export const createEndpoint = async (req: AuthRequest, res: Response) => {
aql_body || null, aql_body || null,
JSON.stringify(aql_query_params || {}), JSON.stringify(aql_query_params || {}),
detailed_response || false, detailed_response || false,
response_schema ? JSON.stringify(response_schema) : null,
] ]
); );
@@ -183,6 +185,7 @@ export const updateEndpoint = async (req: AuthRequest, res: Response) => {
aql_body, aql_body,
aql_query_params, aql_query_params,
detailed_response, detailed_response,
response_schema,
} = req.body; } = req.body;
const result = await mainPool.query( const result = await mainPool.query(
@@ -206,8 +209,9 @@ export const updateEndpoint = async (req: AuthRequest, res: Response) => {
aql_body = $17, aql_body = $17,
aql_query_params = $18, aql_query_params = $18,
detailed_response = $19, detailed_response = $19,
response_schema = $20,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $20 WHERE id = $21
RETURNING *`, RETURNING *`,
[ [
name, name,
@@ -229,6 +233,7 @@ export const updateEndpoint = async (req: AuthRequest, res: Response) => {
aql_body || null, aql_body || null,
aql_query_params ? JSON.stringify(aql_query_params) : null, aql_query_params ? JSON.stringify(aql_query_params) : null,
detailed_response || false, detailed_response || false,
response_schema ? JSON.stringify(response_schema) : null,
id, id,
] ]
); );
@@ -388,13 +393,14 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => {
return res.status(400).json({ error: 'Invalid execution_type' }); return res.status(400).json({ error: 'Invalid execution_type' });
} }
} catch (error: any) { } catch (error: any) {
const isScriptError = error instanceof ScriptExecutionError;
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: error.message, error: error.message,
detail: error.detail || undefined, detail: error.detail || undefined,
hint: error.hint || undefined, hint: error.hint || undefined,
logs: [], logs: isScriptError ? error.logs : [],
queries: [], queries: isScriptError ? error.queries : [],
}); });
} }
}; };
@@ -487,14 +493,16 @@ export const exportEndpoint = async (req: AuthRequest, res: Response) => {
is_public: endpoint.is_public || false, is_public: endpoint.is_public || false,
enable_logging: endpoint.enable_logging || false, enable_logging: endpoint.enable_logging || false,
detailed_response: endpoint.detailed_response || false, detailed_response: endpoint.detailed_response || false,
response_schema: endpoint.response_schema || null,
folder_name: folderName, folder_name: folderName,
}; };
const encrypted = encryptEndpointData(exportData); const encrypted = encryptEndpointData(exportData);
const safeFileName = endpoint.name.replace(/[^a-zA-Z0-9_\-а-яА-ЯёЁ]/g, '_'); const safeFileName = endpoint.name.replace(/[^a-zA-Z0-9_\-]/g, '_');
const encodedFileName = encodeURIComponent(endpoint.name.replace(/[\/\\:*?"<>|]/g, '_')) + '.kabe';
res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${safeFileName}.kabe"`); res.setHeader('Content-Disposition', `attachment; filename="${safeFileName}.kabe"; filename*=UTF-8''${encodedFileName}`);
res.send(encrypted); res.send(encrypted);
} catch (error) { } catch (error) {
console.error('Export endpoint error:', error); console.error('Export endpoint error:', error);
@@ -698,9 +706,9 @@ export const importEndpoint = async (req: AuthRequest, res: Response) => {
name, description, method, path, database_id, sql_query, parameters, name, description, method, path, database_id, sql_query, parameters,
folder_id, user_id, is_public, enable_logging, folder_id, user_id, is_public, enable_logging,
execution_type, script_language, script_code, script_queries, execution_type, script_language, script_code, script_queries,
aql_method, aql_endpoint, aql_body, aql_query_params, detailed_response aql_method, aql_endpoint, aql_body, aql_query_params, detailed_response, response_schema
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
RETURNING *`, RETURNING *`,
[ [
exportData.name, exportData.name,
@@ -723,6 +731,7 @@ export const importEndpoint = async (req: AuthRequest, res: Response) => {
exportData.aql_body || null, exportData.aql_body || null,
JSON.stringify(exportData.aql_query_params || {}), JSON.stringify(exportData.aql_query_params || {}),
exportData.detailed_response || false, exportData.detailed_response || false,
exportData.response_schema ? JSON.stringify(exportData.response_schema) : null,
] ]
); );

View File

@@ -0,0 +1,296 @@
import { Response } from 'express';
import { AuthRequest } from '../middleware/auth';
import { mainPool } from '../config/database';
/**
* GET /api/sync/pull
* Returns all endpoints + folders + database name mappings for CLI sync
*/
export const syncPull = async (req: AuthRequest, res: Response) => {
try {
// Get all endpoints with folder and database names
const endpointsResult = await mainPool.query(`
SELECT
e.*,
f.name as folder_name,
d.name as database_name,
d.type as database_type
FROM endpoints e
LEFT JOIN folders f ON e.folder_id = f.id
LEFT JOIN databases d ON e.database_id = d.id
ORDER BY e.created_at ASC
`);
// Get all folders with parent info
const foldersResult = await mainPool.query(`
SELECT f.*, pf.name as parent_name
FROM folders f
LEFT JOIN folders pf ON f.parent_id = pf.id
ORDER BY f.created_at ASC
`);
// Get database name->id mapping (active only)
const databasesResult = await mainPool.query(
'SELECT id, name, type FROM databases WHERE is_active = true ORDER BY name'
);
// Resolve script_queries database names
const endpoints = await Promise.all(endpointsResult.rows.map(async (ep: any) => {
const scriptQueries = ep.script_queries || [];
const resolvedScriptQueries = [];
for (const sq of scriptQueries) {
let sqDbName: string | null = null;
let sqDbType: string | null = null;
if (sq.database_id) {
const sqDb = databasesResult.rows.find((d: any) => d.id === sq.database_id);
if (sqDb) {
sqDbName = sqDb.name;
sqDbType = sqDb.type;
}
}
resolvedScriptQueries.push({
...sq,
database_name: sqDbName,
database_type: sqDbType,
});
}
return {
id: ep.id,
name: ep.name,
description: ep.description,
method: ep.method,
path: ep.path,
execution_type: ep.execution_type || 'sql',
database_name: ep.database_name,
database_type: ep.database_type,
database_id: ep.database_id,
sql_query: ep.sql_query,
parameters: ep.parameters || [],
script_language: ep.script_language,
script_code: ep.script_code,
script_queries: resolvedScriptQueries,
aql_method: ep.aql_method,
aql_endpoint: ep.aql_endpoint,
aql_body: ep.aql_body,
aql_query_params: ep.aql_query_params,
is_public: ep.is_public,
enable_logging: ep.enable_logging,
detailed_response: ep.detailed_response,
response_schema: ep.response_schema,
folder_id: ep.folder_id,
folder_name: ep.folder_name,
created_at: ep.created_at,
updated_at: ep.updated_at,
};
}));
const folders = foldersResult.rows.map((f: any) => ({
id: f.id,
name: f.name,
parent_id: f.parent_id,
parent_name: f.parent_name,
created_at: f.created_at,
updated_at: f.updated_at,
}));
res.json({
endpoints,
folders,
databases: databasesResult.rows,
server_time: new Date().toISOString(),
});
} catch (error) {
console.error('Sync pull error:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
/**
* POST /api/sync/status
* Accepts {endpoints: [{id, updated_at}], folders: [{id, updated_at}]}
* Returns which items changed on server since the given timestamps
*/
export const syncStatus = async (req: AuthRequest, res: Response) => {
try {
const { endpoints: clientEndpoints, folders: clientFolders } = req.body;
// Get current server state
const serverEndpoints = await mainPool.query(
'SELECT id, name, updated_at FROM endpoints'
);
const serverFolders = await mainPool.query(
'SELECT id, name, updated_at FROM folders'
);
const clientEndpointMap = new Map(
(clientEndpoints || []).map((e: any) => [e.id, e.updated_at])
);
const clientFolderMap = new Map(
(clientFolders || []).map((f: any) => [f.id, f.updated_at])
);
// Find changed/new/deleted endpoints
const changedEndpoints: any[] = [];
const newEndpoints: any[] = [];
for (const ep of serverEndpoints.rows) {
const clientUpdatedAt = clientEndpointMap.get(ep.id);
if (!clientUpdatedAt) {
newEndpoints.push({ id: ep.id, name: ep.name });
} else if (new Date(String(ep.updated_at)).getTime() > new Date(String(clientUpdatedAt)).getTime()) {
changedEndpoints.push({ id: ep.id, name: ep.name, server_updated_at: ep.updated_at });
}
}
const serverEndpointIds = new Set(serverEndpoints.rows.map((e: any) => e.id));
const deletedEndpoints = (clientEndpoints || [])
.filter((e: any) => !serverEndpointIds.has(e.id))
.map((e: any) => ({ id: e.id }));
// Find changed/new/deleted folders
const changedFolders: any[] = [];
const newFolders: any[] = [];
for (const f of serverFolders.rows) {
const clientUpdatedAt = clientFolderMap.get(f.id);
if (!clientUpdatedAt) {
newFolders.push({ id: f.id, name: f.name });
} else if (new Date(String(f.updated_at)).getTime() > new Date(String(clientUpdatedAt)).getTime()) {
changedFolders.push({ id: f.id, name: f.name, server_updated_at: f.updated_at });
}
}
const serverFolderIds = new Set(serverFolders.rows.map((f: any) => f.id));
const deletedFolders = (clientFolders || [])
.filter((f: any) => !serverFolderIds.has(f.id))
.map((f: any) => ({ id: f.id }));
res.json({
endpoints: {
changed: changedEndpoints,
new: newEndpoints,
deleted: deletedEndpoints,
},
folders: {
changed: changedFolders,
new: newFolders,
deleted: deletedFolders,
},
});
} catch (error) {
console.error('Sync status error:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
/**
* POST /api/sync/push
* Accepts array of endpoints to create/update with conflict detection
*/
export const syncPush = async (req: AuthRequest, res: Response) => {
try {
const { endpoints: pushEndpoints, force } = req.body;
if (!pushEndpoints || !Array.isArray(pushEndpoints)) {
return res.status(400).json({ error: 'endpoints array is required' });
}
const conflicts: any[] = [];
const results: any[] = [];
for (const ep of pushEndpoints) {
// Check if endpoint exists
if (ep.id) {
const existing = await mainPool.query(
'SELECT id, updated_at, name FROM endpoints WHERE id = $1',
[ep.id]
);
if (existing.rows.length > 0) {
const serverUpdatedAt = new Date(existing.rows[0].updated_at).getTime();
const clientBaseUpdatedAt = ep._base_updated_at
? new Date(ep._base_updated_at).getTime()
: 0;
// Conflict: server was modified after client's last sync
if (!force && clientBaseUpdatedAt && serverUpdatedAt > clientBaseUpdatedAt) {
conflicts.push({
id: ep.id,
name: existing.rows[0].name,
server_updated_at: existing.rows[0].updated_at,
client_base_updated_at: ep._base_updated_at,
});
continue;
}
// Update existing endpoint
const result = await mainPool.query(
`UPDATE endpoints
SET name = $1, description = $2, method = $3, path = $4,
database_id = $5, sql_query = $6, parameters = $7,
folder_id = $8, is_public = $9, enable_logging = $10,
execution_type = $11, script_language = $12, script_code = $13,
script_queries = $14, aql_method = $15, aql_endpoint = $16,
aql_body = $17, aql_query_params = $18, detailed_response = $19,
response_schema = $20, updated_at = CURRENT_TIMESTAMP
WHERE id = $21
RETURNING *`,
[
ep.name, ep.description || '', ep.method, ep.path,
ep.database_id || null, ep.sql_query || '', JSON.stringify(ep.parameters || []),
ep.folder_id || null, ep.is_public || false, ep.enable_logging || false,
ep.execution_type || 'sql', ep.script_language || null, ep.script_code || null,
JSON.stringify(ep.script_queries || []), ep.aql_method || null,
ep.aql_endpoint || null, ep.aql_body || null,
JSON.stringify(ep.aql_query_params || {}), ep.detailed_response || false,
ep.response_schema ? JSON.stringify(ep.response_schema) : null,
ep.id,
]
);
results.push({ action: 'updated', endpoint: result.rows[0] });
} else {
// Endpoint with this ID doesn't exist on server — create it
const result = await mainPool.query(
`INSERT INTO endpoints (
id, name, description, method, path, database_id, sql_query, parameters,
folder_id, user_id, is_public, enable_logging,
execution_type, script_language, script_code, script_queries,
aql_method, aql_endpoint, aql_body, aql_query_params, detailed_response, response_schema
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22)
RETURNING *`,
[
ep.id, ep.name, ep.description || '', ep.method, ep.path,
ep.database_id || null, ep.sql_query || '', JSON.stringify(ep.parameters || []),
ep.folder_id || null, req.user!.id, ep.is_public || false, ep.enable_logging || false,
ep.execution_type || 'sql', ep.script_language || null, ep.script_code || null,
JSON.stringify(ep.script_queries || []), ep.aql_method || null,
ep.aql_endpoint || null, ep.aql_body || null,
JSON.stringify(ep.aql_query_params || {}), ep.detailed_response || false,
ep.response_schema ? JSON.stringify(ep.response_schema) : null,
]
);
results.push({ action: 'created', endpoint: result.rows[0] });
}
}
}
if (conflicts.length > 0) {
return res.status(409).json({
error: 'Conflicts detected',
conflicts,
applied: results,
});
}
res.json({
success: true,
results,
});
} catch (error: any) {
console.error('Sync push error:', error);
if (error.code === '23505') {
return res.status(400).json({ error: 'Endpoint path already exists' });
}
res.status(500).json({ error: 'Internal server error' });
}
};

View File

@@ -0,0 +1,7 @@
-- Add response_schema to endpoints for Swagger documentation
-- Stores an OpenAPI-compatible JSON schema describing the 200 response
ALTER TABLE endpoints
ADD COLUMN IF NOT EXISTS response_schema JSONB DEFAULT NULL;
COMMENT ON COLUMN endpoints.response_schema IS 'Optional OpenAPI JSON schema for the 200 response, displayed in Swagger documentation.';

View File

@@ -0,0 +1,13 @@
import express from 'express';
import { authMiddleware } from '../middleware/auth';
import { syncPull, syncPush, syncStatus } from '../controllers/syncController';
const router = express.Router();
router.use(authMiddleware);
router.get('/pull', syncPull);
router.post('/status', syncStatus);
router.post('/push', syncPush);
export default router;

View File

@@ -20,6 +20,7 @@ import userRoutes from './routes/users';
import logsRoutes from './routes/logs'; import logsRoutes from './routes/logs';
import sqlInterfaceRoutes from './routes/sqlInterface'; import sqlInterfaceRoutes from './routes/sqlInterface';
import dynamicRoutes from './routes/dynamic'; import dynamicRoutes from './routes/dynamic';
import syncRoutes from './routes/sync';
const app: Express = express(); const app: Express = express();
@@ -34,8 +35,8 @@ app.set('trust proxy', true);
// crossOriginEmbedderPolicy: false, // crossOriginEmbedderPolicy: false,
// })); // }));
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true, limit: '50mb' }));
// Rate limiting - DISABLED // Rate limiting - DISABLED
// const limiter = rateLimit({ // const limiter = rateLimit({
@@ -93,6 +94,7 @@ app.use('/api/db-management', databaseManagementRoutes);
app.use('/api/users', userRoutes); app.use('/api/users', userRoutes);
app.use('/api/logs', logsRoutes); app.use('/api/logs', logsRoutes);
app.use('/api/workbench', sqlInterfaceRoutes); app.use('/api/workbench', sqlInterfaceRoutes);
app.use('/api/sync', syncRoutes);
// Dynamic API routes (user-created endpoints) // Dynamic API routes (user-created endpoints)
app.use('/api/v1', dynamicRoutes); app.use('/api/v1', dynamicRoutes);

View File

@@ -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 } from '../types'; import { ScriptQuery, EndpointParameter, LogEntry, QueryExecution, IsolatedExecutionResult, ScriptExecutionError } from '../types';
import { databasePoolManager } from './DatabasePoolManager'; import { databasePoolManager } from './DatabasePoolManager';
interface IsolatedScriptContext { interface IsolatedScriptContext {
@@ -228,7 +228,9 @@ export class IsolatedScriptExecutor {
} }
timerIds.clear(); timerIds.clear();
throw new Error(`JavaScript execution error: ${error.message}`); // Preserve captured logs and queries in the error
logs.push({ type: 'error', message: error.message, timestamp: Date.now() });
throw new ScriptExecutionError(`JavaScript execution error: ${error.message}`, logs, queries);
} }
} }

View File

@@ -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 } from '../types'; import { ScriptQuery, EndpointParameter, LogEntry, QueryExecution, IsolatedExecutionResult, ScriptExecutionError } from '../types';
import { databasePoolManager } from './DatabasePoolManager'; import { databasePoolManager } from './DatabasePoolManager';
import { isolatedScriptExecutor } from './IsolatedScriptExecutor'; import { isolatedScriptExecutor } from './IsolatedScriptExecutor';
@@ -258,7 +258,8 @@ print(json.dumps(result))
python.on('close', (exitCode) => { python.on('close', (exitCode) => {
if (exitCode !== 0) { if (exitCode !== 0) {
reject(new Error(`Python execution error: ${errorOutput}`)); logs.push({ type: 'error', message: errorOutput, timestamp: Date.now() });
reject(new ScriptExecutionError(`Python execution error: ${errorOutput}`, logs, queries));
} else { } else {
try { try {
// Последняя строка вывода - результат, остальные - логи // Последняя строка вывода - результат, остальные - логи
@@ -276,7 +277,7 @@ print(json.dumps(result))
const result = JSON.parse(resultLine); const result = JSON.parse(resultLine);
resolve({ result, logs, queries }); resolve({ result, logs, queries });
} catch (error) { } catch (error) {
reject(new Error(`Failed to parse Python output: ${output}`)); reject(new ScriptExecutionError(`Failed to parse Python output: ${output}`, logs, queries));
} }
} }
}); });
@@ -284,7 +285,7 @@ print(json.dumps(result))
// Таймаут 10 минут // Таймаут 10 минут
setTimeout(() => { setTimeout(() => {
python.kill(); python.kill();
reject(new Error('Python script execution timeout (10min)')); reject(new ScriptExecutionError('Python script execution timeout (10min)', logs, queries));
}, 600000); }, 600000);
}); });
} }

View File

@@ -71,6 +71,8 @@ export interface Endpoint {
aql_endpoint?: string; aql_endpoint?: string;
aql_body?: string; aql_body?: string;
aql_query_params?: Record<string, string>; aql_query_params?: Record<string, string>;
// Response schema for Swagger docs
response_schema?: object | null;
created_at: Date; created_at: Date;
updated_at: Date; updated_at: Date;
} }
@@ -81,6 +83,7 @@ export interface EndpointParameter {
required: boolean; required: boolean;
default_value?: any; default_value?: any;
description?: string; description?: string;
example_value?: string;
in: 'query' | 'body' | 'path'; in: 'query' | 'body' | 'path';
} }
@@ -122,6 +125,18 @@ export interface IsolatedExecutionResult {
queries: QueryExecution[]; queries: QueryExecution[];
} }
export class ScriptExecutionError extends Error {
logs: LogEntry[];
queries: QueryExecution[];
constructor(message: string, logs: LogEntry[], queries: QueryExecution[]) {
super(message);
this.name = 'ScriptExecutionError';
this.logs = logs;
this.queries = queries;
}
}
export interface SwaggerEndpoint { export interface SwaggerEndpoint {
tags: string[]; tags: string[];
summary: string; summary: string;
@@ -163,5 +178,7 @@ export interface ExportedEndpoint {
is_public: boolean; is_public: boolean;
enable_logging: boolean; enable_logging: boolean;
detailed_response: boolean; detailed_response: boolean;
response_schema: object | null;
folder_name: string | null; folder_name: string | null;
} }

1
cli Submodule

Submodule cli added at 5d6e7bbe56

View File

@@ -9,6 +9,7 @@ import Sidebar from '@/components/Sidebar';
import Login from '@/pages/Login'; import Login from '@/pages/Login';
import Dashboard from '@/pages/Dashboard'; import Dashboard from '@/pages/Dashboard';
import Endpoints from '@/pages/Endpoints'; import Endpoints from '@/pages/Endpoints';
import EndpointEditor from '@/pages/EndpointEditor';
import ApiKeys from '@/pages/ApiKeys'; import ApiKeys from '@/pages/ApiKeys';
import Folders from '@/pages/Folders'; import Folders from '@/pages/Folders';
import Logs from '@/pages/Logs'; import Logs from '@/pages/Logs';
@@ -97,6 +98,26 @@ function App() {
</PrivateRoute> </PrivateRoute>
} }
/> />
<Route
path="/endpoints/new"
element={
<PrivateRoute>
<Layout>
<EndpointEditor />
</Layout>
</PrivateRoute>
}
/>
<Route
path="/endpoints/:id"
element={
<PrivateRoute>
<Layout>
<EndpointEditor />
</Layout>
</PrivateRoute>
}
/>
<Route <Route
path="/endpoints" path="/endpoints"
element={ element={

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,17 @@
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { endpointsApi, databasesApi } from '@/services/api'; import { endpointsApi } from '@/services/api';
import { Endpoint, ImportPreviewResponse } from '@/types'; import { ImportPreviewResponse } from '@/types';
import { Plus, Search, Edit2, Trash2, Download, Upload } from 'lucide-react'; import { Plus, Search, Edit2, Trash2, Download, Upload } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import EndpointModal from '@/components/EndpointModal';
import ImportEndpointModal from '@/components/ImportEndpointModal'; import ImportEndpointModal from '@/components/ImportEndpointModal';
import Dialog from '@/components/Dialog'; import Dialog from '@/components/Dialog';
export default function Endpoints() { export default function Endpoints() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [showModal, setShowModal] = useState(false);
const [editingEndpoint, setEditingEndpoint] = useState<Endpoint | null>(null);
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
const [importFile, setImportFile] = useState<File | null>(null); const [importFile, setImportFile] = useState<File | null>(null);
const [importPreview, setImportPreview] = useState<ImportPreviewResponse | null>(null); const [importPreview, setImportPreview] = useState<ImportPreviewResponse | null>(null);
@@ -35,11 +34,6 @@ export default function Endpoints() {
queryFn: () => endpointsApi.getAll(search).then(res => res.data), queryFn: () => endpointsApi.getAll(search).then(res => res.data),
}); });
const { data: databases } = useQuery({
queryKey: ['databases'],
queryFn: () => databasesApi.getAll().then(res => res.data),
});
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: string) => endpointsApi.delete(id), mutationFn: (id: string) => endpointsApi.delete(id),
onSuccess: () => { onSuccess: () => {
@@ -61,16 +55,6 @@ export default function Endpoints() {
}); });
}; };
const handleEdit = (endpoint: Endpoint) => {
setEditingEndpoint(endpoint);
setShowModal(true);
};
const handleCreate = () => {
setEditingEndpoint(null);
setShowModal(true);
};
const handleExport = async (endpointId: string, endpointName: string) => { const handleExport = async (endpointId: string, endpointName: string) => {
try { try {
const response = await endpointsApi.exportEndpoint(endpointId); const response = await endpointsApi.exportEndpoint(endpointId);
@@ -129,7 +113,7 @@ export default function Endpoints() {
<Upload size={20} /> <Upload size={20} />
Импорт Импорт
</button> </button>
<button onClick={handleCreate} className="btn btn-primary flex items-center gap-2"> <button onClick={() => navigate('/endpoints/new')} className="btn btn-primary flex items-center gap-2">
<Plus size={20} /> <Plus size={20} />
Новый эндпоинт Новый эндпоинт
</button> </button>
@@ -203,7 +187,7 @@ export default function Endpoints() {
<Download size={18} className="text-gray-600" /> <Download size={18} className="text-gray-600" />
</button> </button>
<button <button
onClick={() => handleEdit(endpoint)} onClick={() => navigate(`/endpoints/${endpoint.id}`)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors" className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
title="Редактировать" title="Редактировать"
> >
@@ -229,14 +213,6 @@ export default function Endpoints() {
</div> </div>
)} )}
{showModal && (
<EndpointModal
endpoint={editingEndpoint}
databases={databases || []}
onClose={() => setShowModal(false)}
/>
)}
{showImportModal && importPreview && importFile && ( {showImportModal && importPreview && importFile && (
<ImportEndpointModal <ImportEndpointModal
preview={importPreview} preview={importPreview}

View File

@@ -1,20 +1,20 @@
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { foldersApi, endpointsApi, databasesApi } from '@/services/api'; import { foldersApi, endpointsApi } from '@/services/api';
import { Folder, Endpoint } from '@/types'; import { Folder, Endpoint } from '@/types';
import { Plus, Edit2, Trash2, Folder as FolderIcon, FolderOpen, FileCode, ChevronRight, ChevronDown } from 'lucide-react'; import { Plus, Edit2, Trash2, Folder as FolderIcon, FolderOpen, FileCode, ChevronRight, ChevronDown, Search, ChevronsDownUp, ChevronsUpDown } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import EndpointModal from '@/components/EndpointModal';
import Dialog from '@/components/Dialog'; import Dialog from '@/components/Dialog';
export default function Folders() { export default function Folders() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate();
const [showFolderModal, setShowFolderModal] = useState(false); const [showFolderModal, setShowFolderModal] = useState(false);
const [showEndpointModal, setShowEndpointModal] = useState(false);
const [editingFolder, setEditingFolder] = useState<Folder | null>(null); const [editingFolder, setEditingFolder] = useState<Folder | null>(null);
const [editingEndpoint, setEditingEndpoint] = useState<Endpoint | null>(null);
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null); const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set()); const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState('');
const [dialog, setDialog] = useState<{ const [dialog, setDialog] = useState<{
isOpen: boolean; isOpen: boolean;
title: string; title: string;
@@ -38,11 +38,6 @@ export default function Folders() {
queryFn: () => endpointsApi.getAll().then(res => res.data), queryFn: () => endpointsApi.getAll().then(res => res.data),
}); });
const { data: databases } = useQuery({
queryKey: ['databases'],
queryFn: () => databasesApi.getAll().then(res => res.data),
});
const deleteFolderMutation = useMutation({ const deleteFolderMutation = useMutation({
mutationFn: (id: string) => foldersApi.delete(id), mutationFn: (id: string) => foldersApi.delete(id),
onSuccess: () => { onSuccess: () => {
@@ -74,14 +69,11 @@ export default function Folders() {
}; };
const handleCreateEndpoint = (folderId?: string) => { const handleCreateEndpoint = (folderId?: string) => {
setSelectedFolderId(folderId || null); navigate(folderId ? `/endpoints/new?folder=${folderId}` : '/endpoints/new');
setEditingEndpoint(null);
setShowEndpointModal(true);
}; };
const handleEditEndpoint = (endpoint: Endpoint) => { const handleEditEndpoint = (endpoint: Endpoint) => {
setEditingEndpoint(endpoint); navigate(`/endpoints/${endpoint.id}`);
setShowEndpointModal(true);
}; };
const handleDeleteFolder = (id: string) => { const handleDeleteFolder = (id: string) => {
@@ -120,6 +112,16 @@ export default function Folders() {
}); });
}; };
const getAllFolderIds = (nodes: any[]): string[] => {
const ids: string[] = [];
const collect = (ns: any[]) => ns.forEach(n => { ids.push(n.id); if (n.children?.length) collect(n.children); });
collect(nodes);
return ids;
};
const expandAll = () => setExpandedFolders(new Set(getAllFolderIds(tree)));
const collapseAll = () => setExpandedFolders(new Set());
// Построение дерева папок // Построение дерева папок
const buildTree = () => { const buildTree = () => {
if (!folders || !endpoints) return []; if (!folders || !endpoints) return [];
@@ -152,6 +154,33 @@ export default function Folders() {
const tree = buildTree(); const tree = buildTree();
// Поиск
const searchResults = useMemo(() => {
if (!searchQuery.trim()) return null;
const q = searchQuery.toLowerCase();
// Строим карту путей папок
const folderPathMap = new Map<string, string>();
const buildPaths = (nodes: any[], prefix = '') => {
nodes.forEach(n => {
const p = prefix ? `${prefix} / ${n.name}` : n.name;
folderPathMap.set(n.id, p);
if (n.children?.length) buildPaths(n.children, p);
});
};
buildPaths(tree);
const matchedEndpoints = (endpoints || []).filter(e =>
e.name.toLowerCase().includes(q) ||
e.path.toLowerCase().includes(q) ||
e.method.toLowerCase().includes(q)
).map(e => ({ ...e, folderPath: e.folder_id ? (folderPathMap.get(e.folder_id) || '') : '' }));
const matchedFolders = (folders || []).filter(f => f.name.toLowerCase().includes(q));
return { endpoints: matchedEndpoints, folders: matchedFolders, folderPathMap };
}, [searchQuery, endpoints, folders, tree]);
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
@@ -178,40 +207,126 @@ export default function Folders() {
</div> </div>
) : ( ) : (
<div className="card p-6"> <div className="card p-6">
{/* Toolbar: search + expand/collapse */}
<div className="flex items-center gap-2 mb-4">
<div className="relative flex-1">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Поиск по имени, пути, методу..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="input w-full pl-9 text-sm"
/>
</div>
<button
onClick={expandAll}
className="btn btn-secondary flex items-center gap-1.5 text-sm py-1.5"
title="Раскрыть все"
>
<ChevronsUpDown size={16} />
Раскрыть все
</button>
<button
onClick={collapseAll}
className="btn btn-secondary flex items-center gap-1.5 text-sm py-1.5"
title="Свернуть все"
>
<ChevronsDownUp size={16} />
Свернуть все
</button>
</div>
<div className="space-y-1"> <div className="space-y-1">
{/* Корневые папки */} {searchResults ? (
{tree.map(folder => ( /* Результаты поиска */
<TreeNode <>
key={folder.id} {searchResults.endpoints.length === 0 && searchResults.folders.length === 0 ? (
folder={folder} <div className="text-center py-12 text-gray-500">
level={0} <p>Ничего не найдено по запросу «{searchQuery}»</p>
expandedFolders={expandedFolders} </div>
onToggle={toggleFolder} ) : (
onEditFolder={handleEditFolder} <>
onDeleteFolder={handleDeleteFolder} {searchResults.folders.map((folder: any) => (
onCreateSubfolder={handleCreateFolder} <div key={folder.id} className="flex items-center gap-2 px-3 py-2 rounded bg-yellow-50 border border-yellow-100">
onCreateEndpoint={handleCreateEndpoint} <FolderIcon size={16} className="text-yellow-600 flex-shrink-0" />
onEditEndpoint={handleEditEndpoint} <span className="text-sm font-medium text-gray-900">{folder.name}</span>
onDeleteEndpoint={handleDeleteEndpoint} <span className="text-xs text-gray-400">{searchResults.folderPathMap.get(folder.id)}</span>
/> <div className="flex gap-1 ml-auto">
))} <button onClick={() => handleEditFolder(folder)} className="p-1.5 hover:bg-gray-200 rounded" title="Редактировать">
<Edit2 size={14} className="text-gray-600" />
</button>
<button onClick={() => handleDeleteFolder(folder.id)} className="p-1.5 hover:bg-red-100 rounded" title="Удалить">
<Trash2 size={14} className="text-red-600" />
</button>
</div>
</div>
))}
{searchResults.endpoints.map((endpoint: any) => (
<div key={endpoint.id} className="flex items-center gap-2 px-3 py-2 rounded hover:bg-gray-50 border border-transparent">
<FileCode size={16} className="text-blue-600 flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm text-gray-900">{endpoint.name}</span>
{endpoint.folderPath && (
<span className="ml-2 text-xs text-gray-400">{endpoint.folderPath}</span>
)}
</div>
<span className={`text-xs px-2 py-0.5 rounded font-medium ${
endpoint.method === 'GET' ? 'bg-green-100 text-green-700' :
endpoint.method === 'POST' ? 'bg-blue-100 text-blue-700' :
endpoint.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>{endpoint.method}</span>
<code className="text-xs text-gray-600 hidden sm:block">{endpoint.path}</code>
<div className="flex gap-1">
<button onClick={() => handleEditEndpoint(endpoint)} className="p-1.5 hover:bg-gray-200 rounded" title="Редактировать">
<Edit2 size={14} className="text-gray-600" />
</button>
<button onClick={() => handleDeleteEndpoint(endpoint.id)} className="p-1.5 hover:bg-red-100 rounded" title="Удалить">
<Trash2 size={14} className="text-red-600" />
</button>
</div>
</div>
))}
</>
)}
</>
) : (
/* Обычное дерево */
<>
{tree.map(folder => (
<TreeNode
key={folder.id}
folder={folder}
level={0}
expandedFolders={expandedFolders}
onToggle={toggleFolder}
onEditFolder={handleEditFolder}
onDeleteFolder={handleDeleteFolder}
onCreateSubfolder={handleCreateFolder}
onCreateEndpoint={handleCreateEndpoint}
onEditEndpoint={handleEditEndpoint}
onDeleteEndpoint={handleDeleteEndpoint}
/>
))}
{/* Корневые эндпоинты (без папки) */} {rootEndpoints.map(endpoint => (
{rootEndpoints.map(endpoint => ( <EndpointNode
<EndpointNode key={endpoint.id}
key={endpoint.id} endpoint={endpoint}
endpoint={endpoint} level={0}
level={0} onEdit={handleEditEndpoint}
onEdit={handleEditEndpoint} onDelete={handleDeleteEndpoint}
onDelete={handleDeleteEndpoint} />
/> ))}
))}
{tree.length === 0 && rootEndpoints.length === 0 && ( {tree.length === 0 && rootEndpoints.length === 0 && (
<div className="text-center py-12 text-gray-500"> <div className="text-center py-12 text-gray-500">
<p>Нет папок и эндпоинтов.</p> <p>Нет папок и эндпоинтов.</p>
<p className="text-sm mt-2">Создайте первую папку или эндпоинт!</p> <p className="text-sm mt-2">Создайте первую папку или эндпоинт!</p>
</div> </div>
)}
</>
)} )}
</div> </div>
</div> </div>
@@ -226,15 +341,6 @@ export default function Folders() {
/> />
)} )}
{showEndpointModal && (
<EndpointModal
endpoint={editingEndpoint}
folderId={selectedFolderId}
databases={databases || []}
onClose={() => setShowEndpointModal(false)}
/>
)}
<Dialog <Dialog
isOpen={dialog.isOpen} isOpen={dialog.isOpen}
onClose={() => setDialog({ ...dialog, isOpen: false })} onClose={() => setDialog({ ...dialog, isOpen: false })}

View File

@@ -41,6 +41,7 @@ export interface EndpointParameter {
required: boolean; required: boolean;
default_value?: any; default_value?: any;
description?: string; description?: string;
example_value?: string;
in: 'query' | 'body' | 'path'; in: 'query' | 'body' | 'path';
} }
@@ -80,6 +81,7 @@ export interface Endpoint {
aql_query_params?: Record<string, string>; aql_query_params?: Record<string, string>;
// Response format // Response format
detailed_response?: boolean; detailed_response?: boolean;
response_schema?: object | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }