riftenlabs-indexer/contrib/api_doc_generator.py

483 lines
16 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
2026-01-21 12:34:59 +01:00
# Copyright (C) 2025-2026 Whiterun LLC,
# This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.,
# A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
"""
API Documentation Generator for Riften Labs Indexer
This tool parses routes from main.rs and generates API documentation
in markdown format for each mount point.
"""
import re
import os
from typing import List, Optional
from dataclasses import dataclass
@dataclass
class RouteInfo:
"""Information about a route"""
2025-07-23 08:47:52 +02:00
path: str
function_name: str
method: str = "GET"
parameters: List[str] = None
query_params: List[str] = None
path_params: List[str] = None
description: str = ""
status: str = "unstable"
is_deprecated: bool = False
@dataclass
class MountInfo:
"""Information about a mount point"""
2025-07-23 08:47:52 +02:00
path: str
routes: List[RouteInfo]
class RustRouteParser:
"""Parser for Rust route definitions"""
2025-07-23 08:47:52 +02:00
def __init__(self, main_rs_path: str):
self.main_rs_path = main_rs_path
self.mounts: List[MountInfo] = []
2025-07-23 08:47:52 +02:00
def parse_mounts(self) -> List[MountInfo]:
"""Parse all mount points from main.rs"""
2025-07-23 08:47:52 +02:00
with open(self.main_rs_path, "r") as f:
content = f.read()
2025-07-23 08:47:52 +02:00
# Find all mount blocks - simpler approach
# Look for .mount( followed by path and routes![
mount_pattern = r'\.mount\(\s*"([^"]+)"\s*,\s*routes!\[(.*?)\]'
matches = list(re.finditer(mount_pattern, content, re.DOTALL))
2025-07-23 08:47:52 +02:00
for match in matches:
mount_path = match.group(1)
routes_content = match.group(2)
2025-07-23 08:47:52 +02:00
routes = self._parse_routes(routes_content)
mount_info = MountInfo(path=mount_path, routes=routes)
self.mounts.append(mount_info)
2025-07-23 08:47:52 +02:00
return self.mounts
2025-07-23 08:47:52 +02:00
def _parse_routes(self, routes_content: str) -> List[RouteInfo]:
"""Parse individual routes from a routes! macro"""
routes = []
2025-07-23 08:47:52 +02:00
# Split by commas and clean up
2025-07-23 08:47:52 +02:00
route_items = [
item.strip() for item in routes_content.split(",") if item.strip()
]
for item in route_items:
# Remove any trailing commas and whitespace
2025-07-23 08:47:52 +02:00
item = item.strip().rstrip(",")
if item:
route_info = self._parse_single_route(item)
if route_info:
routes.append(route_info)
2025-07-23 08:47:52 +02:00
return routes
2025-07-23 08:47:52 +02:00
def _parse_single_route(self, route_item: str) -> Optional[RouteInfo]:
"""Parse a single route item"""
# Extract function name (remove module prefix if present)
2025-07-23 08:47:52 +02:00
if "::" in route_item:
function_name = route_item.split("::")[-1]
else:
function_name = route_item
2025-07-23 08:47:52 +02:00
# For now, we'll get the actual route path and parameters from the function definition
# This is a simplified approach - in a real implementation you'd want to parse the actual function signatures
return RouteInfo(
path="", # Will be filled in by function signature parsing
function_name=function_name,
parameters=[],
query_params=[],
2025-07-23 08:47:52 +02:00
path_params=[],
)
class FunctionSignatureParser:
"""Parser for Rust function signatures to extract route information"""
2025-07-23 08:47:52 +02:00
def __init__(self, src_dir: str):
self.src_dir = src_dir
self.rpc_modules = {}
self._load_rpc_modules()
2025-07-23 08:47:52 +02:00
def _load_rpc_modules(self):
"""Load all RPC module files"""
2025-07-23 08:47:52 +02:00
rpc_dir = os.path.join(self.src_dir, "rpc")
if not os.path.exists(rpc_dir):
return
2025-07-23 08:47:52 +02:00
# Load files directly in rpc directory
for file in os.listdir(rpc_dir):
2025-07-23 08:47:52 +02:00
if file.endswith(".rs") and file != "mod.rs":
module_name = file[:-3] # Remove .rs extension
file_path = os.path.join(rpc_dir, file)
self.rpc_modules[module_name] = file_path
2025-07-23 08:47:52 +02:00
# Load files in subdirectories
for subdir in os.listdir(rpc_dir):
subdir_path = os.path.join(rpc_dir, subdir)
if os.path.isdir(subdir_path):
# Look for mod.rs files in subdirectories
2025-07-23 08:47:52 +02:00
mod_rs_path = os.path.join(subdir_path, "mod.rs")
if os.path.exists(mod_rs_path):
self.rpc_modules[subdir] = mod_rs_path
2025-07-23 08:47:52 +02:00
def get_function_signature(self, function_name: str) -> Optional[RouteInfo]:
"""Get function signature and route information for a given function name"""
for module_name, file_path in self.rpc_modules.items():
route_info = self._parse_function_in_file(file_path, function_name)
if route_info:
return route_info
return None
2025-07-23 08:47:52 +02:00
def _parse_function_in_file(
self, file_path: str, function_name: str
) -> Optional[RouteInfo]:
"""Parse a specific function in a file"""
try:
2025-07-23 08:47:52 +02:00
with open(file_path, "r") as f:
content = f.read()
2025-07-23 08:47:52 +02:00
# Look for the function definition
func_pattern = rf"pub(?:\s+async)?\s+fn {function_name}\b\s*\([^)]*\)\s*->[^{{]*{{"
func_match = re.search(func_pattern, content)
2025-07-23 08:47:52 +02:00
if not func_match:
return None
2025-07-23 08:47:52 +02:00
# Extract description, status, and deprecated flag from comments above the function
2025-07-23 08:47:52 +02:00
description, status, is_deprecated = self._extract_function_description(
content, function_name
)
# Skip deprecated functions
if is_deprecated:
return None
2025-07-23 08:47:52 +02:00
# Find the #[get("...")] attribute before the function
get_attr_pattern = rf'#\[get\("([^"]+)"\)\]\s*pub(?:\s+async)?\s+fn {function_name}\b'
get_match = re.search(get_attr_pattern, content, re.DOTALL)
2025-07-23 08:47:52 +02:00
if get_match:
route_path = get_match.group(1)
route_info = self._parse_route_path(route_path, function_name)
route_info.description = description
route_info.status = status
route_info.is_deprecated = is_deprecated
return route_info
2025-07-23 08:47:52 +02:00
# If no #[get] attribute found, return None - we can't determine the route
return None
2025-07-23 08:47:52 +02:00
except Exception as e:
print(f"Error parsing {file_path}: {e}")
return None
2025-07-23 08:47:52 +02:00
def _extract_function_description(
self, content: str, function_name: str
) -> tuple[str, str, bool]:
"""Extract description, status, and deprecated flag from comments above the function"""
# Look for the function definition - simpler pattern
func_pattern = rf"pub(?:\s+async)?\s+fn {function_name}\b"
func_match = re.search(func_pattern, content)
2025-07-23 08:47:52 +02:00
if not func_match:
return "no description", "unstable", False
2025-07-23 08:47:52 +02:00
# Get the position of the function
func_start = func_match.start()
2025-07-23 08:47:52 +02:00
# Look backwards from the function for comments
# Find consecutive /// comment lines before the function
2025-07-23 08:47:52 +02:00
lines = content[:func_start].split("\n")
comment_lines = []
2025-07-23 08:47:52 +02:00
# Go backwards from the function to find comment lines
for line in reversed(lines):
line = line.strip()
2025-07-23 08:47:52 +02:00
if line.startswith("///"):
comment_lines.insert(0, line[3:].strip()) # Remove /// and strip
elif line.startswith("pub fn") or line.startswith("pub async fn"):
break
2025-07-23 08:47:52 +02:00
elif line and not line.startswith("///") and not line.startswith("#["):
break
2025-07-23 08:47:52 +02:00
if comment_lines:
# Filter out status lines and clean up the description
filtered_lines = []
for line in comment_lines:
# Skip status lines (they're handled separately)
if "Status:" in line:
continue
# Keep empty lines to preserve paragraph structure
if not line.strip():
filtered_lines.append("")
else:
# Preserve indentation by removing only the /// prefix
2025-07-23 08:47:52 +02:00
cleaned_line = line[3:] if line.startswith("///") else line
filtered_lines.append(cleaned_line.rstrip())
2025-07-23 08:47:52 +02:00
# Join filtered lines, preserving original structure
2025-07-23 08:47:52 +02:00
description = "\n".join(filtered_lines).strip()
# Check for deprecated functions
2025-07-23 08:47:52 +02:00
is_deprecated = any("deprecated" in line.lower() for line in comment_lines)
# Check for status
status = "unstable" # default
for line in comment_lines:
if "Status: Stable" in line:
status = "stable"
break
2025-07-23 08:47:52 +02:00
return description, status, is_deprecated
2025-07-23 08:47:52 +02:00
return "no description", "unstable", False
def _parse_route_path(self, route_path: str, function_name: str) -> RouteInfo:
"""Parse route path to extract parameters"""
path_params = []
query_params = []
2025-07-23 08:47:52 +02:00
# Split path and query parts
2025-07-23 08:47:52 +02:00
if "?" in route_path:
path_part, query_part = route_path.split("?", 1)
else:
path_part = route_path
query_part = ""
2025-07-23 08:47:52 +02:00
# Extract path parameters (e.g., <token_id>)
2025-07-23 08:47:52 +02:00
path_param_pattern = r"<([^>]+)>"
path_params = re.findall(path_param_pattern, path_part)
2025-07-23 08:47:52 +02:00
# Extract query parameters (e.g., <token_id>&<timestamp>)
if query_part:
2025-07-23 08:47:52 +02:00
query_param_pattern = r"<([^>]+)>"
query_params = re.findall(query_param_pattern, query_part)
2025-07-23 08:47:52 +02:00
# Clean up the route path for display
display_path = path_part
2025-07-23 08:47:52 +02:00
return RouteInfo(
path=display_path,
function_name=function_name,
method="GET",
path_params=path_params,
query_params=query_params,
2025-07-23 08:47:52 +02:00
parameters=path_params + query_params,
)
class APIDocGenerator:
"""Generate API documentation in markdown format"""
2025-07-23 08:47:52 +02:00
def __init__(self, output_dir: str = "apidoc"):
self.output_dir = output_dir
os.makedirs(output_dir, exist_ok=True)
2025-07-23 08:47:52 +02:00
def generate_docs(
self, mounts: List[MountInfo], function_parser: FunctionSignatureParser
):
"""Generate documentation for all mounts"""
# Generate index page
self._generate_index(mounts)
2025-07-23 08:47:52 +02:00
# Generate individual mount pages
for mount in mounts:
self._generate_mount_page(mount, function_parser)
2025-07-23 08:47:52 +02:00
def _generate_index(self, mounts: List[MountInfo]):
"""Generate the main index page"""
index_content = """# Riften Labs Indexer API Documentation
This document provides API documentation for the Riften Labs Indexer service.
Note that endpoints marked at unsable are subject to change or may be removed.
If you are using unstable endpoints, let us know in the telegram channel so that we can prioritize them.
## Available Endpoints
"""
2025-07-23 08:47:52 +02:00
for mount in mounts:
2025-07-23 08:47:52 +02:00
mount_name = mount.path.strip("/").replace("/", "_") or "root"
mount_display = mount.path if mount.path else "/"
index_content += f"- [{mount_display}]({mount_name}.md) - {len(mount.routes)} endpoints\n"
2025-07-23 08:47:52 +02:00
index_content += """
## Getting Started
All endpoints return JSON responses. Most endpoints support GET requests only.
### Authentication
Currently, no authentication is required for API access.
"""
2025-07-23 08:47:52 +02:00
with open(os.path.join(self.output_dir, "index.md"), "w") as f:
f.write(index_content)
2025-07-23 08:47:52 +02:00
def _generate_mount_page(
self, mount: MountInfo, function_parser: FunctionSignatureParser
):
"""Generate documentation for a specific mount point"""
2025-07-23 08:47:52 +02:00
mount_name = mount.path.strip("/").replace("/", "_") or "root"
mount_display = mount.path if mount.path else "/"
content = f"""# {mount_display} Endpoints
Base URL: `{mount_display}`
## Available Endpoints
"""
2025-07-23 08:47:52 +02:00
# Get detailed route information
detailed_routes = []
for route in mount.routes:
detailed_route = function_parser.get_function_signature(route.function_name)
if detailed_route:
detailed_routes.append(detailed_route)
# Skip routes without #[get] attributes - they can't be documented properly
2025-07-23 08:47:52 +02:00
# Sort routes alphabetically by their path
detailed_routes.sort(key=lambda x: x.path)
2025-07-23 08:47:52 +02:00
for route in detailed_routes:
2025-07-23 08:47:52 +02:00
content += self._generate_route_doc(mount, route)
# Add navigation
content += f"""
---
[ Back to Index](index.md)
"""
2025-07-23 08:47:52 +02:00
with open(os.path.join(self.output_dir, f"{mount_name}.md"), "w") as f:
f.write(content)
2025-07-23 08:47:52 +02:00
def generate_example_uri(self, mount: MountInfo, route: RouteInfo) -> str:
# Default placeholder for `<token>` is a representative cauldron pair
# token. Routes under `/delphi/` operate on Delphi oracle contract
# categories instead, so substitute the live mainnet BCH/USD v2
# oracle there — that's what users actually want to see linked.
is_delphi = "/delphi/" in route.path
delphi_v2_bchusd = (
"be0d0d8324e8cda41d34b85bd203ce2482256eb337a0ad0fea82c2ddd7306c88"
)
cauldron_token = (
"b79bfc8246b5fc4707e7c7dedcb6619ef1ab91f494a790c20b0f4c422ed95b92"
)
token_for_route = delphi_v2_bchusd if is_delphi else cauldron_token
2025-07-23 08:47:52 +02:00
example_params = {
"token": token_for_route,
"category": cauldron_token,
2025-07-23 08:47:52 +02:00
"pkh": "36c0020dd39e7cd66c21f237dc53d384661a557f",
"start": 1716537600,
"end": 1716624000,
"stepsize": 3600,
"token_id": token_for_route,
2025-07-23 08:47:52 +02:00
"timestamp": 1716537600,
}
tpl_title = f"{route.path}"
tpl = f"https://indexer.riften.net{mount.path}{route.path}"
2025-07-23 08:47:52 +02:00
for param in route.path_params:
if param not in example_params:
print(
f"Warn: Could not create example URI; missing placeholder for {param} in {route.path}"
)
return ""
tpl = tpl.replace(f"<{param}>", str(example_params[param]))
tpl_title = tpl_title.replace(f"<{param}>", str(example_params[param]))
return f"""
**Example URI:** [{tpl_title}]({tpl})
"""
def _generate_route_doc(self, mount: MountInfo, route: RouteInfo) -> str:
"""Generate documentation for a single route"""
2025-07-23 08:47:52 +02:00
full_path = route.path if route.path.startswith("/") else f"/{route.path}"
# Build the complete route including query parameters
complete_route = full_path
if route.query_params:
2025-07-23 08:47:52 +02:00
complete_route += "?" + "&".join(
f"<{param}>" for param in route.query_params
)
doc = f"""### `{complete_route}`
**Endpoint:** `{route.method} {full_path}`
**Status:** {route.status.title()}
"""
2025-07-23 08:47:52 +02:00
if route.description and route.description != "no description":
doc += f"**Description:** {route.description}\n\n"
2025-07-23 08:47:52 +02:00
doc += self.generate_example_uri(mount, route)
2025-07-23 08:47:52 +02:00
return doc
def main():
"""Main function to generate API documentation"""
# Configuration
main_rs_path = "src/main.rs"
src_dir = "src"
output_dir = "apidoc"
2025-07-23 08:47:52 +02:00
print("🔍 Parsing routes from main.rs...")
2025-07-23 08:47:52 +02:00
# Parse mounts from main.rs
route_parser = RustRouteParser(main_rs_path)
mounts = route_parser.parse_mounts()
2025-07-23 08:47:52 +02:00
print(f"📁 Found {len(mounts)} mount points:")
for mount in mounts:
print(f" - {mount.path}: {len(mount.routes)} routes")
2025-07-23 08:47:52 +02:00
# Initialize function signature parser
function_parser = FunctionSignatureParser(src_dir)
2025-07-23 08:47:52 +02:00
print("📝 Generating API documentation...")
2025-07-23 08:47:52 +02:00
# Generate documentation
doc_generator = APIDocGenerator(output_dir)
doc_generator.generate_docs(mounts, function_parser)
2025-07-23 08:47:52 +02:00
print(f"✅ API documentation generated in '{output_dir}/' directory")
print("📄 Generated files:")
2025-07-23 08:47:52 +02:00
# List generated files
for file in os.listdir(output_dir):
2025-07-23 08:47:52 +02:00
if file.endswith(".md"):
print(f" - {output_dir}/{file}")
if __name__ == "__main__":
2025-07-23 08:47:52 +02:00
main()