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.
@@ -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
+ [![PyPI - License](https://img.shields.io/pypi/l/fastapi-dynamic-route-registration)](https://pypi.org/project/fastapi-dynamic-route-registration/)
31
+ [![PyPI - Version](https://img.shields.io/pypi/v/fastapi-dynamic-route-registration)](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
+ [![PyPI - License](https://img.shields.io/pypi/l/fastapi-dynamic-route-registration)](https://pypi.org/project/fastapi-dynamic-route-registration/)
4
+ [![PyPI - Version](https://img.shields.io/pypi/v/fastapi-dynamic-route-registration)](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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"]
@@ -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
@@ -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)
@@ -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
+ [![PyPI - License](https://img.shields.io/pypi/l/fastapi-dynamic-route-registration)](https://pypi.org/project/fastapi-dynamic-route-registration/)
31
+ [![PyPI - Version](https://img.shields.io/pypi/v/fastapi-dynamic-route-registration)](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,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,9 @@
1
+ fastapi>=0.100.0
2
+ typing-extensions>=4.0.0
3
+
4
+ [dev]
5
+ black
6
+ pytest
7
+ ruff
8
+ mypy
9
+ httpx
@@ -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"]