Skip to content

Commit 19337bd

Browse files
committed
Adding command to generate swagger/openapi doc
1 parent b65b8a4 commit 19337bd

File tree

10 files changed

+246
-0
lines changed

10 files changed

+246
-0
lines changed

.idea/fastapi-cli.iml

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/inspectionProfiles/Project_Default.xml

Lines changed: 47 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/misc.xml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/modules.xml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/vcs.xml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

openapi.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"openapi": "3.1.0",
3+
"info": {
4+
"title": "FastAPI",
5+
"version": "0.1.0"
6+
},
7+
"paths": {
8+
"/": {
9+
"get": {
10+
"summary": "App Root",
11+
"operationId": "app_root__get",
12+
"responses": {
13+
"200": {
14+
"description": "Successful Response",
15+
"content": {
16+
"application/json": {
17+
"schema": {}
18+
}
19+
}
20+
}
21+
}
22+
}
23+
}
24+
}
25+
}

src/fastapi_cli/cli.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import json
2+
import sys
13
from logging import getLogger
24
from pathlib import Path
35
from typing import Any, Union
@@ -9,6 +11,7 @@
911
from typing_extensions import Annotated
1012

1113
from fastapi_cli.discover import get_import_string
14+
from fastapi_cli.discover import get_app
1215
from fastapi_cli.exceptions import FastAPICLIException
1316

1417
from . import __version__
@@ -272,6 +275,43 @@ def run(
272275
proxy_headers=proxy_headers,
273276
)
274277

278+
@app.command()
279+
def schema(
280+
path: Annotated[
281+
Union[Path, None],
282+
typer.Argument(
283+
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."
284+
),
285+
] = None,
286+
*,
287+
app: Annotated[
288+
Union[str, None],
289+
typer.Option(
290+
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."
291+
),
292+
] = None,
293+
output: Annotated[
294+
Union[str, None],
295+
typer.Option(
296+
help="The filename to write schema to. If not provided, write to stderr."
297+
),
298+
] = None,
299+
indent: Annotated[
300+
int,
301+
typer.Option(
302+
help="JSON format indent. If 0, disable pretty printing"
303+
),
304+
] = 2,
305+
) -> Any:
306+
""" Generate schema """
307+
app = get_app(path=path, app_name=app)
308+
schema = app.openapi()
309+
310+
stream = open(output, "w") if output else sys.stderr
311+
json.dump(schema, stream, indent=indent if indent > 0 else None)
312+
if output:
313+
stream.close()
314+
return 0
275315

276316
def main() -> None:
277317
app()

src/fastapi_cli/discover.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import importlib
22
import sys
3+
from contextlib import contextmanager
34
from dataclasses import dataclass
45
from logging import getLogger
56
from pathlib import Path
@@ -46,6 +47,18 @@ class ModuleData:
4647
module_import_str: str
4748
extra_sys_path: Path
4849

50+
@contextmanager
51+
def sys_path(self):
52+
""" Context manager to temporarily alter sys.path"""
53+
extra_sys_path = str(self.extra_sys_path) if self.extra_sys_path else ""
54+
if extra_sys_path:
55+
logger.warning("Adding %s to sys.path...", extra_sys_path)
56+
sys.path.insert(0, extra_sys_path)
57+
yield
58+
if extra_sys_path and sys.path and sys.path[0] == extra_sys_path:
59+
logger.warning("Removing %s from sys.path...", extra_sys_path)
60+
sys.path.pop(0)
61+
4962

5063
def get_module_data_from_path(path: Path) -> ModuleData:
5164
logger.info(
@@ -165,3 +178,54 @@ def get_import_string(
165178
import_string = f"{mod_data.module_import_str}:{use_app_name}"
166179
logger.info(f"Using import string [b green]{import_string}[/b green]")
167180
return import_string
181+
182+
def get_app(
183+
*, path: Union[Path, None] = None, app_name: Union[str, None] = None
184+
) -> FastAPI:
185+
if not path:
186+
path = get_default_path()
187+
logger.debug(f"Using path [blue]{path}[/blue]")
188+
logger.debug(f"Resolved absolute path {path.resolve()}")
189+
if not path.exists():
190+
raise FastAPICLIException(f"Path does not exist {path}")
191+
mod_data = get_module_data_from_path(path)
192+
try:
193+
with mod_data.sys_path():
194+
mod = importlib.import_module(mod_data.module_import_str)
195+
except (ImportError, ValueError) as e:
196+
logger.error(f"Import error: {e}")
197+
logger.warning(
198+
"Ensure all the package directories have an [blue]__init__.py["
199+
"/blue] file"
200+
)
201+
raise
202+
if not FastAPI: # type: ignore[truthy-function]
203+
raise FastAPICLIException(
204+
"Could not import FastAPI, try running 'pip install fastapi'"
205+
) from None
206+
object_names = dir(mod)
207+
object_names_set = set(object_names)
208+
if app_name:
209+
if app_name not in object_names_set:
210+
raise FastAPICLIException(
211+
f"Could not find app name {app_name} in "
212+
f"{mod_data.module_import_str}"
213+
)
214+
app = getattr(mod, app_name)
215+
if not isinstance(app, FastAPI):
216+
raise FastAPICLIException(
217+
f"The app name {app_name} in {mod_data.module_import_str} "
218+
f"doesn't seem to be a FastAPI app"
219+
)
220+
return app
221+
for preferred_name in ["app", "api"]:
222+
if preferred_name in object_names_set:
223+
obj = getattr(mod, preferred_name)
224+
if isinstance(obj, FastAPI):
225+
return obj
226+
for name in object_names:
227+
obj = getattr(mod, name)
228+
if isinstance(obj, FastAPI):
229+
return obj
230+
raise FastAPICLIException(
231+
"Could not find FastAPI app in module, try using --app")

tests/assets/openapi.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"openapi": "3.1.0",
3+
"info": {
4+
"title": "FastAPI",
5+
"version": "0.1.0"
6+
},
7+
"paths": {
8+
"/": {
9+
"get": {
10+
"summary": "App Root",
11+
"operationId": "app_root__get",
12+
"responses": {
13+
"200": {
14+
"description": "Successful Response",
15+
"content": {
16+
"application/json": {
17+
"schema": {}
18+
}
19+
}
20+
}
21+
}
22+
}
23+
}
24+
}
25+
}

tests/test_cli.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,15 @@ def test_dev_help() -> None:
179179
assert "The name of the variable that contains the FastAPI app" in result.output
180180
assert "Use multiple worker processes." not in result.output
181181

182+
def test_schema() -> None:
183+
with changing_dir(assets_path):
184+
with open('openapi.json', 'r') as stream:
185+
expected = stream.read()
186+
assert expected != "" , "Failed to read expected result"
187+
result = runner.invoke(app, ["schema", "single_file_app.py"])
188+
assert result.exit_code == 0, result.output
189+
assert expected in result.output, result.output
190+
182191

183192
def test_run_help() -> None:
184193
result = runner.invoke(app, ["run", "--help"])

0 commit comments

Comments
 (0)