docs-v2/scripts/docs-cli/lib/api-parser.js

392 lines
14 KiB
JavaScript

/**
* API endpoint parser for InfluxDB 3 HTTP API
*
* Discovers API endpoints from Rust source code by parsing:
* - influxdb3_server/src/all_paths.rs - endpoint path constants
* - influxdb3_server/src/http.rs - route_request function for method/handler mappings
* - Handler function signatures for parameter extraction
*
* @module api-parser
*/
import { promises as fs } from 'fs';
import { join } from 'path';
// Endpoints that require --test-mode flag to be available
// These are filtered out from public API documentation
const TEST_MODE_PATH_PATTERNS = [
'/api/v3/test/', // All /api/v3/test/* endpoints require --test-mode
];
// Endpoints that may need clarification about whether they should be public
const NEEDS_CLARIFICATION_PATTERNS = [
{ pattern: '/api/v3/plugin_test/', reason: 'Plugin testing endpoints - confirm if intended for public use' },
];
/**
* Parses API endpoints from InfluxDB Rust source code
*/
export class APIParser {
constructor(repoPath) {
this.repoPath = repoPath;
this.endpoints = new Map(); // path -> { methods: [], handler: '', params: [], description: '' }
this.testModeEndpoints = new Map(); // Endpoints that require --test-mode
}
/**
* Discover all API endpoints from source code
*/
async discoverEndpoints() {
console.log('🔍 Parsing API endpoints from source code...');
try {
// Step 1: Parse endpoint path constants from all_paths.rs
const pathConstants = await this.parsePathConstants();
// Step 2: Parse route_request function to map methods to endpoints
const routeMappings = await this.parseRouteMappings();
// Step 3: Combine path constants with route mappings
this.buildEndpointMap(pathConstants, routeMappings);
console.log(`✅ Discovered ${this.endpoints.size} API endpoints`);
return this.endpoints;
} catch (error) {
console.error('❌ Error discovering endpoints:', error.message);
throw error;
}
}
/**
* Parse endpoint path constants from all_paths.rs
* Also detects test-mode-only endpoints from doc comments
*/
async parsePathConstants() {
const pathsFile = join(this.repoPath, 'influxdb3_server/src/all_paths.rs');
const content = await fs.readFile(pathsFile, 'utf-8');
const constants = new Map();
const testModeConstants = new Set();
// First pass: identify test-mode-only constants from comments
// Pattern: /// Test-only endpoint... \n pub(crate) const API_V3_TEST_*
const testModePattern = /\/\/\/\s*Test-only[^\n]*\n(?:\/\/\/[^\n]*\n)*\s*pub(?:\(crate\))?\s+const\s+(\w+):/g;
let testMatch;
while ((testMatch = testModePattern.exec(content)) !== null) {
testModeConstants.add(testMatch[1]);
}
// Second pass: extract all path constants
// Match pattern: pub const API_V3_WRITE: &str = "/api/v3/write_lp";
const constPattern = /pub(?:\(crate\))?\s+const\s+(\w+):\s+&str\s+=\s+"([^"]+)"/g;
let match;
while ((match = constPattern.exec(content)) !== null) {
const [, constantName, path] = match;
// Check if this is a test-mode-only endpoint
const isTestMode = testModeConstants.has(constantName) ||
TEST_MODE_PATH_PATTERNS.some(p => path.startsWith(p));
// Check if this endpoint needs clarification
const clarification = NEEDS_CLARIFICATION_PATTERNS.find(p => path.startsWith(p.pattern));
constants.set(constantName, {
path,
constantName,
version: this.extractApiVersion(path),
isTestMode,
needsClarification: clarification ? clarification.reason : null,
});
}
const testModeCount = Array.from(constants.values()).filter(c => c.isTestMode).length;
console.log(` 📋 Found ${constants.size} path constants (${testModeCount} test-mode-only)`);
return constants;
}
/**
* Parse route_request function to map HTTP methods to endpoints
*/
async parseRouteMappings() {
const httpFile = join(this.repoPath, 'influxdb3_server/src/http.rs');
const content = await fs.readFile(httpFile, 'utf-8');
const mappings = [];
// Find the perform_routing function and extract the main match block
// The main routing is in perform_routing function, not with "let response = match"
// Pattern looks for: match (method.clone(), path) { ... }
const patterns = [
// Main routing in perform_routing function - captures match block until the closing brace and .map_err
/async fn perform_routing[\s\S]*?match \(method\.clone\(\), path\) \{([\s\S]+?)\n \}\s*\n\s*\.map_err/,
// Alternative pattern for match block ending with just closing brace
/async fn perform_routing[\s\S]*?match \(method\.clone\(\), path\) \{([\s\S]+?)\n \}/,
// Fallback: legacy patterns for older code structure
/let response = match \(method\.clone\(\), path\) \{([\s\S]+?)\n \};/,
/let response = match \(method\.clone\(\), uri\.path\(\)\) \{([\s\S]+?)\n \};/,
];
let matchContent = null;
for (const pattern of patterns) {
const matchBlock = pattern.exec(content);
if (matchBlock) {
matchContent = matchBlock[1];
break;
}
}
if (!matchContent) {
throw new Error('Could not find route_request match block in perform_routing function');
}
// Parse individual route patterns
// Pattern 1: (Method::POST, all_paths::API_V3_WRITE) => handler
const simpleRoutePattern = /\(Method::(\w+)(?:\s*\|\s*Method::(\w+))?,\s+all_paths::(\w+)\)\s*=>/g;
let match;
while ((match = simpleRoutePattern.exec(matchContent)) !== null) {
const [, method1, method2, constantName] = match;
const methods = method2 ? [method1, method2] : [method1];
// Extract handler function name
const handlerMatch = matchContent.substring(match.index).match(/=>\s*(\{[\s\S]*?\n\s+\}|http_server\.(\w+)\([^)]*\))/);
const handler = handlerMatch ? (handlerMatch[2] || 'inline_handler') : 'unknown';
mappings.push({
methods,
constantName,
handler,
});
}
// Pattern 2: (Method::GET | Method::POST, path) if path.starts_with(...) => { ... handler ... }
// Need to extract the handler from within the block, not the next match arm
// Handle nested braces and multiline blocks
const conditionalRoutePattern = /\(Method::(\w+)(?:\s*\|\s*Method::(\w+))?,\s*path\)\s+if\s+path\.starts_with\(all_paths::(\w+)\)\s*=>\s*\{([\s\S]*?)\n \}/g;
while ((match = conditionalRoutePattern.exec(matchContent)) !== null) {
const [, method1, method2, constantName, blockContent] = match;
const methods = method2 ? [method1, method2] : [method1];
// Extract handler from within the block content - handle multiline with .await
// Pattern: http_server.handler_name(...) or http_server\n.handler_name(...)
const handlerMatch = blockContent.match(/http_server\s*[\n\s]*\.(\w+)\s*\(/);
const handler = handlerMatch ? handlerMatch[1] : 'prefix_handler';
mappings.push({
methods,
constantName,
handler,
isPrefix: true,
});
}
console.log(` 🔗 Found ${mappings.length} route mappings`);
return mappings;
}
/**
* Build endpoint map by combining path constants and route mappings
* Filters out test-mode-only endpoints and flags those needing clarification
*/
buildEndpointMap(pathConstants, routeMappings) {
let filteredCount = 0;
for (const mapping of routeMappings) {
const pathInfo = pathConstants.get(mapping.constantName);
if (!pathInfo) {
console.warn(` ⚠️ Route mapping references unknown constant: ${mapping.constantName}`);
continue;
}
// Skip test-mode-only endpoints - they shouldn't be in public docs
if (pathInfo.isTestMode) {
this.testModeEndpoints.set(pathInfo.path, {
path: pathInfo.path,
methods: mapping.methods,
handler: mapping.handler,
constantName: mapping.constantName,
reason: 'Requires --test-mode flag',
});
filteredCount++;
continue;
}
const endpoint = {
path: pathInfo.path,
methods: mapping.methods,
handler: mapping.handler,
constantName: mapping.constantName,
version: pathInfo.version,
isPrefix: mapping.isPrefix || false,
params: [], // Will be populated by parseHandlerParams if needed
description: this.generateDescription(pathInfo.path, mapping.handler),
needsClarification: pathInfo.needsClarification,
};
this.endpoints.set(pathInfo.path, endpoint);
}
if (filteredCount > 0) {
console.log(` 🚫 Filtered ${filteredCount} test-mode-only endpoints`);
}
const clarificationCount = Array.from(this.endpoints.values())
.filter(e => e.needsClarification).length;
if (clarificationCount > 0) {
console.log(` ⚠️ ${clarificationCount} endpoints need clarification`);
}
}
/**
* Extract API version from path (v1, v2, v3)
*/
extractApiVersion(path) {
if (path.includes('/api/v3/')) return 'v3';
if (path.includes('/api/v2/')) return 'v2';
if (path.includes('/api/v1/')) return 'v1';
if (path === '/write' || path === '/query') return 'v1-compat';
return 'unversioned';
}
/**
* Generate endpoint description from path and handler
*/
generateDescription(path, handler) {
// Convert handler name to human-readable description
const descriptions = {
write_lp: 'Write line protocol data',
query_sql: 'Execute SQL query',
query_influxql: 'Execute InfluxQL query',
v1_query: 'Execute v1 query',
health: 'Check health status',
ping: 'Ping the server',
handle_metrics: 'Get Prometheus metrics',
create_database: 'Create a database',
delete_database: 'Delete a database',
show_databases: 'List databases',
update_database: 'Update database configuration',
create_table: 'Create a table',
delete_table: 'Delete a table',
configure_distinct_cache_create: 'Create distinct value cache',
configure_distinct_cache_delete: 'Delete distinct value cache',
configure_last_cache_create: 'Create last value cache',
configure_last_cache_delete: 'Delete last value cache',
configure_processing_engine_trigger: 'Create processing engine trigger',
delete_processing_engine_trigger: 'Delete processing engine trigger',
enable_processing_engine_trigger: 'Enable processing engine trigger',
disable_processing_engine_trigger: 'Disable processing engine trigger',
processing_engine_request_plugin: 'Execute processing engine plugin',
install_plugin_environment_packages: 'Install Python packages',
install_plugin_environment_requirements: 'Install packages from requirements file',
create_admin_token: 'Create admin token',
regenerate_admin_token: 'Regenerate admin token',
create_named_admin_token: 'Create named admin token',
delete_token: 'Delete token',
set_retention_period_for_database: 'Set database retention period',
clear_retention_period_for_database: 'Clear database retention period',
test_processing_engine_wal_plugin: 'Test WAL plugin',
test_processing_engine_schedule_plugin: 'Test scheduled plugin',
};
return descriptions[handler] || `Endpoint: ${path}`;
}
/**
* Get endpoints grouped by version
*/
getEndpointsByVersion() {
const byVersion = {
v3: [],
v2: [],
v1: [],
'v1-compat': [],
unversioned: [],
};
for (const endpoint of this.endpoints.values()) {
byVersion[endpoint.version].push(endpoint);
}
return byVersion;
}
/**
* Get endpoints grouped by category
*/
getEndpointsByCategory() {
const categories = {
write: [],
query: [],
database: [],
table: [],
cache: [],
'processing-engine': [],
token: [],
health: [],
other: [],
};
for (const endpoint of this.endpoints.values()) {
const path = endpoint.path.toLowerCase();
if (path.includes('write')) {
categories.write.push(endpoint);
} else if (path.includes('query')) {
categories.query.push(endpoint);
} else if (path.includes('database')) {
categories.database.push(endpoint);
} else if (path.includes('table')) {
categories.table.push(endpoint);
} else if (path.includes('cache')) {
categories.cache.push(endpoint);
} else if (path.includes('processing_engine') || path.includes('plugin') || path.includes('engine')) {
categories['processing-engine'].push(endpoint);
} else if (path.includes('token')) {
categories.token.push(endpoint);
} else if (path.includes('health') || path.includes('ping') || path.includes('metrics')) {
categories.health.push(endpoint);
} else {
categories.other.push(endpoint);
}
}
return categories;
}
}
/**
* Detect Enterprise-specific endpoints by checking if they exist in Enterprise repo
*/
export async function detectEnterpriseEndpoints(coreEndpoints, enterpriseRepoPath) {
// For now, we'll mark endpoints as Enterprise if they're in the Enterprise repo
// In practice, Enterprise includes all Core endpoints plus additional ones
// Check if Enterprise repo has additional routes
try {
const enterpriseHttpFile = join(enterpriseRepoPath, 'influxdb3_server/src/http.rs');
await fs.access(enterpriseHttpFile);
// Enterprise repo exists, parse its endpoints
const enterpriseParser = new APIParser(enterpriseRepoPath);
const enterpriseEndpoints = await enterpriseParser.discoverEndpoints();
// Mark Enterprise-specific endpoints
const corePaths = new Set(coreEndpoints.keys());
const enterpriseOnly = new Map();
for (const [path, endpoint] of enterpriseEndpoints.entries()) {
if (!corePaths.has(path)) {
enterpriseOnly.set(path, { ...endpoint, enterpriseOnly: true });
}
}
return enterpriseOnly;
} catch (error) {
console.warn(' ⚠️ Could not access Enterprise repo, skipping Enterprise detection');
return new Map();
}
}