fastware 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.
- fastware/__init__.py +104 -0
- fastware/__main__.py +8 -0
- fastware/app.py +453 -0
- fastware/audit.py +49 -0
- fastware/auth.py +427 -0
- fastware/config.py +40 -0
- fastware/dev.py +99 -0
- fastware/di.py +110 -0
- fastware/error_log.py +107 -0
- fastware/features.py +84 -0
- fastware/logging.py +140 -0
- fastware/mcp.py +205 -0
- fastware/middleware.py +559 -0
- fastware/request.py +269 -0
- fastware/responses.py +222 -0
- fastware/routing.py +335 -0
- fastware/server.py +653 -0
- fastware/sse.py +145 -0
- fastware/tasks.py +110 -0
- fastware/testing.py +156 -0
- fastware/types.py +20 -0
- fastware/websocket.py +87 -0
- fastware-0.1.0.dist-info/METADATA +33 -0
- fastware-0.1.0.dist-info/RECORD +26 -0
- fastware-0.1.0.dist-info/WHEEL +4 -0
- fastware-0.1.0.dist-info/licenses/LICENSE +21 -0
fastware/__init__.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""A fast, batteries-included ASGI framework. The FastAPI alternative."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
|
|
7
|
+
# -- Tier 1: Core symbols (eager imports -- lightweight modules) -------------
|
|
8
|
+
|
|
9
|
+
from fastware.types import Scope, Receive, Send
|
|
10
|
+
from fastware.responses import (
|
|
11
|
+
set_cookie,
|
|
12
|
+
delete_cookie,
|
|
13
|
+
HTTPError,
|
|
14
|
+
JSONResponse,
|
|
15
|
+
TextResponse,
|
|
16
|
+
HTMLResponse,
|
|
17
|
+
BytesResponse,
|
|
18
|
+
StreamResponse,
|
|
19
|
+
FileResponse,
|
|
20
|
+
send_error,
|
|
21
|
+
)
|
|
22
|
+
from fastware.request import State, Request
|
|
23
|
+
from fastware.routing import Router, ParsedSegment
|
|
24
|
+
from fastware.websocket import WebSocket
|
|
25
|
+
from fastware.app import AppConfig, create_app
|
|
26
|
+
from fastware.di import DependencyResolver
|
|
27
|
+
from fastware.sse import Broadcaster, sse_route
|
|
28
|
+
|
|
29
|
+
__version__ = importlib.metadata.version("fastware")
|
|
30
|
+
|
|
31
|
+
# -- Tier 1: Server symbols (lazy -- granian is ~60ms to import) ------------
|
|
32
|
+
|
|
33
|
+
_SERVER_SYMBOLS = {
|
|
34
|
+
"check_already_running",
|
|
35
|
+
"ensure_port_available",
|
|
36
|
+
"read_port_file",
|
|
37
|
+
"serve_background",
|
|
38
|
+
"serve",
|
|
39
|
+
"stop",
|
|
40
|
+
"status",
|
|
41
|
+
"ServerStatus",
|
|
42
|
+
"PortInUseError",
|
|
43
|
+
"AlreadyRunningError",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def __getattr__(name: str) -> object:
|
|
48
|
+
if name in _SERVER_SYMBOLS:
|
|
49
|
+
from fastware import server
|
|
50
|
+
return getattr(server, name)
|
|
51
|
+
raise AttributeError(f"module 'fastware' has no attribute {name!r}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# -- Tier 2+: Feature modules stay in sub-modules ---------------------------
|
|
55
|
+
# fastware.auth, fastware.middleware, fastware.logging, fastware.testing,
|
|
56
|
+
# fastware.features, fastware.audit, fastware.error_log, fastware.tasks,
|
|
57
|
+
# fastware.config, fastware.mcp, fastware.dev
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
# types
|
|
61
|
+
"Scope",
|
|
62
|
+
"Receive",
|
|
63
|
+
"Send",
|
|
64
|
+
# responses
|
|
65
|
+
"set_cookie",
|
|
66
|
+
"delete_cookie",
|
|
67
|
+
"HTTPError",
|
|
68
|
+
"JSONResponse",
|
|
69
|
+
"TextResponse",
|
|
70
|
+
"HTMLResponse",
|
|
71
|
+
"BytesResponse",
|
|
72
|
+
"StreamResponse",
|
|
73
|
+
"FileResponse",
|
|
74
|
+
"send_error",
|
|
75
|
+
# request
|
|
76
|
+
"State",
|
|
77
|
+
"Request",
|
|
78
|
+
# routing
|
|
79
|
+
"Router",
|
|
80
|
+
"ParsedSegment",
|
|
81
|
+
# websocket
|
|
82
|
+
"WebSocket",
|
|
83
|
+
# app
|
|
84
|
+
"AppConfig",
|
|
85
|
+
"create_app",
|
|
86
|
+
# di
|
|
87
|
+
"DependencyResolver",
|
|
88
|
+
# sse
|
|
89
|
+
"Broadcaster",
|
|
90
|
+
"sse_route",
|
|
91
|
+
# server (lazy)
|
|
92
|
+
"check_already_running",
|
|
93
|
+
"ensure_port_available",
|
|
94
|
+
"read_port_file",
|
|
95
|
+
"serve_background",
|
|
96
|
+
"serve",
|
|
97
|
+
"stop",
|
|
98
|
+
"status",
|
|
99
|
+
"ServerStatus",
|
|
100
|
+
"PortInUseError",
|
|
101
|
+
"AlreadyRunningError",
|
|
102
|
+
# metadata
|
|
103
|
+
"__version__",
|
|
104
|
+
]
|
fastware/__main__.py
ADDED
fastware/app.py
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""ASGI application factory with middleware, static files, SPA fallback, and lifespan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import logging
|
|
7
|
+
import mimetypes
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
import msgspec
|
|
12
|
+
|
|
13
|
+
from fastware.types import Scope, Receive, Send
|
|
14
|
+
from fastware.responses import (
|
|
15
|
+
JSONResponse,
|
|
16
|
+
TextResponse,
|
|
17
|
+
HTMLResponse,
|
|
18
|
+
BytesResponse,
|
|
19
|
+
StreamResponse,
|
|
20
|
+
FileResponse,
|
|
21
|
+
HTTPError,
|
|
22
|
+
_send_response,
|
|
23
|
+
send_error,
|
|
24
|
+
)
|
|
25
|
+
from fastware.request import Request, State
|
|
26
|
+
from fastware.routing import Router
|
|
27
|
+
from fastware.websocket import WebSocket
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"AppConfig",
|
|
31
|
+
"create_app",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# ASGI send helpers (stream + result dispatch)
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
async def _send_stream(send: Callable, resp: StreamResponse) -> None:
|
|
40
|
+
"""Send a streaming HTTP response, iterating the async generator."""
|
|
41
|
+
headers = [
|
|
42
|
+
[b"content-type", resp.content_type.encode()],
|
|
43
|
+
]
|
|
44
|
+
for key, value in resp.headers.items():
|
|
45
|
+
headers.append([key.encode(), value.encode()])
|
|
46
|
+
for cookie in resp.cookies:
|
|
47
|
+
headers.append([b"set-cookie", cookie.encode()])
|
|
48
|
+
await send({"type": "http.response.start", "status": resp.status, "headers": headers})
|
|
49
|
+
async for chunk in resp.generator:
|
|
50
|
+
payload = chunk.encode() if isinstance(chunk, str) else chunk
|
|
51
|
+
await send({"type": "http.response.body", "body": payload, "more_body": True})
|
|
52
|
+
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _deep_convert_pydantic(obj: Any) -> Any:
|
|
56
|
+
"""Recursively convert Pydantic models to plain dicts/lists.
|
|
57
|
+
|
|
58
|
+
Walks dicts and lists, calling ``.model_dump(mode="json")`` on any
|
|
59
|
+
object that has ``model_dump`` (i.e. Pydantic BaseModel instances).
|
|
60
|
+
"""
|
|
61
|
+
if hasattr(obj, "model_dump"):
|
|
62
|
+
return obj.model_dump(mode="json")
|
|
63
|
+
if isinstance(obj, dict):
|
|
64
|
+
return {k: _deep_convert_pydantic(v) for k, v in obj.items()}
|
|
65
|
+
if isinstance(obj, list):
|
|
66
|
+
return [_deep_convert_pydantic(item) for item in obj]
|
|
67
|
+
return obj
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def _send_result(send: Callable, result: Any) -> None:
|
|
71
|
+
"""Dispatch a handler return value to the appropriate sender."""
|
|
72
|
+
# Pydantic BaseModel instances: serialize via model_dump before JSON encoding
|
|
73
|
+
if hasattr(result, "model_dump"):
|
|
74
|
+
result = result.model_dump(mode="json")
|
|
75
|
+
|
|
76
|
+
# Auto-wrap plain dicts/lists as JSON responses, converting nested Pydantic models
|
|
77
|
+
if isinstance(result, (dict, list)):
|
|
78
|
+
result = JSONResponse(_deep_convert_pydantic(result))
|
|
79
|
+
|
|
80
|
+
if isinstance(result, JSONResponse):
|
|
81
|
+
await _send_response(
|
|
82
|
+
send, result.status, msgspec.json.encode(result.data),
|
|
83
|
+
"application/json", extra_headers=result.headers, cookies=result.cookies,
|
|
84
|
+
)
|
|
85
|
+
elif isinstance(result, TextResponse):
|
|
86
|
+
await _send_response(
|
|
87
|
+
send, result.status, result.text.encode(),
|
|
88
|
+
result.content_type, extra_headers=result.headers, cookies=result.cookies,
|
|
89
|
+
)
|
|
90
|
+
elif isinstance(result, HTMLResponse):
|
|
91
|
+
await _send_response(
|
|
92
|
+
send, result.status, result.html.encode(),
|
|
93
|
+
"text/html", extra_headers=result.headers, cookies=result.cookies,
|
|
94
|
+
)
|
|
95
|
+
elif isinstance(result, BytesResponse):
|
|
96
|
+
await _send_response(
|
|
97
|
+
send, result.status, result.data,
|
|
98
|
+
result.content_type, extra_headers=result.headers, cookies=result.cookies,
|
|
99
|
+
)
|
|
100
|
+
elif isinstance(result, StreamResponse):
|
|
101
|
+
await _send_stream(send, result)
|
|
102
|
+
elif isinstance(result, FileResponse):
|
|
103
|
+
content_type = result.content_type
|
|
104
|
+
if content_type is None:
|
|
105
|
+
mime, _ = mimetypes.guess_type(str(result.path))
|
|
106
|
+
content_type = mime or "application/octet-stream"
|
|
107
|
+
body = result.path.read_bytes()
|
|
108
|
+
await _send_response(
|
|
109
|
+
send, result.status, body, content_type,
|
|
110
|
+
extra_headers=result.headers, cookies=result.cookies,
|
|
111
|
+
)
|
|
112
|
+
else:
|
|
113
|
+
# Fallback: try JSON-encoding anything else
|
|
114
|
+
await _send_response(send, 200, msgspec.json.encode(result), "application/json")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Static file + SPA helpers
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
async def _serve_static(send: Callable, static_dir: Path, rel_path: str) -> bool:
|
|
122
|
+
"""Serve a static file. Returns True if served, False if not found."""
|
|
123
|
+
file_path = (static_dir / rel_path).resolve()
|
|
124
|
+
# Prevent path traversal
|
|
125
|
+
if not str(file_path).startswith(str(static_dir.resolve())):
|
|
126
|
+
return False
|
|
127
|
+
if not file_path.is_file():
|
|
128
|
+
return False
|
|
129
|
+
mime, _ = mimetypes.guess_type(str(file_path))
|
|
130
|
+
body = file_path.read_bytes()
|
|
131
|
+
await _send_response(send, 200, body, mime or "application/octet-stream")
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def _serve_spa_fallback(send: Callable, spa_fallback: Path) -> None:
|
|
136
|
+
"""Serve the SPA fallback file (typically index.html)."""
|
|
137
|
+
if spa_fallback.is_file():
|
|
138
|
+
body = spa_fallback.read_bytes()
|
|
139
|
+
await _send_response(send, 200, body, "text/html")
|
|
140
|
+
else:
|
|
141
|
+
await _send_response(send, 404, msgspec.json.encode({"detail": "Not found"}), "application/json")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# App configuration
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
@dataclasses.dataclass
|
|
149
|
+
class AppConfig:
|
|
150
|
+
"""Configuration for :func:`create_app`.
|
|
151
|
+
|
|
152
|
+
All fields correspond to the keyword arguments of ``create_app``.
|
|
153
|
+
Pass an ``AppConfig`` instance as the *config* parameter, and/or
|
|
154
|
+
supply individual keyword arguments. Keyword arguments override
|
|
155
|
+
matching fields on the config object.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
middleware: list[Callable] | None = None
|
|
159
|
+
static_dir: Path | None = None
|
|
160
|
+
static_path: str = "/assets"
|
|
161
|
+
spa_fallback: Path | None = None
|
|
162
|
+
api_prefix: str | None = None
|
|
163
|
+
lifespan: Callable | None = None
|
|
164
|
+
name: str | None = None
|
|
165
|
+
exception_handlers: dict[type, Callable] | None = None
|
|
166
|
+
dependency_overrides: dict[Callable, Callable] | None = None
|
|
167
|
+
cors_origins: list[str] | None = None
|
|
168
|
+
trusted_hosts: list[str] | None = None
|
|
169
|
+
request_id: bool = True
|
|
170
|
+
request_timing: bool = True
|
|
171
|
+
vite_dev_port: int | None = None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# App factory
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def create_app(
|
|
179
|
+
router: Router,
|
|
180
|
+
config: AppConfig | None = None,
|
|
181
|
+
**kwargs: Any,
|
|
182
|
+
) -> Callable:
|
|
183
|
+
"""Create an ASGI application callable.
|
|
184
|
+
|
|
185
|
+
Accepts an optional *config* (:class:`AppConfig`) and/or keyword
|
|
186
|
+
arguments. Keyword arguments override matching fields on the config
|
|
187
|
+
object. If neither is supplied, defaults from ``AppConfig`` are used.
|
|
188
|
+
|
|
189
|
+
If *api_prefix* is set (e.g. ``"/api"``), the SPA fallback will not
|
|
190
|
+
serve index.html for paths that start with the prefix -- they fall
|
|
191
|
+
through to the 404 handler instead.
|
|
192
|
+
|
|
193
|
+
Built-in middleware (applied when their parameters are truthy):
|
|
194
|
+
- ``trusted_hosts``: TrustedHostMiddleware (outermost)
|
|
195
|
+
- ``vite_dev_port``: ViteDevProxy
|
|
196
|
+
- ``cors_origins``: CORSMiddleware
|
|
197
|
+
- ``request_id``: RequestIDMiddleware
|
|
198
|
+
- ``request_timing``: RequestTimingMiddleware (innermost)
|
|
199
|
+
|
|
200
|
+
Custom middleware supplied via *middleware* wraps after built-in
|
|
201
|
+
middleware (between the app and the built-in stack).
|
|
202
|
+
"""
|
|
203
|
+
# Build effective config: start from defaults, overlay config, overlay kwargs.
|
|
204
|
+
if config is None:
|
|
205
|
+
config = AppConfig()
|
|
206
|
+
_field_names = {f.name for f in dataclasses.fields(AppConfig)}
|
|
207
|
+
unknown = set(kwargs) - _field_names
|
|
208
|
+
if unknown:
|
|
209
|
+
raise TypeError(
|
|
210
|
+
f"create_app() got unexpected keyword argument(s): {', '.join(sorted(unknown))}"
|
|
211
|
+
)
|
|
212
|
+
# Merge: kwargs override config fields.
|
|
213
|
+
effective = dataclasses.replace(config, **kwargs)
|
|
214
|
+
|
|
215
|
+
middleware = effective.middleware
|
|
216
|
+
static_dir = effective.static_dir
|
|
217
|
+
static_path = effective.static_path
|
|
218
|
+
spa_fallback = effective.spa_fallback
|
|
219
|
+
api_prefix = effective.api_prefix
|
|
220
|
+
lifespan = effective.lifespan
|
|
221
|
+
name = effective.name
|
|
222
|
+
exception_handlers = effective.exception_handlers
|
|
223
|
+
dependency_overrides = effective.dependency_overrides
|
|
224
|
+
cors_origins = effective.cors_origins
|
|
225
|
+
trusted_hosts = effective.trusted_hosts
|
|
226
|
+
request_id = effective.request_id
|
|
227
|
+
request_timing = effective.request_timing
|
|
228
|
+
vite_dev_port = effective.vite_dev_port
|
|
229
|
+
|
|
230
|
+
from fastware.di import DependencyResolver
|
|
231
|
+
|
|
232
|
+
log = logging.getLogger(name or "fastware")
|
|
233
|
+
_resolver = DependencyResolver(overrides=dependency_overrides)
|
|
234
|
+
|
|
235
|
+
# Pre-sort exception handlers by MRO depth (most specific first) so
|
|
236
|
+
# that a handler for a subclass is checked before a handler for its
|
|
237
|
+
# parent. Ties are broken by insertion order.
|
|
238
|
+
_exc_handlers: list[tuple[type, Callable]] = []
|
|
239
|
+
if exception_handlers:
|
|
240
|
+
_exc_handlers = sorted(
|
|
241
|
+
exception_handlers.items(),
|
|
242
|
+
key=lambda pair: len(pair[0].__mro__),
|
|
243
|
+
reverse=True,
|
|
244
|
+
)
|
|
245
|
+
_lifespan_state: dict[str, Any] = {}
|
|
246
|
+
|
|
247
|
+
async def app(scope: dict, receive: Callable, send: Callable) -> None:
|
|
248
|
+
nonlocal _lifespan_state
|
|
249
|
+
|
|
250
|
+
# -- Lifespan protocol --
|
|
251
|
+
if scope["type"] == "lifespan":
|
|
252
|
+
message = await receive()
|
|
253
|
+
if message["type"] == "lifespan.startup":
|
|
254
|
+
if lifespan is not None:
|
|
255
|
+
# Enter the context manager; it stays open until shutdown
|
|
256
|
+
ctx = lifespan(app)
|
|
257
|
+
yielded = await ctx.__aenter__()
|
|
258
|
+
if isinstance(yielded, dict):
|
|
259
|
+
_lifespan_state = yielded
|
|
260
|
+
await send({"type": "lifespan.startup.complete"})
|
|
261
|
+
await receive() # blocks until lifespan.shutdown
|
|
262
|
+
await ctx.__aexit__(None, None, None)
|
|
263
|
+
else:
|
|
264
|
+
await send({"type": "lifespan.startup.complete"})
|
|
265
|
+
await receive()
|
|
266
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
# -- WebSocket routing (app-scoped via router) --
|
|
270
|
+
if scope["type"] == "websocket":
|
|
271
|
+
# Merge lifespan state into scope for WebSocket connections
|
|
272
|
+
if "state" not in scope:
|
|
273
|
+
scope["state"] = {}
|
|
274
|
+
scope["state"].update(_lifespan_state)
|
|
275
|
+
|
|
276
|
+
path = scope.get("path", "")
|
|
277
|
+
ws_match = router._match_ws_with_deps(path)
|
|
278
|
+
if ws_match:
|
|
279
|
+
ws_handler, ws_params, ws_deps = ws_match
|
|
280
|
+
scope["path_params"] = ws_params
|
|
281
|
+
ws = WebSocket(scope, receive, send)
|
|
282
|
+
if ws_deps:
|
|
283
|
+
resolved, cleanups = await _resolver.resolve(ws_deps, ws)
|
|
284
|
+
try:
|
|
285
|
+
await ws_handler(ws, **resolved)
|
|
286
|
+
finally:
|
|
287
|
+
await DependencyResolver.cleanup(cleanups)
|
|
288
|
+
else:
|
|
289
|
+
await ws_handler(ws)
|
|
290
|
+
else:
|
|
291
|
+
# Reject unknown WebSocket paths
|
|
292
|
+
await receive() # consume websocket.connect per ASGI spec
|
|
293
|
+
await send({"type": "websocket.close", "code": 4004})
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
if scope["type"] != "http":
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
# Merge lifespan state into scope for every request
|
|
300
|
+
if "state" not in scope:
|
|
301
|
+
scope["state"] = {}
|
|
302
|
+
scope["state"].update(_lifespan_state)
|
|
303
|
+
|
|
304
|
+
method = scope["method"]
|
|
305
|
+
path = scope["path"]
|
|
306
|
+
|
|
307
|
+
# -- Route matching --
|
|
308
|
+
match = router._match_with_deps(method, path)
|
|
309
|
+
if match:
|
|
310
|
+
handler, path_params, route_deps, response_model = match
|
|
311
|
+
try:
|
|
312
|
+
# Read body for all methods (GET with empty body costs nothing)
|
|
313
|
+
body = b""
|
|
314
|
+
while True:
|
|
315
|
+
msg = await receive()
|
|
316
|
+
body += msg.get("body", b"")
|
|
317
|
+
if not msg.get("more_body", False):
|
|
318
|
+
break
|
|
319
|
+
body = body or None
|
|
320
|
+
|
|
321
|
+
request = Request(scope, path_params, body, receive=receive)
|
|
322
|
+
|
|
323
|
+
if route_deps:
|
|
324
|
+
resolved, cleanups = await _resolver.resolve(
|
|
325
|
+
route_deps, request,
|
|
326
|
+
)
|
|
327
|
+
try:
|
|
328
|
+
# Filter resolved deps to only those the handler
|
|
329
|
+
# actually accepts, so router-level deps (e.g. auth)
|
|
330
|
+
# don't cause TypeError on handlers that don't need
|
|
331
|
+
# the resolved value.
|
|
332
|
+
import inspect as _inspect
|
|
333
|
+
_sig = _inspect.signature(handler)
|
|
334
|
+
_params = _sig.parameters
|
|
335
|
+
if any(
|
|
336
|
+
p.kind == _inspect.Parameter.VAR_KEYWORD
|
|
337
|
+
for p in _params.values()
|
|
338
|
+
):
|
|
339
|
+
filtered = resolved
|
|
340
|
+
else:
|
|
341
|
+
accepted = set(_params.keys())
|
|
342
|
+
filtered = {
|
|
343
|
+
k: v for k, v in resolved.items()
|
|
344
|
+
if k in accepted
|
|
345
|
+
}
|
|
346
|
+
result = await handler(request, **filtered)
|
|
347
|
+
finally:
|
|
348
|
+
await DependencyResolver.cleanup(cleanups)
|
|
349
|
+
else:
|
|
350
|
+
result = await handler(request)
|
|
351
|
+
|
|
352
|
+
# Apply response_model validation if configured and result
|
|
353
|
+
# is a plain dict (skip if handler returned a Response type)
|
|
354
|
+
if response_model is not None and isinstance(result, dict):
|
|
355
|
+
from pydantic import ValidationError
|
|
356
|
+
try:
|
|
357
|
+
validated = response_model.model_validate(result)
|
|
358
|
+
result = validated.model_dump(mode="json")
|
|
359
|
+
except ValidationError as ve:
|
|
360
|
+
log.error(
|
|
361
|
+
"response_model validation failed on %s %s: %s",
|
|
362
|
+
method, path, ve,
|
|
363
|
+
)
|
|
364
|
+
await _send_response(
|
|
365
|
+
send, 500,
|
|
366
|
+
msgspec.json.encode({"detail": str(ve)}),
|
|
367
|
+
"application/json",
|
|
368
|
+
)
|
|
369
|
+
return
|
|
370
|
+
|
|
371
|
+
await _send_result(send, result)
|
|
372
|
+
except HTTPError as exc:
|
|
373
|
+
await _send_response(
|
|
374
|
+
send, exc.status_code,
|
|
375
|
+
msgspec.json.encode({"detail": exc.detail}),
|
|
376
|
+
"application/json",
|
|
377
|
+
)
|
|
378
|
+
except Exception as exc:
|
|
379
|
+
# Check registered exception handlers (most specific first)
|
|
380
|
+
handled = False
|
|
381
|
+
for exc_type, exc_handler in _exc_handlers:
|
|
382
|
+
if isinstance(exc, exc_type):
|
|
383
|
+
try:
|
|
384
|
+
result = await exc_handler(request, exc)
|
|
385
|
+
await _send_result(send, result)
|
|
386
|
+
handled = True
|
|
387
|
+
except Exception:
|
|
388
|
+
log.exception(
|
|
389
|
+
"Exception handler error on %s %s",
|
|
390
|
+
method, path,
|
|
391
|
+
)
|
|
392
|
+
break
|
|
393
|
+
if not handled:
|
|
394
|
+
log.exception("Handler error on %s %s", method, path)
|
|
395
|
+
await _send_response(
|
|
396
|
+
send, 500,
|
|
397
|
+
msgspec.json.encode({"detail": "Internal server error"}),
|
|
398
|
+
"application/json",
|
|
399
|
+
)
|
|
400
|
+
return
|
|
401
|
+
|
|
402
|
+
# -- Static files --
|
|
403
|
+
if static_dir and path.startswith(static_path + "/"):
|
|
404
|
+
rel = path[len(static_path) + 1:]
|
|
405
|
+
if await _serve_static(send, static_dir, rel):
|
|
406
|
+
return
|
|
407
|
+
|
|
408
|
+
# -- SPA fallback (GET only, skip API paths) --
|
|
409
|
+
if spa_fallback and method == "GET":
|
|
410
|
+
# Never serve index.html for API paths -- they should 404
|
|
411
|
+
if api_prefix and path.startswith(api_prefix):
|
|
412
|
+
pass # fall through to 404
|
|
413
|
+
else:
|
|
414
|
+
# Before returning index.html, check if the path maps to an
|
|
415
|
+
# actual file under the static root (spa_fallback's parent dir).
|
|
416
|
+
# This lets sub-directories be served without registering each
|
|
417
|
+
# one as a separate static_path prefix.
|
|
418
|
+
static_root = spa_fallback.parent
|
|
419
|
+
candidate = path.lstrip("/")
|
|
420
|
+
if candidate and await _serve_static(send, static_root, candidate):
|
|
421
|
+
return
|
|
422
|
+
await _serve_spa_fallback(send, spa_fallback)
|
|
423
|
+
return
|
|
424
|
+
|
|
425
|
+
# -- 404 --
|
|
426
|
+
await _send_response(send, 404, msgspec.json.encode({"detail": "Not found"}), "application/json")
|
|
427
|
+
|
|
428
|
+
# -- Apply middleware in reverse so the first in the list is outermost --
|
|
429
|
+
wrapped = app
|
|
430
|
+
for mw in reversed(middleware or []):
|
|
431
|
+
wrapped = mw(wrapped)
|
|
432
|
+
|
|
433
|
+
# -- Built-in middleware (innermost first, outermost last) --
|
|
434
|
+
from fastware.middleware import (
|
|
435
|
+
CORSMiddleware as _CORSMiddleware,
|
|
436
|
+
RequestIDMiddleware as _RequestIDMiddleware,
|
|
437
|
+
RequestTimingMiddleware as _RequestTimingMiddleware,
|
|
438
|
+
TrustedHostMiddleware as _TrustedHostMiddleware,
|
|
439
|
+
ViteDevProxy as _ViteDevProxy,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
if request_timing:
|
|
443
|
+
wrapped = _RequestTimingMiddleware(wrapped)
|
|
444
|
+
if request_id:
|
|
445
|
+
wrapped = _RequestIDMiddleware(wrapped)
|
|
446
|
+
if cors_origins:
|
|
447
|
+
wrapped = _CORSMiddleware(wrapped, allow_origins=cors_origins)
|
|
448
|
+
if vite_dev_port is not None:
|
|
449
|
+
wrapped = _ViteDevProxy(wrapped, vite_port=vite_dev_port)
|
|
450
|
+
if trusted_hosts:
|
|
451
|
+
wrapped = _TrustedHostMiddleware(wrapped, allowed_hosts=trusted_hosts)
|
|
452
|
+
|
|
453
|
+
return wrapped
|
fastware/audit.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Append-only JSONL audit log writer.
|
|
2
|
+
|
|
3
|
+
Each entry is a single JSON line with an ISO timestamp, event type, and
|
|
4
|
+
optional payload dict. Thread-safe via ``threading.Lock``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import threading
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AuditLog",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuditLog:
|
|
20
|
+
"""Append-only JSONL audit log.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
path:
|
|
25
|
+
Path to the JSONL file. Created on first write if it does not exist.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, path: str | Path) -> None:
|
|
29
|
+
self._path = Path(path)
|
|
30
|
+
self._lock = threading.Lock()
|
|
31
|
+
|
|
32
|
+
def log(self, event_type: str, payload: dict | None = None) -> None:
|
|
33
|
+
"""Append a single audit entry as a JSON line.
|
|
34
|
+
|
|
35
|
+
Each line has the shape::
|
|
36
|
+
|
|
37
|
+
{"timestamp": "...", "event_type": "...", "payload": {...}}
|
|
38
|
+
"""
|
|
39
|
+
entry: dict = {
|
|
40
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
41
|
+
"event_type": event_type,
|
|
42
|
+
}
|
|
43
|
+
if payload is not None:
|
|
44
|
+
entry["payload"] = payload
|
|
45
|
+
line = json.dumps(entry, separators=(",", ":"))
|
|
46
|
+
with self._lock:
|
|
47
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
with self._path.open("a") as f:
|
|
49
|
+
f.write(line + "\n")
|