This commit is contained in:
408
src/cli.py
408
src/cli.py
@@ -1,365 +1,97 @@
|
||||
"""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])
|
||||
from src.core.parser import parse_openapi_spec
|
||||
from src.utils.search import create_search_index, search_index
|
||||
from src.utils.templates import generate_html, generate_json, generate_markdown, serve_docs
|
||||
|
||||
|
||||
@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)
|
||||
def main():
|
||||
"""LocalAPI Docs - Privacy-First OpenAPI Documentation CLI"""
|
||||
pass
|
||||
|
||||
|
||||
@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)
|
||||
@click.argument("spec_path", type=click.Path(exists=True))
|
||||
@click.option("--host", default="127.0.0.1", help="Host to bind the server to")
|
||||
@click.option("--port", default=8080, type=int, help="Port to bind the server to")
|
||||
def serve(spec_path: str, host: str, port: int):
|
||||
"""Serve interactive API documentation locally"""
|
||||
serve_docs(spec_path, host=host, port=port)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
@click.argument("spec_path", type=click.Path(exists=True))
|
||||
@click.option("--output", "-o", type=click.Path(), help="Output file path")
|
||||
@click.option(
|
||||
"--format", "fmt", type=click.Choice(["html", "markdown", "json"]),
|
||||
default="html", help="Output format"
|
||||
)
|
||||
@click.option("--template", type=click.Path(exists=True), help="Custom template file path")
|
||||
def generate(spec_path: str, output: str | None, fmt: str, template: str | None):
|
||||
"""Generate documentation in various formats"""
|
||||
if output is None:
|
||||
if fmt == "html":
|
||||
output = "docs.html"
|
||||
elif fmt == "markdown":
|
||||
output = "docs.md"
|
||||
else:
|
||||
output = "docs.json"
|
||||
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)
|
||||
if fmt == "html":
|
||||
generate_html(spec_path, output, template_path=template)
|
||||
elif fmt == "markdown":
|
||||
generate_markdown(spec_path, output, template_path=template)
|
||||
else:
|
||||
generate_json(spec_path, output, template_path=template)
|
||||
click.echo(f"Documentation generated: {output}")
|
||||
except Exception as e:
|
||||
click.echo(click.style("Error: ", fg="red") + f"Unexpected error: {e}")
|
||||
if verbose:
|
||||
raise
|
||||
sys.exit(1)
|
||||
click.echo(f"Error generating documentation: {e}", err=True)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
@click.argument("spec_path", type=click.Path(exists=True))
|
||||
def validate(spec_path: str):
|
||||
"""Validate an OpenAPI specification file"""
|
||||
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)
|
||||
spec = parse_openapi_spec(spec_path)
|
||||
click.echo(f"Valid OpenAPI spec: {spec.info.title} v{spec.info.version}")
|
||||
return True
|
||||
except ValueError as e:
|
||||
click.echo(f"Validation failed: {e}", err=True)
|
||||
return False
|
||||
|
||||
|
||||
@main.command("search")
|
||||
@click.argument("spec_file", type=click.Path())
|
||||
@click.argument("spec_path", type=click.Path(exists=True))
|
||||
@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)
|
||||
|
||||
def search(spec_path: str, query: tuple):
|
||||
"""Search for endpoints in an OpenAPI specification"""
|
||||
query_str = " ".join(query)
|
||||
if not query_str:
|
||||
click.echo("Please provide a search query")
|
||||
return
|
||||
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}")
|
||||
try:
|
||||
spec = parse_openapi_spec(spec_path)
|
||||
spec_dict = spec.model_dump()
|
||||
except Exception:
|
||||
content = Path(spec_path).read_text()
|
||||
if spec_path.endswith(('.yaml', '.yml')):
|
||||
import yaml
|
||||
spec_dict = yaml.safe_load(content)
|
||||
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)
|
||||
spec_dict = json.loads(content)
|
||||
index = create_search_index(spec_dict)
|
||||
results = search_index(index, query_str)
|
||||
if results:
|
||||
click.echo(f"Found {len(results)} results for '{query_str}':")
|
||||
for r in results:
|
||||
click.echo(f" [{r.method}] {r.path} - {r.summary or ''}")
|
||||
else:
|
||||
click.echo(f"No results found for '{query_str}'")
|
||||
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()
|
||||
click.echo(f"Search failed: {e}", err=True)
|
||||
|
||||
Reference in New Issue
Block a user