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:
2026-05-23 21:04:11 +03:00
parent c918f34595
commit 801d0cce5f
19 changed files with 1263 additions and 223 deletions

View File

@@ -3,7 +3,7 @@ import { ApiKeyRequest } from '../middleware/apiKey';
import { mainPool } from '../config/database';
import { sqlExecutor } from '../services/SqlExecutor';
import { scriptExecutor } from '../services/ScriptExecutor';
import { EndpointParameter, ScriptQuery, ScriptExecutionError } from '../types';
import { EndpointParameter, ScriptQuery, ScriptExecutionError, Environment } from '../types';
export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response) => {
const startTime = Date.now();
@@ -11,6 +11,9 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
let endpointId: string | null = null;
try {
const environment: Environment =
(req.headers['x-environment'] as string)?.toLowerCase() === 'test' ? 'test' : 'prod';
// Extract the path from the request (remove /api/v1 prefix)
const requestPath = req.path; // This already has the path without /api/v1
const requestMethod = req.method.toUpperCase();
@@ -29,9 +32,21 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
});
}
const endpoint = endpointResult.rows[0];
let endpoint = endpointResult.rows[0];
endpointId = endpoint.id;
// If test environment requested and a draft exists, serve draft content
if (environment === 'test' && endpoint.draft_version_id) {
const draftResult = await mainPool.query(
`SELECT * FROM endpoint_versions WHERE id = $1 AND status = 'draft'`,
[endpoint.draft_version_id]
);
if (draftResult.rows.length > 0) {
const draft = draftResult.rows[0];
endpoint = { ...endpoint, ...draft, id: endpoint.id, folder_id: endpoint.folder_id, user_id: endpoint.user_id };
}
}
// Check if logging is enabled (on endpoint OR on API key, but log only once)
const endpointLogging = endpoint.enable_logging || false;
const apiKeyLogging = req.apiKey?.enable_logging || false;
@@ -171,7 +186,7 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
body: aqlBody,
queryParams: aqlQueryParams,
parameters: requestParams,
});
}, environment);
} else if (executionType === 'script') {
// Execute script
const scriptLanguage = endpoint.script_language;
@@ -199,6 +214,7 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
scriptQueries,
requestParams,
endpointParameters: parameters,
environment,
});
result = {
@@ -231,7 +247,8 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
result = await sqlExecutor.executeQuery(
endpoint.database_id,
processedQuery,
queryParams
queryParams,
environment
);
}
@@ -264,6 +281,7 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
error_message: null,
ip_address: req.ip || req.socket.remoteAddress || 'unknown',
user_agent: req.headers['user-agent'] || 'unknown',
environment,
});
}
@@ -295,6 +313,7 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
error_message: error.message,
ip_address: req.ip || req.socket.remoteAddress || 'unknown',
user_agent: req.headers['user-agent'] || 'unknown',
environment: (req.headers['x-environment'] as string)?.toLowerCase() === 'test' ? 'test' : 'prod',
});
}
@@ -315,6 +334,7 @@ async function logRequest(data: {
error_message: string | null;
ip_address: string;
user_agent: string;
environment?: string;
}) {
try {
await mainPool.query(
@@ -322,8 +342,8 @@ async function logRequest(data: {
endpoint_id, api_key_id, method, path,
request_params, request_body, response_status,
response_data, execution_time, error_message,
ip_address, user_agent
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
ip_address, user_agent, environment
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
[
data.endpoint_id,
data.api_key_id,
@@ -337,6 +357,7 @@ async function logRequest(data: {
data.error_message,
data.ip_address,
data.user_agent,
data.environment || 'prod',
]
);
} catch (error) {