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)