mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-18 01:51:50 +00:00
410 lines
13 KiB
Python
Executable File
410 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Prowler API Documentation Auto-Generator
|
|
Generates MDX files from OpenAPI specification
|
|
|
|
Usage:
|
|
python3 generate-api-docs.py [--update-existing] [--dry-run]
|
|
|
|
Options:
|
|
--update-existing Update existing MDX files (default: skip existing files)
|
|
--dry-run Show what would be generated without writing files
|
|
--help Show this help message
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Optional
|
|
|
|
import yaml
|
|
|
|
|
|
class APIDocGenerator:
|
|
"""Generate Mintlify MDX documentation from OpenAPI spec"""
|
|
|
|
def __init__(self, openapi_file: str, api_reference_dir: str):
|
|
self.openapi_file = openapi_file
|
|
self.api_reference_dir = Path(api_reference_dir)
|
|
self.spec = None
|
|
self.stats = {"generated": 0, "updated": 0, "skipped": 0, "errors": 0}
|
|
|
|
def load_spec(self):
|
|
"""Load OpenAPI specification"""
|
|
print(f"📖 Loading OpenAPI spec from {self.openapi_file}")
|
|
with open(self.openapi_file, "r") as f:
|
|
self.spec = yaml.safe_load(f)
|
|
print(f"✅ Loaded spec version {self.spec.get('info', {}).get('version')}")
|
|
|
|
def operation_id_to_path(self, operation_id: str) -> Optional[str]:
|
|
"""Convert operation ID to file path
|
|
|
|
Examples:
|
|
providers_secrets_create -> providers/secrets/create.mdx
|
|
tokens_create -> tokens/create.mdx
|
|
users_memberships_list -> users/memberships-list.mdx
|
|
"""
|
|
parts = operation_id.split("_")
|
|
|
|
# Map operation IDs to directory names
|
|
path_map = {
|
|
"tokens": "tokens",
|
|
"api_keys": "api-keys",
|
|
"users": "users",
|
|
"tenants": "tenants",
|
|
"invitations": "invitations",
|
|
"providers": "providers",
|
|
"scans": "scans",
|
|
"findings": "findings",
|
|
"resources": "resources",
|
|
"compliance": "compliance",
|
|
"overviews": "overviews",
|
|
"integrations": "integrations",
|
|
"lighthouse": "lighthouse",
|
|
"processors": "processors",
|
|
"schedules": "schedules",
|
|
"tasks": "tasks",
|
|
"roles": "roles",
|
|
"provider_groups": "provider-groups",
|
|
"saml_config": "saml",
|
|
}
|
|
|
|
if not parts:
|
|
return None
|
|
|
|
base = parts[0]
|
|
if base not in path_map:
|
|
return None
|
|
|
|
directory = path_map[base]
|
|
|
|
# Handle nested resources
|
|
if len(parts) > 2:
|
|
# Check if second part is a sub-resource (not an action)
|
|
actions = [
|
|
"list",
|
|
"create",
|
|
"retrieve",
|
|
"update",
|
|
"delete",
|
|
"revoke",
|
|
"accept",
|
|
"refresh",
|
|
"switch",
|
|
"report",
|
|
"compliance",
|
|
"threatscore",
|
|
"latest",
|
|
"metadata",
|
|
]
|
|
|
|
if parts[1] not in actions:
|
|
subdirectory = parts[1].replace("_", "-")
|
|
action = "-".join(parts[2:])
|
|
return f"{directory}/{subdirectory}/{action}.mdx"
|
|
|
|
# Simple resource action
|
|
action = "-".join(parts[1:])
|
|
return f"{directory}/{action}.mdx"
|
|
|
|
def format_title(self, operation_id: str, summary: Optional[str]) -> str:
|
|
"""Format title from operation ID or summary"""
|
|
if summary:
|
|
return summary
|
|
|
|
parts = operation_id.split("_")
|
|
action = parts[-1].replace("_", " ").title()
|
|
resource = " ".join(parts[:-1]).replace("_", " ").title()
|
|
return f"{action} {resource}"
|
|
|
|
def extract_parameters(self, operation: Dict[str, Any]) -> str:
|
|
"""Extract and format parameters section"""
|
|
parameters = operation.get("parameters", [])
|
|
if not parameters:
|
|
return ""
|
|
|
|
path_params = [p for p in parameters if p.get("in") == "path"]
|
|
query_params = [p for p in parameters if p.get("in") == "query"]
|
|
|
|
sections = []
|
|
|
|
if path_params:
|
|
sections.append("## Path Parameters\n")
|
|
for param in path_params:
|
|
name = param.get("name")
|
|
required = "required" if param.get("required") else "optional"
|
|
desc = param.get("description", "No description")
|
|
schema = param.get("schema", {})
|
|
param_type = schema.get("type", "string")
|
|
sections.append(f"- `{name}` ({required}, {param_type}) - {desc}")
|
|
|
|
if query_params:
|
|
sections.append("\n## Query Parameters\n")
|
|
for param in query_params:
|
|
name = param.get("name")
|
|
required = "required" if param.get("required") else "optional"
|
|
desc = param.get("description", "No description")
|
|
schema = param.get("schema", {})
|
|
param_type = schema.get("type", "string")
|
|
sections.append(f"- `{name}` ({required}, {param_type}) - {desc}")
|
|
|
|
return "\n".join(sections)
|
|
|
|
def extract_request_body(self, operation: Dict[str, Any]) -> str:
|
|
"""Extract and format request body section"""
|
|
request_body = operation.get("requestBody")
|
|
if not request_body:
|
|
return ""
|
|
|
|
content = request_body.get("content", {})
|
|
json_content = content.get("application/vnd.api+json", {})
|
|
|
|
if not json_content:
|
|
return ""
|
|
|
|
example = json_content.get("example", {})
|
|
|
|
if example:
|
|
example_json = json.dumps(example, indent=2)
|
|
return f"""
|
|
## Request Body
|
|
|
|
```json
|
|
{example_json}
|
|
```
|
|
"""
|
|
|
|
# If no example, show schema info
|
|
schema = json_content.get("schema", {})
|
|
if schema:
|
|
return """
|
|
## Request Body
|
|
|
|
Request body follows JSON:API specification. See OpenAPI spec for detailed schema.
|
|
"""
|
|
|
|
return ""
|
|
|
|
def extract_responses(self, operation: Dict[str, Any]) -> str:
|
|
"""Extract and format responses section"""
|
|
responses = operation.get("responses", {})
|
|
if not responses:
|
|
return ""
|
|
|
|
sections = []
|
|
|
|
# Success response
|
|
for status in ["200", "201", "204"]:
|
|
response = responses.get(status)
|
|
if response:
|
|
desc = response.get("description", "Success")
|
|
sections.append(f"## Response\n\n{desc}\n")
|
|
|
|
content = response.get("content", {})
|
|
json_content = content.get("application/vnd.api+json", {})
|
|
example = json_content.get("example", {})
|
|
|
|
if example:
|
|
example_json = json.dumps(example, indent=2)
|
|
sections.append(f"```json\n{example_json}\n```\n")
|
|
|
|
break
|
|
|
|
return "\n".join(sections)
|
|
|
|
def generate_mdx(
|
|
self, operation_id: str, method: str, path: str, operation: Dict[str, Any]
|
|
) -> str:
|
|
"""Generate complete MDX content for an endpoint"""
|
|
|
|
title = self.format_title(operation_id, operation.get("summary"))
|
|
description = operation.get("description") or operation.get("summary") or ""
|
|
api_path = f"{method.upper()} {path}"
|
|
|
|
# Extract sections
|
|
parameters = self.extract_parameters(operation)
|
|
request_body = self.extract_request_body(operation)
|
|
responses = self.extract_responses(operation)
|
|
|
|
# Build MDX content
|
|
mdx_parts = [
|
|
"---",
|
|
f'title: "{title}"',
|
|
f'api: "{api_path}"',
|
|
f'description: "{description}"',
|
|
"---",
|
|
"",
|
|
description,
|
|
"",
|
|
]
|
|
|
|
if parameters:
|
|
mdx_parts.extend([parameters, ""])
|
|
|
|
if request_body:
|
|
mdx_parts.extend([request_body, ""])
|
|
|
|
if responses:
|
|
mdx_parts.extend([responses, ""])
|
|
|
|
return "\n".join(mdx_parts).strip() + "\n"
|
|
|
|
def generate_docs(self, update_existing: bool = False, dry_run: bool = False):
|
|
"""Generate MDX files for all operations"""
|
|
|
|
if not self.spec:
|
|
raise ValueError("OpenAPI spec not loaded. Call load_spec() first.")
|
|
|
|
print("\n🔄 Generating documentation files...")
|
|
print(f" Update existing: {update_existing}")
|
|
print(f" Dry run: {dry_run}\n")
|
|
|
|
generated_files = []
|
|
skipped_files = []
|
|
error_files = []
|
|
|
|
paths = self.spec.get("paths", {})
|
|
total_operations = sum(
|
|
len([m for m in p.keys() if m in ["get", "post", "put", "patch", "delete"]])
|
|
for p in paths.values()
|
|
)
|
|
|
|
print(f"Found {total_operations} operations to process\n")
|
|
|
|
for path, path_item in paths.items():
|
|
for method in ["get", "post", "put", "patch", "delete"]:
|
|
operation = path_item.get(method)
|
|
if not operation:
|
|
continue
|
|
|
|
operation_id = operation.get("operationId")
|
|
if not operation_id:
|
|
continue
|
|
|
|
try:
|
|
file_path = self.operation_id_to_path(operation_id)
|
|
if not file_path:
|
|
skipped_files.append((operation_id, "Could not determine path"))
|
|
self.stats["skipped"] += 1
|
|
continue
|
|
|
|
full_path = self.api_reference_dir / file_path
|
|
|
|
# Check if file exists and should be skipped
|
|
if full_path.exists() and not update_existing:
|
|
# Check if file has substantial content
|
|
if full_path.stat().st_size > 150:
|
|
skipped_files.append(
|
|
(operation_id, "File exists with content")
|
|
)
|
|
self.stats["skipped"] += 1
|
|
continue
|
|
|
|
# Generate MDX content
|
|
mdx_content = self.generate_mdx(
|
|
operation_id, method, path, operation
|
|
)
|
|
|
|
if dry_run:
|
|
print(f"Would generate: {file_path}")
|
|
generated_files.append(file_path)
|
|
continue
|
|
|
|
# Create directory if needed
|
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Write file
|
|
action = "Updated" if full_path.exists() else "Generated"
|
|
with open(full_path, "w") as f:
|
|
f.write(mdx_content)
|
|
|
|
generated_files.append(file_path)
|
|
if action == "Updated":
|
|
self.stats["updated"] += 1
|
|
else:
|
|
self.stats["generated"] += 1
|
|
|
|
print(f"✅ {action}: {file_path}")
|
|
|
|
except Exception as e:
|
|
error_files.append((operation_id, str(e)))
|
|
self.stats["errors"] += 1
|
|
print(f"❌ Error processing {operation_id}: {e}")
|
|
|
|
# Print summary
|
|
print("\n" + "=" * 60)
|
|
print("📊 Generation Summary")
|
|
print("=" * 60)
|
|
print(f"Generated: {self.stats['generated']}")
|
|
print(f"Updated: {self.stats['updated']}")
|
|
print(f"Skipped: {self.stats['skipped']}")
|
|
print(f"Errors: {self.stats['errors']}")
|
|
print(f"Total: {total_operations}")
|
|
|
|
if skipped_files and len(skipped_files) <= 10:
|
|
print("\nSkipped operations:")
|
|
for op_id, reason in skipped_files:
|
|
print(f" - {op_id}: {reason}")
|
|
|
|
if error_files:
|
|
print("\nErrors:")
|
|
for op_id, error in error_files:
|
|
print(f" - {op_id}: {error}")
|
|
|
|
return self.stats
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Generate Mintlify MDX documentation from OpenAPI spec"
|
|
)
|
|
parser.add_argument(
|
|
"--update-existing", action="store_true", help="Update existing MDX files"
|
|
)
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="Show what would be generated without writing files",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Paths
|
|
script_dir = Path(__file__).parent
|
|
openapi_file = script_dir / "api-reference" / "openapi.yaml"
|
|
api_reference_dir = script_dir / "api-reference"
|
|
|
|
# Check if OpenAPI file exists
|
|
if not openapi_file.exists():
|
|
print(f"❌ Error: OpenAPI spec not found at {openapi_file}")
|
|
print(" Run ./sync-api-spec.sh first to download the spec")
|
|
sys.exit(1)
|
|
|
|
# Generate documentation
|
|
generator = APIDocGenerator(str(openapi_file), str(api_reference_dir))
|
|
|
|
try:
|
|
generator.load_spec()
|
|
generator.generate_docs(
|
|
update_existing=args.update_existing, dry_run=args.dry_run
|
|
)
|
|
|
|
if not args.dry_run:
|
|
print("\n✅ Documentation generation complete!")
|
|
print("\n💡 Next steps:")
|
|
print(" 1. Review generated files in api-reference/")
|
|
print(" 2. Add detailed descriptions and examples")
|
|
print(" 3. Update docs.json if new groups were added")
|
|
print(" 4. Test with: cd docs && mintlify dev")
|
|
else:
|
|
print("\n🔍 Dry run complete - no files were modified")
|
|
|
|
except Exception as e:
|
|
print(f"❌ Fatal error: {e}")
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|