fastapi2postman 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastapi2postman-0.2.0 → fastapi2postman-0.3.0}/PKG-INFO +39 -1
- {fastapi2postman-0.2.0 → fastapi2postman-0.3.0}/README.md +38 -0
- {fastapi2postman-0.2.0 → fastapi2postman-0.3.0}/pyproject.toml +1 -1
- {fastapi2postman-0.2.0 → fastapi2postman-0.3.0}/pyproject.toml.orig +1 -1
- fastapi2postman-0.3.0/src/fastapi2postman/cli.py +168 -0
- fastapi2postman-0.3.0/src/fastapi2postman/routes.py +92 -0
- fastapi2postman-0.2.0/src/fastapi2postman/cli.py +0 -89
- {fastapi2postman-0.2.0 → fastapi2postman-0.3.0}/LICENSE +0 -0
- {fastapi2postman-0.2.0 → fastapi2postman-0.3.0}/src/fastapi2postman/__init__.py +0 -0
- {fastapi2postman-0.2.0 → fastapi2postman-0.3.0}/src/fastapi2postman/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi2postman
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Turn a FastAPI app into a Postman collection you can run straight away.
|
|
5
5
|
Keywords: fastapi,postman,newman,openapi,api,cli
|
|
6
6
|
Author: Ashhad Ahsan Rehman
|
|
@@ -77,6 +77,44 @@ Secret variables start blank, so the file holds no credentials. You fill them in
|
|
|
77
77
|
|
|
78
78
|
**Auth the schema can't show.** Middleware, or a dependency that reads `request.headers`, doesn't appear in OpenAPI. Use `--bearer` to send `{{bearerToken}}` on every request that has no auth of its own.
|
|
79
79
|
|
|
80
|
+
## Check your routes
|
|
81
|
+
|
|
82
|
+
Two more commands read the routes themselves, so they also see what the schema leaves out: routes hidden from the docs, websockets, and plain Starlette routes.
|
|
83
|
+
|
|
84
|
+
`routes` lists everything the app serves and what protects it, like `flask routes`:
|
|
85
|
+
|
|
86
|
+
```console
|
|
87
|
+
$ fastapi2postman routes --app main.py
|
|
88
|
+
METHOD PATH AUTH TAGS NOTE
|
|
89
|
+
GET /health - -
|
|
90
|
+
GET /internal - - hidden from the docs
|
|
91
|
+
GET /me HTTPBearer -
|
|
92
|
+
POST /orders - orders
|
|
93
|
+
* /static ? - can't read: StaticFiles
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`audit` fails when a route has no visible auth, so a route merged without its `Depends()` turns CI red:
|
|
97
|
+
|
|
98
|
+
```console
|
|
99
|
+
$ fastapi2postman audit --app main.py --allow "/health" --allow "/static*"
|
|
100
|
+
2 of 5 routes have no visible auth:
|
|
101
|
+
|
|
102
|
+
GET /internal no auth, hidden from the docs
|
|
103
|
+
POST /orders no auth
|
|
104
|
+
|
|
105
|
+
Protect them, or allow the ones meant to be open, for example --allow "/health" or --allow "GET /public/*".
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
It exits with 1 when it finds any, and 0 otherwise. `--allow` takes a path or a method and a path, and `*` matches anything, including `/`. An `--allow` that matches no route prints a warning, because it could let a route added later through unnoticed.
|
|
109
|
+
|
|
110
|
+
Auth counts when a route's dependencies declare it, at any depth: a security scheme such as `HTTPBearer`, or a required header, query parameter or cookie named like a credential, such as `x_token` or `session_id`.
|
|
111
|
+
|
|
112
|
+
**`audit` reads what routes declare and doesn't run them.** So:
|
|
113
|
+
|
|
114
|
+
- Auth in middleware, or in a dependency reading `request.headers`, looks like no auth. Allow those routes.
|
|
115
|
+
- A dependency that declares a token but never checks it still counts as auth. So does `auto_error=False`, which `routes` shows as `HTTPBearer (optional)`.
|
|
116
|
+
- Plain Starlette routes and mounted apps such as `StaticFiles` have nothing to read, so they're always flagged until you allow them.
|
|
117
|
+
|
|
80
118
|
## Pointing it at your app
|
|
81
119
|
|
|
82
120
|
Run it from your project root.
|
|
@@ -50,6 +50,44 @@ Secret variables start blank, so the file holds no credentials. You fill them in
|
|
|
50
50
|
|
|
51
51
|
**Auth the schema can't show.** Middleware, or a dependency that reads `request.headers`, doesn't appear in OpenAPI. Use `--bearer` to send `{{bearerToken}}` on every request that has no auth of its own.
|
|
52
52
|
|
|
53
|
+
## Check your routes
|
|
54
|
+
|
|
55
|
+
Two more commands read the routes themselves, so they also see what the schema leaves out: routes hidden from the docs, websockets, and plain Starlette routes.
|
|
56
|
+
|
|
57
|
+
`routes` lists everything the app serves and what protects it, like `flask routes`:
|
|
58
|
+
|
|
59
|
+
```console
|
|
60
|
+
$ fastapi2postman routes --app main.py
|
|
61
|
+
METHOD PATH AUTH TAGS NOTE
|
|
62
|
+
GET /health - -
|
|
63
|
+
GET /internal - - hidden from the docs
|
|
64
|
+
GET /me HTTPBearer -
|
|
65
|
+
POST /orders - orders
|
|
66
|
+
* /static ? - can't read: StaticFiles
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`audit` fails when a route has no visible auth, so a route merged without its `Depends()` turns CI red:
|
|
70
|
+
|
|
71
|
+
```console
|
|
72
|
+
$ fastapi2postman audit --app main.py --allow "/health" --allow "/static*"
|
|
73
|
+
2 of 5 routes have no visible auth:
|
|
74
|
+
|
|
75
|
+
GET /internal no auth, hidden from the docs
|
|
76
|
+
POST /orders no auth
|
|
77
|
+
|
|
78
|
+
Protect them, or allow the ones meant to be open, for example --allow "/health" or --allow "GET /public/*".
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
It exits with 1 when it finds any, and 0 otherwise. `--allow` takes a path or a method and a path, and `*` matches anything, including `/`. An `--allow` that matches no route prints a warning, because it could let a route added later through unnoticed.
|
|
82
|
+
|
|
83
|
+
Auth counts when a route's dependencies declare it, at any depth: a security scheme such as `HTTPBearer`, or a required header, query parameter or cookie named like a credential, such as `x_token` or `session_id`.
|
|
84
|
+
|
|
85
|
+
**`audit` reads what routes declare and doesn't run them.** So:
|
|
86
|
+
|
|
87
|
+
- Auth in middleware, or in a dependency reading `request.headers`, looks like no auth. Allow those routes.
|
|
88
|
+
- A dependency that declares a token but never checks it still counts as auth. So does `auto_error=False`, which `routes` shows as `HTTPBearer (optional)`.
|
|
89
|
+
- Plain Starlette routes and mounted apps such as `StaticFiles` have nothing to read, so they're always flagged until you allow them.
|
|
90
|
+
|
|
53
91
|
## Pointing it at your app
|
|
54
92
|
|
|
55
93
|
Run it from your project root.
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""The fastapi2postman command."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import textwrap
|
|
8
|
+
from fnmatch import fnmatchcase
|
|
9
|
+
from importlib import import_module, metadata
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from fastapi import FastAPI
|
|
13
|
+
|
|
14
|
+
from . import to_postman
|
|
15
|
+
from .routes import Route, list_routes
|
|
16
|
+
|
|
17
|
+
_APP_HELP = "the app: main.py, main.py:api or package.main:api. The name defaults to app, and may be a factory"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _module_of(path: Path) -> tuple[str, Path]:
|
|
21
|
+
"""The dotted name of a file and the folder to import it from, which is above its package if it's in one."""
|
|
22
|
+
parts = [path.stem]
|
|
23
|
+
root = path.parent
|
|
24
|
+
while (root / "__init__.py").is_file():
|
|
25
|
+
parts.insert(0, root.name)
|
|
26
|
+
root = root.parent
|
|
27
|
+
return ".".join(parts), root
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _load_app(spec: str) -> FastAPI:
|
|
31
|
+
target, attribute = spec, "app"
|
|
32
|
+
if ":" in spec and not Path(spec).is_file(): # a colon can be part of a path: C:\project\app.py
|
|
33
|
+
target, attribute = spec.rsplit(":", 1)
|
|
34
|
+
|
|
35
|
+
name = target
|
|
36
|
+
search = [os.getcwd()] # a console script has its own folder on sys.path, not the project's
|
|
37
|
+
if Path(target).is_file():
|
|
38
|
+
name, root = _module_of(Path(target).resolve())
|
|
39
|
+
search.insert(0, str(root))
|
|
40
|
+
sys.path[:0] = search
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
module = import_module(name)
|
|
44
|
+
except ModuleNotFoundError as error:
|
|
45
|
+
if error.name != name and not name.startswith(f"{error.name}."):
|
|
46
|
+
raise # something the app itself imports is missing, and the traceback says what
|
|
47
|
+
raise SystemExit(
|
|
48
|
+
f"fastapi2postman: cannot find {target!r}: give a .py file, or a module importable from {os.getcwd()}"
|
|
49
|
+
) from error
|
|
50
|
+
if not hasattr(module, attribute):
|
|
51
|
+
raise SystemExit(f"fastapi2postman: {name} has no attribute {attribute!r}")
|
|
52
|
+
|
|
53
|
+
app = getattr(module, attribute)
|
|
54
|
+
if not isinstance(app, FastAPI) and callable(app):
|
|
55
|
+
app = app() # a factory
|
|
56
|
+
if not isinstance(app, FastAPI):
|
|
57
|
+
raise SystemExit(f"fastapi2postman: {spec} gives {type(app).__name__}, not a FastAPI app")
|
|
58
|
+
return app
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _export(argv: list[str]) -> None:
|
|
62
|
+
parser = argparse.ArgumentParser(
|
|
63
|
+
prog="fastapi2postman",
|
|
64
|
+
description="Turn a FastAPI app into a Postman collection you can run straight away.",
|
|
65
|
+
epilog="other commands: 'routes' lists every route with its auth, and 'audit' fails when a route has no auth. "
|
|
66
|
+
"Add --help to either one for details.",
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument("--app", required=True, help=_APP_HELP)
|
|
69
|
+
parser.add_argument("--output", default="output.json", help="output file (default: %(default)s)")
|
|
70
|
+
parser.add_argument("--name", help="collection name (default: the app's title)")
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--base-url", help="where the requests go (default: the app's first server, else http://localhost:8000)"
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument("--host", help="shorthand for --base-url http://HOST:PORT")
|
|
75
|
+
parser.add_argument("--port", type=int, help="shorthand for --base-url http://HOST:PORT")
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--bearer",
|
|
78
|
+
action="store_true",
|
|
79
|
+
help="send {{bearerToken}} on every request without auth of its own: for auth in middleware, or in a "
|
|
80
|
+
"dependency reading request.headers, which the app's schema can't show",
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument("--version", action="version", version=metadata.version("fastapi2postman"))
|
|
83
|
+
args = parser.parse_args(argv)
|
|
84
|
+
|
|
85
|
+
base_url = args.base_url
|
|
86
|
+
if not base_url and (args.host or args.port):
|
|
87
|
+
base_url = f"http://{args.host or 'localhost'}:{args.port or 8000}"
|
|
88
|
+
|
|
89
|
+
collection = to_postman(_load_app(args.app), name=args.name, base_url=base_url, bearer=args.bearer)
|
|
90
|
+
text = json.dumps(collection, indent=2, ensure_ascii=False)
|
|
91
|
+
Path(args.output).write_text(text + "\n", encoding="utf-8")
|
|
92
|
+
count = sum(len(entry.get("item", [entry])) for entry in collection["item"])
|
|
93
|
+
print(f"{args.output}: {count} requests")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _table(rows: list[list[str]]) -> str:
|
|
97
|
+
widths = [max(len(row[column]) for row in rows) for column in range(len(rows[0]))]
|
|
98
|
+
lines = [" ".join(cell.ljust(width) for cell, width in zip(row, widths, strict=True)).rstrip() for row in rows]
|
|
99
|
+
return "\n".join(lines)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _routes(argv: list[str]) -> None:
|
|
103
|
+
parser = argparse.ArgumentParser(
|
|
104
|
+
prog="fastapi2postman routes",
|
|
105
|
+
description="List every route the app serves with what protects it, including routes hidden from the docs.",
|
|
106
|
+
)
|
|
107
|
+
parser.add_argument("--app", required=True, help=_APP_HELP)
|
|
108
|
+
args = parser.parse_args(argv)
|
|
109
|
+
|
|
110
|
+
rows = [["METHOD", "PATH", "AUTH", "TAGS", "NOTE"]]
|
|
111
|
+
for route in list_routes(_load_app(args.app)):
|
|
112
|
+
auth = ", ".join(route.auth) or ("?" if route.unreadable else "-")
|
|
113
|
+
note = f"can't read: {route.unreadable}" if route.unreadable else "hidden from the docs" if route.hidden else ""
|
|
114
|
+
rows.append([route.method, route.path, auth, ", ".join(route.tags) or "-", note])
|
|
115
|
+
print(_table(rows))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _allows(pattern: str, route: Route) -> bool:
|
|
119
|
+
return fnmatchcase(f"{route.method} {route.path}" if " " in pattern else route.path, pattern)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _audit(argv: list[str]) -> None:
|
|
123
|
+
parser = argparse.ArgumentParser(
|
|
124
|
+
prog="fastapi2postman audit",
|
|
125
|
+
description="Fail when a route has no visible auth, including routes hidden from the docs. It reads what the "
|
|
126
|
+
"routes declare and doesn't run them, so it can't see auth in middleware, or what a dependency's code does.",
|
|
127
|
+
)
|
|
128
|
+
parser.add_argument("--app", required=True, help=_APP_HELP)
|
|
129
|
+
parser.add_argument(
|
|
130
|
+
"--allow",
|
|
131
|
+
action="append",
|
|
132
|
+
default=[],
|
|
133
|
+
metavar="PATTERN",
|
|
134
|
+
help='a route meant to be open, such as "/health" or "GET /public/*". Repeat it for more',
|
|
135
|
+
)
|
|
136
|
+
args = parser.parse_args(argv)
|
|
137
|
+
|
|
138
|
+
routes = list_routes(_load_app(args.app))
|
|
139
|
+
for pattern in args.allow:
|
|
140
|
+
if not any(_allows(pattern, route) for route in routes):
|
|
141
|
+
print(f"fastapi2postman: --allow {pattern!r} matches no route", file=sys.stderr)
|
|
142
|
+
|
|
143
|
+
unprotected = [route for route in routes if not route.auth]
|
|
144
|
+
flagged = [route for route in unprotected if not any(_allows(pattern, route) for pattern in args.allow)]
|
|
145
|
+
if not flagged:
|
|
146
|
+
with_auth = len(routes) - len(unprotected)
|
|
147
|
+
print(f"{len(routes)} routes: {with_auth} with auth, {len(unprotected)} allowed")
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
rows = []
|
|
151
|
+
for route in flagged:
|
|
152
|
+
why = f"can't read: {route.unreadable}" if route.unreadable else "no auth"
|
|
153
|
+
rows.append([route.method, route.path, why + (", hidden from the docs" if route.hidden else "")])
|
|
154
|
+
print(f"{len(flagged)} of {len(routes)} routes have no visible auth:\n")
|
|
155
|
+
print(textwrap.indent(_table(rows), " "))
|
|
156
|
+
print(
|
|
157
|
+
'\nProtect them, or allow the ones meant to be open, for example --allow "/health" or --allow "GET /public/*".'
|
|
158
|
+
)
|
|
159
|
+
raise SystemExit(1)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def main() -> None:
|
|
163
|
+
commands = {"routes": _routes, "audit": _audit}
|
|
164
|
+
argv = sys.argv[1:]
|
|
165
|
+
if argv and argv[0] in commands:
|
|
166
|
+
commands[argv[0]](argv[1:])
|
|
167
|
+
else:
|
|
168
|
+
_export(argv)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Every route an app really serves, and what protects it.
|
|
2
|
+
|
|
3
|
+
The schema can't answer this. It leaves out routes hidden from the docs, websockets and plain Starlette routes, which
|
|
4
|
+
are exactly what a check for open routes has to see, so this reads the routes themselves. Anything it doesn't recognise
|
|
5
|
+
is reported as unreadable instead of being skipped, so a change in FastAPI's internals shows up in the audit rather than
|
|
6
|
+
hiding routes from it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Iterable, Iterator
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from fastapi import FastAPI
|
|
15
|
+
from fastapi.security.base import SecurityBase
|
|
16
|
+
from starlette.routing import Mount, WebSocketRoute
|
|
17
|
+
|
|
18
|
+
from . import _SECRET
|
|
19
|
+
|
|
20
|
+
_AUTH_PARAMETER = re.compile(f"{_SECRET.pattern}|authorization|session", re.IGNORECASE)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Route:
|
|
25
|
+
method: str # an HTTP method, WS for a websocket, or * when a mounted app decides
|
|
26
|
+
path: str
|
|
27
|
+
auth: list[str] # security schemes by name, and required parameters that look like credentials
|
|
28
|
+
tags: list[str]
|
|
29
|
+
hidden: bool # left out of the docs
|
|
30
|
+
unreadable: str # the kind of route, when it has no dependencies to read auth from
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _auth(dependant: Any) -> list[str]:
|
|
34
|
+
"""What protects a route, from its dependencies at any depth. Only what they declare: their code isn't run."""
|
|
35
|
+
found = []
|
|
36
|
+
if isinstance(dependant.call, SecurityBase):
|
|
37
|
+
optional = not getattr(dependant.call, "auto_error", True)
|
|
38
|
+
found.append(dependant.call.scheme_name + (" (optional)" if optional else ""))
|
|
39
|
+
for parameter in (*dependant.header_params, *dependant.query_params, *dependant.cookie_params):
|
|
40
|
+
if parameter.field_info.is_required() and _AUTH_PARAMETER.search(parameter.alias):
|
|
41
|
+
found.append(parameter.alias)
|
|
42
|
+
for dependency in dependant.dependencies:
|
|
43
|
+
found.extend(_auth(dependency))
|
|
44
|
+
return list(dict.fromkeys(found))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _flatten(routes: Iterable[Any]) -> Iterator[Any]:
|
|
48
|
+
"""The routes behind include_router(), which FastAPI 0.137+ keeps in a private object. Before that they're flat."""
|
|
49
|
+
for route in routes:
|
|
50
|
+
contexts = getattr(route, "effective_route_contexts", None)
|
|
51
|
+
if contexts is None:
|
|
52
|
+
yield route
|
|
53
|
+
else:
|
|
54
|
+
for context in contexts():
|
|
55
|
+
# Websockets, plain routes and mounts come with a ready-made route. API routes are the context itself.
|
|
56
|
+
yield getattr(context, "starlette_route", None) or context
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _walk(routes: Iterable[Any], prefix: str, docs: set[str]) -> Iterator[Route]:
|
|
60
|
+
for route in _flatten(routes):
|
|
61
|
+
if isinstance(route, Mount):
|
|
62
|
+
mounted = prefix + route.path
|
|
63
|
+
if isinstance(route.app, FastAPI):
|
|
64
|
+
yield from _app_routes(route.app, mounted)
|
|
65
|
+
elif route.routes:
|
|
66
|
+
yield from _walk(route.routes, mounted, set())
|
|
67
|
+
else:
|
|
68
|
+
yield Route("*", mounted or "/", [], [], False, type(route.app).__name__)
|
|
69
|
+
continue
|
|
70
|
+
dependant = getattr(route, "dependant", None)
|
|
71
|
+
path = prefix + getattr(route, "path_format", getattr(route, "path", ""))
|
|
72
|
+
if dependant is None and path in docs:
|
|
73
|
+
continue
|
|
74
|
+
for method in getattr(route, "methods", None) or ["WS" if isinstance(route, WebSocketRoute) else "*"]:
|
|
75
|
+
yield Route(
|
|
76
|
+
method=method,
|
|
77
|
+
path=path or "/",
|
|
78
|
+
auth=[] if dependant is None else _auth(dependant),
|
|
79
|
+
tags=[getattr(tag, "value", tag) for tag in getattr(route, "tags", [])],
|
|
80
|
+
hidden=not getattr(route, "include_in_schema", True),
|
|
81
|
+
unreadable=type(route).__name__ if dependant is None else "",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _app_routes(app: FastAPI, prefix: str) -> Iterator[Route]:
|
|
86
|
+
own_docs = (app.openapi_url, app.docs_url, app.redoc_url, app.swagger_ui_oauth2_redirect_url)
|
|
87
|
+
yield from _walk(app.routes, prefix, {prefix + url for url in own_docs if url})
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def list_routes(app: FastAPI) -> list[Route]:
|
|
91
|
+
"""Every route the app serves, apart from FastAPI's own docs, sorted by path and then method."""
|
|
92
|
+
return sorted(_app_routes(app, ""), key=lambda route: (route.path, route.method))
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
"""The fastapi2postman command."""
|
|
2
|
-
|
|
3
|
-
import argparse
|
|
4
|
-
import json
|
|
5
|
-
import os
|
|
6
|
-
import sys
|
|
7
|
-
from importlib import import_module, metadata
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
|
|
10
|
-
from fastapi import FastAPI
|
|
11
|
-
|
|
12
|
-
from . import to_postman
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def _module_of(path: Path) -> tuple[str, Path]:
|
|
16
|
-
"""The dotted name of a file and the folder to import it from, which is above its package if it's in one."""
|
|
17
|
-
parts = [path.stem]
|
|
18
|
-
root = path.parent
|
|
19
|
-
while (root / "__init__.py").is_file():
|
|
20
|
-
parts.insert(0, root.name)
|
|
21
|
-
root = root.parent
|
|
22
|
-
return ".".join(parts), root
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def _load_app(spec: str) -> FastAPI:
|
|
26
|
-
target, attribute = spec, "app"
|
|
27
|
-
if ":" in spec and not Path(spec).is_file(): # a colon can be part of a path: C:\project\app.py
|
|
28
|
-
target, attribute = spec.rsplit(":", 1)
|
|
29
|
-
|
|
30
|
-
name = target
|
|
31
|
-
search = [os.getcwd()] # a console script has its own folder on sys.path, not the project's
|
|
32
|
-
if Path(target).is_file():
|
|
33
|
-
name, root = _module_of(Path(target).resolve())
|
|
34
|
-
search.insert(0, str(root))
|
|
35
|
-
sys.path[:0] = search
|
|
36
|
-
|
|
37
|
-
try:
|
|
38
|
-
module = import_module(name)
|
|
39
|
-
except ModuleNotFoundError as error:
|
|
40
|
-
if error.name != name and not name.startswith(f"{error.name}."):
|
|
41
|
-
raise # something the app itself imports is missing, and the traceback says what
|
|
42
|
-
raise SystemExit(
|
|
43
|
-
f"fastapi2postman: cannot find {target!r}: give a .py file, or a module importable from {os.getcwd()}"
|
|
44
|
-
) from error
|
|
45
|
-
if not hasattr(module, attribute):
|
|
46
|
-
raise SystemExit(f"fastapi2postman: {name} has no attribute {attribute!r}")
|
|
47
|
-
|
|
48
|
-
app = getattr(module, attribute)
|
|
49
|
-
if not isinstance(app, FastAPI) and callable(app):
|
|
50
|
-
app = app() # a factory
|
|
51
|
-
if not isinstance(app, FastAPI):
|
|
52
|
-
raise SystemExit(f"fastapi2postman: {spec} gives {type(app).__name__}, not a FastAPI app")
|
|
53
|
-
return app
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def main() -> None:
|
|
57
|
-
parser = argparse.ArgumentParser(
|
|
58
|
-
prog="fastapi2postman", description="Turn a FastAPI app into a Postman collection you can run straight away."
|
|
59
|
-
)
|
|
60
|
-
parser.add_argument(
|
|
61
|
-
"--app",
|
|
62
|
-
required=True,
|
|
63
|
-
help="the app: main.py, main.py:api or package.main:api. The name defaults to app, and may be a factory",
|
|
64
|
-
)
|
|
65
|
-
parser.add_argument("--output", default="output.json", help="output file (default: %(default)s)")
|
|
66
|
-
parser.add_argument("--name", help="collection name (default: the app's title)")
|
|
67
|
-
parser.add_argument(
|
|
68
|
-
"--base-url", help="where the requests go (default: the app's first server, else http://localhost:8000)"
|
|
69
|
-
)
|
|
70
|
-
parser.add_argument("--host", help="shorthand for --base-url http://HOST:PORT")
|
|
71
|
-
parser.add_argument("--port", type=int, help="shorthand for --base-url http://HOST:PORT")
|
|
72
|
-
parser.add_argument(
|
|
73
|
-
"--bearer",
|
|
74
|
-
action="store_true",
|
|
75
|
-
help="send {{bearerToken}} on every request without auth of its own: for auth in middleware, or in a "
|
|
76
|
-
"dependency reading request.headers, which the app's schema can't show",
|
|
77
|
-
)
|
|
78
|
-
parser.add_argument("--version", action="version", version=metadata.version("fastapi2postman"))
|
|
79
|
-
args = parser.parse_args()
|
|
80
|
-
|
|
81
|
-
base_url = args.base_url
|
|
82
|
-
if not base_url and (args.host or args.port):
|
|
83
|
-
base_url = f"http://{args.host or 'localhost'}:{args.port or 8000}"
|
|
84
|
-
|
|
85
|
-
collection = to_postman(_load_app(args.app), name=args.name, base_url=base_url, bearer=args.bearer)
|
|
86
|
-
text = json.dumps(collection, indent=2, ensure_ascii=False)
|
|
87
|
-
Path(args.output).write_text(text + "\n", encoding="utf-8")
|
|
88
|
-
count = sum(len(entry.get("item", [entry])) for entry in collection["item"])
|
|
89
|
-
print(f"{args.output}: {count} requests")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|