diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index d5bcb8e..5603a33 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -8,11 +8,12 @@ from rich.panel import Panel from typing_extensions import Annotated -from fastapi_cli.discover import get_import_string +from fastapi_cli.discover import get_api_spec, get_import_string from fastapi_cli.exceptions import FastAPICLIException from . import __version__ from .logging import setup_logging +from .utils import generate_markdown app = typer.Typer(rich_markup_mode="rich") @@ -273,5 +274,54 @@ def run( ) +@app.command() +def doc( + path: Annotated[ + Union[Path, None], + typer.Argument( + help="A path to a Python file or package directory (with [blue]__init__.py[/blue] files) containing a [bold]FastAPI[/bold] app. If not provided, a default set of paths will be tried." + ), + ] = None, + *, + app: Annotated[ + Union[str, None], + typer.Option( + help="The name of the variable that contains the [bold]FastAPI[/bold] app in the imported module or package. If not provided, it is detected automatically." + ), + ] = None, + title: Annotated[ + Union[str, None], + typer.Option( + help="The title to use for the generated markdown file. If not provided, it is detected automatically." + ), + ] = None, +) -> None: + """ + Generate [bold]FastAPI[/bold] API docs. 📚 + + It uses openapi spec to generate a markdown. + """ + try: + fastapi_app = get_import_string(path=path, app_name=app) + except FastAPICLIException as e: + logger.error(str(e)) + raise typer.Exit(code=1) from None + spec = get_api_spec(fastapi_app) + markdown = generate_markdown(spec, title) + panel = Panel( + f"{markdown}", + title="Generated Markdown", + expand=False, + padding=(1, 2), + style="white on black", + ) + print(Padding(panel, 3)) + if not title: + title = markdown.split("\n")[0].replace("#", "").strip() + title = title.replace(" ", "_") + with open(f"{title.upper()}.md", "w") as f: + f.write(markdown) + + def main() -> None: app() diff --git a/src/fastapi_cli/discover.py b/src/fastapi_cli/discover.py index f442438..97066be 100644 --- a/src/fastapi_cli/discover.py +++ b/src/fastapi_cli/discover.py @@ -1,4 +1,5 @@ import importlib +import inspect import sys from dataclasses import dataclass from logging import getLogger @@ -17,6 +18,7 @@ try: from fastapi import FastAPI + from fastapi.openapi.utils import get_openapi except ImportError: # pragma: no cover FastAPI = None # type: ignore[misc, assignment] @@ -165,3 +167,18 @@ def get_import_string( import_string = f"{mod_data.module_import_str}:{use_app_name}" logger.info(f"Using import string [b green]{import_string}[/b green]") return import_string + + +def get_api_spec(import_string: str) -> dict: + app_module, app_name = import_string.replace("/", ".").rsplit(":", 1) + app = getattr(__import__(app_module, fromlist=[app_name]), app_name) + signature = inspect.signature(get_openapi) + props = { + prop.name: getattr(app, prop.name, None) + for prop in signature.parameters.values() + } + + props["webhooks"] = None + spec = get_openapi(**props) + + return spec diff --git a/src/fastapi_cli/utils.py b/src/fastapi_cli/utils.py new file mode 100644 index 0000000..1d723de --- /dev/null +++ b/src/fastapi_cli/utils.py @@ -0,0 +1,108 @@ +def generate_markdown(api_spec, title): + md = [] + + # Info + info = api_spec.get("info", {}) + if title: + words = [word.capitalize() for word in title.split(" ")] + title = " ".join(words) + md.append(f"# {title}") + else: + md.append(f"# {info.get('title', 'API Documentation')}") + md.append(f"\n## Version: {info.get('version', 'N/A')}\n") + + # Paths + md.append("### Paths\n") + paths = api_spec.get("paths", {}) + for path, methods in paths.items(): + for method, details in methods.items(): + md.append(f"#### {path}\n") + md.append(f"**{method.upper()}**\n") + md.append(f"**Summary:** {details.get('summary', 'No summary')}\n") + md.append(f"**Operation ID:** {details.get('operationId', 'N/A')}\n") + + # Parameters + parameters = details.get("parameters", []) + if parameters: + md.append("**Parameters:**\n") + md.append("| Name | In | Required | Schema | Description | Example |") + md.append("|------|----|----------|--------|-------------|---------|") + for param in parameters: + name = param.get("name", "N/A") + param_in = param.get("in", "N/A") + required = param.get("required", False) + schema = param.get("schema", {}) + schema_str = f"`type: {schema.get('type', 'N/A')}`
`title: {schema.get('title', 'N/A')}`" + if "description" in schema: + schema_str += ( + f"
`description: {schema.get('description', 'N/A')}`" + ) + if "enum" in schema: + schema_str += f"
`enum: {schema.get('enum')}`" + if "default" in schema: + schema_str += f"
`default: {schema.get('default')}`" + description = param.get("description", "N/A") + example = param.get("example", "N/A") + md.append( + f"| {name} | {param_in} | {required} | {schema_str} | {description} | {example} |" + ) + + # Request Body + request_body = details.get("requestBody", {}) + if request_body: + md.append("**Request Body:**") + md.append(f"- **Required:** {request_body.get('required', False)}") + md.append("- **Content:**") + content = request_body.get("content", {}) + for content_type, content_schema in content.items(): + md.append(f" - **{content_type}:**") + schema_ref = content_schema.get("schema", {}).get("$ref", "N/A") + md.append( + f" - **Schema:** [{schema_ref.split('/')[-1]}](#{schema_ref.split('/')[-1].lower()})\n" + ) + + # Responses + responses = details.get("responses", {}) + if responses: + md.append("**Responses:**\n") + md.append("| Status Code | Description | Content |") + md.append("|-------------|-------------|---------|") + for status, response in responses.items(): + description = response.get("description", "N/A") + content = response.get("content", {}) + content_str = "" + for content_type, content_schema in content.items(): + schema_ref = content_schema.get("schema", {}).get("$ref", "N/A") + content_str += f"{content_type}: `schema: [{schema_ref.split('/')[-1]}](#{schema_ref.split('/')[-1].lower()})`
" + md.append( + f"| {status} | {description} | {content_str.strip('
')} |" + ) + + # Components + components = api_spec.get("components", {}).get("schemas", {}) + if components: + md.append("### Components") + md.append("#### Schemas") + for schema_name, schema_details in components.items(): + md.append(f"##### {schema_name}") + md.append(f"- **Type:** {schema_details.get('type', 'N/A')}") + if "required" in schema_details: + md.append( + f"- **Required:** {', '.join(schema_details.get('required', []))}" + ) + md.append(f"- **Title:** {schema_details.get('title', 'N/A')}") + md.append("- **Properties:**") + properties = schema_details.get("properties", {}) + for prop_name, prop_details in properties.items(): + prop_type = prop_details.get("type", "N/A") + prop_format = prop_details.get("format", "N/A") + prop_title = prop_details.get("title", "N/A") + prop_desc = prop_details.get("description", "N/A") + md.append(f" - **{prop_name}:**") + md.append(f" - **Type:** {prop_type}") + if prop_format != "N/A": + md.append(f" - **Format:** {prop_format}") + md.append(f" - **Title:** {prop_title}") + md.append(f" - **Description:** {prop_desc}") + + return "\n".join(md)