Skip to content

Commit df0329b

Browse files
committed
feat: Add markdown doc based on app's openapi specification
1 parent 63267a3 commit df0329b

3 files changed

Lines changed: 164 additions & 2 deletions

File tree

src/fastapi_cli/cli.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@
88
from rich.panel import Panel
99
from typing_extensions import Annotated
1010

11-
from fastapi_cli.discover import get_import_string
11+
from fastapi_cli.discover import get_import_string, get_api_spec
1212
from fastapi_cli.exceptions import FastAPICLIException
1313

1414
from . import __version__
1515
from .logging import setup_logging
16-
16+
from .utils import generate_markdown
1717
app = typer.Typer(rich_markup_mode="rich")
1818

1919
setup_logging()
@@ -272,6 +272,53 @@ def run(
272272
proxy_headers=proxy_headers,
273273
)
274274

275+
@app.command()
276+
def doc(
277+
path: Annotated[
278+
Union[Path, None],
279+
typer.Argument(
280+
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."
281+
),
282+
] = None,
283+
*,
284+
app: Annotated[
285+
Union[str, None],
286+
typer.Option(
287+
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."
288+
),
289+
] = None,
290+
title: Annotated[
291+
Union[str, None],
292+
typer.Option(
293+
help="The title to use for the generated markdown file. If not provided, it is detected automatically."
294+
)
295+
] = None,
296+
) -> None:
297+
"""
298+
Generate [bold]FastAPI[/bold] API docs. 📚
299+
300+
It uses openapi spec to generate a markdown.
301+
"""
302+
try:
303+
fastapi_app = get_import_string(path=path, app_name=app)
304+
except FastAPICLIException as e:
305+
logger.error(str(e))
306+
raise typer.Exit(code=1) from None
307+
spec = get_api_spec(fastapi_app)
308+
markdown = generate_markdown(spec, title)
309+
panel = Panel(
310+
f"{markdown}",
311+
title="Generated Markdown",
312+
expand=False,
313+
padding=(1, 2),
314+
style="white on black",
315+
)
316+
print(Padding(panel, 3))
317+
if not title:
318+
title = markdown.split("\n")[0].replace("#", "").strip()
319+
title = title.replace(" ", "_")
320+
with open(f"{title.upper()}.md", "w") as f:
321+
f.write(markdown)
275322

276323
def main() -> None:
277324
app()

src/fastapi_cli/discover.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from logging import getLogger
55
from pathlib import Path
66
from typing import Union
7+
import inspect
78

89
from rich import print
910
from rich.padding import Padding
@@ -17,6 +18,7 @@
1718

1819
try:
1920
from fastapi import FastAPI
21+
from fastapi.openapi.utils import get_openapi
2022
except ImportError: # pragma: no cover
2123
FastAPI = None # type: ignore[misc, assignment]
2224

