vega-framework 0.1.34__py3-none-any.whl → 0.2.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.
- vega/cli/commands/add.py +9 -10
- vega/cli/commands/init.py +9 -8
- vega/cli/main.py +4 -4
- vega/cli/scaffolds/__init__.py +6 -2
- vega/cli/scaffolds/vega_web.py +109 -0
- vega/cli/templates/__init__.py +34 -8
- vega/cli/templates/components.py +29 -13
- vega/cli/templates/web/app.py.j2 +5 -5
- vega/cli/templates/web/health_route.py.j2 +2 -2
- vega/cli/templates/web/router.py.j2 +2 -2
- vega/cli/templates/web/routes_init_autodiscovery.py.j2 +2 -2
- vega/cli/templates/web/users_route.py.j2 +2 -2
- vega/discovery/routes.py +13 -13
- vega/web/__init__.py +100 -0
- vega/web/application.py +234 -0
- vega/web/builtin_middlewares.py +288 -0
- vega/web/exceptions.py +151 -0
- vega/web/middleware.py +185 -0
- vega/web/request.py +120 -0
- vega/web/response.py +220 -0
- vega/web/route_middleware.py +266 -0
- vega/web/router.py +350 -0
- vega/web/routing.py +347 -0
- {vega_framework-0.1.34.dist-info → vega_framework-0.2.0.dist-info}/METADATA +10 -9
- {vega_framework-0.1.34.dist-info → vega_framework-0.2.0.dist-info}/RECORD +28 -17
- {vega_framework-0.1.34.dist-info → vega_framework-0.2.0.dist-info}/WHEEL +0 -0
- {vega_framework-0.1.34.dist-info → vega_framework-0.2.0.dist-info}/entry_points.txt +0 -0
- {vega_framework-0.1.34.dist-info → vega_framework-0.2.0.dist-info}/licenses/LICENSE +0 -0
vega/web/__init__.py
ADDED
@@ -0,0 +1,100 @@
|
|
1
|
+
"""
|
2
|
+
Vega Web Framework
|
3
|
+
|
4
|
+
A lightweight web framework built on Starlette, providing a FastAPI-like
|
5
|
+
developer experience while being deeply integrated with Vega's architecture.
|
6
|
+
|
7
|
+
Example:
|
8
|
+
from vega.web import Router, VegaApp, HTTPException, status
|
9
|
+
|
10
|
+
router = Router()
|
11
|
+
|
12
|
+
@router.get("/users/{user_id}")
|
13
|
+
async def get_user(user_id: str):
|
14
|
+
if user_id == "invalid":
|
15
|
+
raise HTTPException(status_code=404, detail="User not found")
|
16
|
+
return {"id": user_id, "name": "John"}
|
17
|
+
|
18
|
+
app = VegaApp(title="My API", version="1.0.0")
|
19
|
+
app.include_router(router, prefix="/api")
|
20
|
+
|
21
|
+
# Run with: uvicorn main:app --reload
|
22
|
+
"""
|
23
|
+
|
24
|
+
__version__ = "0.1.0"
|
25
|
+
|
26
|
+
# Core application
|
27
|
+
from .application import VegaApp
|
28
|
+
|
29
|
+
# Routing
|
30
|
+
from .router import Router
|
31
|
+
|
32
|
+
# Request/Response
|
33
|
+
from .request import Request
|
34
|
+
from .response import (
|
35
|
+
Response,
|
36
|
+
JSONResponse,
|
37
|
+
HTMLResponse,
|
38
|
+
PlainTextResponse,
|
39
|
+
RedirectResponse,
|
40
|
+
StreamingResponse,
|
41
|
+
FileResponse,
|
42
|
+
)
|
43
|
+
|
44
|
+
# Exceptions
|
45
|
+
from .exceptions import (
|
46
|
+
HTTPException,
|
47
|
+
ValidationError,
|
48
|
+
NotFoundError,
|
49
|
+
UnauthorizedError,
|
50
|
+
ForbiddenError,
|
51
|
+
BadRequestError,
|
52
|
+
status,
|
53
|
+
)
|
54
|
+
|
55
|
+
# Middleware (optional, can be imported explicitly)
|
56
|
+
from .middleware import (
|
57
|
+
VegaMiddleware,
|
58
|
+
CORSMiddleware,
|
59
|
+
RequestLoggingMiddleware,
|
60
|
+
)
|
61
|
+
|
62
|
+
# Route middleware
|
63
|
+
from .route_middleware import (
|
64
|
+
RouteMiddleware,
|
65
|
+
MiddlewarePhase,
|
66
|
+
middleware,
|
67
|
+
)
|
68
|
+
|
69
|
+
__all__ = [
|
70
|
+
# Version
|
71
|
+
"__version__",
|
72
|
+
# Core
|
73
|
+
"VegaApp",
|
74
|
+
"Router",
|
75
|
+
# Request/Response
|
76
|
+
"Request",
|
77
|
+
"Response",
|
78
|
+
"JSONResponse",
|
79
|
+
"HTMLResponse",
|
80
|
+
"PlainTextResponse",
|
81
|
+
"RedirectResponse",
|
82
|
+
"StreamingResponse",
|
83
|
+
"FileResponse",
|
84
|
+
# Exceptions
|
85
|
+
"HTTPException",
|
86
|
+
"ValidationError",
|
87
|
+
"NotFoundError",
|
88
|
+
"UnauthorizedError",
|
89
|
+
"ForbiddenError",
|
90
|
+
"BadRequestError",
|
91
|
+
"status",
|
92
|
+
# Middleware
|
93
|
+
"VegaMiddleware",
|
94
|
+
"CORSMiddleware",
|
95
|
+
"RequestLoggingMiddleware",
|
96
|
+
# Route Middleware
|
97
|
+
"RouteMiddleware",
|
98
|
+
"MiddlewarePhase",
|
99
|
+
"middleware",
|
100
|
+
]
|
vega/web/application.py
ADDED
@@ -0,0 +1,234 @@
|
|
1
|
+
"""Main application class for Vega Web Framework"""
|
2
|
+
|
3
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence
|
4
|
+
|
5
|
+
from starlette.applications import Starlette
|
6
|
+
from starlette.middleware import Middleware
|
7
|
+
from starlette.middleware.cors import CORSMiddleware
|
8
|
+
from starlette.routing import Mount, Route as StarletteRoute
|
9
|
+
from starlette.types import ASGIApp
|
10
|
+
|
11
|
+
from .router import Router
|
12
|
+
from .exceptions import HTTPException
|
13
|
+
from .response import JSONResponse
|
14
|
+
|
15
|
+
|
16
|
+
class VegaApp:
|
17
|
+
"""
|
18
|
+
Main application class for Vega Web Framework.
|
19
|
+
|
20
|
+
This is the core ASGI application that handles all HTTP requests.
|
21
|
+
It's built on Starlette but provides a FastAPI-like API for familiarity.
|
22
|
+
|
23
|
+
Args:
|
24
|
+
title: Application title
|
25
|
+
description: Application description
|
26
|
+
version: Application version
|
27
|
+
debug: Enable debug mode
|
28
|
+
middleware: List of middleware to apply
|
29
|
+
on_startup: Functions to run on startup
|
30
|
+
on_shutdown: Functions to run on shutdown
|
31
|
+
|
32
|
+
Example:
|
33
|
+
app = VegaApp(
|
34
|
+
title="My API",
|
35
|
+
version="1.0.0",
|
36
|
+
debug=True
|
37
|
+
)
|
38
|
+
|
39
|
+
@app.get("/")
|
40
|
+
async def root():
|
41
|
+
return {"message": "Hello World"}
|
42
|
+
|
43
|
+
# Or with routers
|
44
|
+
router = Router(prefix="/api")
|
45
|
+
@router.get("/users")
|
46
|
+
async def get_users():
|
47
|
+
return {"users": []}
|
48
|
+
|
49
|
+
app.include_router(router)
|
50
|
+
"""
|
51
|
+
|
52
|
+
def __init__(
|
53
|
+
self,
|
54
|
+
*,
|
55
|
+
title: str = "Vega API",
|
56
|
+
description: str = "",
|
57
|
+
version: str = "0.1.0",
|
58
|
+
debug: bool = False,
|
59
|
+
middleware: Optional[Sequence[Middleware]] = None,
|
60
|
+
on_startup: Optional[Sequence[Callable]] = None,
|
61
|
+
on_shutdown: Optional[Sequence[Callable]] = None,
|
62
|
+
):
|
63
|
+
self.title = title
|
64
|
+
self.description = description
|
65
|
+
self.version = version
|
66
|
+
self.debug = debug
|
67
|
+
|
68
|
+
# Internal router for top-level routes
|
69
|
+
self._router = Router()
|
70
|
+
|
71
|
+
# Middleware stack
|
72
|
+
self._middleware = list(middleware) if middleware else []
|
73
|
+
|
74
|
+
# Lifecycle handlers
|
75
|
+
self._on_startup = list(on_startup) if on_startup else []
|
76
|
+
self._on_shutdown = list(on_shutdown) if on_shutdown else []
|
77
|
+
|
78
|
+
# Starlette app (created lazily)
|
79
|
+
self._starlette_app: Optional[Starlette] = None
|
80
|
+
|
81
|
+
def add_middleware(
|
82
|
+
self,
|
83
|
+
middleware_class: type,
|
84
|
+
**options: Any,
|
85
|
+
) -> None:
|
86
|
+
"""
|
87
|
+
Add middleware to the application.
|
88
|
+
|
89
|
+
Args:
|
90
|
+
middleware_class: Middleware class
|
91
|
+
**options: Middleware configuration options
|
92
|
+
|
93
|
+
Example:
|
94
|
+
from starlette.middleware.cors import CORSMiddleware
|
95
|
+
|
96
|
+
app.add_middleware(
|
97
|
+
CORSMiddleware,
|
98
|
+
allow_origins=["*"],
|
99
|
+
allow_methods=["*"],
|
100
|
+
)
|
101
|
+
"""
|
102
|
+
self._middleware.append(Middleware(middleware_class, **options))
|
103
|
+
# Invalidate cached Starlette app
|
104
|
+
self._starlette_app = None
|
105
|
+
|
106
|
+
def on_event(self, event_type: str) -> Callable:
|
107
|
+
"""
|
108
|
+
Register lifecycle event handler.
|
109
|
+
|
110
|
+
Args:
|
111
|
+
event_type: Either "startup" or "shutdown"
|
112
|
+
|
113
|
+
Example:
|
114
|
+
@app.on_event("startup")
|
115
|
+
async def startup():
|
116
|
+
print("Starting up!")
|
117
|
+
|
118
|
+
@app.on_event("shutdown")
|
119
|
+
async def shutdown():
|
120
|
+
print("Shutting down!")
|
121
|
+
"""
|
122
|
+
|
123
|
+
def decorator(func: Callable) -> Callable:
|
124
|
+
if event_type == "startup":
|
125
|
+
self._on_startup.append(func)
|
126
|
+
elif event_type == "shutdown":
|
127
|
+
self._on_shutdown.append(func)
|
128
|
+
else:
|
129
|
+
raise ValueError(f"Invalid event type: {event_type}")
|
130
|
+
# Invalidate cached Starlette app
|
131
|
+
self._starlette_app = None
|
132
|
+
return func
|
133
|
+
|
134
|
+
return decorator
|
135
|
+
|
136
|
+
def include_router(
|
137
|
+
self,
|
138
|
+
router: Router,
|
139
|
+
prefix: str = "",
|
140
|
+
tags: Optional[List[str]] = None,
|
141
|
+
) -> None:
|
142
|
+
"""
|
143
|
+
Include a router in the application.
|
144
|
+
|
145
|
+
Args:
|
146
|
+
router: Router to include
|
147
|
+
prefix: URL prefix for all routes
|
148
|
+
tags: Tags to add to all routes
|
149
|
+
|
150
|
+
Example:
|
151
|
+
users_router = Router()
|
152
|
+
@users_router.get("/{user_id}")
|
153
|
+
async def get_user(user_id: str):
|
154
|
+
return {"id": user_id}
|
155
|
+
|
156
|
+
app.include_router(users_router, prefix="/users", tags=["users"])
|
157
|
+
"""
|
158
|
+
self._router.include_router(router, prefix=prefix, tags=tags)
|
159
|
+
# Invalidate cached Starlette app
|
160
|
+
self._starlette_app = None
|
161
|
+
|
162
|
+
def get(self, path: str, **kwargs: Any) -> Callable:
|
163
|
+
"""
|
164
|
+
Decorator for GET requests.
|
165
|
+
|
166
|
+
Example:
|
167
|
+
@app.get("/items/{item_id}")
|
168
|
+
async def get_item(item_id: str):
|
169
|
+
return {"id": item_id}
|
170
|
+
"""
|
171
|
+
return self._router.get(path, **kwargs)
|
172
|
+
|
173
|
+
def post(self, path: str, **kwargs: Any) -> Callable:
|
174
|
+
"""Decorator for POST requests."""
|
175
|
+
return self._router.post(path, **kwargs)
|
176
|
+
|
177
|
+
def put(self, path: str, **kwargs: Any) -> Callable:
|
178
|
+
"""Decorator for PUT requests."""
|
179
|
+
return self._router.put(path, **kwargs)
|
180
|
+
|
181
|
+
def patch(self, path: str, **kwargs: Any) -> Callable:
|
182
|
+
"""Decorator for PATCH requests."""
|
183
|
+
return self._router.patch(path, **kwargs)
|
184
|
+
|
185
|
+
def delete(self, path: str, **kwargs: Any) -> Callable:
|
186
|
+
"""Decorator for DELETE requests."""
|
187
|
+
return self._router.delete(path, **kwargs)
|
188
|
+
|
189
|
+
def route(self, path: str, methods: List[str], **kwargs: Any) -> Callable:
|
190
|
+
"""
|
191
|
+
Decorator for custom methods.
|
192
|
+
|
193
|
+
Example:
|
194
|
+
@app.route("/items", methods=["GET", "POST"])
|
195
|
+
async def items():
|
196
|
+
return {"items": []}
|
197
|
+
"""
|
198
|
+
return self._router.route(path, methods, **kwargs)
|
199
|
+
|
200
|
+
def _build_starlette_app(self) -> Starlette:
|
201
|
+
"""Build the Starlette application from routes and middleware."""
|
202
|
+
# Convert Vega routes to Starlette routes
|
203
|
+
starlette_routes = [
|
204
|
+
route.to_starlette_route() for route in self._router.get_routes()
|
205
|
+
]
|
206
|
+
|
207
|
+
# Create Starlette app
|
208
|
+
app = Starlette(
|
209
|
+
debug=self.debug,
|
210
|
+
routes=starlette_routes,
|
211
|
+
middleware=self._middleware,
|
212
|
+
on_startup=self._on_startup,
|
213
|
+
on_shutdown=self._on_shutdown,
|
214
|
+
)
|
215
|
+
|
216
|
+
return app
|
217
|
+
|
218
|
+
def _get_app(self) -> Starlette:
|
219
|
+
"""Get or create the Starlette app."""
|
220
|
+
if self._starlette_app is None:
|
221
|
+
self._starlette_app = self._build_starlette_app()
|
222
|
+
return self._starlette_app
|
223
|
+
|
224
|
+
async def __call__(self, scope: dict, receive: Callable, send: Callable) -> None:
|
225
|
+
"""
|
226
|
+
ASGI application callable.
|
227
|
+
|
228
|
+
This makes VegaApp a valid ASGI application.
|
229
|
+
"""
|
230
|
+
app = self._get_app()
|
231
|
+
await app(scope, receive, send)
|
232
|
+
|
233
|
+
|
234
|
+
__all__ = ["VegaApp"]
|
@@ -0,0 +1,288 @@
|
|
1
|
+
"""Built-in route middleware implementations for Vega Web Framework"""
|
2
|
+
|
3
|
+
import time
|
4
|
+
import logging
|
5
|
+
from typing import Optional
|
6
|
+
|
7
|
+
from .route_middleware import RouteMiddleware, MiddlewarePhase
|
8
|
+
from .request import Request
|
9
|
+
from .response import Response, JSONResponse
|
10
|
+
from .exceptions import HTTPException, status
|
11
|
+
|
12
|
+
|
13
|
+
class AuthMiddleware(RouteMiddleware):
|
14
|
+
"""
|
15
|
+
Authentication middleware - validates Authorization header.
|
16
|
+
|
17
|
+
Executes BEFORE the handler.
|
18
|
+
|
19
|
+
Args:
|
20
|
+
header_name: Name of the header to check (default: "Authorization")
|
21
|
+
scheme: Expected auth scheme (default: "Bearer")
|
22
|
+
|
23
|
+
Example:
|
24
|
+
@router.get("/protected")
|
25
|
+
@middleware(AuthMiddleware())
|
26
|
+
async def protected_route():
|
27
|
+
return {"data": "secret"}
|
28
|
+
"""
|
29
|
+
|
30
|
+
def __init__(self, header_name: str = "Authorization", scheme: str = "Bearer"):
|
31
|
+
super().__init__(phase=MiddlewarePhase.BEFORE)
|
32
|
+
self.header_name = header_name
|
33
|
+
self.scheme = scheme
|
34
|
+
|
35
|
+
async def before(self, request: Request) -> Optional[Response]:
|
36
|
+
"""Check for valid authentication token"""
|
37
|
+
auth_header = request.headers.get(self.header_name.lower())
|
38
|
+
|
39
|
+
if not auth_header:
|
40
|
+
return JSONResponse(
|
41
|
+
content={"detail": "Missing authentication credentials"},
|
42
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
43
|
+
headers={"WWW-Authenticate": f"{self.scheme}"},
|
44
|
+
)
|
45
|
+
|
46
|
+
# Parse scheme and token
|
47
|
+
parts = auth_header.split()
|
48
|
+
if len(parts) != 2 or parts[0].lower() != self.scheme.lower():
|
49
|
+
return JSONResponse(
|
50
|
+
content={"detail": f"Invalid authentication scheme. Expected: {self.scheme}"},
|
51
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
52
|
+
headers={"WWW-Authenticate": f"{self.scheme}"},
|
53
|
+
)
|
54
|
+
|
55
|
+
token = parts[1]
|
56
|
+
|
57
|
+
# TODO: Validate token here (this is a simple example)
|
58
|
+
if token == "invalid":
|
59
|
+
return JSONResponse(
|
60
|
+
content={"detail": "Invalid or expired token"},
|
61
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
62
|
+
)
|
63
|
+
|
64
|
+
# Store user info in request state for handler to use
|
65
|
+
request.state.user_id = "user_from_token"
|
66
|
+
request.state.token = token
|
67
|
+
|
68
|
+
return None # Continue to handler
|
69
|
+
|
70
|
+
|
71
|
+
class TimingMiddleware(RouteMiddleware):
|
72
|
+
"""
|
73
|
+
Request timing middleware - measures execution time.
|
74
|
+
|
75
|
+
Executes BOTH before and after the handler.
|
76
|
+
|
77
|
+
Example:
|
78
|
+
@router.get("/slow-operation")
|
79
|
+
@middleware(TimingMiddleware())
|
80
|
+
async def slow_operation():
|
81
|
+
await asyncio.sleep(1)
|
82
|
+
return {"status": "done"}
|
83
|
+
"""
|
84
|
+
|
85
|
+
def __init__(self):
|
86
|
+
super().__init__(phase=MiddlewarePhase.BOTH)
|
87
|
+
self.logger = logging.getLogger("vega.web.timing")
|
88
|
+
|
89
|
+
async def before(self, request: Request) -> Optional[Response]:
|
90
|
+
"""Record start time"""
|
91
|
+
request.state.start_time = time.time()
|
92
|
+
return None
|
93
|
+
|
94
|
+
async def after(self, request: Request, response: Response) -> Response:
|
95
|
+
"""Calculate and log execution time"""
|
96
|
+
if hasattr(request.state, "start_time"):
|
97
|
+
duration = time.time() - request.state.start_time
|
98
|
+
self.logger.info(
|
99
|
+
f"{request.method} {request.url.path} completed in {duration:.3f}s"
|
100
|
+
)
|
101
|
+
|
102
|
+
# Add timing header to response
|
103
|
+
if hasattr(response, "headers"):
|
104
|
+
response.headers["X-Process-Time"] = f"{duration:.3f}"
|
105
|
+
|
106
|
+
return response
|
107
|
+
|
108
|
+
|
109
|
+
class CacheControlMiddleware(RouteMiddleware):
|
110
|
+
"""
|
111
|
+
Cache control middleware - adds cache headers to response.
|
112
|
+
|
113
|
+
Executes AFTER the handler.
|
114
|
+
|
115
|
+
Args:
|
116
|
+
max_age: Cache max age in seconds
|
117
|
+
public: Whether cache is public (default: True)
|
118
|
+
|
119
|
+
Example:
|
120
|
+
@router.get("/static-data")
|
121
|
+
@middleware(CacheControlMiddleware(max_age=3600))
|
122
|
+
async def get_static_data():
|
123
|
+
return {"data": "rarely changes"}
|
124
|
+
"""
|
125
|
+
|
126
|
+
def __init__(self, max_age: int = 300, public: bool = True):
|
127
|
+
super().__init__(phase=MiddlewarePhase.AFTER)
|
128
|
+
self.max_age = max_age
|
129
|
+
self.public = public
|
130
|
+
|
131
|
+
async def after(self, request: Request, response: Response) -> Response:
|
132
|
+
"""Add cache control headers"""
|
133
|
+
cache_type = "public" if self.public else "private"
|
134
|
+
cache_value = f"{cache_type}, max-age={self.max_age}"
|
135
|
+
|
136
|
+
if hasattr(response, "headers"):
|
137
|
+
response.headers["Cache-Control"] = cache_value
|
138
|
+
|
139
|
+
return response
|
140
|
+
|
141
|
+
|
142
|
+
class CORSMiddleware(RouteMiddleware):
|
143
|
+
"""
|
144
|
+
CORS middleware for specific routes.
|
145
|
+
|
146
|
+
Executes AFTER the handler to add CORS headers.
|
147
|
+
|
148
|
+
Args:
|
149
|
+
allow_origins: List of allowed origins or "*"
|
150
|
+
allow_methods: List of allowed methods
|
151
|
+
allow_headers: List of allowed headers
|
152
|
+
|
153
|
+
Example:
|
154
|
+
@router.get("/public-api/data")
|
155
|
+
@middleware(CORSMiddleware(allow_origins=["*"]))
|
156
|
+
async def public_data():
|
157
|
+
return {"data": "public"}
|
158
|
+
"""
|
159
|
+
|
160
|
+
def __init__(
|
161
|
+
self,
|
162
|
+
allow_origins: list = None,
|
163
|
+
allow_methods: list = None,
|
164
|
+
allow_headers: list = None,
|
165
|
+
):
|
166
|
+
super().__init__(phase=MiddlewarePhase.AFTER)
|
167
|
+
self.allow_origins = allow_origins or ["*"]
|
168
|
+
self.allow_methods = allow_methods or ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
|
169
|
+
self.allow_headers = allow_headers or ["*"]
|
170
|
+
|
171
|
+
async def after(self, request: Request, response: Response) -> Response:
|
172
|
+
"""Add CORS headers to response"""
|
173
|
+
if hasattr(response, "headers"):
|
174
|
+
origin = request.headers.get("origin", "*")
|
175
|
+
|
176
|
+
if "*" in self.allow_origins or origin in self.allow_origins:
|
177
|
+
response.headers["Access-Control-Allow-Origin"] = origin
|
178
|
+
response.headers["Access-Control-Allow-Methods"] = ", ".join(
|
179
|
+
self.allow_methods
|
180
|
+
)
|
181
|
+
response.headers["Access-Control-Allow-Headers"] = ", ".join(
|
182
|
+
self.allow_headers
|
183
|
+
)
|
184
|
+
|
185
|
+
return response
|
186
|
+
|
187
|
+
|
188
|
+
class RateLimitMiddleware(RouteMiddleware):
|
189
|
+
"""
|
190
|
+
Simple rate limiting middleware.
|
191
|
+
|
192
|
+
Executes BEFORE the handler.
|
193
|
+
|
194
|
+
Args:
|
195
|
+
max_requests: Maximum requests allowed
|
196
|
+
window_seconds: Time window in seconds
|
197
|
+
|
198
|
+
Example:
|
199
|
+
@router.post("/expensive-operation")
|
200
|
+
@middleware(RateLimitMiddleware(max_requests=10, window_seconds=60))
|
201
|
+
async def expensive_op():
|
202
|
+
return {"status": "processing"}
|
203
|
+
"""
|
204
|
+
|
205
|
+
def __init__(self, max_requests: int = 100, window_seconds: int = 60):
|
206
|
+
super().__init__(phase=MiddlewarePhase.BEFORE)
|
207
|
+
self.max_requests = max_requests
|
208
|
+
self.window_seconds = window_seconds
|
209
|
+
self.requests = {} # IP -> [timestamps]
|
210
|
+
|
211
|
+
async def before(self, request: Request) -> Optional[Response]:
|
212
|
+
"""Check rate limit"""
|
213
|
+
from datetime import datetime, timedelta
|
214
|
+
|
215
|
+
client_ip = request.client.host if request.client else "unknown"
|
216
|
+
now = datetime.now()
|
217
|
+
|
218
|
+
# Clean old entries
|
219
|
+
if client_ip in self.requests:
|
220
|
+
cutoff = now - timedelta(seconds=self.window_seconds)
|
221
|
+
self.requests[client_ip] = [
|
222
|
+
ts for ts in self.requests[client_ip] if ts > cutoff
|
223
|
+
]
|
224
|
+
|
225
|
+
# Check limit
|
226
|
+
if client_ip in self.requests:
|
227
|
+
if len(self.requests[client_ip]) >= self.max_requests:
|
228
|
+
return JSONResponse(
|
229
|
+
content={
|
230
|
+
"detail": f"Rate limit exceeded. Max {self.max_requests} requests per {self.window_seconds}s"
|
231
|
+
},
|
232
|
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
233
|
+
headers={
|
234
|
+
"Retry-After": str(self.window_seconds),
|
235
|
+
"X-RateLimit-Limit": str(self.max_requests),
|
236
|
+
"X-RateLimit-Remaining": "0",
|
237
|
+
},
|
238
|
+
)
|
239
|
+
|
240
|
+
# Record request
|
241
|
+
if client_ip not in self.requests:
|
242
|
+
self.requests[client_ip] = []
|
243
|
+
self.requests[client_ip].append(now)
|
244
|
+
|
245
|
+
return None
|
246
|
+
|
247
|
+
|
248
|
+
class LoggingMiddleware(RouteMiddleware):
|
249
|
+
"""
|
250
|
+
Request/Response logging middleware.
|
251
|
+
|
252
|
+
Executes BOTH before and after.
|
253
|
+
|
254
|
+
Example:
|
255
|
+
@router.post("/important-action")
|
256
|
+
@middleware(LoggingMiddleware())
|
257
|
+
async def important_action():
|
258
|
+
return {"status": "done"}
|
259
|
+
"""
|
260
|
+
|
261
|
+
def __init__(self, logger_name: str = "vega.web.routes"):
|
262
|
+
super().__init__(phase=MiddlewarePhase.BOTH)
|
263
|
+
self.logger = logging.getLogger(logger_name)
|
264
|
+
|
265
|
+
async def before(self, request: Request) -> Optional[Response]:
|
266
|
+
"""Log incoming request"""
|
267
|
+
self.logger.info(
|
268
|
+
f"→ {request.method} {request.url.path} "
|
269
|
+
f"from {request.client.host if request.client else 'unknown'}"
|
270
|
+
)
|
271
|
+
return None
|
272
|
+
|
273
|
+
async def after(self, request: Request, response: Response) -> Response:
|
274
|
+
"""Log response"""
|
275
|
+
self.logger.info(
|
276
|
+
f"← {request.method} {request.url.path} [{response.status_code}]"
|
277
|
+
)
|
278
|
+
return response
|
279
|
+
|
280
|
+
|
281
|
+
__all__ = [
|
282
|
+
"AuthMiddleware",
|
283
|
+
"TimingMiddleware",
|
284
|
+
"CacheControlMiddleware",
|
285
|
+
"CORSMiddleware",
|
286
|
+
"RateLimitMiddleware",
|
287
|
+
"LoggingMiddleware",
|
288
|
+
]
|