Compare commits
10 Commits
e8aab43a13
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c918f34595 | |||
| c047319afe | |||
| 46396c6fc4 | |||
| dcdd6fee6d | |||
| 4ae281209f | |||
| f0cbe99cb0 | |||
| 727c6765f8 | |||
| b6b7064a41 | |||
| 49f262d8ae | |||
| 3a3c87164d |
@@ -44,6 +44,7 @@ export async function generateDynamicSwagger(): Promise<SwaggerSpec> {
|
||||
e.path,
|
||||
e.parameters,
|
||||
e.is_public,
|
||||
e.response_schema,
|
||||
fp.full_path as folder_name
|
||||
FROM endpoints e
|
||||
LEFT JOIN folder_path fp ON e.folder_id = fp.id
|
||||
@@ -136,7 +137,9 @@ export async function generateDynamicSwagger(): Promise<SwaggerSpec> {
|
||||
description: 'Успешный ответ',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
schema: endpoint.response_schema
|
||||
? endpoint.response_schema
|
||||
: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ApiKeyRequest } from '../middleware/apiKey';
|
||||
import { mainPool } from '../config/database';
|
||||
import { sqlExecutor } from '../services/SqlExecutor';
|
||||
import { scriptExecutor } from '../services/ScriptExecutor';
|
||||
import { EndpointParameter, ScriptQuery } from '../types';
|
||||
import { EndpointParameter, ScriptQuery, ScriptExecutionError } from '../types';
|
||||
|
||||
export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response) => {
|
||||
const startTime = Date.now();
|
||||
@@ -202,9 +202,11 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
|
||||
});
|
||||
|
||||
result = {
|
||||
rows: scriptResult,
|
||||
rows: scriptResult.result,
|
||||
rowCount: 0,
|
||||
executionTime: 0,
|
||||
scriptLogs: scriptResult.logs,
|
||||
scriptQueries: scriptResult.queries,
|
||||
};
|
||||
} else {
|
||||
// Execute SQL query
|
||||
@@ -241,6 +243,8 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
|
||||
data: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
executionTime: result.executionTime,
|
||||
...(result.scriptLogs && result.scriptLogs.length > 0 ? { logs: result.scriptLogs } : {}),
|
||||
...(result.scriptQueries && result.scriptQueries.length > 0 ? { queries: result.scriptQueries } : {}),
|
||||
}
|
||||
: result.rows;
|
||||
|
||||
@@ -267,9 +271,12 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
|
||||
} catch (error: any) {
|
||||
console.error('Dynamic API execution error:', error);
|
||||
|
||||
const errorResponse = {
|
||||
const isScriptError = error instanceof ScriptExecutionError;
|
||||
const errorResponse: any = {
|
||||
success: false,
|
||||
error: error.message,
|
||||
...(isScriptError && error.logs.length > 0 ? { logs: error.logs } : {}),
|
||||
...(isScriptError && error.queries.length > 0 ? { queries: error.queries } : {}),
|
||||
};
|
||||
|
||||
// Log error if needed
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Response } from 'express';
|
||||
import { AuthRequest } from '../middleware/auth';
|
||||
import { mainPool } from '../config/database';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { ExportedEndpoint, ExportedScriptQuery } from '../types';
|
||||
import { ExportedEndpoint, ExportedScriptQuery, ScriptExecutionError } from '../types';
|
||||
import { encryptEndpointData, decryptEndpointData } from '../services/endpointCrypto';
|
||||
|
||||
export const getEndpoints = async (req: AuthRequest, res: Response) => {
|
||||
@@ -88,6 +88,7 @@ export const createEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
aql_body,
|
||||
aql_query_params,
|
||||
detailed_response,
|
||||
response_schema,
|
||||
} = req.body;
|
||||
|
||||
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,
|
||||
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
|
||||
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 *`,
|
||||
[
|
||||
name,
|
||||
@@ -147,6 +148,7 @@ export const createEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
aql_body || null,
|
||||
JSON.stringify(aql_query_params || {}),
|
||||
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_query_params,
|
||||
detailed_response,
|
||||
response_schema,
|
||||
} = req.body;
|
||||
|
||||
const result = await mainPool.query(
|
||||
@@ -206,8 +209,9 @@ export const updateEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
aql_body = $17,
|
||||
aql_query_params = $18,
|
||||
detailed_response = $19,
|
||||
response_schema = $20,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $20
|
||||
WHERE id = $21
|
||||
RETURNING *`,
|
||||
[
|
||||
name,
|
||||
@@ -229,6 +233,7 @@ export const updateEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
aql_body || null,
|
||||
aql_query_params ? JSON.stringify(aql_query_params) : null,
|
||||
detailed_response || false,
|
||||
response_schema ? JSON.stringify(response_schema) : null,
|
||||
id,
|
||||
]
|
||||
);
|
||||
@@ -388,13 +393,14 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
return res.status(400).json({ error: 'Invalid execution_type' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
const isScriptError = error instanceof ScriptExecutionError;
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
detail: error.detail || undefined,
|
||||
hint: error.hint || undefined,
|
||||
logs: [],
|
||||
queries: [],
|
||||
logs: isScriptError ? error.logs : [],
|
||||
queries: isScriptError ? error.queries : [],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -487,14 +493,16 @@ export const exportEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
is_public: endpoint.is_public || false,
|
||||
enable_logging: endpoint.enable_logging || false,
|
||||
detailed_response: endpoint.detailed_response || false,
|
||||
response_schema: endpoint.response_schema || null,
|
||||
folder_name: folderName,
|
||||
};
|
||||
|
||||
const encrypted = encryptEndpointData(exportData);
|
||||
|
||||
const safeFileName = endpoint.name.replace(/[^a-zA-Z0-9_\-а-яА-ЯёЁ]/g, '_');
|
||||
const safeFileName = endpoint.name.replace(/[^a-zA-Z0-9_\-]/g, '_');
|
||||
const encodedFileName = encodeURIComponent(endpoint.name.replace(/[\/\\:*?"<>|]/g, '_')) + '.kabe';
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${safeFileName}.kabe"`);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${safeFileName}.kabe"; filename*=UTF-8''${encodedFileName}`);
|
||||
res.send(encrypted);
|
||||
} catch (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,
|
||||
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
|
||||
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 *`,
|
||||
[
|
||||
exportData.name,
|
||||
@@ -723,6 +731,7 @@ export const importEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
exportData.aql_body || null,
|
||||
JSON.stringify(exportData.aql_query_params || {}),
|
||||
exportData.detailed_response || false,
|
||||
exportData.response_schema ? JSON.stringify(exportData.response_schema) : null,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
296
backend/src/controllers/syncController.ts
Normal file
296
backend/src/controllers/syncController.ts
Normal 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' });
|
||||
}
|
||||
};
|
||||
7
backend/src/migrations/009_add_response_schema.sql
Normal file
7
backend/src/migrations/009_add_response_schema.sql
Normal 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.';
|
||||
13
backend/src/routes/sync.ts
Normal file
13
backend/src/routes/sync.ts
Normal 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;
|
||||
@@ -20,6 +20,7 @@ import userRoutes from './routes/users';
|
||||
import logsRoutes from './routes/logs';
|
||||
import sqlInterfaceRoutes from './routes/sqlInterface';
|
||||
import dynamicRoutes from './routes/dynamic';
|
||||
import syncRoutes from './routes/sync';
|
||||
|
||||
const app: Express = express();
|
||||
|
||||
@@ -34,8 +35,8 @@ app.set('trust proxy', true);
|
||||
// crossOriginEmbedderPolicy: false,
|
||||
// }));
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
||||
|
||||
// Rate limiting - DISABLED
|
||||
// const limiter = rateLimit({
|
||||
@@ -93,6 +94,7 @@ app.use('/api/db-management', databaseManagementRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/logs', logsRoutes);
|
||||
app.use('/api/workbench', sqlInterfaceRoutes);
|
||||
app.use('/api/sync', syncRoutes);
|
||||
|
||||
// Dynamic API routes (user-created endpoints)
|
||||
app.use('/api/v1', dynamicRoutes);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as vm from 'vm';
|
||||
import { sqlExecutor } from './SqlExecutor';
|
||||
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';
|
||||
|
||||
interface IsolatedScriptContext {
|
||||
@@ -228,7 +228,9 @@ export class IsolatedScriptExecutor {
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { sqlExecutor } from './SqlExecutor';
|
||||
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 { isolatedScriptExecutor } from './IsolatedScriptExecutor';
|
||||
|
||||
@@ -258,7 +258,8 @@ print(json.dumps(result))
|
||||
|
||||
python.on('close', (exitCode) => {
|
||||
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 {
|
||||
try {
|
||||
// Последняя строка вывода - результат, остальные - логи
|
||||
@@ -276,7 +277,7 @@ print(json.dumps(result))
|
||||
const result = JSON.parse(resultLine);
|
||||
resolve({ result, logs, queries });
|
||||
} 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 минут
|
||||
setTimeout(() => {
|
||||
python.kill();
|
||||
reject(new Error('Python script execution timeout (10min)'));
|
||||
reject(new ScriptExecutionError('Python script execution timeout (10min)', logs, queries));
|
||||
}, 600000);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -71,6 +71,8 @@ export interface Endpoint {
|
||||
aql_endpoint?: string;
|
||||
aql_body?: string;
|
||||
aql_query_params?: Record<string, string>;
|
||||
// Response schema for Swagger docs
|
||||
response_schema?: object | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -123,6 +125,18 @@ export interface IsolatedExecutionResult {
|
||||
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 {
|
||||
tags: string[];
|
||||
summary: string;
|
||||
@@ -164,5 +178,7 @@ export interface ExportedEndpoint {
|
||||
is_public: boolean;
|
||||
enable_logging: boolean;
|
||||
detailed_response: boolean;
|
||||
response_schema: object | null;
|
||||
folder_name: string | null;
|
||||
}
|
||||
|
||||
|
||||
1
cli
Submodule
1
cli
Submodule
Submodule cli added at 5d6e7bbe56
@@ -75,9 +75,17 @@ export default function EndpointEditor() {
|
||||
aql_query_params: endpointData.aql_query_params || {},
|
||||
detailed_response: endpointData.detailed_response || false,
|
||||
});
|
||||
setResponseSchemaText(
|
||||
endpointData.response_schema
|
||||
? JSON.stringify(endpointData.response_schema, null, 2)
|
||||
: ''
|
||||
);
|
||||
}
|
||||
}, [endpointData]);
|
||||
|
||||
const [responseSchemaText, setResponseSchemaText] = useState('');
|
||||
const [responseSchemaExpanded, setResponseSchemaExpanded] = useState(false);
|
||||
const [responseSchemaError, setResponseSchemaError] = useState('');
|
||||
const [editingQueryIndex, setEditingQueryIndex] = useState<number | null>(null);
|
||||
const [showScriptCodeEditor, setShowScriptCodeEditor] = useState(false);
|
||||
const [parametersExpanded, setParametersExpanded] = useState(true);
|
||||
@@ -138,14 +146,23 @@ export default function EndpointEditor() {
|
||||
}
|
||||
}, [storageKey, testParams, testResult]);
|
||||
|
||||
const [saveAndStay, setSaveAndStay] = useState(false);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: any) =>
|
||||
isEditing ? endpointsApi.update(id!, data) : endpointsApi.create(data),
|
||||
onSuccess: () => {
|
||||
onSuccess: (response) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['endpoints'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['endpoint', id] });
|
||||
toast.success(isEditing ? 'Эндпоинт обновлен' : 'Эндпоинт создан');
|
||||
if (saveAndStay) {
|
||||
// При создании нового — переходим на страницу редактирования созданного
|
||||
if (!isEditing && response.data?.id) {
|
||||
navigate(`/endpoints/${response.data.id}`, { replace: true });
|
||||
}
|
||||
} else {
|
||||
navigate(-1);
|
||||
}
|
||||
},
|
||||
onError: () => toast.error('Не удалось сохранить эндпоинт'),
|
||||
});
|
||||
@@ -215,7 +232,17 @@ export default function EndpointEditor() {
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
saveMutation.mutate(formData);
|
||||
let parsedSchema = null;
|
||||
if (responseSchemaText.trim()) {
|
||||
try {
|
||||
parsedSchema = JSON.parse(responseSchemaText);
|
||||
} catch {
|
||||
setResponseSchemaError('Некорректный JSON');
|
||||
setResponseSchemaExpanded(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
saveMutation.mutate({ ...formData, response_schema: parsedSchema });
|
||||
};
|
||||
|
||||
// cURL generator
|
||||
@@ -738,6 +765,45 @@ export default function EndpointEditor() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Response schema */}
|
||||
<div className="border border-gray-200 rounded-lg">
|
||||
<div
|
||||
className="flex items-center justify-between p-3 bg-gray-50 cursor-pointer hover:bg-gray-100 rounded-t-lg"
|
||||
onClick={() => setResponseSchemaExpanded(!responseSchemaExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{responseSchemaExpanded ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
||||
<label className="text-sm font-medium text-gray-700 cursor-pointer">
|
||||
Схема ответа 200 (Swagger)
|
||||
</label>
|
||||
{responseSchemaText.trim() && (
|
||||
<span className="px-2 py-0.5 bg-green-100 text-green-700 rounded-full text-xs">задана</span>
|
||||
)}
|
||||
{responseSchemaError && (
|
||||
<span className="px-2 py-0.5 bg-red-100 text-red-700 rounded-full text-xs">{responseSchemaError}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{responseSchemaExpanded && (
|
||||
<div className="p-4 bg-white rounded-b-lg space-y-2">
|
||||
<p className="text-xs text-gray-500">
|
||||
JSON Schema в формате OpenAPI для документирования ответа. Если не задана — используется схема по умолчанию.
|
||||
</p>
|
||||
<textarea
|
||||
value={responseSchemaText}
|
||||
onChange={(e) => {
|
||||
setResponseSchemaText(e.target.value);
|
||||
setResponseSchemaError('');
|
||||
}}
|
||||
className="input w-full font-mono text-xs"
|
||||
rows={10}
|
||||
placeholder={'{\n "type": "array",\n "items": {\n "type": "object",\n "properties": {\n "id": { "type": "number" },\n "name": { "type": "string" }\n }\n }\n}'}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Checkboxes */}
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<label className="flex items-center gap-2">
|
||||
@@ -775,8 +841,21 @@ export default function EndpointEditor() {
|
||||
<button type="button" onClick={() => navigate(-1)} className="btn btn-secondary">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" disabled={saveMutation.isPending} className="btn btn-primary">
|
||||
{saveMutation.isPending ? 'Сохранение...' : 'Сохранить эндпоинт'}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saveMutation.isPending}
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setSaveAndStay(true)}
|
||||
>
|
||||
{saveMutation.isPending && saveAndStay ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saveMutation.isPending}
|
||||
className="btn btn-primary"
|
||||
onClick={() => setSaveAndStay(false)}
|
||||
>
|
||||
{saveMutation.isPending && !saveAndStay ? 'Сохранение...' : 'Сохранить и выйти'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { foldersApi, endpointsApi } from '@/services/api';
|
||||
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 Dialog from '@/components/Dialog';
|
||||
|
||||
@@ -14,6 +14,7 @@ export default function Folders() {
|
||||
const [editingFolder, setEditingFolder] = useState<Folder | null>(null);
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [dialog, setDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
@@ -111,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 = () => {
|
||||
if (!folders || !endpoints) return [];
|
||||
@@ -143,6 +154,33 @@ export default function Folders() {
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
@@ -169,8 +207,93 @@ export default function Folders() {
|
||||
</div>
|
||||
) : (
|
||||
<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">
|
||||
{/* Корневые папки */}
|
||||
{searchResults ? (
|
||||
/* Результаты поиска */
|
||||
<>
|
||||
{searchResults.endpoints.length === 0 && searchResults.folders.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<p>Ничего не найдено по запросу «{searchQuery}»</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{searchResults.folders.map((folder: any) => (
|
||||
<div key={folder.id} className="flex items-center gap-2 px-3 py-2 rounded bg-yellow-50 border border-yellow-100">
|
||||
<FolderIcon size={16} className="text-yellow-600 flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-gray-900">{folder.name}</span>
|
||||
<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}
|
||||
@@ -187,7 +310,6 @@ export default function Folders() {
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Корневые эндпоинты (без папки) */}
|
||||
{rootEndpoints.map(endpoint => (
|
||||
<EndpointNode
|
||||
key={endpoint.id}
|
||||
@@ -204,6 +326,8 @@ export default function Folders() {
|
||||
<p className="text-sm mt-2">Создайте первую папку или эндпоинт!</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -81,6 +81,7 @@ export interface Endpoint {
|
||||
aql_query_params?: Record<string, string>;
|
||||
// Response format
|
||||
detailed_response?: boolean;
|
||||
response_schema?: object | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user