lenapi 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.
- lenapi/__init__.py +79 -0
- lenapi/application.py +636 -0
- lenapi/authorization.py +96 -0
- lenapi/background.py +395 -0
- lenapi/cli.py +731 -0
- lenapi/database.py +84 -0
- lenapi/dependencies.py +12 -0
- lenapi/logging.py +13 -0
- lenapi/middleware.py +144 -0
- lenapi/model.py +54 -0
- lenapi/models.py +1052 -0
- lenapi/mongodb.py +262 -0
- lenapi/openapi.py +181 -0
- lenapi/parameters.py +8 -0
- lenapi/payments.py +543 -0
- lenapi/request.py +177 -0
- lenapi/response.py +91 -0
- lenapi/security.py +665 -0
- lenapi/security_store.py +193 -0
- lenapi/service.py +350 -0
- lenapi-0.1.0.dist-info/METADATA +959 -0
- lenapi-0.1.0.dist-info/RECORD +25 -0
- lenapi-0.1.0.dist-info/WHEEL +5 -0
- lenapi-0.1.0.dist-info/entry_points.txt +2 -0
- lenapi-0.1.0.dist-info/top_level.txt +1 -0
lenapi/application.py
ADDED
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
"""ASGI application, routers, and route dispatch."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from importlib import import_module
|
|
5
|
+
import math
|
|
6
|
+
import mimetypes
|
|
7
|
+
import re
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, TypeAdapter, ValidationError
|
|
15
|
+
from sqlalchemy.exc import IntegrityError, MultipleResultsFound
|
|
16
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
17
|
+
|
|
18
|
+
from .request import Request
|
|
19
|
+
from .response import HTTPException, JSONResponse, Response
|
|
20
|
+
from .dependencies import Depends
|
|
21
|
+
from .openapi import build_openapi, swagger_html
|
|
22
|
+
from .parameters import Body
|
|
23
|
+
from .database import AsyncDatabase, Database
|
|
24
|
+
from .mongodb import MongoDatabase
|
|
25
|
+
from .models import (
|
|
26
|
+
reset_current_async_session,
|
|
27
|
+
reset_current_session,
|
|
28
|
+
set_current_async_session,
|
|
29
|
+
set_current_session,
|
|
30
|
+
)
|
|
31
|
+
from .security_store import SecurityStoreUnavailable, create_security_store
|
|
32
|
+
from .background import BackgroundTasks
|
|
33
|
+
from .logging import get_logger
|
|
34
|
+
from .service import Cache, WebSocket, WebSocketDisconnect
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
logger = get_logger("websocket")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Route:
|
|
41
|
+
def __init__(
|
|
42
|
+
self, path: str, methods: set[str], endpoint: Callable, response_model: Any = None,
|
|
43
|
+
*, api_flag: bool = False, cool_time: int = 300, request_body: dict[str, Any] | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
if cool_time <= 0:
|
|
46
|
+
raise ValueError("cool_time must be a positive number of seconds")
|
|
47
|
+
self.path = path
|
|
48
|
+
self.methods = methods
|
|
49
|
+
self.endpoint = endpoint
|
|
50
|
+
self.response_model = response_model
|
|
51
|
+
self.request_body = request_body
|
|
52
|
+
self.api_flag = api_flag
|
|
53
|
+
self.cool_time = cool_time
|
|
54
|
+
self.regex, self.parameter_names = self._compile_path(path)
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def _compile_path(path: str) -> tuple[re.Pattern, list[str]]:
|
|
58
|
+
names: list[str] = []
|
|
59
|
+
pattern = ""
|
|
60
|
+
cursor = 0
|
|
61
|
+
for match in re.finditer(r"\{([A-Za-z_][A-Za-z0-9_]*)\}", path):
|
|
62
|
+
pattern += re.escape(path[cursor:match.start()]) + r"([^/]+)"
|
|
63
|
+
names.append(match.group(1))
|
|
64
|
+
cursor = match.end()
|
|
65
|
+
pattern += re.escape(path[cursor:])
|
|
66
|
+
return re.compile("^" + pattern + "$"), names
|
|
67
|
+
|
|
68
|
+
def match(self, path: str) -> dict[str, str] | None:
|
|
69
|
+
match = self.regex.match(path)
|
|
70
|
+
return None if match is None else dict(zip(self.parameter_names, match.groups()))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Router:
|
|
74
|
+
"""A group of routes that can be mounted on an Application or another Router."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, *, prefix: str = "") -> None:
|
|
77
|
+
self.prefix = self._normalize_prefix(prefix)
|
|
78
|
+
self.routes: list[Route] = []
|
|
79
|
+
self.websocket_routes: list[Route] = []
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _normalize_prefix(prefix: str) -> str:
|
|
83
|
+
if not prefix or prefix == "/":
|
|
84
|
+
return ""
|
|
85
|
+
return "/" + prefix.strip("/")
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def _normalize_path(path: str) -> str:
|
|
89
|
+
if not path:
|
|
90
|
+
return "/"
|
|
91
|
+
return "/" + path.strip("/") if path != "/" else "/"
|
|
92
|
+
|
|
93
|
+
def route(
|
|
94
|
+
self, path: str, *, methods: tuple[str, ...] = ("GET",), response_model: Any = None,
|
|
95
|
+
api_flag: bool = False, cool_time: int = 300,
|
|
96
|
+
request_body: dict[str, Any] | None = None,
|
|
97
|
+
):
|
|
98
|
+
def decorator(endpoint: Callable):
|
|
99
|
+
self.routes.append(Route(
|
|
100
|
+
self.prefix + self._normalize_path(path), {method.upper() for method in methods},
|
|
101
|
+
endpoint, response_model, api_flag=api_flag, cool_time=cool_time,
|
|
102
|
+
request_body=request_body,
|
|
103
|
+
))
|
|
104
|
+
return endpoint
|
|
105
|
+
return decorator
|
|
106
|
+
|
|
107
|
+
def get(self, path: str, *, response_model: Any = None, api_flag: bool = False, cool_time: int = 300):
|
|
108
|
+
return self.route(path, methods=("GET",), response_model=response_model, api_flag=api_flag, cool_time=cool_time)
|
|
109
|
+
|
|
110
|
+
def post(self, path: str, *, response_model: Any = None, api_flag: bool = False, cool_time: int = 300,
|
|
111
|
+
request_body: dict[str, Any] | None = None):
|
|
112
|
+
return self.route(path, methods=("POST",), response_model=response_model, api_flag=api_flag,
|
|
113
|
+
cool_time=cool_time, request_body=request_body)
|
|
114
|
+
|
|
115
|
+
def put(self, path: str, *, response_model: Any = None, api_flag: bool = False, cool_time: int = 300):
|
|
116
|
+
return self.route(path, methods=("PUT",), response_model=response_model, api_flag=api_flag, cool_time=cool_time)
|
|
117
|
+
|
|
118
|
+
def delete(self, path: str, *, response_model: Any = None, api_flag: bool = False, cool_time: int = 300):
|
|
119
|
+
return self.route(path, methods=("DELETE",), response_model=response_model, api_flag=api_flag, cool_time=cool_time)
|
|
120
|
+
|
|
121
|
+
def websocket(self, path: str):
|
|
122
|
+
"""Register an ASGI WebSocket handler on this router."""
|
|
123
|
+
def decorator(endpoint: Callable):
|
|
124
|
+
self.websocket_routes.append(Route(
|
|
125
|
+
self.prefix + self._normalize_path(path), {"WEBSOCKET"}, endpoint
|
|
126
|
+
))
|
|
127
|
+
return endpoint
|
|
128
|
+
return decorator
|
|
129
|
+
|
|
130
|
+
def include_router(self, router: "Router", *, prefix: str = "") -> None:
|
|
131
|
+
mount_prefix = self._normalize_prefix(prefix)
|
|
132
|
+
for route in router.routes:
|
|
133
|
+
self.routes.append(Route(
|
|
134
|
+
mount_prefix + route.path, route.methods, route.endpoint, route.response_model,
|
|
135
|
+
api_flag=route.api_flag, cool_time=route.cool_time,
|
|
136
|
+
request_body=route.request_body,
|
|
137
|
+
))
|
|
138
|
+
for route in router.websocket_routes:
|
|
139
|
+
self.websocket_routes.append(Route(
|
|
140
|
+
mount_prefix + route.path, {"WEBSOCKET"}, route.endpoint
|
|
141
|
+
))
|
|
142
|
+
|
|
143
|
+
def add_router(self, prefix: str, router: "Router") -> None:
|
|
144
|
+
"""Mount a router using Django Ninja style: ``app.add_router('/users/', router)``."""
|
|
145
|
+
self.include_router(router, prefix=prefix)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class Application(Router):
|
|
149
|
+
"""An ASGI app with Pydantic validation and optional SQLAlchemy Session injection."""
|
|
150
|
+
|
|
151
|
+
def __init__(self, *, session_factory: Callable[[], Any] | None = None, database: Any = None,
|
|
152
|
+
async_database: AsyncDatabase | None = None,
|
|
153
|
+
mongodb: MongoDatabase | None = None,
|
|
154
|
+
title: str = "lenApi", version: str = "0.1.0",
|
|
155
|
+
docs_url: str | None = "/docs/", openapi_url: str | None = "/openapi.json",
|
|
156
|
+
max_request_body_size: int | None = 10 * 1024 * 1024,
|
|
157
|
+
request_body_timeout: float | None = 30.0,
|
|
158
|
+
ip_flag_backend: str | None = None, redis_url: str | None = None,
|
|
159
|
+
security_storage: str | None = None,
|
|
160
|
+
cache_backend: str = "memory",
|
|
161
|
+
media_root: str | Path | None = None, media_url: str | None = None) -> None:
|
|
162
|
+
if session_factory is not None and database is not None:
|
|
163
|
+
raise ValueError("Pass either session_factory or database, not both")
|
|
164
|
+
if max_request_body_size is not None and (
|
|
165
|
+
isinstance(max_request_body_size, bool) or not isinstance(max_request_body_size, int)
|
|
166
|
+
or max_request_body_size < 0
|
|
167
|
+
):
|
|
168
|
+
raise ValueError("max_request_body_size must be a non-negative integer or None")
|
|
169
|
+
if request_body_timeout is not None and (
|
|
170
|
+
isinstance(request_body_timeout, bool) or not isinstance(request_body_timeout, (int, float))
|
|
171
|
+
or request_body_timeout <= 0
|
|
172
|
+
):
|
|
173
|
+
raise ValueError("request_body_timeout must be a positive number or None")
|
|
174
|
+
super().__init__()
|
|
175
|
+
self.database = database if database is not None else (
|
|
176
|
+
None if session_factory is not None or async_database is not None else Database()
|
|
177
|
+
)
|
|
178
|
+
self.session_factory = session_factory or (self.database.session_factory if self.database is not None else None)
|
|
179
|
+
self.async_database = async_database
|
|
180
|
+
self.async_session_factory = async_database.session_factory if async_database is not None else None
|
|
181
|
+
self.mongodb = mongodb
|
|
182
|
+
self.title = title
|
|
183
|
+
self.version = version
|
|
184
|
+
self.docs_url = docs_url
|
|
185
|
+
self.openapi_url = openapi_url
|
|
186
|
+
self.media_root = Path(media_root).expanduser().resolve() if media_root is not None else None
|
|
187
|
+
self.media_url = self._normalize_media_url(media_url) if self.media_root is not None and media_url else None
|
|
188
|
+
self.max_request_body_size = max_request_body_size
|
|
189
|
+
self.request_body_timeout = float(request_body_timeout) if request_body_timeout is not None else None
|
|
190
|
+
self._throttle_until: dict[tuple[str, str], float] = {}
|
|
191
|
+
self._throttle_lock = threading.Lock()
|
|
192
|
+
self.user_middleware: list[tuple[type, dict[str, Any]]] = []
|
|
193
|
+
self._middleware_stack = None
|
|
194
|
+
self.security_storage = (security_storage or ip_flag_backend or "memory").lower()
|
|
195
|
+
self.security_store = create_security_store(
|
|
196
|
+
self.security_storage,
|
|
197
|
+
engine=self.database.engine if self.database is not None else None,
|
|
198
|
+
redis_url=redis_url,
|
|
199
|
+
)
|
|
200
|
+
self.ip_flag_backend = self.security_storage
|
|
201
|
+
self.cache = Cache(
|
|
202
|
+
cache_backend,
|
|
203
|
+
engine=self.database.engine if self.database is not None else None,
|
|
204
|
+
redis_url=redis_url,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def add_middleware(self, middleware_class, **options) -> None:
|
|
208
|
+
"""Add ASGI middleware, for example ``app.add_middleware(CORSMiddleware, ...)``.
|
|
209
|
+
|
|
210
|
+
Middleware is wrapped in the order it is added; the first added is outermost.
|
|
211
|
+
"""
|
|
212
|
+
if not callable(middleware_class):
|
|
213
|
+
raise TypeError("middleware_class must be callable")
|
|
214
|
+
self.user_middleware.append((middleware_class, options))
|
|
215
|
+
self._middleware_stack = None
|
|
216
|
+
|
|
217
|
+
async def __call__(self, scope, receive, send) -> None:
|
|
218
|
+
if self._middleware_stack is None:
|
|
219
|
+
app = self._dispatch
|
|
220
|
+
for middleware_class, options in reversed(self.user_middleware):
|
|
221
|
+
app = middleware_class(app, **options)
|
|
222
|
+
self._middleware_stack = app
|
|
223
|
+
await self._middleware_stack(scope, receive, send)
|
|
224
|
+
|
|
225
|
+
async def _dispatch(self, scope, receive, send) -> None:
|
|
226
|
+
if scope["type"] == "lifespan":
|
|
227
|
+
await self._handle_lifespan(receive, send)
|
|
228
|
+
return
|
|
229
|
+
if scope["type"] == "websocket":
|
|
230
|
+
await self._handle_websocket(scope, receive, send)
|
|
231
|
+
return
|
|
232
|
+
if scope["type"] != "http":
|
|
233
|
+
return
|
|
234
|
+
path, method = scope.get("path", "/"), scope.get("method", "GET").upper()
|
|
235
|
+
route_scope = scope
|
|
236
|
+
if self.docs_url is not None and self.openapi_url is not None and path in {self.docs_url, self.docs_url.rstrip("/")} and method == "GET":
|
|
237
|
+
response = Response(swagger_html(self.openapi_url), media_type="text/html; charset=utf-8")
|
|
238
|
+
await response(route_scope, receive, send)
|
|
239
|
+
return
|
|
240
|
+
if self.openapi_url is not None and path == self.openapi_url and method == "GET":
|
|
241
|
+
response = JSONResponse(build_openapi(self, title=self.title, version=self.version))
|
|
242
|
+
await response(route_scope, receive, send)
|
|
243
|
+
return
|
|
244
|
+
if self.media_url is not None and path.startswith(self.media_url) and method == "GET":
|
|
245
|
+
relative_path = path[len(self.media_url):]
|
|
246
|
+
response = self._media_response(relative_path)
|
|
247
|
+
await response(route_scope, receive, send)
|
|
248
|
+
return
|
|
249
|
+
path_matched = False
|
|
250
|
+
for route in self.routes:
|
|
251
|
+
raw_params = route.match(path)
|
|
252
|
+
if raw_params is None:
|
|
253
|
+
continue
|
|
254
|
+
path_matched = True
|
|
255
|
+
if method not in route.methods:
|
|
256
|
+
continue
|
|
257
|
+
if self.max_request_body_size is not None:
|
|
258
|
+
raw_length = next(
|
|
259
|
+
(value for name, value in scope.get("headers", []) if name.lower() == b"content-length"),
|
|
260
|
+
None,
|
|
261
|
+
)
|
|
262
|
+
if raw_length is not None:
|
|
263
|
+
try:
|
|
264
|
+
content_length = int(raw_length)
|
|
265
|
+
except ValueError:
|
|
266
|
+
response = JSONResponse({"detail": "Invalid Content-Length"}, status_code=400)
|
|
267
|
+
await response(scope, receive, send)
|
|
268
|
+
return
|
|
269
|
+
if content_length < 0:
|
|
270
|
+
response = JSONResponse({"detail": "Invalid Content-Length"}, status_code=400)
|
|
271
|
+
await response(scope, receive, send)
|
|
272
|
+
return
|
|
273
|
+
if content_length > self.max_request_body_size:
|
|
274
|
+
response = JSONResponse({"detail": "Request body too large"}, status_code=413)
|
|
275
|
+
await response(scope, receive, send)
|
|
276
|
+
return
|
|
277
|
+
if route.api_flag:
|
|
278
|
+
client = scope.get("client")
|
|
279
|
+
client_ip = str(client[0]) if client else "unknown"
|
|
280
|
+
try:
|
|
281
|
+
retry_after = await self._claim_ip_flag(route.path, client_ip, route.cool_time)
|
|
282
|
+
except Exception:
|
|
283
|
+
hint = (
|
|
284
|
+
"Check REDIS_URL and Redis availability."
|
|
285
|
+
if self.ip_flag_backend == "redis"
|
|
286
|
+
else "Run `python lenora.py migration` to create or update the security store table."
|
|
287
|
+
)
|
|
288
|
+
response = JSONResponse(
|
|
289
|
+
{"detail": f"IP flag storage is unavailable. {hint}"}, status_code=503
|
|
290
|
+
)
|
|
291
|
+
await response(scope, receive, send)
|
|
292
|
+
return
|
|
293
|
+
if retry_after > 0:
|
|
294
|
+
response = JSONResponse(
|
|
295
|
+
{"detail": "Too many requests", "cool_time": route.cool_time},
|
|
296
|
+
status_code=429,
|
|
297
|
+
headers={"Retry-After": str(max(1, math.ceil(retry_after)))},
|
|
298
|
+
)
|
|
299
|
+
await response(scope, receive, send)
|
|
300
|
+
return
|
|
301
|
+
route_scope = {**scope, "path_params": raw_params}
|
|
302
|
+
request = Request(
|
|
303
|
+
route_scope,
|
|
304
|
+
receive,
|
|
305
|
+
max_body_size=self.max_request_body_size,
|
|
306
|
+
body_timeout=self.request_body_timeout,
|
|
307
|
+
)
|
|
308
|
+
request.background_tasks = BackgroundTasks()
|
|
309
|
+
session = None
|
|
310
|
+
async_session = None
|
|
311
|
+
session_token = None
|
|
312
|
+
async_session_token = None
|
|
313
|
+
try:
|
|
314
|
+
if self.session_factory is not None:
|
|
315
|
+
session = self.session_factory()
|
|
316
|
+
session_token = set_current_session(session)
|
|
317
|
+
if self.async_session_factory is not None:
|
|
318
|
+
async_session = self.async_session_factory()
|
|
319
|
+
async_session_token = set_current_async_session(async_session)
|
|
320
|
+
kwargs = await self._build_arguments(
|
|
321
|
+
route.endpoint, request, raw_params, session, {}, set(), async_session
|
|
322
|
+
)
|
|
323
|
+
result = route.endpoint(**kwargs)
|
|
324
|
+
if inspect.isawaitable(result):
|
|
325
|
+
result = await result
|
|
326
|
+
if isinstance(result, Response):
|
|
327
|
+
response = result
|
|
328
|
+
else:
|
|
329
|
+
if route.response_model is not None:
|
|
330
|
+
try:
|
|
331
|
+
result = TypeAdapter(route.response_model).validate_python(
|
|
332
|
+
result, from_attributes=True
|
|
333
|
+
)
|
|
334
|
+
result = TypeAdapter(route.response_model).dump_python(result, mode="json")
|
|
335
|
+
except ValidationError as exc:
|
|
336
|
+
response = JSONResponse({"detail": "Invalid response data"}, status_code=500)
|
|
337
|
+
await response(route_scope, receive, send)
|
|
338
|
+
|
|
339
|
+
return
|
|
340
|
+
response = JSONResponse(result)
|
|
341
|
+
except ValidationError:
|
|
342
|
+
response = JSONResponse({"error": "Invalid request"}, status_code=422)
|
|
343
|
+
except MultipleResultsFound:
|
|
344
|
+
response = JSONResponse({"error": "Multiple entries found"}, status_code=409)
|
|
345
|
+
except IntegrityError:
|
|
346
|
+
response = JSONResponse({"error": "Duplicate or conflicting data"}, status_code=409)
|
|
347
|
+
except HTTPException as exc:
|
|
348
|
+
response = JSONResponse(
|
|
349
|
+
{"detail": exc.detail}, status_code=exc.status_code, headers=exc.headers
|
|
350
|
+
)
|
|
351
|
+
except SecurityStoreUnavailable:
|
|
352
|
+
hint = (
|
|
353
|
+
"Run `python lenora.py migration` to create or update the security store table."
|
|
354
|
+
if self.security_storage == "database"
|
|
355
|
+
else "Install `lenapi[redis]` and check REDIS_URL and Redis availability."
|
|
356
|
+
)
|
|
357
|
+
response = JSONResponse(
|
|
358
|
+
{"detail": f"Security storage is unavailable. {hint}"}, status_code=503
|
|
359
|
+
)
|
|
360
|
+
except (TypeError, ValueError, KeyError):
|
|
361
|
+
response = JSONResponse({"error": "Invalid request"}, status_code=422)
|
|
362
|
+
finally:
|
|
363
|
+
if session_token is not None:
|
|
364
|
+
reset_current_session(session_token)
|
|
365
|
+
if async_session_token is not None:
|
|
366
|
+
reset_current_async_session(async_session_token)
|
|
367
|
+
if session is not None:
|
|
368
|
+
session.close()
|
|
369
|
+
if async_session is not None:
|
|
370
|
+
await async_session.close()
|
|
371
|
+
await response(route_scope, receive, send)
|
|
372
|
+
if response.status_code < 400:
|
|
373
|
+
await request.background_tasks.run()
|
|
374
|
+
return
|
|
375
|
+
response = JSONResponse({"detail": "Method not allowed" if path_matched else "Not found"},
|
|
376
|
+
status_code=405 if path_matched else 404)
|
|
377
|
+
await response(scope, receive, send)
|
|
378
|
+
|
|
379
|
+
async def _handle_websocket(self, scope, receive, send) -> None:
|
|
380
|
+
initial = await receive()
|
|
381
|
+
if initial.get("type") != "websocket.connect":
|
|
382
|
+
await send({"type": "websocket.close", "code": 1002, "reason": "Expected websocket.connect"})
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
path = scope.get("path", "/")
|
|
386
|
+
for route in self.websocket_routes:
|
|
387
|
+
path_params = route.match(path)
|
|
388
|
+
if path_params is None:
|
|
389
|
+
continue
|
|
390
|
+
websocket = WebSocket(scope, receive, send, path_params=path_params)
|
|
391
|
+
try:
|
|
392
|
+
kwargs: dict[str, Any] = {}
|
|
393
|
+
for name, parameter in inspect.signature(route.endpoint).parameters.items():
|
|
394
|
+
annotation = parameter.annotation
|
|
395
|
+
if annotation is WebSocket or name in {"websocket", "ws"}:
|
|
396
|
+
kwargs[name] = websocket
|
|
397
|
+
elif name in path_params:
|
|
398
|
+
raw = path_params[name]
|
|
399
|
+
kwargs[name] = raw if annotation is inspect.Parameter.empty else TypeAdapter(annotation).validate_python(raw)
|
|
400
|
+
elif parameter.default is inspect.Parameter.empty:
|
|
401
|
+
raise ValueError(f"Missing WebSocket parameter: {name}")
|
|
402
|
+
result = route.endpoint(**kwargs)
|
|
403
|
+
if inspect.isawaitable(result):
|
|
404
|
+
await result
|
|
405
|
+
except WebSocketDisconnect:
|
|
406
|
+
pass
|
|
407
|
+
except Exception:
|
|
408
|
+
logger.exception("WebSocket handler failed for %s", path)
|
|
409
|
+
if not websocket.closed:
|
|
410
|
+
await websocket.close(code=1011, reason="Internal server error")
|
|
411
|
+
else:
|
|
412
|
+
if not websocket.closed:
|
|
413
|
+
await websocket.close()
|
|
414
|
+
return
|
|
415
|
+
|
|
416
|
+
await send({"type": "websocket.close", "code": 4404, "reason": "WebSocket route not found"})
|
|
417
|
+
|
|
418
|
+
@staticmethod
|
|
419
|
+
def _normalize_media_url(media_url: str) -> str:
|
|
420
|
+
if not media_url.startswith("/"):
|
|
421
|
+
media_url = "/" + media_url
|
|
422
|
+
media_url = media_url.rstrip("/") + "/"
|
|
423
|
+
if ".." in media_url.split("/"):
|
|
424
|
+
raise ValueError("MEDIA_URL cannot contain '..' path segments")
|
|
425
|
+
return media_url
|
|
426
|
+
|
|
427
|
+
def _media_response(self, relative_path: str) -> Response:
|
|
428
|
+
if self.media_root is None or not relative_path:
|
|
429
|
+
return Response(b"Not found", status_code=404)
|
|
430
|
+
root = self.media_root
|
|
431
|
+
file_path = (root / relative_path).resolve()
|
|
432
|
+
if not file_path.is_relative_to(root) or not file_path.is_file():
|
|
433
|
+
return Response(b"Not found", status_code=404)
|
|
434
|
+
media_type = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
|
|
435
|
+
return Response(file_path.read_bytes(), media_type=media_type)
|
|
436
|
+
|
|
437
|
+
async def _handle_lifespan(self, receive, send) -> None:
|
|
438
|
+
while True:
|
|
439
|
+
message = await receive()
|
|
440
|
+
if message["type"] == "lifespan.startup":
|
|
441
|
+
await send({"type": "lifespan.startup.complete"})
|
|
442
|
+
elif message["type"] == "lifespan.shutdown":
|
|
443
|
+
if self.security_storage == "redis":
|
|
444
|
+
await self.security_store.close()
|
|
445
|
+
await self.cache.close()
|
|
446
|
+
if self.database is not None:
|
|
447
|
+
self.database.close()
|
|
448
|
+
if self.async_database is not None:
|
|
449
|
+
await self.async_database.close()
|
|
450
|
+
if self.mongodb is not None:
|
|
451
|
+
await self.mongodb.aclose()
|
|
452
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
453
|
+
|
|
454
|
+
async def _claim_ip_flag(self, route: str, client_ip: str, cool_time: int) -> int:
|
|
455
|
+
if self.security_storage in {"database", "redis"}:
|
|
456
|
+
return await self.security_store.claim(f"ip_flag:{route}:{client_ip}", cool_time)
|
|
457
|
+
key = (route, client_ip)
|
|
458
|
+
now = time.monotonic()
|
|
459
|
+
with self._throttle_lock:
|
|
460
|
+
if len(self._throttle_until) > 4096:
|
|
461
|
+
self._throttle_until = {
|
|
462
|
+
item: expiry for item, expiry in self._throttle_until.items() if expiry > now
|
|
463
|
+
}
|
|
464
|
+
retry_after = self._throttle_until.get(key, 0.0) - now
|
|
465
|
+
if retry_after <= 0:
|
|
466
|
+
self._throttle_until[key] = now + cool_time
|
|
467
|
+
return 0
|
|
468
|
+
return math.ceil(retry_after)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@staticmethod
|
|
472
|
+
def _needs_session(endpoint: Callable) -> bool:
|
|
473
|
+
try:
|
|
474
|
+
from sqlalchemy.orm import Session
|
|
475
|
+
except ImportError:
|
|
476
|
+
return False
|
|
477
|
+
def visit(callable_: Callable, seen: set[Callable]) -> bool:
|
|
478
|
+
if callable_ in seen:
|
|
479
|
+
return False
|
|
480
|
+
seen.add(callable_)
|
|
481
|
+
for parameter in inspect.signature(callable_).parameters.values():
|
|
482
|
+
if parameter.annotation is Session:
|
|
483
|
+
return True
|
|
484
|
+
if isinstance(parameter.default, Depends) and visit(parameter.default.dependency, seen):
|
|
485
|
+
return True
|
|
486
|
+
return False
|
|
487
|
+
return visit(endpoint, set())
|
|
488
|
+
|
|
489
|
+
@staticmethod
|
|
490
|
+
def _needs_async_session(endpoint: Callable) -> bool:
|
|
491
|
+
def visit(callable_: Callable, seen: set[Callable]) -> bool:
|
|
492
|
+
if callable_ in seen:
|
|
493
|
+
return False
|
|
494
|
+
seen.add(callable_)
|
|
495
|
+
for parameter in inspect.signature(callable_).parameters.values():
|
|
496
|
+
if parameter.annotation is AsyncSession or (
|
|
497
|
+
isinstance(parameter.annotation, str)
|
|
498
|
+
and parameter.annotation in {"AsyncSession", "sqlalchemy.ext.asyncio.session.AsyncSession"}
|
|
499
|
+
):
|
|
500
|
+
return True
|
|
501
|
+
if isinstance(parameter.default, Depends) and visit(parameter.default.dependency, seen):
|
|
502
|
+
return True
|
|
503
|
+
return False
|
|
504
|
+
return visit(endpoint, set())
|
|
505
|
+
|
|
506
|
+
@classmethod
|
|
507
|
+
async def _build_arguments(cls, endpoint: Callable, request: Request, path_params: dict[str, str], session: Any = None,
|
|
508
|
+
cache: dict[Callable, Any] | None = None, resolving: set[Callable] | None = None,
|
|
509
|
+
async_session: AsyncSession | None = None) -> dict[str, Any]:
|
|
510
|
+
cache = {} if cache is None else cache
|
|
511
|
+
resolving = set() if resolving is None else resolving
|
|
512
|
+
values: dict[str, Any] = {}
|
|
513
|
+
query = request.query_params
|
|
514
|
+
body_loaded = False
|
|
515
|
+
body: Any = None
|
|
516
|
+
for name, parameter in inspect.signature(endpoint).parameters.items():
|
|
517
|
+
annotation = parameter.annotation
|
|
518
|
+
if isinstance(parameter.default, Body):
|
|
519
|
+
values[name] = await request.json()
|
|
520
|
+
continue
|
|
521
|
+
if isinstance(parameter.default, Depends):
|
|
522
|
+
dependency = parameter.default.dependency
|
|
523
|
+
if dependency in resolving:
|
|
524
|
+
raise ValueError(f"Circular dependency detected: {dependency.__name__}")
|
|
525
|
+
if dependency not in cache:
|
|
526
|
+
resolving.add(dependency)
|
|
527
|
+
dependency_kwargs = await cls._build_arguments(
|
|
528
|
+
dependency, request, path_params, session, cache, resolving, async_session
|
|
529
|
+
)
|
|
530
|
+
result = dependency(**dependency_kwargs)
|
|
531
|
+
if inspect.isawaitable(result):
|
|
532
|
+
result = await result
|
|
533
|
+
cache[dependency] = result
|
|
534
|
+
resolving.remove(dependency)
|
|
535
|
+
values[name] = cache[dependency]
|
|
536
|
+
continue
|
|
537
|
+
if name == "request" and annotation in (Request, inspect.Parameter.empty):
|
|
538
|
+
values[name] = request
|
|
539
|
+
continue
|
|
540
|
+
if annotation is BackgroundTasks:
|
|
541
|
+
values[name] = request.background_tasks
|
|
542
|
+
continue
|
|
543
|
+
if session is not None and annotation.__module__.startswith("sqlalchemy") and annotation.__name__ == "Session":
|
|
544
|
+
values[name] = session
|
|
545
|
+
continue
|
|
546
|
+
if async_session is not None and (
|
|
547
|
+
annotation is AsyncSession or (
|
|
548
|
+
isinstance(annotation, str)
|
|
549
|
+
and annotation in {"AsyncSession", "sqlalchemy.ext.asyncio.session.AsyncSession"}
|
|
550
|
+
)
|
|
551
|
+
):
|
|
552
|
+
values[name] = async_session
|
|
553
|
+
continue
|
|
554
|
+
if inspect.isclass(annotation) and issubclass(annotation, BaseModel):
|
|
555
|
+
if not body_loaded:
|
|
556
|
+
body = await request.json()
|
|
557
|
+
body_loaded = True
|
|
558
|
+
values[name] = annotation.model_validate(body)
|
|
559
|
+
continue
|
|
560
|
+
raw = path_params.get(name, query.get(name, inspect.Parameter.empty))
|
|
561
|
+
if raw is inspect.Parameter.empty:
|
|
562
|
+
if parameter.default is not inspect.Parameter.empty:
|
|
563
|
+
continue
|
|
564
|
+
raise ValueError(f"Missing required parameter: {name}")
|
|
565
|
+
if isinstance(raw, list):
|
|
566
|
+
raise ValueError(f"Parameter '{name}' may only be supplied once")
|
|
567
|
+
values[name] = raw if annotation is inspect.Parameter.empty else TypeAdapter(annotation).validate_python(raw)
|
|
568
|
+
return values
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
class LenApi(Application):
|
|
572
|
+
"""Application configured from the current project's optional settings.py."""
|
|
573
|
+
|
|
574
|
+
def __init__(self, *, session_factory: Callable[[], Any] | None = None, database: Any = None,
|
|
575
|
+
async_database: AsyncDatabase | None = None,
|
|
576
|
+
mongodb: MongoDatabase | None = None,
|
|
577
|
+
title: str | None = None, version: str | None = None,
|
|
578
|
+
docs_url: str | None = None, openapi_url: str | None = None,
|
|
579
|
+
max_request_body_size: int | None = None,
|
|
580
|
+
request_body_timeout: float | None = None,
|
|
581
|
+
ip_flag_backend: str | None = None, redis_url: str | None = None,
|
|
582
|
+
security_storage: str | None = None,
|
|
583
|
+
cache_backend: str | None = None,
|
|
584
|
+
media_root: str | Path | None = None, media_url: str | None = None) -> None:
|
|
585
|
+
# Project settings are optional, so importing lenapi outside a project
|
|
586
|
+
# continues to work with the framework defaults.
|
|
587
|
+
try:
|
|
588
|
+
project_settings = import_module("settings")
|
|
589
|
+
except ModuleNotFoundError as exc:
|
|
590
|
+
if exc.name != "settings":
|
|
591
|
+
raise
|
|
592
|
+
project_settings = None
|
|
593
|
+
|
|
594
|
+
configured_database = database
|
|
595
|
+
if configured_database is None and session_factory is None and project_settings is not None:
|
|
596
|
+
database_url = getattr(project_settings, "DATABASE_URL", None)
|
|
597
|
+
if database_url:
|
|
598
|
+
configured_database = Database(database_url)
|
|
599
|
+
|
|
600
|
+
configured_async_database = async_database
|
|
601
|
+
if configured_async_database is None and project_settings is not None:
|
|
602
|
+
async_database_url = getattr(project_settings, "ASYNC_DATABASE_URL", None)
|
|
603
|
+
if async_database_url:
|
|
604
|
+
configured_async_database = AsyncDatabase(async_database_url)
|
|
605
|
+
|
|
606
|
+
configured_mongodb = mongodb
|
|
607
|
+
if configured_mongodb is None and project_settings is not None:
|
|
608
|
+
mongodb_uri = getattr(project_settings, "MONGODB_URI", None)
|
|
609
|
+
mongodb_name = getattr(project_settings, "MONGODB_DATABASE", None)
|
|
610
|
+
if mongodb_uri and mongodb_name:
|
|
611
|
+
configured_mongodb = MongoDatabase(mongodb_uri, mongodb_name)
|
|
612
|
+
|
|
613
|
+
super().__init__(
|
|
614
|
+
session_factory=session_factory,
|
|
615
|
+
database=configured_database,
|
|
616
|
+
async_database=configured_async_database,
|
|
617
|
+
mongodb=configured_mongodb,
|
|
618
|
+
title=title or getattr(project_settings, "API_TITLE", "lenApi"),
|
|
619
|
+
version=version or getattr(project_settings, "API_VERSION", "0.1.0"),
|
|
620
|
+
docs_url=docs_url if docs_url is not None else getattr(project_settings, "DOCS_URL", "/docs/"),
|
|
621
|
+
openapi_url=openapi_url if openapi_url is not None else getattr(project_settings, "OPENAPI_URL", "/openapi.json"),
|
|
622
|
+
max_request_body_size=(max_request_body_size if max_request_body_size is not None else
|
|
623
|
+
getattr(project_settings, "MAX_REQUEST_BODY_SIZE", 10 * 1024 * 1024)),
|
|
624
|
+
request_body_timeout=(request_body_timeout if request_body_timeout is not None else
|
|
625
|
+
getattr(project_settings, "REQUEST_BODY_TIMEOUT_SECONDS", 30.0)),
|
|
626
|
+
ip_flag_backend=(ip_flag_backend if ip_flag_backend is not None else
|
|
627
|
+
getattr(project_settings, "IP_FLAG_BACKEND", None)),
|
|
628
|
+
redis_url=redis_url if redis_url is not None else getattr(
|
|
629
|
+
project_settings, "REDIS_URL", "redis://127.0.0.1:6379/0"
|
|
630
|
+
),
|
|
631
|
+
security_storage=(security_storage if security_storage is not None else
|
|
632
|
+
getattr(project_settings, "SECURITY_STORAGE", None)),
|
|
633
|
+
cache_backend=cache_backend or getattr(project_settings, "CACHE_BACKEND", "memory"),
|
|
634
|
+
media_root=media_root if media_root is not None else getattr(project_settings, "MEDIA_ROOT", None),
|
|
635
|
+
media_url=media_url if media_url is not None else getattr(project_settings, "MEDIA_URL", None),
|
|
636
|
+
)
|