bedrock-fastapi 0.1.0__py3-none-any.whl
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,14 @@
|
|
|
1
|
+
"""FastAPI integration for Bedrock.
|
|
2
|
+
|
|
3
|
+
The host application creates and owns its FastAPI app. Add only the pieces it needs.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .exceptions import bedrock_exception_handler
|
|
7
|
+
from .middleware import AsyncDbSessionMiddleware, DbSessionMiddleware, RequestScopeMiddleware
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AsyncDbSessionMiddleware",
|
|
11
|
+
"DbSessionMiddleware",
|
|
12
|
+
"RequestScopeMiddleware",
|
|
13
|
+
"bedrock_exception_handler",
|
|
14
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Translate Bedrock exceptions into HTTP responses."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
4
|
+
|
|
5
|
+
from bedrock.exc import BedrockExc
|
|
6
|
+
from starlette.requests import Request
|
|
7
|
+
from starlette.responses import JSONResponse, Response
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def bedrock_exception_handler(
|
|
11
|
+
status_codes: Mapping[type[BedrockExc], int],
|
|
12
|
+
) -> Callable[[Request, Exception], Awaitable[Response]]:
|
|
13
|
+
"""Build an exception handler that answers mapped Bedrock exceptions with ``{"detail": exc.detail}``.
|
|
14
|
+
|
|
15
|
+
The lookup walks the raised exception's MRO, so mapping a module's base exception covers its subclasses
|
|
16
|
+
and the most specific mapping wins. An unmapped exception is re-raised: the server-error middleware logs
|
|
17
|
+
it and returns a generic 500 without exposing ``detail``, because ``BedrockExc`` is also the base of
|
|
18
|
+
runtime errors such as ``ImproperlyConfigured``. WebSocket connections are never answered: an exception there
|
|
19
|
+
is re-raised, because an HTTP response cannot be sent once the socket is accepted.
|
|
20
|
+
|
|
21
|
+
Example::
|
|
22
|
+
|
|
23
|
+
app.add_exception_handler(
|
|
24
|
+
BedrockExc,
|
|
25
|
+
bedrock_exception_handler(
|
|
26
|
+
{ItemNotFound: 404, ItemConflict: 409}
|
|
27
|
+
),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
status_codes: HTTP status code per exception class.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
An async exception handler for ``app.add_exception_handler``.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
async def handle(request: Request, exc: Exception) -> Response:
|
|
38
|
+
if request.scope["type"] == "http" and isinstance(exc, BedrockExc):
|
|
39
|
+
matches = [cls for cls in status_codes if isinstance(exc, cls)]
|
|
40
|
+
if matches:
|
|
41
|
+
most_specific = min(matches, key=type(exc).__mro__.index)
|
|
42
|
+
return JSONResponse({"detail": exc.detail}, status_code=status_codes[most_specific])
|
|
43
|
+
raise exc
|
|
44
|
+
|
|
45
|
+
return handle
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Pure ASGI middleware that give each request its own Bedrock DI scope and database sessions.
|
|
2
|
+
|
|
3
|
+
The middleware enter and leave request state in the request's own context. FastAPI runs sync (``def``)
|
|
4
|
+
dependencies and endpoints in worker threads with copied contexts, so state entered there cannot be reset
|
|
5
|
+
or cleaned up reliably at the end of the request.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from anyio import CancelScope
|
|
9
|
+
from bedrock.database import db
|
|
10
|
+
from bedrock.di import container
|
|
11
|
+
from starlette.concurrency import run_in_threadpool
|
|
12
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
13
|
+
|
|
14
|
+
_REQUEST_TYPES = frozenset({"http", "websocket"})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RequestScopeMiddleware:
|
|
18
|
+
"""Run each HTTP or WebSocket connection inside ``container.scope("request")``.
|
|
19
|
+
|
|
20
|
+
``SCOPED`` services resolved while the connection is handled share one instance, and their ``close()``
|
|
21
|
+
runs when it ends. Lifespan events pass through.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
25
|
+
"""Wrap the downstream application.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
app: The downstream ASGI application.
|
|
29
|
+
"""
|
|
30
|
+
self.app = app
|
|
31
|
+
|
|
32
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
33
|
+
"""Handle one ASGI connection.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
scope: The ASGI connection scope.
|
|
37
|
+
receive: The ASGI receive channel.
|
|
38
|
+
send: The ASGI send channel.
|
|
39
|
+
"""
|
|
40
|
+
if scope["type"] not in _REQUEST_TYPES:
|
|
41
|
+
await self.app(scope, receive, send)
|
|
42
|
+
return
|
|
43
|
+
with container.scope("request"):
|
|
44
|
+
await self.app(scope, receive, send)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DbSessionMiddleware:
|
|
48
|
+
"""Run each HTTP request inside ``db.session_scope()`` without committing on its behalf.
|
|
49
|
+
|
|
50
|
+
Services work in ``db.session`` and commit, as they do outside HTTP. Whatever is still uncommitted when the
|
|
51
|
+
request ends, including work done by background tasks, is rolled back, so a write nobody commits is lost
|
|
52
|
+
while the response may still be a success.
|
|
53
|
+
|
|
54
|
+
Because a session is already bound, ``db.session_scope()`` raises ``RuntimeError`` inside a request; use
|
|
55
|
+
``db.independent_session()`` for side transactions. WebSocket and lifespan events pass through.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
59
|
+
"""Wrap the downstream application.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
app: The downstream ASGI application.
|
|
63
|
+
"""
|
|
64
|
+
self.app = app
|
|
65
|
+
|
|
66
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
67
|
+
"""Handle one ASGI connection.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
scope: The ASGI connection scope.
|
|
71
|
+
receive: The ASGI receive channel.
|
|
72
|
+
send: The ASGI send channel.
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
DatabaseNotConfiguredError: When ``db.init()`` has not been called.
|
|
76
|
+
"""
|
|
77
|
+
if scope["type"] != "http":
|
|
78
|
+
await self.app(scope, receive, send)
|
|
79
|
+
return
|
|
80
|
+
with db.session_scope() as session:
|
|
81
|
+
try:
|
|
82
|
+
await self.app(scope, receive, send)
|
|
83
|
+
finally:
|
|
84
|
+
# Discard uncommitted work so the scope's own commit has nothing left. Shielded so a cancelled
|
|
85
|
+
# request still returns its connection, and in a worker thread because the rollback is blocking I/O.
|
|
86
|
+
with CancelScope(shield=True):
|
|
87
|
+
await run_in_threadpool(session.rollback)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class AsyncDbSessionMiddleware:
|
|
91
|
+
"""Run each HTTP request inside ``db.asession_scope()`` without committing on its behalf.
|
|
92
|
+
|
|
93
|
+
Same contract as :class:`DbSessionMiddleware`: services commit with ``await db.asession.commit()``,
|
|
94
|
+
uncommitted work is rolled back when the request ends, and ``db.asession_scope()`` raises ``RuntimeError``
|
|
95
|
+
inside a request (use ``db.aindependent_session()``). The sync and async sessions use separate context
|
|
96
|
+
variables, so both middleware can be installed together; each has its own transaction.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
100
|
+
"""Wrap the downstream application.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
app: The downstream ASGI application.
|
|
104
|
+
"""
|
|
105
|
+
self.app = app
|
|
106
|
+
|
|
107
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
108
|
+
"""Handle one ASGI connection.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
scope: The ASGI connection scope.
|
|
112
|
+
receive: The ASGI receive channel.
|
|
113
|
+
send: The ASGI send channel.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
DatabaseNotConfiguredError: When ``db.init()`` has not been called.
|
|
117
|
+
"""
|
|
118
|
+
if scope["type"] != "http":
|
|
119
|
+
await self.app(scope, receive, send)
|
|
120
|
+
return
|
|
121
|
+
async with db.asession_scope() as session:
|
|
122
|
+
try:
|
|
123
|
+
await self.app(scope, receive, send)
|
|
124
|
+
finally:
|
|
125
|
+
with CancelScope(shield=True):
|
|
126
|
+
await session.rollback()
|
bedrock_fastapi/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bedrock-fastapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: FastAPI integration for Bedrock: request DI scopes, request-bound database sessions, and BedrockExc responses.
|
|
5
|
+
Keywords: bedrock,fastapi,starlette,asgi,middleware
|
|
6
|
+
Author: maacck
|
|
7
|
+
Author-email: maacck <c.mai@madainchina.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Framework :: FastAPI
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Dist: anyio>=3.4.0
|
|
18
|
+
Requires-Dist: bedrock-core>=0.3.1
|
|
19
|
+
Requires-Dist: starlette>=0.40.0
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Project-URL: Bug Tracker, https://github.com/maacck/bedrock-py/issues
|
|
22
|
+
Project-URL: Documentation, https://bedrock-py.com
|
|
23
|
+
Project-URL: Homepage, https://bedrock-py.com
|
|
24
|
+
Project-URL: Repository, https://github.com/maacck/bedrock-py
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# bedrock-fastapi
|
|
28
|
+
|
|
29
|
+
FastAPI integration for [Bedrock](https://bedrock-py.com). Your application creates and owns the FastAPI app;
|
|
30
|
+
this package supplies pieces you add as needed:
|
|
31
|
+
|
|
32
|
+
- `RequestScopeMiddleware`: one Bedrock DI scope per request.
|
|
33
|
+
- `DbSessionMiddleware` / `AsyncDbSessionMiddleware`: each request runs inside `db.session_scope()` /
|
|
34
|
+
`db.asession_scope()`. Services commit; uncommitted work is rolled back when the request ends.
|
|
35
|
+
- `bedrock_exception_handler(status_codes)`: answers mapped `BedrockExc` subclasses with `{"detail": ...}`.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from bedrock.exc import BedrockExc
|
|
39
|
+
from bedrock_fastapi import AsyncDbSessionMiddleware, RequestScopeMiddleware, bedrock_exception_handler
|
|
40
|
+
|
|
41
|
+
app.add_middleware(AsyncDbSessionMiddleware)
|
|
42
|
+
app.add_middleware(RequestScopeMiddleware)
|
|
43
|
+
app.add_exception_handler(BedrockExc, bedrock_exception_handler({ItemNotFound: 404}))
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
See the FastAPI integration docs at https://bedrock-py.com/docs/integrations/fastapi for installation, the
|
|
47
|
+
transaction convention, and a complete example.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
bedrock_fastapi/__init__.py,sha256=uxOv_ftPyItV1pJddBhAB8YG7af9zzu_KU_9UKEGJ5c,408
|
|
2
|
+
bedrock_fastapi/exceptions.py,sha256=Mcc168RcftuLmA2u3KDY84JtiKR1WckzGmYIqEt0r2U,1829
|
|
3
|
+
bedrock_fastapi/middleware.py,sha256=z860ixz-VQZrl1pXLvrQkkG-zBDg2xFvwPrM8edUK0w,4796
|
|
4
|
+
bedrock_fastapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
bedrock_fastapi-0.1.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
6
|
+
bedrock_fastapi-0.1.0.dist-info/METADATA,sha256=ZFpKsNQhKCUcRbc-YSPYHTiLV0WNx3n4QB_q-eTs_js,2087
|
|
7
|
+
bedrock_fastapi-0.1.0.dist-info/RECORD,,
|