pythonboot-web 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.
- pythonboot_web-0.1.0/.gitignore +47 -0
- pythonboot_web-0.1.0/PKG-INFO +45 -0
- pythonboot_web-0.1.0/README.md +35 -0
- pythonboot_web-0.1.0/pyproject.toml +18 -0
- pythonboot_web-0.1.0/src/pythonboot_web/__init__.py +24 -0
- pythonboot_web-0.1.0/src/pythonboot_web/application.py +151 -0
- pythonboot_web-0.1.0/src/pythonboot_web/bootstrap.py +38 -0
- pythonboot_web-0.1.0/src/pythonboot_web/context_middleware.py +42 -0
- pythonboot_web-0.1.0/src/pythonboot_web/datetime.py +208 -0
- pythonboot_web-0.1.0/src/pythonboot_web/decorators.py +129 -0
- pythonboot_web-0.1.0/src/pythonboot_web/docs.py +70 -0
- pythonboot_web-0.1.0/src/pythonboot_web/integration.py +154 -0
- pythonboot_web-0.1.0/src/pythonboot_web/request_context.py +150 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
build/
|
|
8
|
+
develop-eggs/
|
|
9
|
+
dist/
|
|
10
|
+
downloads/
|
|
11
|
+
eggs/
|
|
12
|
+
.eggs/
|
|
13
|
+
lib/
|
|
14
|
+
lib64/
|
|
15
|
+
parts/
|
|
16
|
+
sdist/
|
|
17
|
+
var/
|
|
18
|
+
wheels/
|
|
19
|
+
share/python-wheels/
|
|
20
|
+
*.egg-info/
|
|
21
|
+
.installed.cfg
|
|
22
|
+
*.egg
|
|
23
|
+
MANIFEST
|
|
24
|
+
|
|
25
|
+
# Virtual environments
|
|
26
|
+
.venv/
|
|
27
|
+
venv/
|
|
28
|
+
ENV/
|
|
29
|
+
env/
|
|
30
|
+
|
|
31
|
+
# Tools
|
|
32
|
+
.pytest_cache/
|
|
33
|
+
.mypy_cache/
|
|
34
|
+
.ruff_cache/
|
|
35
|
+
.coverage
|
|
36
|
+
coverage.xml
|
|
37
|
+
htmlcov/
|
|
38
|
+
|
|
39
|
+
# Node.js and VitePress generated files
|
|
40
|
+
node_modules/
|
|
41
|
+
viteDocs/docs/.vitepress/cache/
|
|
42
|
+
viteDocs/docs/.vitepress/dist/
|
|
43
|
+
|
|
44
|
+
# IDE and OS
|
|
45
|
+
.idea/
|
|
46
|
+
.vscode/
|
|
47
|
+
.DS_Store
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pythonboot-web
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: FastAPI integration and Web application startup for PythonBoot
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: fastapi<1,>=0.115
|
|
7
|
+
Requires-Dist: pythonboot<0.2,>=0.1.0
|
|
8
|
+
Requires-Dist: uvicorn[standard]<1,>=0.34
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# pythonboot-web
|
|
12
|
+
|
|
13
|
+
PythonBoot 的独立 Web 实现包,包含 Controller、路由、异常处理、请求与响应上下文、
|
|
14
|
+
日期格式化、API 文档、ASGI 生命周期和 Web 启动类。
|
|
15
|
+
|
|
16
|
+
依赖 `pythonboot`、FastAPI 和 Uvicorn,用户推荐安装 `pythonboot-starter-web`。
|
|
17
|
+
核心接口从 `pythonboot` 导入,Web 接口从 `pythonboot_web` 导入。
|
|
18
|
+
原来的 `pythonboot.web` 和核心包顶层 Web 导出已移除。
|
|
19
|
+
YAML 的 `pythonboot.web` 配置键保持不变。
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from pythonboot import autowired, service
|
|
23
|
+
from pythonboot_web import PythonBootApplication, controller, get, pythonboot_application
|
|
24
|
+
|
|
25
|
+
@service
|
|
26
|
+
class GreetingService:
|
|
27
|
+
def greeting(self) -> str:
|
|
28
|
+
return "Hello"
|
|
29
|
+
|
|
30
|
+
@controller(prefix="/greeting")
|
|
31
|
+
class GreetingController:
|
|
32
|
+
service: GreetingService = autowired()
|
|
33
|
+
|
|
34
|
+
@get("")
|
|
35
|
+
def greeting(self) -> str:
|
|
36
|
+
return self.service.greeting()
|
|
37
|
+
|
|
38
|
+
@pythonboot_application(scan_packages=("myapp",))
|
|
39
|
+
class Application(PythonBootApplication):
|
|
40
|
+
pass
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
完整分层应用见 [全量 Starter 示例](../../example/README.md),包含全部 Starter、PostgreSQL/Redis 和同步/异步 HTTP 用法。
|
|
45
|
+
See the [complete Starter application](../../example/README.md) for all Starters and sync/async HTTP examples.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# pythonboot-web
|
|
2
|
+
|
|
3
|
+
PythonBoot 的独立 Web 实现包,包含 Controller、路由、异常处理、请求与响应上下文、
|
|
4
|
+
日期格式化、API 文档、ASGI 生命周期和 Web 启动类。
|
|
5
|
+
|
|
6
|
+
依赖 `pythonboot`、FastAPI 和 Uvicorn,用户推荐安装 `pythonboot-starter-web`。
|
|
7
|
+
核心接口从 `pythonboot` 导入,Web 接口从 `pythonboot_web` 导入。
|
|
8
|
+
原来的 `pythonboot.web` 和核心包顶层 Web 导出已移除。
|
|
9
|
+
YAML 的 `pythonboot.web` 配置键保持不变。
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from pythonboot import autowired, service
|
|
13
|
+
from pythonboot_web import PythonBootApplication, controller, get, pythonboot_application
|
|
14
|
+
|
|
15
|
+
@service
|
|
16
|
+
class GreetingService:
|
|
17
|
+
def greeting(self) -> str:
|
|
18
|
+
return "Hello"
|
|
19
|
+
|
|
20
|
+
@controller(prefix="/greeting")
|
|
21
|
+
class GreetingController:
|
|
22
|
+
service: GreetingService = autowired()
|
|
23
|
+
|
|
24
|
+
@get("")
|
|
25
|
+
def greeting(self) -> str:
|
|
26
|
+
return self.service.greeting()
|
|
27
|
+
|
|
28
|
+
@pythonboot_application(scan_packages=("myapp",))
|
|
29
|
+
class Application(PythonBootApplication):
|
|
30
|
+
pass
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
完整分层应用见 [全量 Starter 示例](../../example/README.md),包含全部 Starter、PostgreSQL/Redis 和同步/异步 HTTP 用法。
|
|
35
|
+
See the [complete Starter application](../../example/README.md) for all Starters and sync/async HTTP examples.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27,<2"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pythonboot-web"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "FastAPI integration and Web application startup for PythonBoot"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"pythonboot>=0.1.0,<0.2",
|
|
13
|
+
"fastapi>=0.115,<1",
|
|
14
|
+
"uvicorn[standard]>=0.34,<1",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[tool.hatch.build.targets.wheel]
|
|
18
|
+
packages = ["src/pythonboot_web"]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""FastAPI routing, dependency injection, and exception handling integration."""
|
|
2
|
+
|
|
3
|
+
from pythonboot_web.integration import inject, register_exception_handlers, register_routers
|
|
4
|
+
from pythonboot_web.docs import register_api_docs, resolve_docs_options
|
|
5
|
+
from pythonboot_web.datetime import DateTimeFormatMiddleware, date_time, resolve_datetime_format
|
|
6
|
+
from pythonboot_web.context_middleware import ApplicationContextMiddleware
|
|
7
|
+
from pythonboot_web.request_context import RequestContextHolder
|
|
8
|
+
from pythonboot_web.decorators import (
|
|
9
|
+
controller, controller_advise, delete, exception_handler, get, patch, post, put, route,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"controller", "controller_advise", "exception_handler", "route", "get", "post", "put", "patch", "delete",
|
|
14
|
+
"inject", "register_routers", "register_exception_handlers", "resolve_datetime_format",
|
|
15
|
+
"DateTimeFormatMiddleware", "date_time",
|
|
16
|
+
"resolve_docs_options", "register_api_docs",
|
|
17
|
+
"ApplicationContextMiddleware",
|
|
18
|
+
"RequestContextHolder",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
from pythonboot_web.application import PythonBootApplication, create_app
|
|
22
|
+
from pythonboot_web.bootstrap import pythonboot_application
|
|
23
|
+
|
|
24
|
+
__all__ += ["PythonBootApplication", "create_app", "pythonboot_application"]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""PythonBoot application creation and startup entry points."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import uvicorn
|
|
11
|
+
from fastapi import FastAPI
|
|
12
|
+
|
|
13
|
+
from pythonboot.beans import scan_components
|
|
14
|
+
from pythonboot.boot.autoconfiguration import assemble_auto_configurations
|
|
15
|
+
from pythonboot.context import ApplicationContext
|
|
16
|
+
from pythonboot.config import configure_logging, load_configuration
|
|
17
|
+
from pythonboot.util import ContextUtil
|
|
18
|
+
from pythonboot_web import (
|
|
19
|
+
ApplicationContextMiddleware, DateTimeFormatMiddleware, register_api_docs, register_exception_handlers,
|
|
20
|
+
register_routers, resolve_datetime_format, resolve_docs_options,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def create_app(
|
|
27
|
+
*, title: str = "PythonBoot", version: str = "0.1.0",
|
|
28
|
+
scan_packages: tuple[str, ...] = (),
|
|
29
|
+
exclude_modules: tuple[str, ...] = (), config_dir: str | Path | None = None,
|
|
30
|
+
auto_configure: bool = True, exclude_auto_configurations: tuple[str, ...] = (),
|
|
31
|
+
exclude: tuple[type, ...] = (),
|
|
32
|
+
**options: Any,
|
|
33
|
+
) -> FastAPI:
|
|
34
|
+
"""Create an independent application instance."""
|
|
35
|
+
if isinstance(scan_packages, str):
|
|
36
|
+
raise ValueError("scan_packages must be a sequence of package names, not a string")
|
|
37
|
+
if isinstance(exclude, (str, type)) or any(not isinstance(target, type) for target in exclude):
|
|
38
|
+
raise TypeError("exclude must be a sequence of classes")
|
|
39
|
+
excluded_classes = tuple(exclude)
|
|
40
|
+
result = scan_components(
|
|
41
|
+
tuple(scan_packages), exclude_modules=exclude_modules, exclude=excluded_classes,
|
|
42
|
+
)
|
|
43
|
+
logger.info("Scanned %d component definitions from %d modules.", len(result.definitions), len(result.modules))
|
|
44
|
+
configuration = load_configuration(config_dir) if config_dir is not None else {}
|
|
45
|
+
configure_logging(configuration)
|
|
46
|
+
docs_options = resolve_docs_options(configuration)
|
|
47
|
+
date_time_format = resolve_datetime_format(configuration)
|
|
48
|
+
definitions = result.definitions
|
|
49
|
+
if auto_configure:
|
|
50
|
+
definitions = assemble_auto_configurations(
|
|
51
|
+
definitions, result.modules, configuration, exclude=exclude_auto_configurations,
|
|
52
|
+
exclude_classes=excluded_classes,
|
|
53
|
+
)
|
|
54
|
+
logger.info("Auto-configuration assembled %d bean definitions.", len(definitions))
|
|
55
|
+
else:
|
|
56
|
+
logger.info("Auto-configuration is disabled.")
|
|
57
|
+
context = ApplicationContext(definitions, configuration=configuration)
|
|
58
|
+
fastapi_options = {
|
|
59
|
+
**options,
|
|
60
|
+
"docs_url": None,
|
|
61
|
+
"redoc_url": None,
|
|
62
|
+
"openapi_url": docs_options.openapi_path if docs_options.enabled else None,
|
|
63
|
+
}
|
|
64
|
+
app = FastAPI(title=title, version=version, **fastapi_options)
|
|
65
|
+
app.state.context = context
|
|
66
|
+
native_lifespan = app.router.lifespan_context
|
|
67
|
+
|
|
68
|
+
@asynccontextmanager
|
|
69
|
+
async def lifespan(application: FastAPI) -> AsyncIterator[Any]:
|
|
70
|
+
with ContextUtil.use(context):
|
|
71
|
+
logger.info("Starting application context.")
|
|
72
|
+
await context.start()
|
|
73
|
+
try:
|
|
74
|
+
async with native_lifespan(application) as state:
|
|
75
|
+
yield state
|
|
76
|
+
finally:
|
|
77
|
+
logger.info("Closing application context.")
|
|
78
|
+
await context.close()
|
|
79
|
+
|
|
80
|
+
app.router.lifespan_context = lifespan
|
|
81
|
+
app.add_middleware(ApplicationContextMiddleware, context=context)
|
|
82
|
+
app.add_middleware(DateTimeFormatMiddleware, date_time_format=date_time_format)
|
|
83
|
+
register_api_docs(app, docs_options)
|
|
84
|
+
register_routers(
|
|
85
|
+
app, result.modules, exclude=excluded_classes, date_time_format=date_time_format,
|
|
86
|
+
)
|
|
87
|
+
register_exception_handlers(app, result.modules, exclude=excluded_classes)
|
|
88
|
+
logger.info("Created FastAPI application '%s' version %s.", title, version)
|
|
89
|
+
return app
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class PythonBootApplication:
|
|
93
|
+
"""Extensible startup class with an independent FastAPI application per instance."""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
*,
|
|
98
|
+
title: str = "PythonBoot",
|
|
99
|
+
version: str = "0.1.0",
|
|
100
|
+
scan_packages: tuple[str, ...] | None = None,
|
|
101
|
+
config_dir: str | Path | None = None,
|
|
102
|
+
auto_configure: bool = True,
|
|
103
|
+
exclude_auto_configurations: tuple[str, ...] | None = None,
|
|
104
|
+
exclude: tuple[type, ...] | None = None,
|
|
105
|
+
**options: Any,
|
|
106
|
+
) -> None:
|
|
107
|
+
packages = (
|
|
108
|
+
scan_packages if scan_packages is not None
|
|
109
|
+
else getattr(type(self), "__pythonboot_scan_packages__", None)
|
|
110
|
+
)
|
|
111
|
+
module_name = type(self).__module__
|
|
112
|
+
module = sys.modules.get(module_name)
|
|
113
|
+
if module_name == "__main__":
|
|
114
|
+
module_name = getattr(getattr(module, "__spec__", None), "name", module_name)
|
|
115
|
+
if packages is None:
|
|
116
|
+
if type(self) is PythonBootApplication:
|
|
117
|
+
packages = ()
|
|
118
|
+
else:
|
|
119
|
+
package_name = getattr(module, "__package__", None) or module_name.rpartition(".")[0]
|
|
120
|
+
if not package_name:
|
|
121
|
+
raise ValueError("The application class has no importable parent package; set scan_packages explicitly")
|
|
122
|
+
packages = (package_name,)
|
|
123
|
+
self.app: FastAPI = create_app(
|
|
124
|
+
title=title, version=version, scan_packages=packages,
|
|
125
|
+
exclude_modules=(module_name,), config_dir=config_dir,
|
|
126
|
+
auto_configure=auto_configure,
|
|
127
|
+
exclude_auto_configurations=(
|
|
128
|
+
exclude_auto_configurations if exclude_auto_configurations is not None
|
|
129
|
+
else getattr(type(self), "__pythonboot_exclude_auto_configurations__", ())
|
|
130
|
+
),
|
|
131
|
+
exclude=(
|
|
132
|
+
exclude if exclude is not None else getattr(type(self), "__pythonboot_exclude__", ())
|
|
133
|
+
),
|
|
134
|
+
**options,
|
|
135
|
+
)
|
|
136
|
+
self.context: ApplicationContext = self.app.state.context
|
|
137
|
+
self.configure(self.app)
|
|
138
|
+
|
|
139
|
+
def configure(self, app: FastAPI) -> None:
|
|
140
|
+
"""Allow subclasses to register routes, middleware, and exception handlers."""
|
|
141
|
+
|
|
142
|
+
def run(
|
|
143
|
+
self,
|
|
144
|
+
*,
|
|
145
|
+
host: str = "127.0.0.1",
|
|
146
|
+
port: int = 8000,
|
|
147
|
+
log_level: str = "info",
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Start the application; use a Uvicorn module entry for reload or multiple workers."""
|
|
150
|
+
logger.info("Starting Uvicorn server on %s:%d.", host, port)
|
|
151
|
+
uvicorn.run(self.app, host=host, port=port, log_level=log_level)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Composite application startup declarations."""
|
|
2
|
+
|
|
3
|
+
from typing import Callable, TypeVar
|
|
4
|
+
|
|
5
|
+
T = TypeVar("T", bound=type)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def pythonboot_application(
|
|
9
|
+
cls: T | None = None, *, scan_packages: tuple[str, ...] | None = None,
|
|
10
|
+
exclude_auto_configurations: tuple[str, ...] = (),
|
|
11
|
+
exclude: tuple[type, ...] = (),
|
|
12
|
+
) -> T | Callable[[T], T]:
|
|
13
|
+
"""Scan the application package by default, or use explicit package patterns."""
|
|
14
|
+
if isinstance(scan_packages, str):
|
|
15
|
+
raise ValueError("scan_packages must be a sequence of package names, not a string")
|
|
16
|
+
packages = tuple(scan_packages) if scan_packages is not None else None
|
|
17
|
+
if packages is not None and any(not isinstance(package, str) or not package for package in packages):
|
|
18
|
+
raise ValueError("Scan package names must be non-empty strings")
|
|
19
|
+
if isinstance(exclude_auto_configurations, str) or any(
|
|
20
|
+
not isinstance(name, str) or not name for name in exclude_auto_configurations
|
|
21
|
+
):
|
|
22
|
+
raise ValueError("Auto-configuration exclusions must be a sequence of non-empty names")
|
|
23
|
+
exclusions = tuple(exclude_auto_configurations)
|
|
24
|
+
if isinstance(exclude, (str, type)) or any(not isinstance(target, type) for target in exclude):
|
|
25
|
+
raise TypeError("exclude must be a sequence of classes")
|
|
26
|
+
excluded_classes = tuple(exclude)
|
|
27
|
+
|
|
28
|
+
def decorate(cls: T) -> T:
|
|
29
|
+
from pythonboot_web.application import PythonBootApplication
|
|
30
|
+
|
|
31
|
+
if not issubclass(cls, PythonBootApplication):
|
|
32
|
+
raise TypeError("Application classes must inherit PythonBootApplication")
|
|
33
|
+
cls.__pythonboot_scan_packages__ = packages
|
|
34
|
+
cls.__pythonboot_exclude_auto_configurations__ = exclusions
|
|
35
|
+
cls.__pythonboot_exclude__ = excluded_classes
|
|
36
|
+
return cls
|
|
37
|
+
|
|
38
|
+
return decorate(cls) if cls is not None else decorate
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Bind the application container for HTTP, WebSocket, and background callbacks."""
|
|
2
|
+
|
|
3
|
+
from fastapi import Request
|
|
4
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
5
|
+
|
|
6
|
+
from pythonboot.context import ApplicationContext
|
|
7
|
+
from pythonboot.util import ContextUtil
|
|
8
|
+
from pythonboot_web.request_context import RequestContextHolder, ResponseContext
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ApplicationContextMiddleware:
|
|
12
|
+
def __init__(self, app: ASGIApp, context: ApplicationContext) -> None:
|
|
13
|
+
self.app = app
|
|
14
|
+
self.context = context
|
|
15
|
+
|
|
16
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
17
|
+
with ContextUtil.use(self.context):
|
|
18
|
+
if scope["type"] != "http":
|
|
19
|
+
await self.app(scope, receive, send)
|
|
20
|
+
return
|
|
21
|
+
body_parts: list[bytes] = []
|
|
22
|
+
|
|
23
|
+
async def request_receive() -> Message:
|
|
24
|
+
message = await receive()
|
|
25
|
+
if message["type"] == "http.request":
|
|
26
|
+
body_parts.append(message.get("body", b""))
|
|
27
|
+
if not message.get("more_body", False):
|
|
28
|
+
request._body = b"".join(body_parts)
|
|
29
|
+
body_parts.clear()
|
|
30
|
+
return message
|
|
31
|
+
|
|
32
|
+
request = Request(scope, receive=request_receive)
|
|
33
|
+
response = ResponseContext()
|
|
34
|
+
|
|
35
|
+
async def response_send(message: Message) -> None:
|
|
36
|
+
if message["type"] == "http.response.start":
|
|
37
|
+
await send(response.apply(message))
|
|
38
|
+
return
|
|
39
|
+
await send(message)
|
|
40
|
+
|
|
41
|
+
with RequestContextHolder.use(request, response):
|
|
42
|
+
await self.app(scope, request_receive, response_send)
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Date-time request parameter configuration and parsing."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
import json
|
|
7
|
+
from types import UnionType
|
|
8
|
+
from typing import Annotated, Any, Union, get_args, get_origin
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, BeforeValidator
|
|
11
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
12
|
+
|
|
13
|
+
from pythonboot.exceptions import ConfigurationError
|
|
14
|
+
|
|
15
|
+
DEFAULT_DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class DateTimeFormat:
|
|
20
|
+
"""Per-parameter date-time format metadata."""
|
|
21
|
+
|
|
22
|
+
value: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def date_time(format: str) -> DateTimeFormat:
|
|
26
|
+
"""Set a controller parameter or body field date-time format."""
|
|
27
|
+
if not isinstance(format, str) or not format:
|
|
28
|
+
raise ValueError("date_time format must be a non-empty string")
|
|
29
|
+
return DateTimeFormat(format)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DateTimeFormatMiddleware:
|
|
33
|
+
"""Format serialized JSON date-time values using the application setting."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, app: ASGIApp, date_time_format: str) -> None:
|
|
36
|
+
self.app = app
|
|
37
|
+
self.date_time_format = date_time_format
|
|
38
|
+
|
|
39
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
40
|
+
if scope["type"] != "http":
|
|
41
|
+
await self.app(scope, receive, send)
|
|
42
|
+
return
|
|
43
|
+
start: Message | None = None
|
|
44
|
+
body_parts: list[bytes] = []
|
|
45
|
+
json_response = False
|
|
46
|
+
|
|
47
|
+
async def capture(message: Message) -> None:
|
|
48
|
+
nonlocal start, json_response
|
|
49
|
+
if message["type"] == "http.response.start":
|
|
50
|
+
start = message
|
|
51
|
+
json_response = _is_json_response(message)
|
|
52
|
+
if not json_response:
|
|
53
|
+
await send(message)
|
|
54
|
+
return
|
|
55
|
+
if message["type"] != "http.response.body":
|
|
56
|
+
await send(message)
|
|
57
|
+
return
|
|
58
|
+
if not json_response:
|
|
59
|
+
await send(message)
|
|
60
|
+
return
|
|
61
|
+
body_parts.append(message.get("body", b""))
|
|
62
|
+
if message.get("more_body", False):
|
|
63
|
+
return
|
|
64
|
+
if start is None:
|
|
65
|
+
await send(message)
|
|
66
|
+
return
|
|
67
|
+
body = b"".join(body_parts)
|
|
68
|
+
body = _format_json_date_times(body, self.date_time_format)
|
|
69
|
+
await send(_response_start_with_length(start, len(body)))
|
|
70
|
+
await send({"type": "http.response.body", "body": body, "more_body": False})
|
|
71
|
+
|
|
72
|
+
await self.app(scope, receive, capture)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def resolve_datetime_format(configuration: Mapping[str, Any]) -> str:
|
|
76
|
+
"""Read the PythonBoot web date-time format setting."""
|
|
77
|
+
pythonboot = configuration.get("pythonboot", {})
|
|
78
|
+
if not isinstance(pythonboot, Mapping):
|
|
79
|
+
raise ConfigurationError("pythonboot configuration must be a mapping")
|
|
80
|
+
web = pythonboot.get("web", {})
|
|
81
|
+
if not isinstance(web, Mapping):
|
|
82
|
+
raise ConfigurationError("pythonboot.web configuration must be a mapping")
|
|
83
|
+
value = web.get("datetime_format", DEFAULT_DATETIME_FORMAT)
|
|
84
|
+
if not isinstance(value, str) or not value:
|
|
85
|
+
raise ConfigurationError("pythonboot.web.datetime_format must be a non-empty string")
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def date_time_annotation(annotation: Any, date_time_format: str) -> Any:
|
|
90
|
+
"""Add configured date-time parsing to controller parameters and body models."""
|
|
91
|
+
arguments = get_args(annotation)
|
|
92
|
+
if get_origin(annotation) is Annotated:
|
|
93
|
+
target, *metadata = arguments
|
|
94
|
+
else:
|
|
95
|
+
target, metadata = annotation, []
|
|
96
|
+
format_value = _metadata_format(metadata) or date_time_format
|
|
97
|
+
parser = _date_time_parser(format_value)
|
|
98
|
+
|
|
99
|
+
if target is datetime:
|
|
100
|
+
return Annotated[datetime, *metadata, BeforeValidator(parser)]
|
|
101
|
+
if isinstance(target, type) and issubclass(target, BaseModel):
|
|
102
|
+
def normalize_body(value: Any) -> Any:
|
|
103
|
+
return _normalize_model_values(value, target, parser)
|
|
104
|
+
|
|
105
|
+
return Annotated[target, *metadata, BeforeValidator(normalize_body)]
|
|
106
|
+
return annotation
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _date_time_parser(format_value: str) -> Any:
|
|
110
|
+
def parse(value: Any) -> Any:
|
|
111
|
+
if not isinstance(value, str):
|
|
112
|
+
return value
|
|
113
|
+
try:
|
|
114
|
+
return datetime.strptime(value, format_value)
|
|
115
|
+
except ValueError as error:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"Date-time value must match the configured format: {format_value}"
|
|
118
|
+
) from error
|
|
119
|
+
return parse
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _normalize_model_values(value: Any, model_type: type[BaseModel], parser: Any) -> Any:
|
|
123
|
+
"""Normalize only declared date-time fields before Pydantic validates a body model."""
|
|
124
|
+
if not isinstance(value, Mapping):
|
|
125
|
+
return value
|
|
126
|
+
return {
|
|
127
|
+
name: _normalize_value(item, _field_annotation(model_type.model_fields[name]), parser)
|
|
128
|
+
if name in model_type.model_fields else item
|
|
129
|
+
for name, item in value.items()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _normalize_value(value: Any, annotation: Any, parser: Any) -> Any:
|
|
134
|
+
"""Recursively normalize date-time fields in nested body values."""
|
|
135
|
+
if annotation is datetime:
|
|
136
|
+
parsed = parser(value)
|
|
137
|
+
return parsed.isoformat() if isinstance(parsed, datetime) else parsed
|
|
138
|
+
origin = get_origin(annotation)
|
|
139
|
+
arguments = get_args(annotation)
|
|
140
|
+
if origin is Annotated:
|
|
141
|
+
return _normalize_value(
|
|
142
|
+
value, arguments[0], _date_time_parser(_metadata_format(arguments[1:]))
|
|
143
|
+
if _metadata_format(arguments[1:]) else parser,
|
|
144
|
+
)
|
|
145
|
+
if origin in (Union, UnionType):
|
|
146
|
+
date_time_types = [item for item in arguments if item is datetime]
|
|
147
|
+
if date_time_types:
|
|
148
|
+
return _normalize_value(value, date_time_types[0], parser)
|
|
149
|
+
for item in arguments:
|
|
150
|
+
if item is not type(None):
|
|
151
|
+
return _normalize_value(value, item, parser)
|
|
152
|
+
return value
|
|
153
|
+
if origin in (list, set, tuple) and isinstance(value, list) and arguments:
|
|
154
|
+
return [_normalize_value(item, arguments[0], parser) for item in value]
|
|
155
|
+
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
|
156
|
+
return _normalize_model_values(value, annotation, parser)
|
|
157
|
+
return value
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _metadata_format(metadata: list[Any] | tuple[Any, ...]) -> str | None:
|
|
161
|
+
for item in metadata:
|
|
162
|
+
if isinstance(item, DateTimeFormat):
|
|
163
|
+
return item.value
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _field_annotation(field: Any) -> Any:
|
|
168
|
+
return Annotated[field.annotation, *field.metadata] if field.metadata else field.annotation
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _is_json_response(message: Message) -> bool:
|
|
172
|
+
return any(
|
|
173
|
+
name.lower() == b"content-type" and b"application/json" in value.lower()
|
|
174
|
+
for name, value in message.get("headers", [])
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _format_json_date_times(body: bytes, date_time_format: str) -> bytes:
|
|
179
|
+
try:
|
|
180
|
+
value = json.loads(body)
|
|
181
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
182
|
+
return body
|
|
183
|
+
return json.dumps(
|
|
184
|
+
_format_value(value, date_time_format), ensure_ascii=False, separators=(",", ":"),
|
|
185
|
+
).encode()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _format_value(value: Any, date_time_format: str) -> Any:
|
|
189
|
+
if isinstance(value, list):
|
|
190
|
+
return [_format_value(item, date_time_format) for item in value]
|
|
191
|
+
if isinstance(value, dict):
|
|
192
|
+
return {name: _format_value(item, date_time_format) for name, item in value.items()}
|
|
193
|
+
if not isinstance(value, str) or "T" not in value:
|
|
194
|
+
return value
|
|
195
|
+
try:
|
|
196
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
197
|
+
except ValueError:
|
|
198
|
+
return value
|
|
199
|
+
return parsed.strftime(date_time_format)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _response_start_with_length(message: Message, length: int) -> Message:
|
|
203
|
+
headers = [
|
|
204
|
+
(name, value) for name, value in message.get("headers", [])
|
|
205
|
+
if name.lower() != b"content-length"
|
|
206
|
+
]
|
|
207
|
+
headers.append((b"content-length", str(length).encode()))
|
|
208
|
+
return {**message, "headers": headers}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Class-based controllers and method route declarations."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Callable, TypeVar
|
|
6
|
+
|
|
7
|
+
from pythonboot.beans import Scope, component
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T", bound=type)
|
|
10
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class RouteDefinition:
|
|
15
|
+
path: str
|
|
16
|
+
methods: tuple[str, ...]
|
|
17
|
+
options: dict[str, Any]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def controller(
|
|
21
|
+
cls: T | None = None,
|
|
22
|
+
*,
|
|
23
|
+
prefix: str = "",
|
|
24
|
+
tags: list[str] | None = None,
|
|
25
|
+
name: str | None = None,
|
|
26
|
+
init_method: str | None = None,
|
|
27
|
+
destroy_method: str | None = None,
|
|
28
|
+
lazy: bool = False,
|
|
29
|
+
scope: Scope = "singleton",
|
|
30
|
+
primary: bool = False,
|
|
31
|
+
qualifiers: tuple[str, ...] = (),
|
|
32
|
+
depends_on: tuple[str, ...] = (),
|
|
33
|
+
**router_options: Any,
|
|
34
|
+
) -> T | Callable[[T], T]:
|
|
35
|
+
"""Declare a controller, singleton by default, with container-injected dependencies."""
|
|
36
|
+
def decorate(target: T) -> T:
|
|
37
|
+
if not inspect.isclass(target):
|
|
38
|
+
raise TypeError("@controller can only decorate classes")
|
|
39
|
+
component(
|
|
40
|
+
target, name=name, init_method=init_method, destroy_method=destroy_method,
|
|
41
|
+
lazy=lazy, scope=scope, primary=primary, qualifiers=qualifiers, depends_on=depends_on,
|
|
42
|
+
)
|
|
43
|
+
target.__pythonboot_controller__ = {
|
|
44
|
+
**router_options, "prefix": prefix, "tags": list(tags or []),
|
|
45
|
+
}
|
|
46
|
+
return target
|
|
47
|
+
|
|
48
|
+
return decorate(cls) if cls is not None else decorate
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def controller_advise(
|
|
52
|
+
cls: T | None = None,
|
|
53
|
+
*,
|
|
54
|
+
name: str | None = None,
|
|
55
|
+
init_method: str | None = None,
|
|
56
|
+
destroy_method: str | None = None,
|
|
57
|
+
lazy: bool = False,
|
|
58
|
+
scope: Scope = "singleton",
|
|
59
|
+
primary: bool = False,
|
|
60
|
+
qualifiers: tuple[str, ...] = (),
|
|
61
|
+
depends_on: tuple[str, ...] = (),
|
|
62
|
+
) -> T | Callable[[T], T]:
|
|
63
|
+
"""Declare a managed global exception handler component."""
|
|
64
|
+
def decorate(target: T) -> T:
|
|
65
|
+
if not inspect.isclass(target):
|
|
66
|
+
raise TypeError("@controller_advise can only decorate classes")
|
|
67
|
+
component(
|
|
68
|
+
target, name=name, init_method=init_method, destroy_method=destroy_method,
|
|
69
|
+
lazy=lazy, scope=scope, primary=primary, qualifiers=qualifiers, depends_on=depends_on,
|
|
70
|
+
)
|
|
71
|
+
target.__pythonboot_controller_advise__ = True
|
|
72
|
+
return target
|
|
73
|
+
|
|
74
|
+
return decorate(cls) if cls is not None else decorate
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def exception_handler(
|
|
78
|
+
exception_type: int | type[Exception],
|
|
79
|
+
) -> Callable[[F], F]:
|
|
80
|
+
"""Map a controller advice instance method to an exception or status code."""
|
|
81
|
+
if not isinstance(exception_type, int) and (
|
|
82
|
+
not inspect.isclass(exception_type) or not issubclass(exception_type, Exception)
|
|
83
|
+
):
|
|
84
|
+
raise TypeError("exception_handler target must be an exception type or status code")
|
|
85
|
+
|
|
86
|
+
def decorate(method: F) -> F:
|
|
87
|
+
if not inspect.isfunction(method):
|
|
88
|
+
raise TypeError("@exception_handler can only decorate instance methods")
|
|
89
|
+
handlers = list(getattr(method, "__pythonboot_exception_handlers__", ()))
|
|
90
|
+
handlers.append(exception_type)
|
|
91
|
+
method.__pythonboot_exception_handlers__ = tuple(handlers)
|
|
92
|
+
return method
|
|
93
|
+
|
|
94
|
+
return decorate
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def route(
|
|
98
|
+
path: str, *, methods: tuple[str, ...] = ("GET",), **options: Any
|
|
99
|
+
) -> Callable[[F], F]:
|
|
100
|
+
"""Declare a controller instance route using FastAPI add_api_route options."""
|
|
101
|
+
def decorate(method: F) -> F:
|
|
102
|
+
if not inspect.isfunction(method):
|
|
103
|
+
raise TypeError("Route decorators can only decorate controller instance methods")
|
|
104
|
+
definitions = list(getattr(method, "__pythonboot_routes__", ()))
|
|
105
|
+
definitions.append(RouteDefinition(path, tuple(methods), dict(options)))
|
|
106
|
+
method.__pythonboot_routes__ = tuple(definitions)
|
|
107
|
+
return method
|
|
108
|
+
|
|
109
|
+
return decorate
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get(path: str, **options: Any) -> Callable[[F], F]:
|
|
113
|
+
return route(path, methods=("GET",), **options)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def post(path: str, **options: Any) -> Callable[[F], F]:
|
|
117
|
+
return route(path, methods=("POST",), **options)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def put(path: str, **options: Any) -> Callable[[F], F]:
|
|
121
|
+
return route(path, methods=("PUT",), **options)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def patch(path: str, **options: Any) -> Callable[[F], F]:
|
|
125
|
+
return route(path, methods=("PATCH",), **options)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def delete(path: str, **options: Any) -> Callable[[F], F]:
|
|
129
|
+
return route(path, methods=("DELETE",), **options)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""OpenAPI documentation UI registration."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from html import escape
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI, Request
|
|
9
|
+
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
|
|
10
|
+
from fastapi.responses import HTMLResponse
|
|
11
|
+
|
|
12
|
+
from pythonboot.exceptions import ConfigurationError
|
|
13
|
+
|
|
14
|
+
DocsProvider = Literal["scalar", "swagger", "redoc"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class DocsOptions:
|
|
19
|
+
enabled: bool
|
|
20
|
+
provider: DocsProvider
|
|
21
|
+
path: str
|
|
22
|
+
openapi_path: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def resolve_docs_options(configuration: Mapping[str, Any]) -> DocsOptions:
|
|
26
|
+
"""Read documentation UI settings from the application configuration."""
|
|
27
|
+
pythonboot = configuration.get("pythonboot", {})
|
|
28
|
+
if not isinstance(pythonboot, Mapping):
|
|
29
|
+
raise ConfigurationError("pythonboot configuration must be a mapping")
|
|
30
|
+
values = pythonboot.get("docs", {})
|
|
31
|
+
if not isinstance(values, Mapping):
|
|
32
|
+
raise ConfigurationError("pythonboot.docs configuration must be a mapping")
|
|
33
|
+
enabled = values.get("enabled", True)
|
|
34
|
+
provider = values.get("provider", "scalar")
|
|
35
|
+
path = values.get("path", "/docs")
|
|
36
|
+
openapi_path = values.get("openapi_path", "/openapi.json")
|
|
37
|
+
if not isinstance(enabled, bool):
|
|
38
|
+
raise ConfigurationError("pythonboot.docs.enabled must be a boolean")
|
|
39
|
+
if provider not in ("scalar", "swagger", "redoc"):
|
|
40
|
+
raise ConfigurationError("pythonboot.docs.provider must be scalar, swagger, or redoc")
|
|
41
|
+
for name, value in (("pythonboot.docs.path", path), ("pythonboot.docs.openapi_path", openapi_path)):
|
|
42
|
+
if not isinstance(value, str) or not value.startswith("/") or value == "/":
|
|
43
|
+
raise ConfigurationError(f"{name} must be an absolute non-root path")
|
|
44
|
+
if path == openapi_path:
|
|
45
|
+
raise ConfigurationError("pythonboot.docs.path and pythonboot.docs.openapi_path must be different")
|
|
46
|
+
return DocsOptions(enabled, provider, path, openapi_path)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def register_api_docs(app: FastAPI, options: DocsOptions) -> None:
|
|
50
|
+
"""Register the selected documentation UI for an application."""
|
|
51
|
+
if not options.enabled:
|
|
52
|
+
return
|
|
53
|
+
async def endpoint(request: Request) -> HTMLResponse:
|
|
54
|
+
openapi_url = f"{request.scope.get('root_path', '')}{app.openapi_url}"
|
|
55
|
+
title = f"{app.title} API Reference"
|
|
56
|
+
if options.provider == "swagger":
|
|
57
|
+
return get_swagger_ui_html(openapi_url=openapi_url, title=title)
|
|
58
|
+
if options.provider == "redoc":
|
|
59
|
+
return get_redoc_html(openapi_url=openapi_url, title=title)
|
|
60
|
+
return HTMLResponse(
|
|
61
|
+
"<!doctype html>"
|
|
62
|
+
"<html><head>"
|
|
63
|
+
f"<title>{escape(title)}</title>"
|
|
64
|
+
"</head><body>"
|
|
65
|
+
f'<script id="api-reference" data-url="{escape(openapi_url, quote=True)}"></script>'
|
|
66
|
+
'<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>'
|
|
67
|
+
"</body></html>"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
app.add_api_route(options.path, endpoint, methods=["GET"], include_in_schema=False)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Container integration preserving native FastAPI routing and dependencies."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from functools import wraps
|
|
5
|
+
from types import ModuleType
|
|
6
|
+
from typing import Any, Callable, TypeVar, get_type_hints
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, FastAPI, Request
|
|
9
|
+
|
|
10
|
+
from pythonboot.exceptions import BeanDefinitionError
|
|
11
|
+
from pythonboot_web.datetime import date_time_annotation
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def inject(
|
|
17
|
+
bean_type: type[T], *, name: str | None = None, qualifier: str | None = None,
|
|
18
|
+
) -> Callable[[Request], T]:
|
|
19
|
+
"""Use Depends(inject(Service)) to resolve a bean according to its scope."""
|
|
20
|
+
def dependency(request: Request) -> T:
|
|
21
|
+
return request.app.state.context.get_bean(bean_type, name=name, qualifier=qualifier)
|
|
22
|
+
|
|
23
|
+
return dependency
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _controller_endpoint(
|
|
27
|
+
cls: type, method: Callable[..., Any], *, date_time_format: str,
|
|
28
|
+
) -> Callable[..., Any]:
|
|
29
|
+
"""Hide self and resolve the current application's controller for each request."""
|
|
30
|
+
signature = inspect.signature(method)
|
|
31
|
+
parameters = list(signature.parameters.values())
|
|
32
|
+
if not parameters or parameters[0].name != "self":
|
|
33
|
+
raise BeanDefinitionError(f"Controller route {cls.__name__}.{method.__name__} must be an instance method")
|
|
34
|
+
parameters = parameters[1:]
|
|
35
|
+
if any(parameter.kind in (
|
|
36
|
+
parameter.POSITIONAL_ONLY, parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD,
|
|
37
|
+
) for parameter in parameters):
|
|
38
|
+
raise BeanDefinitionError("Controller routes do not support positional-only parameters, *args, or **kwargs")
|
|
39
|
+
try:
|
|
40
|
+
hints = get_type_hints(method, include_extras=True)
|
|
41
|
+
except (NameError, TypeError) as error:
|
|
42
|
+
raise BeanDefinitionError(f"Cannot resolve type hints for controller route {cls.__name__}.{method.__name__}") from error
|
|
43
|
+
parameters = [
|
|
44
|
+
parameter.replace(annotation=date_time_annotation(
|
|
45
|
+
hints.get(parameter.name, parameter.annotation), date_time_format,
|
|
46
|
+
))
|
|
47
|
+
for parameter in parameters
|
|
48
|
+
]
|
|
49
|
+
request_name = "_pythonboot_request"
|
|
50
|
+
while request_name in signature.parameters:
|
|
51
|
+
request_name += "_"
|
|
52
|
+
parameters.append(inspect.Parameter(
|
|
53
|
+
request_name, kind=inspect.Parameter.KEYWORD_ONLY, annotation=Request,
|
|
54
|
+
))
|
|
55
|
+
component_options = vars(cls)["__pythonboot_component__"]
|
|
56
|
+
bean_name = component_options.name or cls.__name__[:1].lower() + cls.__name__[1:]
|
|
57
|
+
|
|
58
|
+
def bound_method(kwargs: dict[str, Any]) -> Callable[..., Any]:
|
|
59
|
+
request: Request = kwargs.pop(request_name)
|
|
60
|
+
instance = request.app.state.context.get_bean(cls, name=bean_name)
|
|
61
|
+
return getattr(instance, method.__name__)
|
|
62
|
+
|
|
63
|
+
if inspect.iscoroutinefunction(method):
|
|
64
|
+
@wraps(method)
|
|
65
|
+
async def endpoint(*args: Any, **kwargs: Any) -> Any:
|
|
66
|
+
target = bound_method(kwargs)
|
|
67
|
+
return await target(*args, **kwargs)
|
|
68
|
+
else:
|
|
69
|
+
@wraps(method)
|
|
70
|
+
def endpoint(*args: Any, **kwargs: Any) -> Any:
|
|
71
|
+
target = bound_method(kwargs)
|
|
72
|
+
return target(*args, **kwargs)
|
|
73
|
+
|
|
74
|
+
endpoint.__name__ = f"{cls.__name__}_{method.__name__}"
|
|
75
|
+
endpoint.__signature__ = signature.replace(
|
|
76
|
+
parameters=parameters,
|
|
77
|
+
return_annotation=hints.get("return", signature.return_annotation),
|
|
78
|
+
)
|
|
79
|
+
return endpoint
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def register_routers(
|
|
83
|
+
app: FastAPI, modules: tuple[ModuleType, ...], *, exclude: tuple[type, ...] = (),
|
|
84
|
+
date_time_format: str,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Automatically register class-based controllers and native APIRouter objects."""
|
|
87
|
+
seen: set[int] = set()
|
|
88
|
+
for module in modules:
|
|
89
|
+
for value in vars(module).values():
|
|
90
|
+
if isinstance(value, APIRouter) and id(value) not in seen:
|
|
91
|
+
seen.add(id(value))
|
|
92
|
+
app.include_router(value)
|
|
93
|
+
elif (
|
|
94
|
+
inspect.isclass(value)
|
|
95
|
+
and value not in exclude
|
|
96
|
+
and value.__module__ == module.__name__
|
|
97
|
+
and "__pythonboot_controller__" in vars(value)
|
|
98
|
+
and id(value) not in seen
|
|
99
|
+
):
|
|
100
|
+
seen.add(id(value))
|
|
101
|
+
router = APIRouter(**vars(value)["__pythonboot_controller__"])
|
|
102
|
+
for _, method in inspect.getmembers(value, inspect.isfunction):
|
|
103
|
+
definitions = getattr(method, "__pythonboot_routes__", ())
|
|
104
|
+
if not definitions:
|
|
105
|
+
continue
|
|
106
|
+
endpoint = _controller_endpoint(value, method, date_time_format=date_time_format)
|
|
107
|
+
for definition in definitions:
|
|
108
|
+
router.add_api_route(
|
|
109
|
+
definition.path, endpoint,
|
|
110
|
+
methods=list(definition.methods), **definition.options,
|
|
111
|
+
)
|
|
112
|
+
app.include_router(router)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def register_exception_handlers(
|
|
116
|
+
app: FastAPI, modules: tuple[ModuleType, ...], *, exclude: tuple[type, ...] = (),
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Register managed controller advice methods as FastAPI exception handlers."""
|
|
119
|
+
excluded_classes = set(exclude)
|
|
120
|
+
for module in modules:
|
|
121
|
+
for advice_type in vars(module).values():
|
|
122
|
+
if (
|
|
123
|
+
not inspect.isclass(advice_type)
|
|
124
|
+
or advice_type in excluded_classes
|
|
125
|
+
or advice_type.__module__ != module.__name__
|
|
126
|
+
or not vars(advice_type).get("__pythonboot_controller_advise__")
|
|
127
|
+
):
|
|
128
|
+
continue
|
|
129
|
+
component_options = vars(advice_type)["__pythonboot_component__"]
|
|
130
|
+
bean_name = component_options.name or advice_type.__name__[:1].lower() + advice_type.__name__[1:]
|
|
131
|
+
for method_name, method in vars(advice_type).items():
|
|
132
|
+
targets = getattr(method, "__pythonboot_exception_handlers__", ())
|
|
133
|
+
if not targets:
|
|
134
|
+
continue
|
|
135
|
+
signature = inspect.signature(method)
|
|
136
|
+
parameters = list(signature.parameters.values())
|
|
137
|
+
if not parameters or parameters[0].name != "self":
|
|
138
|
+
raise BeanDefinitionError(
|
|
139
|
+
f"Exception handler {advice_type.__name__}.{method_name} must be an instance method"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
async def handler(
|
|
143
|
+
request: Request, error: Exception, *,
|
|
144
|
+
_method_name: str = method_name, _advice_type: type = advice_type,
|
|
145
|
+
_bean_name: str = bean_name,
|
|
146
|
+
) -> Any:
|
|
147
|
+
instance = request.app.state.context.get_bean(_advice_type, name=_bean_name)
|
|
148
|
+
result = getattr(instance, _method_name)(request, error)
|
|
149
|
+
if inspect.isawaitable(result):
|
|
150
|
+
return await result
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
for target in targets:
|
|
154
|
+
app.add_exception_handler(target, handler)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Access the HTTP request bound to the current execution context."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from http.cookies import SimpleCookie
|
|
8
|
+
from threading import RLock
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from fastapi import Request
|
|
12
|
+
from starlette.types import Message
|
|
13
|
+
|
|
14
|
+
_current_request: ContextVar[Request | None] = ContextVar(
|
|
15
|
+
"pythonboot_current_request", default=None,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class _RequestData:
|
|
21
|
+
values: dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
lock: Any = field(default_factory=RLock)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_current_data: ContextVar[_RequestData | None] = ContextVar(
|
|
26
|
+
"pythonboot_request_data", default=None,
|
|
27
|
+
)
|
|
28
|
+
_current_response: ContextVar["ResponseContext | None"] = ContextVar(
|
|
29
|
+
"pythonboot_current_response", default=None,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ResponseContext:
|
|
34
|
+
"""Mutable response settings applied before the ASGI response starts."""
|
|
35
|
+
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self._status_code: int | None = None
|
|
38
|
+
self._headers: dict[bytes, bytes] = {}
|
|
39
|
+
self._cookies: list[bytes] = []
|
|
40
|
+
self._committed = False
|
|
41
|
+
self._lock = RLock()
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def status_code(self) -> int | None:
|
|
45
|
+
return self._status_code
|
|
46
|
+
|
|
47
|
+
@status_code.setter
|
|
48
|
+
def status_code(self, value: int) -> None:
|
|
49
|
+
if not isinstance(value, int) or not 100 <= value <= 599:
|
|
50
|
+
raise ValueError("status_code must be an integer between 100 and 599")
|
|
51
|
+
with self._lock:
|
|
52
|
+
self._ensure_mutable()
|
|
53
|
+
self._status_code = value
|
|
54
|
+
|
|
55
|
+
def set_header(self, name: str, value: str) -> None:
|
|
56
|
+
"""Set a response header."""
|
|
57
|
+
if not isinstance(name, str) or not isinstance(value, str):
|
|
58
|
+
raise TypeError("response header name and value must be strings")
|
|
59
|
+
with self._lock:
|
|
60
|
+
self._ensure_mutable()
|
|
61
|
+
self._headers[name.encode("latin-1").lower()] = value.encode("latin-1")
|
|
62
|
+
|
|
63
|
+
def set_cookie(self, key: str, value: str, **options: Any) -> None:
|
|
64
|
+
"""Add a Set-Cookie header."""
|
|
65
|
+
cookie = SimpleCookie()
|
|
66
|
+
cookie[key] = value
|
|
67
|
+
for name, option_value in options.items():
|
|
68
|
+
cookie[key][name.replace("_", "-")] = option_value
|
|
69
|
+
with self._lock:
|
|
70
|
+
self._ensure_mutable()
|
|
71
|
+
self._cookies.append(cookie.output(header="").strip().encode("latin-1"))
|
|
72
|
+
|
|
73
|
+
def redirect(self, url: str, status_code: int = 307) -> None:
|
|
74
|
+
"""Set a redirect location and status code."""
|
|
75
|
+
self.status_code = status_code
|
|
76
|
+
self.set_header("Location", url)
|
|
77
|
+
|
|
78
|
+
def apply(self, message: Message) -> Message:
|
|
79
|
+
"""Apply pending settings to an ASGI response-start message."""
|
|
80
|
+
with self._lock:
|
|
81
|
+
self._committed = True
|
|
82
|
+
headers = [
|
|
83
|
+
(name, value) for name, value in message.get("headers", [])
|
|
84
|
+
if name.lower() not in self._headers
|
|
85
|
+
]
|
|
86
|
+
headers.extend(self._headers.items())
|
|
87
|
+
headers.extend((b"set-cookie", cookie) for cookie in self._cookies)
|
|
88
|
+
return {
|
|
89
|
+
**message,
|
|
90
|
+
"status": self._status_code if self._status_code is not None else message["status"],
|
|
91
|
+
"headers": headers,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
def _ensure_mutable(self) -> None:
|
|
95
|
+
if self._committed:
|
|
96
|
+
raise RuntimeError("The HTTP response has already started")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class RequestContextHolder:
|
|
100
|
+
"""Expose the current request to controllers, services, and advice."""
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def get_request() -> Request:
|
|
104
|
+
request = _current_request.get()
|
|
105
|
+
if request is None:
|
|
106
|
+
raise RuntimeError("No HTTP request is bound to the current execution context")
|
|
107
|
+
return request
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def get_response() -> ResponseContext:
|
|
111
|
+
"""Return the mutable response context for the current request."""
|
|
112
|
+
response = _current_response.get()
|
|
113
|
+
if response is None:
|
|
114
|
+
raise RuntimeError("No HTTP request is bound to the current execution context")
|
|
115
|
+
return response
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def putData(key: str, value: Any) -> None:
|
|
119
|
+
"""Store a custom value in the current request's thread-safe map."""
|
|
120
|
+
data = RequestContextHolder._data()
|
|
121
|
+
with data.lock:
|
|
122
|
+
data.values[key] = value
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def getData(key: str, default: Any = None) -> Any:
|
|
126
|
+
"""Read a custom value from the current request's thread-safe map."""
|
|
127
|
+
data = RequestContextHolder._data()
|
|
128
|
+
with data.lock:
|
|
129
|
+
return data.values.get(key, default)
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def _data() -> _RequestData:
|
|
133
|
+
data = _current_data.get()
|
|
134
|
+
if data is None:
|
|
135
|
+
raise RuntimeError("No HTTP request is bound to the current execution context")
|
|
136
|
+
return data
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
@contextmanager
|
|
140
|
+
def use(request: Request, response: ResponseContext) -> Iterator[Request]:
|
|
141
|
+
"""Bind a request temporarily and restore the previous binding on exit."""
|
|
142
|
+
token = _current_request.set(request)
|
|
143
|
+
data_token = _current_data.set(_RequestData())
|
|
144
|
+
response_token = _current_response.set(response)
|
|
145
|
+
try:
|
|
146
|
+
yield request
|
|
147
|
+
finally:
|
|
148
|
+
_current_response.reset(response_token)
|
|
149
|
+
_current_data.reset(data_token)
|
|
150
|
+
_current_request.reset(token)
|