"""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()