340 lines
10 KiB
JavaScript
340 lines
10 KiB
JavaScript
/**
|
|
* API documentation scanner for docs-v2 repository
|
|
*
|
|
* Parses OpenAPI spec files in api-docs/influxdb3/{core,enterprise}/v3/ref.yml
|
|
* to find documented API endpoints and their parameters.
|
|
*
|
|
* @module api-doc-scanner
|
|
*/
|
|
|
|
import { promises as fs } from 'fs';
|
|
import { join } from 'path';
|
|
import yaml from 'js-yaml';
|
|
|
|
/**
|
|
* Scans API documentation in docs-v2 repository
|
|
*/
|
|
export class APIDocScanner {
|
|
constructor(docsRepoPath, product) {
|
|
this.docsRepoPath = docsRepoPath;
|
|
this.product = product; // 'core' or 'enterprise'
|
|
this.documentedEndpoints = new Map(); // endpoint -> { file: '', title: '', description: '' }
|
|
}
|
|
|
|
/**
|
|
* Scan for documented API endpoints from OpenAPI specs
|
|
*/
|
|
async scanDocumentation() {
|
|
console.log(`🔍 Scanning ${this.product} API documentation...`);
|
|
|
|
const specPaths = this.getOpenAPISpecPaths();
|
|
|
|
for (const specPath of specPaths) {
|
|
await this.parseOpenAPISpec(specPath);
|
|
}
|
|
|
|
console.log(` 📄 Found ${this.documentedEndpoints.size} documented endpoints`);
|
|
return this.documentedEndpoints;
|
|
}
|
|
|
|
/**
|
|
* Get OpenAPI spec file paths based on product
|
|
*/
|
|
getOpenAPISpecPaths() {
|
|
const paths = [];
|
|
|
|
if (this.product === 'core' || this.product === 'both') {
|
|
paths.push(join(this.docsRepoPath, 'api-docs/influxdb3/core/v3/ref.yml'));
|
|
}
|
|
|
|
if (this.product === 'enterprise' || this.product === 'both') {
|
|
paths.push(join(this.docsRepoPath, 'api-docs/influxdb3/enterprise/v3/ref.yml'));
|
|
}
|
|
|
|
return paths;
|
|
}
|
|
|
|
/**
|
|
* Parse OpenAPI spec file to extract endpoints
|
|
*/
|
|
async parseOpenAPISpec(specPath) {
|
|
try {
|
|
const content = await fs.readFile(specPath, 'utf-8');
|
|
const spec = yaml.load(content);
|
|
|
|
if (!spec.paths) {
|
|
console.warn(` ⚠️ No paths found in OpenAPI spec: ${specPath}`);
|
|
return;
|
|
}
|
|
|
|
// Extract endpoints from paths
|
|
for (const [path, pathItem] of Object.entries(spec.paths)) {
|
|
// Get all HTTP methods for this path
|
|
const methods = [];
|
|
const operations = [];
|
|
|
|
for (const method of ['get', 'post', 'put', 'delete', 'patch']) {
|
|
if (pathItem[method]) {
|
|
methods.push(method.toUpperCase());
|
|
operations.push(pathItem[method]);
|
|
}
|
|
}
|
|
|
|
if (methods.length > 0) {
|
|
// Use first operation for description
|
|
const operation = operations[0];
|
|
|
|
// Extract documented parameters from requestBody schema
|
|
const documentedParams = this.extractDocumentedParams(operation, spec);
|
|
|
|
this.documentedEndpoints.set(path, {
|
|
file: specPath.replace(this.docsRepoPath + '/', ''),
|
|
title: operation.summary || 'API Endpoint',
|
|
description: operation.description || '',
|
|
methods,
|
|
operationId: operation.operationId || '',
|
|
tags: operation.tags || [],
|
|
parameters: documentedParams,
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') {
|
|
console.warn(` ⚠️ OpenAPI spec not found: ${specPath}`);
|
|
} else {
|
|
console.warn(` ⚠️ Error parsing OpenAPI spec ${specPath}:`, error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract documented parameters from an operation's requestBody
|
|
*/
|
|
extractDocumentedParams(operation, spec) {
|
|
const params = {
|
|
query: [], // URL query parameters
|
|
body: [], // Request body properties
|
|
path: [], // Path parameters
|
|
};
|
|
|
|
// Extract query and path parameters
|
|
if (operation.parameters) {
|
|
for (const param of operation.parameters) {
|
|
const paramInfo = {
|
|
name: param.name,
|
|
type: param.schema?.type || 'unknown',
|
|
required: param.required || false,
|
|
description: param.description || '',
|
|
in: param.in,
|
|
};
|
|
|
|
if (param.in === 'query') {
|
|
params.query.push(paramInfo);
|
|
} else if (param.in === 'path') {
|
|
params.path.push(paramInfo);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract request body properties
|
|
if (operation.requestBody?.content) {
|
|
const content = operation.requestBody.content;
|
|
const jsonContent = content['application/json'] || content['application/x-www-form-urlencoded'];
|
|
|
|
if (jsonContent?.schema) {
|
|
const schema = this.resolveSchemaRef(jsonContent.schema, spec);
|
|
if (schema?.properties) {
|
|
const requiredFields = schema.required || [];
|
|
|
|
for (const [propName, propSchema] of Object.entries(schema.properties)) {
|
|
const resolvedProp = this.resolveSchemaRef(propSchema, spec);
|
|
params.body.push({
|
|
name: propName,
|
|
type: this.getSchemaType(resolvedProp),
|
|
required: requiredFields.includes(propName),
|
|
description: resolvedProp.description || '',
|
|
properties: resolvedProp.properties ? Object.keys(resolvedProp.properties) : null,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return params;
|
|
}
|
|
|
|
/**
|
|
* Resolve a $ref schema reference
|
|
*/
|
|
resolveSchemaRef(schema, spec) {
|
|
if (!schema) return null;
|
|
|
|
if (schema.$ref) {
|
|
// Format: #/components/schemas/SchemaName
|
|
const refPath = schema.$ref.replace('#/', '').split('/');
|
|
let resolved = spec;
|
|
for (const part of refPath) {
|
|
resolved = resolved?.[part];
|
|
}
|
|
return resolved || schema;
|
|
}
|
|
|
|
return schema;
|
|
}
|
|
|
|
/**
|
|
* Get a human-readable type from schema
|
|
*/
|
|
getSchemaType(schema) {
|
|
if (!schema) return 'unknown';
|
|
|
|
if (schema.type === 'array') {
|
|
const itemType = schema.items?.type || 'unknown';
|
|
return `array<${itemType}>`;
|
|
}
|
|
|
|
if (schema.type === 'object' && schema.additionalProperties) {
|
|
const valueType = schema.additionalProperties.type || 'unknown';
|
|
return `map<string, ${valueType}>`;
|
|
}
|
|
|
|
if (schema.enum) {
|
|
return `enum(${schema.enum.join('|')})`;
|
|
}
|
|
|
|
return schema.type || 'object';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compare discovered endpoints with documented endpoints
|
|
*/
|
|
export function compareEndpoints(discoveredEndpoints, documentedEndpoints) {
|
|
const missing = [];
|
|
const documented = [];
|
|
const coverage = {
|
|
total: discoveredEndpoints.size,
|
|
documented: 0,
|
|
missing: 0,
|
|
percentage: 0,
|
|
};
|
|
|
|
for (const [path, endpoint] of discoveredEndpoints.entries()) {
|
|
const isDocumented = documentedEndpoints.has(path) ||
|
|
Array.from(documentedEndpoints.keys()).some(docPath =>
|
|
path.startsWith(docPath) && endpoint.isPrefix
|
|
);
|
|
|
|
if (isDocumented) {
|
|
documented.push({
|
|
...endpoint,
|
|
documentation: documentedEndpoints.get(path),
|
|
});
|
|
coverage.documented++;
|
|
} else {
|
|
missing.push(endpoint);
|
|
coverage.missing++;
|
|
}
|
|
}
|
|
|
|
coverage.percentage = Math.round((coverage.documented / coverage.total) * 100);
|
|
|
|
return {
|
|
missing,
|
|
documented,
|
|
coverage,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Compare discovered request parameters with documented parameters
|
|
*
|
|
* @param {Array<{endpoint: string, method: string, request: Object|null, response: Object|null}>} discoveredParams - Parameters extracted from Rust structs
|
|
* @param {Map<string, {file: string, title: string, description: string, methods: string[], parameters: Object}>} documentedEndpoints - Endpoints with documented parameters from OpenAPI
|
|
* @returns {{endpoints: Array<Object>, summary: {totalEndpoints: number, endpointsWithMissingParams: number, totalMissingParams: number, totalDocumentedParams: number, totalDiscoveredParams: number}}} Parameter comparison results
|
|
*/
|
|
export function compareParameters(discoveredParams, documentedEndpoints) {
|
|
const results = {
|
|
endpoints: [],
|
|
summary: {
|
|
totalEndpoints: 0,
|
|
endpointsWithMissingParams: 0,
|
|
totalMissingParams: 0,
|
|
totalDocumentedParams: 0,
|
|
totalDiscoveredParams: 0,
|
|
},
|
|
};
|
|
|
|
for (const discovered of discoveredParams) {
|
|
const endpointKey = `${discovered.method} ${discovered.endpoint}`;
|
|
const documented = documentedEndpoints.get(discovered.endpoint);
|
|
|
|
results.summary.totalEndpoints++;
|
|
|
|
const endpointResult = {
|
|
endpoint: discovered.endpoint,
|
|
method: discovered.method,
|
|
missingParams: [],
|
|
extraDocumentedParams: [],
|
|
matchedParams: [],
|
|
discoveredParams: [],
|
|
documentedParams: [],
|
|
};
|
|
|
|
// Get discovered request parameters
|
|
if (discovered.request?.fields) {
|
|
for (const field of discovered.request.fields) {
|
|
endpointResult.discoveredParams.push({
|
|
name: field.serializedName || field.name,
|
|
type: field.type,
|
|
required: field.required,
|
|
description: field.description,
|
|
});
|
|
results.summary.totalDiscoveredParams++;
|
|
}
|
|
}
|
|
|
|
// Get documented parameters
|
|
if (documented?.parameters?.body) {
|
|
for (const param of documented.parameters.body) {
|
|
endpointResult.documentedParams.push({
|
|
name: param.name,
|
|
type: param.type,
|
|
required: param.required,
|
|
description: param.description,
|
|
});
|
|
results.summary.totalDocumentedParams++;
|
|
}
|
|
}
|
|
|
|
// Compare discovered vs documented
|
|
const documentedNames = new Set(endpointResult.documentedParams.map(p => p.name));
|
|
const discoveredNames = new Set(endpointResult.discoveredParams.map(p => p.name));
|
|
|
|
// Find missing parameters (in source but not in docs)
|
|
for (const param of endpointResult.discoveredParams) {
|
|
if (!documentedNames.has(param.name)) {
|
|
endpointResult.missingParams.push(param);
|
|
results.summary.totalMissingParams++;
|
|
} else {
|
|
endpointResult.matchedParams.push(param);
|
|
}
|
|
}
|
|
|
|
// Find extra documented parameters (in docs but not in source)
|
|
for (const param of endpointResult.documentedParams) {
|
|
if (!discoveredNames.has(param.name)) {
|
|
endpointResult.extraDocumentedParams.push(param);
|
|
}
|
|
}
|
|
|
|
if (endpointResult.missingParams.length > 0) {
|
|
results.summary.endpointsWithMissingParams++;
|
|
}
|
|
|
|
results.endpoints.push(endpointResult);
|
|
}
|
|
|
|
return results;
|
|
}
|