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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi2postman
3
- Version: 0.2.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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi2postman"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Turn a FastAPI app into a Postman collection you can run straight away."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi2postman"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Turn a FastAPI app into a Postman collection you can run straight away."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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