fastapi-dynamic-route-registration 0.1.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.
- fastapi_dynamic_route_registration-0.1.0/LICENSE +21 -0
- fastapi_dynamic_route_registration-0.1.0/PKG-INFO +149 -0
- fastapi_dynamic_route_registration-0.1.0/README.md +122 -0
- fastapi_dynamic_route_registration-0.1.0/pyproject.toml +47 -0
- fastapi_dynamic_route_registration-0.1.0/setup.cfg +4 -0
- fastapi_dynamic_route_registration-0.1.0/src/fastapi_dynamic_route_registration/__init__.py +14 -0
- fastapi_dynamic_route_registration-0.1.0/src/fastapi_dynamic_route_registration/py.typed +0 -0
- fastapi_dynamic_route_registration-0.1.0/src/fastapi_dynamic_route_registration/register_routers.py +109 -0
- fastapi_dynamic_route_registration-0.1.0/src/fastapi_dynamic_route_registration/router_decorator.py +139 -0
- fastapi_dynamic_route_registration-0.1.0/src/fastapi_dynamic_route_registration.egg-info/PKG-INFO +149 -0
- fastapi_dynamic_route_registration-0.1.0/src/fastapi_dynamic_route_registration.egg-info/SOURCES.txt +14 -0
- fastapi_dynamic_route_registration-0.1.0/src/fastapi_dynamic_route_registration.egg-info/dependency_links.txt +1 -0
- fastapi_dynamic_route_registration-0.1.0/src/fastapi_dynamic_route_registration.egg-info/requires.txt +9 -0
- fastapi_dynamic_route_registration-0.1.0/src/fastapi_dynamic_route_registration.egg-info/top_level.txt +1 -0
- fastapi_dynamic_route_registration-0.1.0/tests/test_integration.py +138 -0
- fastapi_dynamic_route_registration-0.1.0/tests/test_register_route.py +221 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DIAZ Jérôme
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi-dynamic-route-registration
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A library to help dynamically register routes in FastAPI applications.
|
|
5
|
+
Author-email: Jérôme DIAZ <jerome_diaz@me.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jeromediaz/fastapi-dynamic-route-registration
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/jeromediaz/fastapi-dynamic-route-registration/issues
|
|
9
|
+
Keywords: fastapi,route,dynamic
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Framework :: FastAPI
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: fastapi>=0.100.0
|
|
19
|
+
Requires-Dist: typing-extensions>=4.0.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: black; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest; extra == "dev"
|
|
23
|
+
Requires-Dist: ruff; extra == "dev"
|
|
24
|
+
Requires-Dist: mypy; extra == "dev"
|
|
25
|
+
Requires-Dist: httpx; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# FastAPI dynamic route registration
|
|
29
|
+
|
|
30
|
+
[](https://pypi.org/project/fastapi-dynamic-route-registration/)
|
|
31
|
+
[](https://pypi.org/project/fastapi-dynamic-route-registration/)
|
|
32
|
+
|
|
33
|
+
A library to help dynamically register routes in FastAPI applications.
|
|
34
|
+
|
|
35
|
+
It is the FastAPI successor to [flask-dynamic-route-registration](https://github.com/jeromediaz/flask-dynamic-route-registration): endpoint modules decorate their functions with `@register_route`, and the application mounts whole modules from a configuration map — with per-router prefixes and shared values injected as parameter defaults.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install fastapi-dynamic-route-registration
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## How to Use
|
|
42
|
+
### Basic Setup
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from fastapi import FastAPI
|
|
46
|
+
from fastapi_dynamic_route_registration import register_router
|
|
47
|
+
|
|
48
|
+
app = FastAPI()
|
|
49
|
+
|
|
50
|
+
register_router(app, "healthcheck", "", {})
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Inside the `healthcheck/__init__.py` file
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from fastapi_dynamic_route_registration import register_route
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@register_route("/health")
|
|
60
|
+
def health() -> dict:
|
|
61
|
+
return {"status": "OK"}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Reusing a module with different parameters, conditional routes
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
api_versions = [
|
|
68
|
+
{"router_kwargs": {"prefix": "/api/1"}, "params": {"version": 1}},
|
|
69
|
+
{"router_kwargs": {"prefix": "/api/2"}, "params": {"version": 2}},
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
register_router(app, "api", api_versions, {})
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Inside the `api/__init__.py` file
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from fastapi_dynamic_route_registration import register_route
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@register_route("/status")
|
|
82
|
+
def status(*, version: int = 1) -> dict:
|
|
83
|
+
return {"status": "OK", "version": version}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@register_route("/foo", enabled=lambda **params: params.get("version", 1) >= 2)
|
|
87
|
+
def foo_a() -> dict:
|
|
88
|
+
return {"hello": "world"}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@register_route("/foo/{subject}", enabled=lambda **params: params.get("version", 1) >= 2)
|
|
92
|
+
def foo_b(subject: str) -> dict:
|
|
93
|
+
return {"hello": subject}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
This registers four routes:
|
|
97
|
+
|
|
98
|
+
| URL | Return value | Function |
|
|
99
|
+
|----------------------|--------------------------------|----------|
|
|
100
|
+
| /api/1/status | {"status": "OK", "version": 1} | status |
|
|
101
|
+
| /api/2/status | {"status": "OK", "version": 2} | status |
|
|
102
|
+
| /api/2/foo | {"hello": "world"} | foo_a |
|
|
103
|
+
| /api/2/foo/{subject} | {"hello": subject} | foo_b |
|
|
104
|
+
|
|
105
|
+
Values from `params` (and from the `route_kwargs` passed to `register_router`) become the **default value** of the parameter with the same name. FastAPI still sees that parameter: a plain `version: int = 1` stays a query parameter, whose default is now the injected value. Keys a function does not declare are dropped — unless it accepts `**kwargs`, in which case they are passed on every call while `**kwargs` itself stays hidden from FastAPI. One shared mapping (e.g. an application context object) can therefore be broadcast to every route.
|
|
106
|
+
|
|
107
|
+
### Other route options
|
|
108
|
+
|
|
109
|
+
`register_route` forwards extra keyword arguments to `APIRouter.add_api_route`:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
@register_route("/items", methods=["POST"], status_code=201, tags=["items"])
|
|
113
|
+
def create_item(item: Item) -> Item:
|
|
114
|
+
return item
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Registering multiple modules at once
|
|
118
|
+
|
|
119
|
+
`register_routers` (plural) registers several modules under a shared parent router:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from fastapi import FastAPI
|
|
123
|
+
from fastapi_dynamic_route_registration import register_routers
|
|
124
|
+
|
|
125
|
+
app = FastAPI()
|
|
126
|
+
|
|
127
|
+
register_routers(
|
|
128
|
+
app,
|
|
129
|
+
"myapp.api",
|
|
130
|
+
{
|
|
131
|
+
"healthcheck": "",
|
|
132
|
+
"users": {"router_kwargs": {"prefix": "/users"}},
|
|
133
|
+
},
|
|
134
|
+
route_kwargs={},
|
|
135
|
+
prefix="/v1",
|
|
136
|
+
)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Each value accepts the same shapes as `register_router`: a prefix string, a dict with `router_kwargs` (passed to `APIRouter`) and `params`, or a list of those. A configuration map like this is easy to keep in a JSON file per application.
|
|
140
|
+
|
|
141
|
+
### Differences from the Flask version
|
|
142
|
+
|
|
143
|
+
- Path parameters use FastAPI syntax: `{param}`, not `<param>`.
|
|
144
|
+
- `router_kwargs` replaces `blueprint_kwargs`; a Flask-style `url_prefix` key is still accepted and mapped to `prefix`.
|
|
145
|
+
- One function registers one route.
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
This project is licensed under the MIT License - see the [LICENSE](https://github.com/jeromediaz/fastapi-dynamic-route-registration/blob/main/LICENSE) file for details.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# FastAPI dynamic route registration
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/fastapi-dynamic-route-registration/)
|
|
4
|
+
[](https://pypi.org/project/fastapi-dynamic-route-registration/)
|
|
5
|
+
|
|
6
|
+
A library to help dynamically register routes in FastAPI applications.
|
|
7
|
+
|
|
8
|
+
It is the FastAPI successor to [flask-dynamic-route-registration](https://github.com/jeromediaz/flask-dynamic-route-registration): endpoint modules decorate their functions with `@register_route`, and the application mounts whole modules from a configuration map — with per-router prefixes and shared values injected as parameter defaults.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install fastapi-dynamic-route-registration
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## How to Use
|
|
15
|
+
### Basic Setup
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from fastapi import FastAPI
|
|
19
|
+
from fastapi_dynamic_route_registration import register_router
|
|
20
|
+
|
|
21
|
+
app = FastAPI()
|
|
22
|
+
|
|
23
|
+
register_router(app, "healthcheck", "", {})
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Inside the `healthcheck/__init__.py` file
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from fastapi_dynamic_route_registration import register_route
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@register_route("/health")
|
|
33
|
+
def health() -> dict:
|
|
34
|
+
return {"status": "OK"}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Reusing a module with different parameters, conditional routes
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
api_versions = [
|
|
41
|
+
{"router_kwargs": {"prefix": "/api/1"}, "params": {"version": 1}},
|
|
42
|
+
{"router_kwargs": {"prefix": "/api/2"}, "params": {"version": 2}},
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
register_router(app, "api", api_versions, {})
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Inside the `api/__init__.py` file
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from fastapi_dynamic_route_registration import register_route
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@register_route("/status")
|
|
55
|
+
def status(*, version: int = 1) -> dict:
|
|
56
|
+
return {"status": "OK", "version": version}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@register_route("/foo", enabled=lambda **params: params.get("version", 1) >= 2)
|
|
60
|
+
def foo_a() -> dict:
|
|
61
|
+
return {"hello": "world"}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@register_route("/foo/{subject}", enabled=lambda **params: params.get("version", 1) >= 2)
|
|
65
|
+
def foo_b(subject: str) -> dict:
|
|
66
|
+
return {"hello": subject}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
This registers four routes:
|
|
70
|
+
|
|
71
|
+
| URL | Return value | Function |
|
|
72
|
+
|----------------------|--------------------------------|----------|
|
|
73
|
+
| /api/1/status | {"status": "OK", "version": 1} | status |
|
|
74
|
+
| /api/2/status | {"status": "OK", "version": 2} | status |
|
|
75
|
+
| /api/2/foo | {"hello": "world"} | foo_a |
|
|
76
|
+
| /api/2/foo/{subject} | {"hello": subject} | foo_b |
|
|
77
|
+
|
|
78
|
+
Values from `params` (and from the `route_kwargs` passed to `register_router`) become the **default value** of the parameter with the same name. FastAPI still sees that parameter: a plain `version: int = 1` stays a query parameter, whose default is now the injected value. Keys a function does not declare are dropped — unless it accepts `**kwargs`, in which case they are passed on every call while `**kwargs` itself stays hidden from FastAPI. One shared mapping (e.g. an application context object) can therefore be broadcast to every route.
|
|
79
|
+
|
|
80
|
+
### Other route options
|
|
81
|
+
|
|
82
|
+
`register_route` forwards extra keyword arguments to `APIRouter.add_api_route`:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
@register_route("/items", methods=["POST"], status_code=201, tags=["items"])
|
|
86
|
+
def create_item(item: Item) -> Item:
|
|
87
|
+
return item
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Registering multiple modules at once
|
|
91
|
+
|
|
92
|
+
`register_routers` (plural) registers several modules under a shared parent router:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from fastapi import FastAPI
|
|
96
|
+
from fastapi_dynamic_route_registration import register_routers
|
|
97
|
+
|
|
98
|
+
app = FastAPI()
|
|
99
|
+
|
|
100
|
+
register_routers(
|
|
101
|
+
app,
|
|
102
|
+
"myapp.api",
|
|
103
|
+
{
|
|
104
|
+
"healthcheck": "",
|
|
105
|
+
"users": {"router_kwargs": {"prefix": "/users"}},
|
|
106
|
+
},
|
|
107
|
+
route_kwargs={},
|
|
108
|
+
prefix="/v1",
|
|
109
|
+
)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Each value accepts the same shapes as `register_router`: a prefix string, a dict with `router_kwargs` (passed to `APIRouter`) and `params`, or a list of those. A configuration map like this is easy to keep in a JSON file per application.
|
|
113
|
+
|
|
114
|
+
### Differences from the Flask version
|
|
115
|
+
|
|
116
|
+
- Path parameters use FastAPI syntax: `{param}`, not `<param>`.
|
|
117
|
+
- `router_kwargs` replaces `blueprint_kwargs`; a Flask-style `url_prefix` key is still accepted and mapped to `prefix`.
|
|
118
|
+
- One function registers one route.
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
This project is licensed under the MIT License - see the [LICENSE](https://github.com/jeromediaz/fastapi-dynamic-route-registration/blob/main/LICENSE) file for details.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[tool.setuptools.package-data]
|
|
6
|
+
"fastapi_dynamic_route_registration" = ["py.typed"]
|
|
7
|
+
|
|
8
|
+
[tool.setuptools.packages.find]
|
|
9
|
+
where = ["src"]
|
|
10
|
+
|
|
11
|
+
[project]
|
|
12
|
+
name = "fastapi-dynamic-route-registration"
|
|
13
|
+
version = "0.1.0"
|
|
14
|
+
authors = [
|
|
15
|
+
{ name="Jérôme DIAZ", email="jerome_diaz@me.com" },
|
|
16
|
+
]
|
|
17
|
+
description = "A library to help dynamically register routes in FastAPI applications."
|
|
18
|
+
readme = "README.md"
|
|
19
|
+
license = "MIT"
|
|
20
|
+
license-files = ["LICENSE"]
|
|
21
|
+
requires-python = ">=3.12"
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
"Framework :: FastAPI",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
keywords = ["fastapi", "route", "dynamic"]
|
|
30
|
+
dependencies = [
|
|
31
|
+
"fastapi>=0.100.0",
|
|
32
|
+
"typing-extensions>=4.0.0",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
dev = ["black", "pytest", "ruff", "mypy", "httpx"]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
"Homepage" = "https://github.com/jeromediaz/fastapi-dynamic-route-registration"
|
|
40
|
+
"Bug Tracker" = "https://github.com/jeromediaz/fastapi-dynamic-route-registration/issues"
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
|
|
45
|
+
[tool.black]
|
|
46
|
+
line-length = 88
|
|
47
|
+
target-version = ["py312"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI Dynamic Route Registration
|
|
3
|
+
------------------------------------
|
|
4
|
+
A library to help dynamically register routes in FastAPI applications.
|
|
5
|
+
|
|
6
|
+
Drop-in spiritual successor to ``flask-dynamic-route-registration``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .register_routers import register_router, register_routers
|
|
10
|
+
from .router_decorator import register_route
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
__all__ = ["register_route", "register_router", "register_routers"]
|
|
File without changes
|
fastapi_dynamic_route_registration-0.1.0/src/fastapi_dynamic_route_registration/register_routers.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from inspect import getmembers
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, FastAPI
|
|
6
|
+
|
|
7
|
+
from .router_decorator import is_register_route
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def register_router(
|
|
11
|
+
container: FastAPI | APIRouter,
|
|
12
|
+
module_name: str,
|
|
13
|
+
routers_data: str | Any | list[str | Any],
|
|
14
|
+
route_kwargs: Mapping[str, Any],
|
|
15
|
+
) -> list[APIRouter]:
|
|
16
|
+
"""Register a single module's routes into *container*.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
container: A ``FastAPI`` app or parent ``APIRouter`` to mount the new
|
|
20
|
+
router(s) into.
|
|
21
|
+
module_name: Dotted import path of the module that contains
|
|
22
|
+
``@register_route``-decorated functions.
|
|
23
|
+
routers_data: Configuration data.
|
|
24
|
+
|
|
25
|
+
- ``str`` — treated as the URL prefix for a single router.
|
|
26
|
+
- ``dict`` — may have keys ``router_name``, ``router_kwargs``
|
|
27
|
+
(passed to ``APIRouter()``) and ``params`` (injected into every
|
|
28
|
+
route as default values, same semantics as the Flask version).
|
|
29
|
+
- ``list`` — list of the above; mounts the module multiple times
|
|
30
|
+
at different prefixes or with different params.
|
|
31
|
+
|
|
32
|
+
route_kwargs: Extra key/value pairs merged into every router's
|
|
33
|
+
``params`` dict and bound as default parameter values in decorated
|
|
34
|
+
route functions. Use this to inject shared dependencies such as
|
|
35
|
+
an application context object.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
List of the newly created ``APIRouter`` instances (empty list if the
|
|
39
|
+
module contains no ``@register_route``-decorated functions).
|
|
40
|
+
"""
|
|
41
|
+
module_object = __import__(module_name, fromlist=[""])
|
|
42
|
+
functions = getmembers(module_object, is_register_route)
|
|
43
|
+
|
|
44
|
+
if not functions:
|
|
45
|
+
return []
|
|
46
|
+
|
|
47
|
+
if not isinstance(routers_data, list):
|
|
48
|
+
routers_data = [routers_data]
|
|
49
|
+
|
|
50
|
+
new_routers: list[APIRouter] = []
|
|
51
|
+
|
|
52
|
+
for router_data in routers_data:
|
|
53
|
+
if isinstance(router_data, str):
|
|
54
|
+
router_data = {"router_kwargs": {"prefix": router_data}}
|
|
55
|
+
|
|
56
|
+
router_kwargs: dict[str, Any] = router_data.get("router_kwargs", {})
|
|
57
|
+
params: dict[str, Any] = {**router_data.get("params", {}), **route_kwargs}
|
|
58
|
+
|
|
59
|
+
# Normalise Flask-style url_prefix key in router_kwargs
|
|
60
|
+
if "url_prefix" in router_kwargs and "prefix" not in router_kwargs:
|
|
61
|
+
router_kwargs["prefix"] = router_kwargs.pop("url_prefix")
|
|
62
|
+
|
|
63
|
+
new_router = APIRouter(**router_kwargs)
|
|
64
|
+
|
|
65
|
+
for _name, register_fn in functions:
|
|
66
|
+
register_fn(new_router, **params)
|
|
67
|
+
|
|
68
|
+
container.include_router(new_router)
|
|
69
|
+
new_routers.append(new_router)
|
|
70
|
+
|
|
71
|
+
return new_routers
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def register_routers(
|
|
75
|
+
container: FastAPI | APIRouter,
|
|
76
|
+
module_prefix: str,
|
|
77
|
+
routers: Mapping[str, Any],
|
|
78
|
+
route_kwargs: Mapping[str, Any],
|
|
79
|
+
**kwargs: Any,
|
|
80
|
+
) -> APIRouter:
|
|
81
|
+
"""Register multiple modules' routes into *container*.
|
|
82
|
+
|
|
83
|
+
Mirrors ``register_blueprints`` from the Flask version. Each key in
|
|
84
|
+
*routers* is a module suffix (appended to *module_prefix* with a ``.``
|
|
85
|
+
separator) and its value is the *routers_data* accepted by
|
|
86
|
+
:func:`register_router`.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
container: A ``FastAPI`` app or parent ``APIRouter``.
|
|
90
|
+
module_prefix: Common dotted prefix for all modules (e.g.
|
|
91
|
+
``"myapp.api"``).
|
|
92
|
+
routers: Mapping of ``{module_suffix: routers_data}``.
|
|
93
|
+
route_kwargs: Extra key/value pairs merged into every route's params
|
|
94
|
+
and bound as default parameter values (e.g.
|
|
95
|
+
``{"app_context": ctx}``).
|
|
96
|
+
**kwargs: Extra kwargs forwarded to the top-level ``APIRouter``
|
|
97
|
+
constructor (e.g. ``tags``, ``dependencies``).
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
The top-level ``APIRouter`` that wraps all sub-routers.
|
|
101
|
+
"""
|
|
102
|
+
parent_router = APIRouter(**kwargs)
|
|
103
|
+
|
|
104
|
+
for module_suffix, routers_data in routers.items():
|
|
105
|
+
module_name = f"{module_prefix}.{module_suffix}"
|
|
106
|
+
register_router(parent_router, module_name, routers_data, route_kwargs)
|
|
107
|
+
|
|
108
|
+
container.include_router(parent_router)
|
|
109
|
+
return parent_router
|
fastapi_dynamic_route_registration-0.1.0/src/fastapi_dynamic_route_registration/router_decorator.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import inspect
|
|
3
|
+
import types
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any, Protocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EndpointEnabledCallback(Protocol):
|
|
9
|
+
def __call__(self, **params: Any) -> bool: ...
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _bind_params_as_defaults(
|
|
13
|
+
func: Callable[..., Any], params: dict[str, Any]
|
|
14
|
+
) -> Callable[..., Any]:
|
|
15
|
+
"""Return a new callable with *params* injected as parameter defaults.
|
|
16
|
+
|
|
17
|
+
FastAPI introspects ``__signature__`` to build request validation and
|
|
18
|
+
dependency injection. We replace the signature so that any key in
|
|
19
|
+
*params* that matches a parameter name becomes its default value,
|
|
20
|
+
overriding any pre-existing default. This mirrors the
|
|
21
|
+
``defaults=params`` mechanism of Flask blueprints.
|
|
22
|
+
|
|
23
|
+
Only params whose keys appear in the function signature (or the function
|
|
24
|
+
accepts ``**kwargs``) are injected; unrecognized keys are silently dropped
|
|
25
|
+
so that one ``route_kwargs`` dict can be broadcast to all routes without
|
|
26
|
+
causing ``TypeError`` for routes that don't declare every key.
|
|
27
|
+
|
|
28
|
+
``**kwargs`` (VAR_KEYWORD) parameters are stripped from the
|
|
29
|
+
FastAPI-visible signature because FastAPI cannot handle them and would
|
|
30
|
+
attempt to treat them as query parameters.
|
|
31
|
+
"""
|
|
32
|
+
sig = inspect.signature(func)
|
|
33
|
+
accepts_var_keyword = any(
|
|
34
|
+
p.kind is inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
|
|
35
|
+
)
|
|
36
|
+
# Params to actually inject: only those accepted by the function.
|
|
37
|
+
injectable = {
|
|
38
|
+
k: v for k, v in params.items() if k in sig.parameters or accepts_var_keyword
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
new_params = []
|
|
42
|
+
for name, param in sig.parameters.items():
|
|
43
|
+
# Drop **kwargs from the FastAPI-visible signature: FastAPI cannot
|
|
44
|
+
# handle VAR_KEYWORD parameters and would try to make them query params.
|
|
45
|
+
if param.kind is inspect.Parameter.VAR_KEYWORD:
|
|
46
|
+
continue
|
|
47
|
+
if name in injectable:
|
|
48
|
+
# Always override the existing default so that injected values
|
|
49
|
+
# from per-router params take precedence over function-level defaults.
|
|
50
|
+
new_params.append(param.replace(default=injectable[name]))
|
|
51
|
+
else:
|
|
52
|
+
new_params.append(param)
|
|
53
|
+
|
|
54
|
+
if inspect.iscoroutinefunction(func):
|
|
55
|
+
|
|
56
|
+
@functools.wraps(func)
|
|
57
|
+
async def async_bound(*args: Any, **kwargs: Any) -> Any:
|
|
58
|
+
for k, v in injectable.items():
|
|
59
|
+
kwargs.setdefault(k, v)
|
|
60
|
+
return await func(*args, **kwargs)
|
|
61
|
+
|
|
62
|
+
async_bound.__signature__ = sig.replace(parameters=new_params) # type: ignore[attr-defined]
|
|
63
|
+
return async_bound
|
|
64
|
+
else:
|
|
65
|
+
|
|
66
|
+
@functools.wraps(func)
|
|
67
|
+
def sync_bound(*args: Any, **kwargs: Any) -> Any:
|
|
68
|
+
for k, v in injectable.items():
|
|
69
|
+
kwargs.setdefault(k, v)
|
|
70
|
+
return func(*args, **kwargs)
|
|
71
|
+
|
|
72
|
+
sync_bound.__signature__ = sig.replace(parameters=new_params) # type: ignore[attr-defined]
|
|
73
|
+
return sync_bound
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def register_route(
|
|
77
|
+
path: str,
|
|
78
|
+
*,
|
|
79
|
+
methods: list[str] | None = None,
|
|
80
|
+
endpoint: str | None = None,
|
|
81
|
+
enabled: bool | EndpointEnabledCallback = True,
|
|
82
|
+
**route_kwargs: Any,
|
|
83
|
+
) -> Callable[..., Any]:
|
|
84
|
+
"""Decorator that marks a function as a FastAPI route to be registered later.
|
|
85
|
+
|
|
86
|
+
Usage is intentionally identical to the Flask ``register_route`` decorator so
|
|
87
|
+
that endpoint modules can be ported with minimal diff.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
path: The URL path relative to the router prefix (e.g. ``""`` or
|
|
91
|
+
``"/{dag_id}"``). Flask ``<param>`` syntax is **not** supported;
|
|
92
|
+
use FastAPI path syntax ``{param}`` directly.
|
|
93
|
+
methods: HTTP methods (default ``["GET"]``).
|
|
94
|
+
endpoint: Override the operation name; defaults to the function name.
|
|
95
|
+
enabled: ``True``, ``False``, or a callable ``(**params) -> bool`` that
|
|
96
|
+
receives the per-router *params* at registration time.
|
|
97
|
+
**route_kwargs: Extra kwargs forwarded to ``router.add_api_route()``.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
A ``Callable`` wrapper tagged with ``.is_url_rule = True``. When
|
|
101
|
+
called as ``wrapper(router, **params)``, it registers the original
|
|
102
|
+
function on the given ``APIRouter`` with *params* bound as parameter
|
|
103
|
+
defaults via :func:`_bind_params_as_defaults`.
|
|
104
|
+
"""
|
|
105
|
+
_methods = methods or ["GET"]
|
|
106
|
+
|
|
107
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
108
|
+
_endpoint = endpoint or func.__name__
|
|
109
|
+
|
|
110
|
+
def wrapper(router: Any, **params: Any) -> None:
|
|
111
|
+
if callable(enabled):
|
|
112
|
+
endpoint_enabled: bool = enabled(**params)
|
|
113
|
+
else:
|
|
114
|
+
endpoint_enabled = enabled
|
|
115
|
+
|
|
116
|
+
if not endpoint_enabled:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
# Always run through _bind_params_as_defaults so that **kwargs
|
|
120
|
+
# is stripped from the FastAPI-visible signature even when params
|
|
121
|
+
# is empty.
|
|
122
|
+
bound_func = _bind_params_as_defaults(func, params)
|
|
123
|
+
|
|
124
|
+
router.add_api_route(
|
|
125
|
+
path,
|
|
126
|
+
bound_func,
|
|
127
|
+
methods=_methods,
|
|
128
|
+
name=_endpoint,
|
|
129
|
+
**route_kwargs,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
wrapper.is_url_rule = True # type: ignore[attr-defined]
|
|
133
|
+
return wrapper
|
|
134
|
+
|
|
135
|
+
return decorator
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def is_register_route(func: Any) -> bool:
|
|
139
|
+
return isinstance(func, types.FunctionType) and getattr(func, "is_url_rule", False)
|
fastapi_dynamic_route_registration-0.1.0/src/fastapi_dynamic_route_registration.egg-info/PKG-INFO
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi-dynamic-route-registration
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A library to help dynamically register routes in FastAPI applications.
|
|
5
|
+
Author-email: Jérôme DIAZ <jerome_diaz@me.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jeromediaz/fastapi-dynamic-route-registration
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/jeromediaz/fastapi-dynamic-route-registration/issues
|
|
9
|
+
Keywords: fastapi,route,dynamic
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Framework :: FastAPI
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: fastapi>=0.100.0
|
|
19
|
+
Requires-Dist: typing-extensions>=4.0.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: black; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest; extra == "dev"
|
|
23
|
+
Requires-Dist: ruff; extra == "dev"
|
|
24
|
+
Requires-Dist: mypy; extra == "dev"
|
|
25
|
+
Requires-Dist: httpx; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# FastAPI dynamic route registration
|
|
29
|
+
|
|
30
|
+
[](https://pypi.org/project/fastapi-dynamic-route-registration/)
|
|
31
|
+
[](https://pypi.org/project/fastapi-dynamic-route-registration/)
|
|
32
|
+
|
|
33
|
+
A library to help dynamically register routes in FastAPI applications.
|
|
34
|
+
|
|
35
|
+
It is the FastAPI successor to [flask-dynamic-route-registration](https://github.com/jeromediaz/flask-dynamic-route-registration): endpoint modules decorate their functions with `@register_route`, and the application mounts whole modules from a configuration map — with per-router prefixes and shared values injected as parameter defaults.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install fastapi-dynamic-route-registration
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## How to Use
|
|
42
|
+
### Basic Setup
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from fastapi import FastAPI
|
|
46
|
+
from fastapi_dynamic_route_registration import register_router
|
|
47
|
+
|
|
48
|
+
app = FastAPI()
|
|
49
|
+
|
|
50
|
+
register_router(app, "healthcheck", "", {})
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Inside the `healthcheck/__init__.py` file
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from fastapi_dynamic_route_registration import register_route
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@register_route("/health")
|
|
60
|
+
def health() -> dict:
|
|
61
|
+
return {"status": "OK"}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Reusing a module with different parameters, conditional routes
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
api_versions = [
|
|
68
|
+
{"router_kwargs": {"prefix": "/api/1"}, "params": {"version": 1}},
|
|
69
|
+
{"router_kwargs": {"prefix": "/api/2"}, "params": {"version": 2}},
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
register_router(app, "api", api_versions, {})
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Inside the `api/__init__.py` file
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from fastapi_dynamic_route_registration import register_route
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@register_route("/status")
|
|
82
|
+
def status(*, version: int = 1) -> dict:
|
|
83
|
+
return {"status": "OK", "version": version}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@register_route("/foo", enabled=lambda **params: params.get("version", 1) >= 2)
|
|
87
|
+
def foo_a() -> dict:
|
|
88
|
+
return {"hello": "world"}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@register_route("/foo/{subject}", enabled=lambda **params: params.get("version", 1) >= 2)
|
|
92
|
+
def foo_b(subject: str) -> dict:
|
|
93
|
+
return {"hello": subject}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
This registers four routes:
|
|
97
|
+
|
|
98
|
+
| URL | Return value | Function |
|
|
99
|
+
|----------------------|--------------------------------|----------|
|
|
100
|
+
| /api/1/status | {"status": "OK", "version": 1} | status |
|
|
101
|
+
| /api/2/status | {"status": "OK", "version": 2} | status |
|
|
102
|
+
| /api/2/foo | {"hello": "world"} | foo_a |
|
|
103
|
+
| /api/2/foo/{subject} | {"hello": subject} | foo_b |
|
|
104
|
+
|
|
105
|
+
Values from `params` (and from the `route_kwargs` passed to `register_router`) become the **default value** of the parameter with the same name. FastAPI still sees that parameter: a plain `version: int = 1` stays a query parameter, whose default is now the injected value. Keys a function does not declare are dropped — unless it accepts `**kwargs`, in which case they are passed on every call while `**kwargs` itself stays hidden from FastAPI. One shared mapping (e.g. an application context object) can therefore be broadcast to every route.
|
|
106
|
+
|
|
107
|
+
### Other route options
|
|
108
|
+
|
|
109
|
+
`register_route` forwards extra keyword arguments to `APIRouter.add_api_route`:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
@register_route("/items", methods=["POST"], status_code=201, tags=["items"])
|
|
113
|
+
def create_item(item: Item) -> Item:
|
|
114
|
+
return item
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Registering multiple modules at once
|
|
118
|
+
|
|
119
|
+
`register_routers` (plural) registers several modules under a shared parent router:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from fastapi import FastAPI
|
|
123
|
+
from fastapi_dynamic_route_registration import register_routers
|
|
124
|
+
|
|
125
|
+
app = FastAPI()
|
|
126
|
+
|
|
127
|
+
register_routers(
|
|
128
|
+
app,
|
|
129
|
+
"myapp.api",
|
|
130
|
+
{
|
|
131
|
+
"healthcheck": "",
|
|
132
|
+
"users": {"router_kwargs": {"prefix": "/users"}},
|
|
133
|
+
},
|
|
134
|
+
route_kwargs={},
|
|
135
|
+
prefix="/v1",
|
|
136
|
+
)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Each value accepts the same shapes as `register_router`: a prefix string, a dict with `router_kwargs` (passed to `APIRouter`) and `params`, or a list of those. A configuration map like this is easy to keep in a JSON file per application.
|
|
140
|
+
|
|
141
|
+
### Differences from the Flask version
|
|
142
|
+
|
|
143
|
+
- Path parameters use FastAPI syntax: `{param}`, not `<param>`.
|
|
144
|
+
- `router_kwargs` replaces `blueprint_kwargs`; a Flask-style `url_prefix` key is still accepted and mapped to `prefix`.
|
|
145
|
+
- One function registers one route.
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
This project is licensed under the MIT License - see the [LICENSE](https://github.com/jeromediaz/fastapi-dynamic-route-registration/blob/main/LICENSE) file for details.
|
fastapi_dynamic_route_registration-0.1.0/src/fastapi_dynamic_route_registration.egg-info/SOURCES.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/fastapi_dynamic_route_registration/__init__.py
|
|
5
|
+
src/fastapi_dynamic_route_registration/py.typed
|
|
6
|
+
src/fastapi_dynamic_route_registration/register_routers.py
|
|
7
|
+
src/fastapi_dynamic_route_registration/router_decorator.py
|
|
8
|
+
src/fastapi_dynamic_route_registration.egg-info/PKG-INFO
|
|
9
|
+
src/fastapi_dynamic_route_registration.egg-info/SOURCES.txt
|
|
10
|
+
src/fastapi_dynamic_route_registration.egg-info/dependency_links.txt
|
|
11
|
+
src/fastapi_dynamic_route_registration.egg-info/requires.txt
|
|
12
|
+
src/fastapi_dynamic_route_registration.egg-info/top_level.txt
|
|
13
|
+
tests/test_integration.py
|
|
14
|
+
tests/test_register_route.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fastapi_dynamic_route_registration
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Integration tests for the full dynamic-route-registration pipeline.
|
|
2
|
+
|
|
3
|
+
These tests simulate how a host application wires endpoint modules at
|
|
4
|
+
startup, without requiring
|
|
5
|
+
a full host application. They verify that:
|
|
6
|
+
|
|
7
|
+
* register_router correctly wires routes from a module into a FastAPI app
|
|
8
|
+
* register_routers groups multiple modules under a parent router
|
|
9
|
+
* Params from a JSON-style config map are injected as defaults
|
|
10
|
+
* The url_prefix / prefix normalisation works for both key names
|
|
11
|
+
* Multiple mounts of the same module at different prefixes work independently
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
from fastapi import FastAPI, APIRouter
|
|
16
|
+
from fastapi.testclient import TestClient
|
|
17
|
+
|
|
18
|
+
from fastapi_dynamic_route_registration import register_route, register_router, register_routers
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Inline endpoint module (simulates an endpoint package __init__.py)
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
@register_route("", methods=["GET"])
|
|
26
|
+
def health(*, version: int = 1, **kwargs) -> dict:
|
|
27
|
+
return {"status": "OK", "version": version}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@register_route("/items/{item_id}", methods=["GET"])
|
|
31
|
+
def get_item(item_id: str) -> dict:
|
|
32
|
+
return {"id": item_id}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@register_route("/secret", methods=["GET"], enabled=False)
|
|
36
|
+
def secret_route() -> dict: # pragma: no cover
|
|
37
|
+
return {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@register_route("/flag", methods=["GET"], enabled=lambda **p: p.get("enable_flag", False))
|
|
41
|
+
def flag_route() -> dict:
|
|
42
|
+
return {"flag": True}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Helpers
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def _make_app(routers_data, route_kwargs=None) -> TestClient:
|
|
50
|
+
app = FastAPI()
|
|
51
|
+
register_router(app, __name__, routers_data, route_kwargs or {})
|
|
52
|
+
return TestClient(app)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# register_router tests
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
class TestRegisterRouter:
|
|
60
|
+
def test_basic_prefix_as_string(self):
|
|
61
|
+
client = _make_app("/v1")
|
|
62
|
+
assert client.get("/v1").json() == {"status": "OK", "version": 1}
|
|
63
|
+
|
|
64
|
+
def test_path_param(self):
|
|
65
|
+
client = _make_app("/v1")
|
|
66
|
+
assert client.get("/v1/items/abc").json() == {"id": "abc"}
|
|
67
|
+
|
|
68
|
+
def test_disabled_route_absent(self):
|
|
69
|
+
client = _make_app("/v1")
|
|
70
|
+
assert client.get("/v1/secret").status_code == 404
|
|
71
|
+
|
|
72
|
+
def test_param_overrides_default(self):
|
|
73
|
+
client = _make_app({"router_kwargs": {"prefix": "/v2"}, "params": {"version": 2}})
|
|
74
|
+
assert client.get("/v2").json() == {"status": "OK", "version": 2}
|
|
75
|
+
|
|
76
|
+
def test_conditional_enabled(self):
|
|
77
|
+
client = _make_app({"router_kwargs": {"prefix": "/v1"}, "params": {"enable_flag": True}})
|
|
78
|
+
assert client.get("/v1/flag").status_code == 200
|
|
79
|
+
|
|
80
|
+
def test_conditional_disabled(self):
|
|
81
|
+
client = _make_app({"router_kwargs": {"prefix": "/v1"}, "params": {"enable_flag": False}})
|
|
82
|
+
assert client.get("/v1/flag").status_code == 404
|
|
83
|
+
|
|
84
|
+
def test_url_prefix_key_accepted(self):
|
|
85
|
+
"""Flask-style 'url_prefix' inside router_kwargs is normalised to 'prefix'."""
|
|
86
|
+
client = _make_app({"router_kwargs": {"url_prefix": "/compat"}})
|
|
87
|
+
assert client.get("/compat").status_code == 200
|
|
88
|
+
|
|
89
|
+
def test_multiple_mounts_independent_params(self):
|
|
90
|
+
"""Same module at /v1 (version=1) and /v2 (version=2) must be independent."""
|
|
91
|
+
app = FastAPI()
|
|
92
|
+
register_router(
|
|
93
|
+
app,
|
|
94
|
+
__name__,
|
|
95
|
+
[
|
|
96
|
+
{"router_kwargs": {"prefix": "/v1"}, "params": {"version": 1}},
|
|
97
|
+
{"router_kwargs": {"prefix": "/v2"}, "params": {"version": 2}},
|
|
98
|
+
],
|
|
99
|
+
{},
|
|
100
|
+
)
|
|
101
|
+
client = TestClient(app)
|
|
102
|
+
assert client.get("/v1").json()["version"] == 1
|
|
103
|
+
assert client.get("/v2").json()["version"] == 2
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# register_routers tests
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
class TestRegisterRouters:
|
|
111
|
+
def test_groups_modules_under_parent_router(self):
|
|
112
|
+
current = __name__
|
|
113
|
+
parts = current.rsplit(".", 1)
|
|
114
|
+
if len(parts) < 2:
|
|
115
|
+
pytest.skip("Module is at top level; register_routers needs a prefix")
|
|
116
|
+
|
|
117
|
+
prefix, suffix = parts
|
|
118
|
+
app = FastAPI()
|
|
119
|
+
register_routers(app, module_prefix=prefix, routers={suffix: "/grp"}, route_kwargs={})
|
|
120
|
+
client = TestClient(app)
|
|
121
|
+
assert client.get("/grp").status_code == 200
|
|
122
|
+
|
|
123
|
+
def test_route_kwargs_injected_into_all_routes(self):
|
|
124
|
+
current = __name__
|
|
125
|
+
parts = current.rsplit(".", 1)
|
|
126
|
+
if len(parts) < 2:
|
|
127
|
+
pytest.skip("Module is at top level")
|
|
128
|
+
|
|
129
|
+
prefix, suffix = parts
|
|
130
|
+
app = FastAPI()
|
|
131
|
+
register_routers(
|
|
132
|
+
app,
|
|
133
|
+
module_prefix=prefix,
|
|
134
|
+
routers={suffix: {"router_kwargs": {"prefix": "/grp2"}}},
|
|
135
|
+
route_kwargs={"version": 99},
|
|
136
|
+
)
|
|
137
|
+
client = TestClient(app)
|
|
138
|
+
assert client.get("/grp2").json()["version"] == 99
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Tests for register_route decorator and register_router / register_routers."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from fastapi import APIRouter, FastAPI
|
|
5
|
+
from fastapi.testclient import TestClient
|
|
6
|
+
|
|
7
|
+
from fastapi_dynamic_route_registration import register_route, register_router, register_routers
|
|
8
|
+
from fastapi_dynamic_route_registration.router_decorator import is_register_route
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
# Helpers / fixture modules defined inline
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
@register_route("", methods=["GET"])
|
|
16
|
+
def index_route():
|
|
17
|
+
return {"status": "OK"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Deliberately placed AFTER more specific routes so FastAPI matches correctly.
|
|
21
|
+
@register_route("/items/{item_id}", methods=["GET"])
|
|
22
|
+
def item_route(item_id: str):
|
|
23
|
+
return {"item": item_id}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@register_route("/versioned", methods=["GET"])
|
|
27
|
+
def versioned_route(*, version: int = 1, **kwargs):
|
|
28
|
+
return {"version": version}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@register_route("/disabled", methods=["GET"], enabled=False)
|
|
32
|
+
def disabled_route(): # pragma: no cover
|
|
33
|
+
return {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@register_route("/conditional", methods=["GET"], enabled=lambda **p: p.get("flag", False))
|
|
37
|
+
def conditional_route():
|
|
38
|
+
return {"ok": True}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@register_route("/async-versioned", methods=["GET"])
|
|
42
|
+
async def async_versioned_route(*, version: int = 1, **kwargs):
|
|
43
|
+
return {"version": version}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@register_route("/star-kwargs", methods=["GET"])
|
|
47
|
+
def route_with_star_kwargs(item_id: str = "default", **kwargs):
|
|
48
|
+
"""**kwargs must be stripped from the FastAPI-visible signature."""
|
|
49
|
+
return {"item": item_id}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Tests: decorator
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
class TestRegisterRouteDecorator:
|
|
57
|
+
def test_marks_is_url_rule(self):
|
|
58
|
+
assert index_route.is_url_rule is True
|
|
59
|
+
|
|
60
|
+
def test_is_register_route_predicate_true(self):
|
|
61
|
+
assert is_register_route(index_route)
|
|
62
|
+
|
|
63
|
+
def test_is_register_route_predicate_false_for_plain_func(self):
|
|
64
|
+
def plain(): ...
|
|
65
|
+
assert not is_register_route(plain)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Tests: register_router with a real FastAPI app
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
class TestRegisterRouterDirect:
|
|
73
|
+
"""register_router using functions defined in this test module."""
|
|
74
|
+
|
|
75
|
+
def _app_with_routes(self, routes_data, route_kwargs=None):
|
|
76
|
+
app = FastAPI()
|
|
77
|
+
# Pass the current module so register_router imports *this* file
|
|
78
|
+
register_router(
|
|
79
|
+
app,
|
|
80
|
+
__name__,
|
|
81
|
+
routes_data,
|
|
82
|
+
route_kwargs or {},
|
|
83
|
+
)
|
|
84
|
+
return TestClient(app)
|
|
85
|
+
|
|
86
|
+
def test_index_route(self):
|
|
87
|
+
client = self._app_with_routes("/api")
|
|
88
|
+
r = client.get("/api")
|
|
89
|
+
assert r.status_code == 200
|
|
90
|
+
assert r.json() == {"status": "OK"}
|
|
91
|
+
|
|
92
|
+
def test_path_param_route(self):
|
|
93
|
+
client = self._app_with_routes("/api")
|
|
94
|
+
r = client.get("/api/items/hello")
|
|
95
|
+
assert r.status_code == 200
|
|
96
|
+
assert r.json() == {"item": "hello"}
|
|
97
|
+
|
|
98
|
+
def test_params_injected_as_defaults(self):
|
|
99
|
+
client = self._app_with_routes(
|
|
100
|
+
{"router_kwargs": {"prefix": "/api"}, "params": {"version": 42}}
|
|
101
|
+
)
|
|
102
|
+
r = client.get("/api/versioned")
|
|
103
|
+
assert r.status_code == 200
|
|
104
|
+
assert r.json() == {"version": 42}
|
|
105
|
+
|
|
106
|
+
def test_default_version_without_params(self):
|
|
107
|
+
client = self._app_with_routes("/api")
|
|
108
|
+
r = client.get("/api/versioned")
|
|
109
|
+
assert r.status_code == 200
|
|
110
|
+
assert r.json() == {"version": 1}
|
|
111
|
+
|
|
112
|
+
def test_disabled_route_not_registered(self):
|
|
113
|
+
client = self._app_with_routes("/api")
|
|
114
|
+
r = client.get("/api/disabled")
|
|
115
|
+
assert r.status_code == 404
|
|
116
|
+
|
|
117
|
+
def test_conditional_route_enabled(self):
|
|
118
|
+
client = self._app_with_routes(
|
|
119
|
+
{"router_kwargs": {"prefix": "/api"}, "params": {"flag": True}}
|
|
120
|
+
)
|
|
121
|
+
r = client.get("/api/conditional")
|
|
122
|
+
assert r.status_code == 200
|
|
123
|
+
|
|
124
|
+
def test_conditional_route_disabled(self):
|
|
125
|
+
client = self._app_with_routes(
|
|
126
|
+
{"router_kwargs": {"prefix": "/api"}, "params": {"flag": False}}
|
|
127
|
+
)
|
|
128
|
+
r = client.get("/api/conditional")
|
|
129
|
+
assert r.status_code == 404
|
|
130
|
+
|
|
131
|
+
def test_multiple_prefixes(self):
|
|
132
|
+
"""Same module mounted at two different prefixes (like api.json list form)."""
|
|
133
|
+
app = FastAPI()
|
|
134
|
+
register_router(
|
|
135
|
+
app,
|
|
136
|
+
__name__,
|
|
137
|
+
[
|
|
138
|
+
{"router_kwargs": {"prefix": "/v1"}, "params": {"version": 1}},
|
|
139
|
+
{"router_kwargs": {"prefix": "/v2"}, "params": {"version": 2}},
|
|
140
|
+
],
|
|
141
|
+
{},
|
|
142
|
+
)
|
|
143
|
+
client = TestClient(app)
|
|
144
|
+
assert client.get("/v1/versioned").json() == {"version": 1}
|
|
145
|
+
assert client.get("/v2/versioned").json() == {"version": 2}
|
|
146
|
+
|
|
147
|
+
def test_url_prefix_key_normalised(self):
|
|
148
|
+
"""Flask-style 'url_prefix' inside router_kwargs is accepted."""
|
|
149
|
+
app = FastAPI()
|
|
150
|
+
register_router(
|
|
151
|
+
app,
|
|
152
|
+
__name__,
|
|
153
|
+
{"router_kwargs": {"url_prefix": "/compat"}},
|
|
154
|
+
{},
|
|
155
|
+
)
|
|
156
|
+
client = TestClient(app)
|
|
157
|
+
r = client.get("/compat")
|
|
158
|
+
assert r.status_code == 200
|
|
159
|
+
|
|
160
|
+
def test_register_routers(self):
|
|
161
|
+
"""register_routers groups multiple modules under one parent router."""
|
|
162
|
+
app = FastAPI()
|
|
163
|
+
# Use __name__ (this module) directly: register_routers builds
|
|
164
|
+
# "module_prefix.module_suffix" so we split the current module name.
|
|
165
|
+
current = __name__ # e.g. "test_register_route"
|
|
166
|
+
parts = current.rsplit(".", 1)
|
|
167
|
+
if len(parts) == 2:
|
|
168
|
+
prefix, suffix = parts
|
|
169
|
+
else:
|
|
170
|
+
# Module is at top level; use a dummy parent router via register_router
|
|
171
|
+
register_router(app, current, "/items_direct", {})
|
|
172
|
+
client = TestClient(app)
|
|
173
|
+
r = client.get("/items_direct")
|
|
174
|
+
assert r.status_code == 200
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
register_routers(
|
|
178
|
+
app,
|
|
179
|
+
module_prefix=prefix,
|
|
180
|
+
routers={suffix: "/items"},
|
|
181
|
+
route_kwargs={},
|
|
182
|
+
)
|
|
183
|
+
client = TestClient(app)
|
|
184
|
+
r = client.get("/items")
|
|
185
|
+
assert r.status_code == 200
|
|
186
|
+
|
|
187
|
+
def test_async_route_registered_and_callable(self):
|
|
188
|
+
"""Async route functions must be registered and return correct responses."""
|
|
189
|
+
client = self._app_with_routes("/api")
|
|
190
|
+
r = client.get("/api/async-versioned")
|
|
191
|
+
assert r.status_code == 200
|
|
192
|
+
assert r.json() == {"version": 1}
|
|
193
|
+
|
|
194
|
+
def test_async_route_param_injected(self):
|
|
195
|
+
"""Injected params must override async route function defaults."""
|
|
196
|
+
client = self._app_with_routes(
|
|
197
|
+
{"router_kwargs": {"prefix": "/api"}, "params": {"version": 99}}
|
|
198
|
+
)
|
|
199
|
+
r = client.get("/api/async-versioned")
|
|
200
|
+
assert r.status_code == 200
|
|
201
|
+
assert r.json() == {"version": 99}
|
|
202
|
+
|
|
203
|
+
def test_var_keyword_stripped_from_signature(self):
|
|
204
|
+
"""**kwargs must be stripped so FastAPI does not treat them as query params."""
|
|
205
|
+
client = self._app_with_routes("/api")
|
|
206
|
+
# Should succeed without FastAPI complaining about unknown parameters
|
|
207
|
+
r = client.get("/api/star-kwargs")
|
|
208
|
+
assert r.status_code == 200
|
|
209
|
+
assert r.json() == {"item": "default"}
|
|
210
|
+
|
|
211
|
+
def test_empty_module_returns_empty_list(self):
|
|
212
|
+
"""register_router must return [] when module has no @register_route functions."""
|
|
213
|
+
import types as _types
|
|
214
|
+
empty = _types.ModuleType("_empty_test_module")
|
|
215
|
+
import sys
|
|
216
|
+
sys.modules["_empty_test_module"] = empty
|
|
217
|
+
try:
|
|
218
|
+
result = register_router(FastAPI(), "_empty_test_module", "/prefix", {})
|
|
219
|
+
assert result == []
|
|
220
|
+
finally:
|
|
221
|
+
del sys.modules["_empty_test_module"]
|