@@ -165,3 +167,14 @@ def get_import_string(
165167
import_string = f"{mod_data.module_import_str}:{use_app_name}"
166168
logger.info(f"Using import string [b green]{import_string}[/b green]")
167169
return import_string
170+
171+
def get_api_spec(import_string: str) -> dict:
172+
app_module, app_name = import_string.replace("/", ".").rsplit(":", 1)
173+
app = getattr(__import__(app_module, fromlist=[app_name]), app_name)
174+
signature = inspect.signature(get_openapi)
175+
props = {prop.name: getattr(app, prop.name, None) for prop in signature.parameters.values()}
176+
177+
props["webhooks"] = None
178+
spec = get_openapi(**props)
179+
180+
return spec

src/fastapi_cli/utils.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
def generate_markdown(api_spec, title):
2+
md = []
3+
4+
# Info
5+
info = api_spec.get("info", {})
6+
if title:
7+
words = [word.capitalize() for word in title.split(" ")]
8+
title = " ".join(words)
9+
md.append(f"# {title}")
10+
else:
11+
md.append(f"# {info.get('title', 'API Documentation')}")
12+
md.append(f"\n## Version: {info.get('version', 'N/A')}\n")
13+
14+
# Paths
15+
md.append("### Paths\n")
16+
paths = api_spec.get("paths", {})
17+
for path, methods in paths.items():
18+
for method, details in methods.items():
19+
md.append(f"#### {path}\n")
20+
md.append(f"**{method.upper()}**\n")
21+
md.append(f"**Summary:** {details.get('summary', 'No summary')}\n")
22+
md.append(f"**Operation ID:** {details.get('operationId', 'N/A')}\n")
23+
24+
# Parameters
25+
parameters = details.get("parameters", [])
26+
if parameters:
27+
md.append("**Parameters:**\n")
28+
md.append("| Name | In | Required | Schema | Description | Example |")
29+
md.append("|------|----|----------|--------|-------------|---------|")
30+
for param in parameters:
31+
name = param.get("name", "N/A")
32+
param_in = param.get("in", "N/A")
33+
required = param.get("required", False)
34+
schema = param.get("schema", {})
35+
schema_str = f"`type: {schema.get('type', 'N/A')}`<br>`title: {schema.get('title', 'N/A')}`"
36+
if "description" in schema:
37+
schema_str += f"<br>`description: {schema.get('description', 'N/A')}`"
38+
if "enum" in schema:
39+
schema_str += f"<br>`enum: {schema.get('enum')}`"
40+
if "default" in schema:
41+
schema_str += f"<br>`default: {schema.get('default')}`"
42+
description = param.get("description", "N/A")
43+
example = param.get("example", "N/A")
44+
md.append(
45+
f"| {name} | {param_in} | {required} | {schema_str} | {description} | {example} |"
46+
)
47+
48+
# Request Body
49+
request_body = details.get("requestBody", {})
50+
if request_body:
51+
md.append("**Request Body:**")
52+
md.append(f"- **Required:** {request_body.get('required', False)}")
53+
md.append(f"- **Content:**")
54+
content = request_body.get("content", {})
55+
for content_type, content_schema in content.items():
56+
md.append(f" - **{content_type}:**")
57+
schema_ref = content_schema.get("schema", {}).get("$ref", "N/A")
58+
md.append(
59+
f" - **Schema:** [{schema_ref.split('/')[-1]}](#{schema_ref.split('/')[-1].lower()})\n"
60+
)
61+
62+
# Responses
63+
responses = details.get("responses", {})
64+
if responses:
65+
md.append("**Responses:**\n")
66+
md.append("| Status Code | Description | Content |")
67+
md.append("|-------------|-------------|---------|")
68+
for status, response in responses.items():
69+
description = response.get("description", "N/A")
70+
content = response.get("content", {})
71+
content_str = ""
72+
for content_type, content_schema in content.items():
73+
schema_ref = content_schema.get("schema", {}).get("$ref", "N/A")
74+
content_str += f"{content_type}: `schema: [{schema_ref.split('/')[-1]}](#{schema_ref.split('/')[-1].lower()})`<br>"
75+
md.append(f"| {status} | {description} | {content_str.strip('<br>')} |")
76+
77+
# Components
78+
components = api_spec.get("components", {}).get("schemas", {})
79+
if components:
80+
md.append("### Components")
81+
md.append("#### Schemas")
82+
for schema_name, schema_details in components.items():
83+
md.append(f"##### {schema_name}")
84+
md.append(f"- **Type:** {schema_details.get('type', 'N/A')}")
85+
if "required" in schema_details:
86+
md.append(f"- **Required:** {', '.join(schema_details.get('required', []))}")
87+
md.append(f"- **Title:** {schema_details.get('title', 'N/A')}")
88+
md.append("- **Properties:**")
89+
properties = schema_details.get("properties", {})
90+
for prop_name, prop_details in properties.items():
91+
prop_type = prop_details.get("type", "N/A")
92+
prop_format = prop_details.get("format", "N/A")
93+
prop_title = prop_details.get("title", "N/A")
94+
prop_desc = prop_details.get("description", "N/A")
95+
md.append(f" - **{prop_name}:**")
96+
md.append(f" - **Type:** {prop_type}")
97+
if prop_format != "N/A":
98+
md.append(f" - **Format:** {prop_format}")
99+
md.append(f" - **Title:** {prop_title}")
100+
md.append(f" - **Description:** {prop_desc}")
101+
102+
return "\n".join(md)

0 commit comments

Comments
 (0)