contrib: Tool for documenting API calls

This commit is contained in:
Dagur Valberg Johannsson 2025-07-22 11:11:39 +02:00
parent 450ae701a8
commit 007aeb06dd
No known key found for this signature in database
GPG key ID: FD701804AEE88107
11 changed files with 661 additions and 4 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
/target
*.db
/apidoc

View file

@ -37,3 +37,12 @@ linters:
script:
- ./linters/run_linters.py
build-api-docs:
image: python:latest
script:
- ./contrib/api_doc_generator.py
artifacts:
paths:
- apidoc
expire_in: 30 days

430
contrib/api_doc_generator.py Executable file
View file

@ -0,0 +1,430 @@
#!/usr/bin/env python3
# Copyright (C) 2025 Riften Labs AS,
# 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
import ast
from pathlib import Path
from typing import List, Optional
from dataclasses import dataclass
@dataclass
class RouteInfo:
"""Information about a route"""
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"""
path: str
routes: List[RouteInfo]
class RustRouteParser:
"""Parser for Rust route definitions"""
def __init__(self, main_rs_path: str):
self.main_rs_path = main_rs_path
self.mounts: List[MountInfo] = []
def parse_mounts(self) -> List[MountInfo]:
"""Parse all mount points from main.rs"""
with open(self.main_rs_path, 'r') as f:
content = f.read()
# 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))
for match in matches:
mount_path = match.group(1)
routes_content = match.group(2)
routes = self._parse_routes(routes_content)
mount_info = MountInfo(path=mount_path, routes=routes)
self.mounts.append(mount_info)
return self.mounts
def _parse_routes(self, routes_content: str) -> List[RouteInfo]:
"""Parse individual routes from a routes! macro"""
routes = []
# Split by commas and clean up
route_items = [item.strip() for item in routes_content.split(',') if item.strip()]
for item in route_items:
# Remove any trailing commas and whitespace
item = item.strip().rstrip(',')
if item:
route_info = self._parse_single_route(item)
if route_info:
routes.append(route_info)
return routes
def _parse_single_route(self, route_item: str) -> Optional[RouteInfo]:
"""Parse a single route item"""
# Extract function name (remove module prefix if present)
if '::' in route_item:
function_name = route_item.split('::')[-1]
else:
function_name = route_item
# 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=[],
path_params=[]
)
class FunctionSignatureParser:
"""Parser for Rust function signatures to extract route information"""
def __init__(self, src_dir: str):
self.src_dir = src_dir
self.rpc_modules = {}
self._load_rpc_modules()
def _load_rpc_modules(self):
"""Load all RPC module files"""
rpc_dir = os.path.join(self.src_dir, 'rpc')
if not os.path.exists(rpc_dir):
return
# Load files directly in rpc directory
for file in os.listdir(rpc_dir):
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
# 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
mod_rs_path = os.path.join(subdir_path, 'mod.rs')
if os.path.exists(mod_rs_path):
self.rpc_modules[subdir] = mod_rs_path
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
def _parse_function_in_file(self, file_path: str, function_name: str) -> Optional[RouteInfo]:
"""Parse a specific function in a file"""
try:
with open(file_path, 'r') as f:
content = f.read()
# Look for the function definition
func_pattern = rf'pub fn {function_name}\s*\([^)]*\)\s*->[^{{]*{{'
func_match = re.search(func_pattern, content)
if not func_match:
return None
# Extract description, status, and deprecated flag from comments above the function
description, status, is_deprecated = self._extract_function_description(content, function_name)
# Skip deprecated functions
if is_deprecated:
return None
# Find the #[get("...")] attribute before the function
get_attr_pattern = rf'#\[get\("([^"]+)"\)\]\s*pub fn {function_name}'
get_match = re.search(get_attr_pattern, content)
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
# If no #[get] attribute found, return None - we can't determine the route
return None
except Exception as e:
print(f"Error parsing {file_path}: {e}")
return None
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 fn {function_name}'
func_match = re.search(func_pattern, content)
if not func_match:
return "no description", "unstable", False
# Get the position of the function
func_start = func_match.start()
# Look backwards from the function for comments
# Find consecutive /// comment lines before the function
lines = content[:func_start].split('\n')
comment_lines = []
# Go backwards from the function to find comment lines
for line in reversed(lines):
line = line.strip()
if line.startswith('///'):
comment_lines.insert(0, line[3:].strip()) # Remove /// and strip
elif line.startswith('pub fn'):
break
elif line and not line.startswith('///') and not line.startswith('#['):
break
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
cleaned_line = line[3:] if line.startswith('///') else line
filtered_lines.append(cleaned_line.rstrip())
# Join filtered lines, preserving original structure
description = '\n'.join(filtered_lines).strip()
# Check for deprecated functions
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
return description, status, is_deprecated
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 = []
# Split path and query parts
if '?' in route_path:
path_part, query_part = route_path.split('?', 1)
else:
path_part = route_path
query_part = ""
# Extract path parameters (e.g., <token_id>)
path_param_pattern = r'<([^>]+)>'
path_params = re.findall(path_param_pattern, path_part)
# Extract query parameters (e.g., <token_id>&<timestamp>)
if query_part:
query_param_pattern = r'<([^>]+)>'
query_params = re.findall(query_param_pattern, query_part)
# Clean up the route path for display
display_path = path_part
return RouteInfo(
path=display_path,
function_name=function_name,
method="GET",
path_params=path_params,
query_params=query_params,
parameters=path_params + query_params
)
class APIDocGenerator:
"""Generate API documentation in markdown format"""
def __init__(self, output_dir: str = "apidoc"):
self.output_dir = output_dir
os.makedirs(output_dir, exist_ok=True)
def generate_docs(self, mounts: List[MountInfo], function_parser: FunctionSignatureParser):
"""Generate documentation for all mounts"""
# Generate index page
self._generate_index(mounts)
# Generate individual mount pages
for mount in mounts:
self._generate_mount_page(mount, function_parser)
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
"""
for mount in mounts:
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"
index_content += """
## Getting Started
All endpoints return JSON responses. Most endpoints support GET requests only.
### Authentication
Currently, no authentication is required for API access.
"""
with open(os.path.join(self.output_dir, 'index.md'), 'w') as f:
f.write(index_content)
def _generate_mount_page(self, mount: MountInfo, function_parser: FunctionSignatureParser):
"""Generate documentation for a specific mount point"""
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
"""
# 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
# Sort routes alphabetically by their path
detailed_routes.sort(key=lambda x: x.path)
for route in detailed_routes:
content += self._generate_route_doc(route)
# Add navigation
content += f"""
---
[← Back to Index](index.md)
"""
with open(os.path.join(self.output_dir, f'{mount_name}.md'), 'w') as f:
f.write(content)
def _generate_route_doc(self, route: RouteInfo) -> str:
"""Generate documentation for a single route"""
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:
complete_route += "?" + "&".join(f"<{param}>" for param in route.query_params)
doc = f"""### `{complete_route}`
**Endpoint:** `{route.method} {full_path}`
**Status:** {route.status.title()}
"""
if route.description and route.description != "no description":
doc += f"**Description:** {route.description}\n\n"
doc += "---\n\n"
return doc
def main():
"""Main function to generate API documentation"""
# Configuration
main_rs_path = "src/main.rs"
src_dir = "src"
output_dir = "apidoc"
print("🔍 Parsing routes from main.rs...")
# Parse mounts from main.rs
route_parser = RustRouteParser(main_rs_path)
mounts = route_parser.parse_mounts()
print(f"📁 Found {len(mounts)} mount points:")
for mount in mounts:
print(f" - {mount.path}: {len(mount.routes)} routes")
# Initialize function signature parser
function_parser = FunctionSignatureParser(src_dir)
print("📝 Generating API documentation...")
# Generate documentation
doc_generator = APIDocGenerator(output_dir)
doc_generator.generate_docs(mounts, function_parser)
print(f"✅ API documentation generated in '{output_dir}/' directory")
print("📄 Generated files:")
# List generated files
for file in os.listdir(output_dir):
if file.endswith('.md'):
print(f" - {output_dir}/{file}")
if __name__ == "__main__":
main()

View file

@ -36,6 +36,22 @@ impl PoolSnapshot {
}
}
/// Fetch apy for a token and/or an account within a given time interval. All variables are optional.
/// A query with no variables will return the AAPY based on all users and all tokens aggregated.
///
/// Status: Stable
///
/// - token: The 32 byte token ID
/// - pkh: Public key hash for wallet account
/// - start: Unix timestamp for period start (default 30 days)
/// - end: Unix timestamp for period end (default NOW)
///
/// **Response Example:**
///
/// ```json
/// {"apy":"10.00","pools":100}
/// ```
///
#[get("/pool/aggregated_apy?<token>&<pkh>&<start>&<end>")]
pub fn aggregate_apy(
token: Option<&str>,

View file

@ -13,7 +13,33 @@ use serde_json::Value;
use crate::db::bcmr::get_well_known_bcmr;
use crate::db::{bcmr::get_token_bcmr, DB};
/// Fetches BCMR data from on-chain registry
/// Fetches BCMR data for token from on-chain registry.
/// Status: Stable
///
/// - category: Token ID or symbol
///
/// **Response Example:**
///
/// ```json
/// {
/// "description" : "Cauldron socks",
/// "filemeta": {
/// "actual_hash":"c705cc90a56ac7ef9a15ef90ebbc8ba7e60e4c622e5464d52d8baf7887949fcc",
/// "expected_hash":"c705cc90a56ac7ef9a15ef90ebbc8ba7e60e4c622e5464d52d8baf7887949fcc"
/// "source":"onchain"
/// },
/// "name":"Cauldron Socks",
/// "token":{
/// "category":"b79bfc8246b5fc4707e7c7dedcb6619ef1ab91f494a790c20b0f4c422ed95b92",
/// "decimals":0,
/// "symbol":"SOCK"
/// },
/// "uris":{
/// "icon":"https://sock.cauldron.quest/sock.png",
/// "web":"https://cauldron.quest"
/// }
/// }
/// ```
#[get("/token/<category>")]
pub fn token_bcmr(category: Option<&str>, db: &State<DB>) -> Result<Json<Value>, Custom<String>> {
let token_id_hex = category
@ -33,7 +59,9 @@ pub fn token_bcmr(category: Option<&str>, db: &State<DB>) -> Result<Json<Value>,
Ok(Json(json!(bcmr)))
}
/// Fetches BCMR data for token from all registries
/// Fetches BCMR data for token from all registries (including OTR).
///
/// Return format is same as `/token/<category>` route; except it returns an array of BCMR entries.
#[get("/token/<category>/all")]
pub fn token_bcmr_all(
category: Option<&str>,

View file

@ -216,6 +216,33 @@ pub fn candlesticks(
Ok(result)
}
/// Fetch candlesticks in BCH satoshis for a token.
///
/// If an interval has no trades, it will be omitted from the result.
///
///
/// - start: unix timestamp for period start (default 30 days)
/// - end: unix timestamp for period end (default NOW)
/// - stepsize: seconds per interval (default: 3600 seconds)
///
/// **Response Example:**
///
/// ```json
/// {
/// "candlesticks": [
/// {"close":64654136.35714286,
/// "high":87959043.0,
/// "low":58755326.0,
/// "open":87959043.0,
/// "time":1752522150,
/// "transaction_count":4,
/// "volume_sats":3170247594,
/// "volume_tokens":43},
/// ]
/// }
/// ```
///
#[get("/price/<token>/candlesticks?<start>&<end>&<stepsize>")]
pub fn price_candlesticks(
token: &str,

View file

@ -69,6 +69,14 @@ fn db_contract_count_by_token(db: &Connection, token_id: &str) -> Result<Contrac
})
}
/// Get number of active and ended cauldron contracts.
/// Status: Stable
///
/// **Response Example:**
///
/// ```json
/// {"active":100,"ended":10}
/// ```
#[get("/contract/count")]
pub fn contract_count_all(conn: &State<DB>) -> Result<Json<Value>, Custom<String>> {
let db = conn
@ -80,6 +88,15 @@ pub fn contract_count_all(conn: &State<DB>) -> Result<Json<Value>, Custom<String
Ok(Json(json!(count)))
}
/// Get number of active and ended cauldron contracts for a given token.
/// Status: Stable
///
/// - token: Token ID or symbol
///
/// **Response Example:**
/// ```json
/// {"active":100,"ended":10}
/// ```
#[get("/contract/count/<token>")]
pub fn contract_count_token(token: &str, conn: &State<DB>) -> Result<Json<Value>, Custom<String>> {
let db = conn
@ -91,6 +108,9 @@ pub fn contract_count_token(token: &str, conn: &State<DB>) -> Result<Json<Value>
Ok(Json(json!(count)))
}
/// Get volume for given token.
/// Status: Deprecated
/// (Needs to be split into interval rather than producing 3 fixed ones)
#[get("/contract/volume?<end>")]
pub fn contract_volume(
end: Option<i64>,

View file

@ -14,6 +14,12 @@ use rocket::serde::json::Json;
use rocket::{get, State};
use serde_json::{json, Value};
/// Get the closest oracle price for a given token and timestamp.
///
/// Status: Stable
///
/// - token_id: The 32 byte token ID
/// - timestamp: Unix timestamp
#[get("/delphi/closest?<token_id>&<timestamp>")]
pub fn oracle_get_closest(
token_id: Option<String>,
@ -38,6 +44,7 @@ pub fn oracle_get_closest(
})))
}
/// Status: Deprecated
#[get("/delphi/range?<token_id>&<start>&<end>")]
pub fn oracle_get_range(
token_id: Option<String>,
@ -67,6 +74,10 @@ pub fn oracle_get_range(
))
}
/// Get historical oracle prices for a given token.
///
/// - token: The 32 byte token ID
/// - start: Unix timestamp for start of period
#[get("/delphi/<token>/history?<start>&<end>&<stepsize>")]
pub fn oracle_get_history(
token: &str,

View file

@ -147,6 +147,7 @@ fn pools_by_apy(connection: &Connection) -> Result<Vec<PoolYield>> {
Ok(pools)
}
/// Status: Deprecated
#[get("/pool/list_by_apy")]
pub fn list_pools_by_apy(conn: &State<DB>) -> Result<Json<Value>, Custom<String>> {
let db = conn
@ -207,6 +208,33 @@ impl PoolVisitor for ActivePoolList {
}
}
/// Get list of active pools for given token and/or user.
/// Either user or token ID must be provided.
///
/// - user: 20 byte hash of a users pkh
/// - token: byte token ID.
///
/// Status: Stable
///
/// - token: Token ID or symbol
/// - pkh: Public key hash
///
/// **Response Example:**
/// ```json
/// {
/// "active": [
/// {
/// "owner_p2pkh_addr": "bitcoincash:zqmvqqsd6w08e4nvy8er0hzn6wzxvxj40u7tlk8wl3",
/// "owner_pkh": "36c0020dd39e7cd66c21f237dc53d384661a557f",
/// "sats": 776661580,
/// "token_id": "b79bfc8246b5fc4707e7c7dedcb6619ef1ab91f494a790c20b0f4c422ed95b92",
/// "tokens": 16,
/// "tx_pos": 0,
/// "txid": "94a933a0fa55093a0965eb867f1b9cac2bb07488ced4825bc31f86c9371f76aa"
/// }
/// ]
/// }
/// ```
#[get("/pool/active?<token>&<pkh>")]
pub fn list_active_pools(
token: Option<&str>,

View file

@ -237,6 +237,18 @@ fn price_at_or_before(
Ok((latest_timestamp, overall_price_f64))
}
/// Get the price of a given token at a specific timestamp.
///
/// - token: The 32 byte token ID
/// - timestamp: Unix timestamp
///
/// **Response Example:**
/// ```json
/// {
/// "price":34493809.347826086,
/// "timestamp":1709468902
/// }
/// ```
#[get("/price/<token>/at/<timestamp>")]
pub fn price_at(
token: &str,
@ -277,6 +289,18 @@ pub fn price_at(
}
}
/// Get the current price of a given token in satoshis.
///
/// Status: Stable
///
/// - token: The 32 byte token ID
///
/// **Response Example:**
///
/// ```json
/// {"price":1000.00}
/// ```
///
#[get("/price/<token>/current")]
pub fn price_current(token: &str, conn: &State<DB>) -> Result<Json<Value>, Custom<String>> {
let db = conn
@ -292,6 +316,41 @@ pub fn price_current(token: &str, conn: &State<DB>) -> Result<Json<Value>, Custo
})))
}
/// Fetch historical price in satoshis for a given token.
///
/// Note: If an interval has no trades, it will be omitted from the response.
///
/// **Response Example:**
/// ```json
/// {
/// "history": [
/// {
/// "avg": 33829844.78947368,
/// "max": 33829844.78947368,
/// "min": 33829844.78947368,
/// "time": 1709470824
/// },
/// {
/// "avg": 37699099,
/// "max": 37699099,
/// "min": 37699099,
/// "time": 1709751624
/// },
/// {
/// "avg": 42271799.176470585,
/// "max": 42271799.176470585,
/// "min": 42271799.176470585,
/// "time": 1709755224
/// },
/// {
/// "avg": 63517742.53043478,
/// "max": 101130081.9090909,
/// "min": 47729344.9375,
/// "time": 1710129624
/// },
/// ]
/// }
/// ```
#[get("/price/<token>/history?<start>&<end>&<stepsize>")]
pub fn price_history(
token: &str,

View file

@ -130,8 +130,9 @@ pub fn get_token_tvl(
Ok((visitor.sats, visitor.tokens))
}
// Deprecated; use valuelocked with optional parameters
// used by defilama; fix adapter first
/// Status: Deprecated
/// use valuelocked with optional parameters
/// used by defilama; fix adapter before removing
#[get("/tvl/<time>")]
pub fn deprecated_tvl(time: usize, conn: &State<DB>) -> Result<Json<Vec<Value>>, Custom<String>> {
let db = conn
@ -156,6 +157,18 @@ pub fn deprecated_tvl(time: usize, conn: &State<DB>) -> Result<Json<Vec<Value>>,
Ok(Json(result))
}
/// Gives total satoshis locked for all tokens.
/// Status: Stable
///
/// - time: Unix timestamp (optional)
///
/// **Response Example:**
///
/// ```json
/// {
/// "satoshis": 1459676788
/// }
/// ```
#[get("/valuelocked?<time>")]
pub fn valuelocked_all(
time: Option<usize>,
@ -176,6 +189,21 @@ pub fn valuelocked_all(
})))
}
/// Gives total value locked for a single token.
/// Status: Stable
///
/// - token: Token identifier / category.
/// - time: Unix timestamp (optional)
///
/// **Response Example:**
///
/// ```json
/// {
/// "satoshis": 1459676788,
/// "token_amount": 19,
/// "token_id": "b79bfc8246b5fc4707e7c7dedcb6619ef1ab91f494a790c20b0f4c422ed95b92"
/// }
/// ```
#[get("/valuelocked/<token>?<time>")]
pub fn valuelocked_token(
token: &str,