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 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 pathlib import Path
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from src.core.parser import OpenAPIParser, ParseError, SpecValidationError
|
from src.core.parser import parse_openapi_spec
|
||||||
from src.templates import HTML_TEMPLATE, MARKDOWN_TEMPLATE, JSON_TEMPLATE
|
from src.utils.search import create_search_index, search_index
|
||||||
from src.utils.examples import ExampleGenerator
|
from src.utils.templates import generate_html, generate_json, generate_markdown, serve_docs
|
||||||
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.group()
|
||||||
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
|
def main():
|
||||||
@click.pass_context
|
"""LocalAPI Docs - Privacy-First OpenAPI Documentation CLI"""
|
||||||
def main(ctx: click.Context, verbose: bool):
|
pass
|
||||||
"""LocalAPI Docs - Generate local, privacy-focused API documentation."""
|
|
||||||
ctx.ensure_object(dict)
|
|
||||||
ctx.obj["verbose"] = verbose
|
|
||||||
setup_logging(verbose)
|
|
||||||
|
|
||||||
|
|
||||||
@main.command("serve")
|
@main.command("serve")
|
||||||
@click.argument("spec_file", type=click.Path())
|
@click.argument("spec_path", type=click.Path(exists=True))
|
||||||
@click.option("--host", "-h", default="127.0.0.1", help="Host to bind to")
|
@click.option("--host", default="127.0.0.1", help="Host to bind the server to")
|
||||||
@click.option("--port", "-p", default=8080, type=int, help="Port to serve on")
|
@click.option("--port", default=8080, type=int, help="Port to bind the server to")
|
||||||
@click.option("--no-browser", is_flag=True, help="Don't open browser automatically")
|
def serve(spec_path: str, host: str, port: int):
|
||||||
@click.pass_context
|
"""Serve interactive API documentation locally"""
|
||||||
def serve(ctx: click.Context, spec_file: str, host: str, port: int, no_browser: bool):
|
serve_docs(spec_path, host=host, port=port)
|
||||||
"""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")
|
@main.command("generate")
|
||||||
@click.argument("spec_file", type=click.Path())
|
@click.argument("spec_path", type=click.Path(exists=True))
|
||||||
@click.option("--output", "-o", type=click.Path(), help="Output file or directory")
|
@click.option("--output", "-o", type=click.Path(), help="Output file path")
|
||||||
@click.option("--format", "-f", type=click.Choice(["html", "markdown", "json", "all"]),
|
@click.option(
|
||||||
default="html", help="Output format")
|
"--format", "fmt", type=click.Choice(["html", "markdown", "json"]),
|
||||||
@click.option("--open", "open_file", is_flag=True, help="Open the generated file in browser")
|
default="html", help="Output format"
|
||||||
@click.pass_context
|
)
|
||||||
def generate(ctx: click.Context, spec_file: str, output: Optional[str],
|
@click.option("--template", type=click.Path(exists=True), help="Custom template file path")
|
||||||
format: str, open_file: bool):
|
def generate(spec_path: str, output: str | None, fmt: str, template: str | None):
|
||||||
"""Generate static documentation in various formats."""
|
"""Generate documentation in various formats"""
|
||||||
if ctx.obj is None:
|
if output is None:
|
||||||
ctx.obj = {}
|
if fmt == "html":
|
||||||
verbose = ctx.obj.get("verbose", False)
|
output = "docs.html"
|
||||||
|
elif fmt == "markdown":
|
||||||
if not Path(spec_file).exists():
|
output = "docs.md"
|
||||||
click.echo(click.style("Error: ", fg="red") + f"Spec file not found: {spec_file}")
|
else:
|
||||||
sys.exit(1)
|
output = "docs.json"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
parser = OpenAPIParser(spec_file)
|
if fmt == "html":
|
||||||
spec_data = parser.parse_with_examples()
|
generate_html(spec_path, output, template_path=template)
|
||||||
|
elif fmt == "markdown":
|
||||||
endpoints_by_tag = {}
|
generate_markdown(spec_path, output, template_path=template)
|
||||||
for ep in spec_data["endpoints"]:
|
else:
|
||||||
tags = ep.get("tags", ["other"])
|
generate_json(spec_path, output, template_path=template)
|
||||||
for tag in tags:
|
click.echo(f"Documentation generated: {output}")
|
||||||
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:
|
except Exception as e:
|
||||||
click.echo(click.style("Error: ", fg="red") + f"Unexpected error: {e}")
|
click.echo(f"Error generating documentation: {e}", err=True)
|
||||||
if verbose:
|
|
||||||
raise
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
@main.command("validate")
|
@main.command("validate")
|
||||||
@click.argument("spec_file", type=click.Path())
|
@click.argument("spec_path", type=click.Path(exists=True))
|
||||||
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
|
def validate(spec_path: str):
|
||||||
@click.pass_context
|
"""Validate an OpenAPI specification file"""
|
||||||
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:
|
try:
|
||||||
parser = OpenAPIParser(spec_file)
|
spec = parse_openapi_spec(spec_path)
|
||||||
is_valid, errors = parser.validate()
|
click.echo(f"Valid OpenAPI spec: {spec.info.title} v{spec.info.version}")
|
||||||
|
return True
|
||||||
if is_valid:
|
except ValueError as e:
|
||||||
if output_json:
|
click.echo(f"Validation failed: {e}", err=True)
|
||||||
click.echo(json.dumps({
|
return False
|
||||||
"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")
|
@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.argument("query", nargs=-1)
|
||||||
@click.option("--limit", "-l", default=10, type=int, help="Maximum results")
|
def search(spec_path: str, query: tuple):
|
||||||
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
|
"""Search for endpoints in an OpenAPI specification"""
|
||||||
@click.pass_context
|
query_str = " ".join(query)
|
||||||
def search(ctx: click.Context, spec_file: str, query: tuple,
|
if not query_str:
|
||||||
limit: int, output_json: bool):
|
click.echo("Please provide a search query")
|
||||||
"""Search for endpoints in an OpenAPI specification."""
|
return
|
||||||
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:
|
try:
|
||||||
parser = OpenAPIParser(spec_file)
|
try:
|
||||||
spec_data = parser.parse_with_examples()
|
spec = parse_openapi_spec(spec_path)
|
||||||
|
spec_dict = spec.model_dump()
|
||||||
search_index = SearchIndex()
|
except Exception:
|
||||||
search_index.build(spec_data["spec"])
|
content = Path(spec_path).read_text()
|
||||||
|
if spec_path.endswith(('.yaml', '.yml')):
|
||||||
search_query = " ".join(query)
|
import yaml
|
||||||
results = search_index.search(search_query, limit=limit)
|
spec_dict = yaml.safe_load(content)
|
||||||
|
|
||||||
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:
|
else:
|
||||||
click.echo(f"Found {len(results)} results for: {search_query}\n")
|
spec_dict = json.loads(content)
|
||||||
for i, result in enumerate(results, 1):
|
index = create_search_index(spec_dict)
|
||||||
method_colors = {
|
results = search_index(index, query_str)
|
||||||
"GET": "green",
|
if results:
|
||||||
"POST": "blue",
|
click.echo(f"Found {len(results)} results for '{query_str}':")
|
||||||
"PUT": "yellow",
|
for r in results:
|
||||||
"PATCH": "magenta",
|
click.echo(f" [{r.method}] {r.path} - {r.summary or ''}")
|
||||||
"DELETE": "red",
|
else:
|
||||||
}
|
click.echo(f"No results found for '{query_str}'")
|
||||||
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:
|
except Exception as e:
|
||||||
click.echo(click.style("Error: ", fg="red") + f"Unexpected error: {e}")
|
click.echo(f"Search failed: {e}", err=True)
|
||||||
if verbose:
|
|
||||||
raise
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|||||||
Reference in New Issue
Block a user