This commit is contained in:
398
src/cli.py
398
src/cli.py
@@ -1,75 +1,365 @@
|
|||||||
|
"""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
|
import click
|
||||||
from .core.parser import parse_openapi_spec
|
|
||||||
from .core.generator import generate_docs
|
from src.core.parser import OpenAPIParser, ParseError, SpecValidationError
|
||||||
from .core.models import APISpec, Endpoint, RequestExample, ResponseExample
|
from src.templates import HTML_TEMPLATE, MARKDOWN_TEMPLATE, JSON_TEMPLATE
|
||||||
from .utils.search import search_endpoints
|
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.group()
|
||||||
def main():
|
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
|
||||||
"""LocalAPI Docs - Generate local API documentation from OpenAPI specs."""
|
@click.pass_context
|
||||||
pass
|
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()
|
@main.command("serve")
|
||||||
@click.argument('spec_file', type=click.Path(exists=True))
|
@click.argument("spec_file", type=click.Path())
|
||||||
@click.option('--host', '-h', default='127.0.0.1', help='Host to bind to')
|
@click.option("--host", "-h", default="127.0.0.1", help="Host to bind to")
|
||||||
@click.option('--port', '-p', default=8080, help='Port to serve on')
|
@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.option("--no-browser", is_flag=True, help="Don't open browser automatically")
|
||||||
def serve(spec_file, host, port, no_browser):
|
@click.pass_context
|
||||||
|
def serve(ctx: click.Context, spec_file: str, host: str, port: int, no_browser: bool):
|
||||||
"""Start an interactive HTML documentation server."""
|
"""Start an interactive HTML documentation server."""
|
||||||
from .templates.html_template import generate_html_server
|
if ctx.obj is None:
|
||||||
generate_html_server(spec_file, host, port, not no_browser)
|
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()
|
@main.command("generate")
|
||||||
@click.argument('spec_file', type=click.Path(exists=True))
|
@click.argument("spec_file", type=click.Path())
|
||||||
@click.option('--output', '-o', help='Output file or directory')
|
@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("--format", "-f", type=click.Choice(["html", "markdown", "json", "all"]),
|
||||||
@click.option('--open', is_flag=True, help='Open the generated file in browser')
|
default="html", help="Output format")
|
||||||
def generate(spec_file, output, format, open):
|
@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."""
|
"""Generate static documentation in various formats."""
|
||||||
generate_docs(spec_file, output, format, open)
|
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()
|
@main.command("validate")
|
||||||
@click.argument('spec_file', type=click.Path(exists=True))
|
@click.argument("spec_file", type=click.Path())
|
||||||
@click.option('--json', is_flag=True, help='Output as JSON')
|
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
|
||||||
def validate(spec_file, json_output):
|
@click.pass_context
|
||||||
|
def validate(ctx: click.Context, spec_file: str, output_json: bool):
|
||||||
"""Validate an OpenAPI specification file."""
|
"""Validate an OpenAPI specification file."""
|
||||||
result = parse_openapi_spec(spec_file)
|
if ctx.obj is None:
|
||||||
if result.get('valid'):
|
ctx.obj = {}
|
||||||
click.echo("✓ OpenAPI spec is valid")
|
verbose = ctx.obj.get("verbose", False)
|
||||||
if json_output:
|
|
||||||
import json
|
if not Path(spec_file).exists():
|
||||||
click.echo(json.dumps(result, indent=2))
|
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:
|
else:
|
||||||
click.echo("✗ OpenAPI spec is invalid")
|
click.echo(click.style("✓ ", fg="green") +
|
||||||
if json_output:
|
f"OpenAPI specification is valid: {spec_file}")
|
||||||
import json
|
|
||||||
click.echo(json.dumps(result, indent=2))
|
|
||||||
else:
|
else:
|
||||||
for error in result.get('errors', []):
|
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}")
|
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()
|
@main.command("search")
|
||||||
@click.argument('spec_file', type=click.Path(exists=True))
|
@click.argument("spec_file", type=click.Path())
|
||||||
@click.argument('query', nargs=-1)
|
@click.argument("query", nargs=-1)
|
||||||
@click.option('--limit', '-l', default=10, help='Maximum results')
|
@click.option("--limit", "-l", default=10, type=int, help="Maximum results")
|
||||||
@click.option('--json', is_flag=True, help='Output as JSON')
|
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
|
||||||
def search(spec_file, query, limit, json_output):
|
@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."""
|
"""Search for endpoints in an OpenAPI specification."""
|
||||||
search_term = ' '.join(query)
|
if ctx.obj is None:
|
||||||
results = search_endpoints(spec_file, search_term, limit)
|
ctx.obj = {}
|
||||||
if json_output:
|
verbose = ctx.obj.get("verbose", False)
|
||||||
import json
|
|
||||||
click.echo(json.dumps(results, indent=2))
|
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:
|
else:
|
||||||
if not results:
|
if not results:
|
||||||
click.echo("No results found.")
|
click.echo(f"No results found for: {search_query}")
|
||||||
return
|
else:
|
||||||
for result in results:
|
click.echo(f"Found {len(results)} results for: {search_query}\n")
|
||||||
click.echo(f"\n{result['method']} {result['path']}")
|
for i, result in enumerate(results, 1):
|
||||||
click.echo(f" {result.get('summary', result.get('description', 'No description'))}")
|
method_colors = {
|
||||||
click.echo(f" Tags: {', '.join(result.get('tags', []))}")
|
"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()
|
||||||
|
|||||||
Reference in New Issue
Block a user