366 lines
13 KiB
Python
366 lines
13 KiB
Python
"""CLI interface for LocalAPI Docs."""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
import threading
|
|
import webbrowser
|
|
from datetime import datetime
|
|
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import click
|
|
|
|
from src.core.parser import OpenAPIParser, ParseError, SpecValidationError
|
|
from src.templates import HTML_TEMPLATE, MARKDOWN_TEMPLATE, JSON_TEMPLATE
|
|
from src.utils.examples import ExampleGenerator
|
|
from src.utils.search import SearchIndex
|
|
|
|
|
|
def setup_logging(verbose: bool = False):
|
|
"""Set up logging configuration."""
|
|
import logging
|
|
log_handler = logging.StreamHandler(sys.stdout)
|
|
if verbose:
|
|
log_handler.setFormatter(
|
|
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
|
)
|
|
logging.basicConfig(level=logging.DEBUG, handlers=[log_handler])
|
|
else:
|
|
log_handler.setFormatter(logging.Formatter("%(message)s"))
|
|
logging.basicConfig(level=logging.INFO, handlers=[log_handler])
|
|
|
|
|
|
@click.group()
|
|
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
|
|
@click.pass_context
|
|
def main(ctx: click.Context, verbose: bool):
|
|
"""LocalAPI Docs - Generate local, privacy-focused API documentation."""
|
|
ctx.ensure_object(dict)
|
|
ctx.obj["verbose"] = verbose
|
|
setup_logging(verbose)
|
|
|
|
|
|
@main.command("serve")
|
|
@click.argument("spec_file", type=click.Path())
|
|
@click.option("--host", "-h", default="127.0.0.1", help="Host to bind to")
|
|
@click.option("--port", "-p", default=8080, type=int, help="Port to serve on")
|
|
@click.option("--no-browser", is_flag=True, help="Don't open browser automatically")
|
|
@click.pass_context
|
|
def serve(ctx: click.Context, spec_file: str, host: str, port: int, no_browser: bool):
|
|
"""Start an interactive HTML documentation server."""
|
|
if ctx.obj is None:
|
|
ctx.obj = {}
|
|
verbose = ctx.obj.get("verbose", False)
|
|
|
|
if not Path(spec_file).exists():
|
|
click.echo(click.style("Error: ", fg="red") + f"Spec file not found: {spec_file}")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
parser = OpenAPIParser(spec_file)
|
|
spec_data = parser.parse_with_examples()
|
|
|
|
output_dir = Path(spec_file).parent / ".localapi-docs"
|
|
output_dir.mkdir(exist_ok=True)
|
|
|
|
endpoints_by_tag: dict = {}
|
|
for ep in spec_data["endpoints"]:
|
|
tags = ep.get("tags", ["other"])
|
|
for tag in tags:
|
|
if tag not in endpoints_by_tag:
|
|
endpoints_by_tag[tag] = []
|
|
endpoints_by_tag[tag].append(ep)
|
|
|
|
html_content = HTML_TEMPLATE.render(
|
|
spec=spec_data["spec"],
|
|
endpoints=spec_data["endpoints"],
|
|
endpoints_by_tag=endpoints_by_tag,
|
|
schemas=spec_data["schemas"],
|
|
tags=spec_data["tags"],
|
|
timestamp=datetime.now().isoformat(),
|
|
)
|
|
|
|
html_file = output_dir / "index.html"
|
|
html_file.write_text(html_content)
|
|
|
|
search_index = SearchIndex()
|
|
search_index.build(spec_data["spec"])
|
|
search_index_file = output_dir / "search.json"
|
|
search_index_file.write_text(json.dumps({
|
|
"endpoints": spec_data["endpoints"],
|
|
"schemas": spec_data["schemas"],
|
|
"tags": spec_data["tags"],
|
|
}))
|
|
|
|
if not no_browser:
|
|
def open_browser():
|
|
webbrowser.open(f"http://{host}:{port}")
|
|
threading.Timer(1.0, open_browser).start()
|
|
|
|
os.chdir(output_dir)
|
|
server = HTTPServer((host, port), SimpleHTTPRequestHandler)
|
|
|
|
click.echo(f"Documentation server running at http://{host}:{port}")
|
|
click.echo(f"Serving from: {output_dir}")
|
|
click.echo("Press Ctrl+C to stop")
|
|
|
|
server.serve_forever()
|
|
except ParseError as e:
|
|
click.echo(click.style("Error: ", fg="red") + f"Parse error: {e}")
|
|
sys.exit(1)
|
|
except KeyboardInterrupt:
|
|
click.echo("Server stopped")
|
|
except Exception as e:
|
|
click.echo(click.style("Error: ", fg="red") + f"Unexpected error: {e}")
|
|
if verbose:
|
|
raise
|
|
sys.exit(1)
|
|
|
|
|
|
@main.command("generate")
|
|
@click.argument("spec_file", type=click.Path())
|
|
@click.option("--output", "-o", type=click.Path(), help="Output file or directory")
|
|
@click.option("--format", "-f", type=click.Choice(["html", "markdown", "json", "all"]),
|
|
default="html", help="Output format")
|
|
@click.option("--open", "open_file", is_flag=True, help="Open the generated file in browser")
|
|
@click.pass_context
|
|
def generate(ctx: click.Context, spec_file: str, output: Optional[str],
|
|
format: str, open_file: bool):
|
|
"""Generate static documentation in various formats."""
|
|
if ctx.obj is None:
|
|
ctx.obj = {}
|
|
verbose = ctx.obj.get("verbose", False)
|
|
|
|
if not Path(spec_file).exists():
|
|
click.echo(click.style("Error: ", fg="red") + f"Spec file not found: {spec_file}")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
parser = OpenAPIParser(spec_file)
|
|
spec_data = parser.parse_with_examples()
|
|
|
|
endpoints_by_tag = {}
|
|
for ep in spec_data["endpoints"]:
|
|
tags = ep.get("tags", ["other"])
|
|
for tag in tags:
|
|
if tag not in endpoints_by_tag:
|
|
endpoints_by_tag[tag] = []
|
|
endpoints_by_tag[tag].append(ep)
|
|
|
|
output_path = Path(output) if output else Path.cwd()
|
|
|
|
if format in ["html", "all"]:
|
|
html_content = HTML_TEMPLATE.render(
|
|
spec=spec_data["spec"],
|
|
endpoints=spec_data["endpoints"],
|
|
endpoints_by_tag=endpoints_by_tag,
|
|
schemas=spec_data["schemas"],
|
|
tags=spec_data["tags"],
|
|
timestamp=datetime.now().isoformat(),
|
|
)
|
|
|
|
if format == "all":
|
|
html_file = output_path / "docs" / "index.html"
|
|
html_file.parent.mkdir(exist_ok=True)
|
|
else:
|
|
if output_path.is_dir():
|
|
html_file = output_path / "api-docs.html"
|
|
else:
|
|
html_file = output_path
|
|
|
|
html_file.write_text(html_content)
|
|
click.echo(f"Generated HTML documentation: {html_file}")
|
|
|
|
if open_file:
|
|
webbrowser.open(f"file://{html_file.absolute()}")
|
|
|
|
if format in ["markdown", "all"]:
|
|
md_content = MARKDOWN_TEMPLATE.render(
|
|
spec=spec_data["spec"],
|
|
endpoints=spec_data["endpoints"],
|
|
endpoints_by_tag=endpoints_by_tag if endpoints_by_tag else {},
|
|
schemas=spec_data["schemas"],
|
|
tags=spec_data["tags"],
|
|
timestamp=datetime.now().isoformat(),
|
|
)
|
|
|
|
if format == "all":
|
|
md_file = output_path / "docs" / "api-docs.md"
|
|
else:
|
|
if output_path.suffix == ".md":
|
|
md_file = output_path
|
|
else:
|
|
md_file = output_path / "api-docs.md"
|
|
|
|
md_file.write_text(md_content)
|
|
click.echo(f"Generated Markdown documentation: {md_file}")
|
|
|
|
if format in ["json", "all"]:
|
|
json_content = JSON_TEMPLATE.render(
|
|
spec=spec_data["spec"],
|
|
endpoints=spec_data["endpoints"],
|
|
schemas=spec_data["schemas"],
|
|
tags=spec_data["tags"],
|
|
timestamp=datetime.now().isoformat(),
|
|
)
|
|
|
|
if format == "all":
|
|
json_file = output_path / "docs" / "api-docs.json"
|
|
else:
|
|
if output_path.suffix == ".json":
|
|
json_file = output_path
|
|
else:
|
|
json_file = output_path / "api-docs.json"
|
|
|
|
json_file.write_text(json_content)
|
|
click.echo(f"Generated JSON documentation: {json_file}")
|
|
|
|
except ParseError as e:
|
|
click.echo(click.style("Error: ", fg="red") + f"Parse error: {e}")
|
|
sys.exit(1)
|
|
except Exception as e:
|
|
click.echo(click.style("Error: ", fg="red") + f"Unexpected error: {e}")
|
|
if verbose:
|
|
raise
|
|
sys.exit(1)
|
|
|
|
|
|
@main.command("validate")
|
|
@click.argument("spec_file", type=click.Path())
|
|
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
|
|
@click.pass_context
|
|
def validate(ctx: click.Context, spec_file: str, output_json: bool):
|
|
"""Validate an OpenAPI specification file."""
|
|
if ctx.obj is None:
|
|
ctx.obj = {}
|
|
verbose = ctx.obj.get("verbose", False)
|
|
|
|
if not Path(spec_file).exists():
|
|
click.echo(click.style("Error: ", fg="red") + f"Spec file not found: {spec_file}")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
parser = OpenAPIParser(spec_file)
|
|
is_valid, errors = parser.validate()
|
|
|
|
if is_valid:
|
|
if output_json:
|
|
click.echo(json.dumps({
|
|
"valid": True,
|
|
"message": "OpenAPI specification is valid",
|
|
"file": spec_file,
|
|
}))
|
|
else:
|
|
click.echo(click.style("✓ ", fg="green") +
|
|
f"OpenAPI specification is valid: {spec_file}")
|
|
else:
|
|
if output_json:
|
|
click.echo(json.dumps({
|
|
"valid": False,
|
|
"errors": errors,
|
|
"file": spec_file,
|
|
}))
|
|
else:
|
|
click.echo(click.style("✗ ", fg="red") +
|
|
f"Invalid OpenAPI specification: {spec_file}")
|
|
for error in errors:
|
|
click.echo(f" - {error}")
|
|
sys.exit(1)
|
|
|
|
except ParseError as e:
|
|
if output_json:
|
|
click.echo(json.dumps({
|
|
"valid": False,
|
|
"errors": [str(e)],
|
|
"file": spec_file,
|
|
}))
|
|
else:
|
|
click.echo(click.style("✗ ", fg="red") +
|
|
f"Failed to parse spec: {e}")
|
|
sys.exit(1)
|
|
|
|
|
|
@main.command("search")
|
|
@click.argument("spec_file", type=click.Path())
|
|
@click.argument("query", nargs=-1)
|
|
@click.option("--limit", "-l", default=10, type=int, help="Maximum results")
|
|
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
|
|
@click.pass_context
|
|
def search(ctx: click.Context, spec_file: str, query: tuple,
|
|
limit: int, output_json: bool):
|
|
"""Search for endpoints in an OpenAPI specification."""
|
|
if ctx.obj is None:
|
|
ctx.obj = {}
|
|
verbose = ctx.obj.get("verbose", False)
|
|
|
|
if not Path(spec_file).exists():
|
|
click.echo(click.style("Error: ", fg="red") + f"Spec file not found: {spec_file}")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
parser = OpenAPIParser(spec_file)
|
|
spec_data = parser.parse_with_examples()
|
|
|
|
search_index = SearchIndex()
|
|
search_index.build(spec_data["spec"])
|
|
|
|
search_query = " ".join(query)
|
|
results = search_index.search(search_query, limit=limit)
|
|
|
|
if output_json:
|
|
output = {
|
|
"query": search_query,
|
|
"count": len(results),
|
|
"results": [
|
|
{
|
|
"path": r.endpoint_path,
|
|
"method": r.endpoint_method,
|
|
"title": r.title,
|
|
"description": r.description,
|
|
"tags": r.tags,
|
|
"score": r.score,
|
|
}
|
|
for r in results
|
|
],
|
|
}
|
|
click.echo(json.dumps(output, indent=2))
|
|
else:
|
|
if not results:
|
|
click.echo(f"No results found for: {search_query}")
|
|
else:
|
|
click.echo(f"Found {len(results)} results for: {search_query}\n")
|
|
for i, result in enumerate(results, 1):
|
|
method_colors = {
|
|
"GET": "green",
|
|
"POST": "blue",
|
|
"PUT": "yellow",
|
|
"PATCH": "magenta",
|
|
"DELETE": "red",
|
|
}
|
|
color = method_colors.get(result.endpoint_method, "white")
|
|
click.echo(
|
|
f"{i}. "
|
|
f"{click.style(result.endpoint_method, fg=color, bold=True)} "
|
|
f"{result.endpoint_path}"
|
|
)
|
|
if result.title:
|
|
click.echo(f" {result.title}")
|
|
click.echo(f" Score: {result.score:.2f}")
|
|
click.echo()
|
|
|
|
except ParseError as e:
|
|
click.echo(click.style("Error: ", fg="red") + f"Parse error: {e}")
|
|
sys.exit(1)
|
|
except Exception as e:
|
|
click.echo(click.style("Error: ", fg="red") + f"Unexpected error: {e}")
|
|
if verbose:
|
|
raise
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|