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/request.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Convenient access to an incoming ASGI request."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from email import policy
|
|
6
|
+
from email.parser import BytesParser
|
|
7
|
+
from http.cookies import SimpleCookie
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from urllib.parse import parse_qs
|
|
12
|
+
|
|
13
|
+
from .response import HTTPException
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UploadFile:
|
|
17
|
+
"""An uploaded multipart file held in memory for a bounded request."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, filename: str, content_type: str, data: bytes) -> None:
|
|
20
|
+
self.filename = Path(filename).name
|
|
21
|
+
self.content_type = content_type
|
|
22
|
+
self.size = len(data)
|
|
23
|
+
self._data = data
|
|
24
|
+
|
|
25
|
+
async def read(self) -> bytes:
|
|
26
|
+
return self._data
|
|
27
|
+
|
|
28
|
+
async def save_image(self, directory: str | Path, *, filename: str | None = None) -> str:
|
|
29
|
+
"""Validate common image signatures and save safely; return the filename."""
|
|
30
|
+
image_types = (
|
|
31
|
+
(self._data.startswith(b"\xff\xd8\xff"), ".jpg", {"image/jpeg", "image/jpg"}),
|
|
32
|
+
(self._data.startswith(b"\x89PNG\r\n\x1a\n"), ".png", {"image/png"}),
|
|
33
|
+
(self._data.startswith((b"GIF87a", b"GIF89a")), ".gif", {"image/gif"}),
|
|
34
|
+
(self._data.startswith(b"RIFF") and self._data[8:12] == b"WEBP", ".webp", {"image/webp"}),
|
|
35
|
+
(self._data.startswith(b"BM"), ".bmp", {"image/bmp", "image/x-ms-bmp"}),
|
|
36
|
+
)
|
|
37
|
+
detected = next((extension for matches, extension, _types in image_types if matches), None)
|
|
38
|
+
allowed_types = next((types for matches, _extension, types in image_types if matches), set())
|
|
39
|
+
if detected is None or self.content_type.lower() not in allowed_types:
|
|
40
|
+
raise ValueError("Uploaded file is not a supported image or its content type does not match")
|
|
41
|
+
if filename:
|
|
42
|
+
requested = Path(filename).name
|
|
43
|
+
suffix = Path(requested).suffix.lower()
|
|
44
|
+
valid_suffixes = {".jpeg", ".jpg"} if detected == ".jpg" else {detected}
|
|
45
|
+
if suffix not in valid_suffixes:
|
|
46
|
+
raise ValueError("Image filename extension does not match its image content")
|
|
47
|
+
safe_name = requested
|
|
48
|
+
else:
|
|
49
|
+
safe_name = f"{uuid.uuid4().hex}{detected}"
|
|
50
|
+
if not safe_name or safe_name in {".", ".."}:
|
|
51
|
+
raise ValueError("Invalid image filename")
|
|
52
|
+
root = Path(directory).expanduser().resolve()
|
|
53
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
destination = (root / safe_name).resolve()
|
|
55
|
+
if not destination.is_relative_to(root):
|
|
56
|
+
raise ValueError("Image destination must stay inside its upload directory")
|
|
57
|
+
await asyncio.to_thread(destination.write_bytes, self._data)
|
|
58
|
+
return safe_name
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Request:
|
|
62
|
+
def __init__(self, scope: dict, receive, *, max_body_size: int | None = 10 * 1024 * 1024,
|
|
63
|
+
body_timeout: float | None = 30.0) -> None:
|
|
64
|
+
self.scope = scope
|
|
65
|
+
self._receive = receive
|
|
66
|
+
self.max_body_size = max_body_size
|
|
67
|
+
self.body_timeout = body_timeout
|
|
68
|
+
self._body: bytes | None = None
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def method(self) -> str:
|
|
72
|
+
return self.scope["method"]
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def path(self) -> str:
|
|
76
|
+
return self.scope["path"]
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def client_ip(self) -> str | None:
|
|
80
|
+
"""Client address from the ASGI connection; proxy headers are not trusted."""
|
|
81
|
+
client = self.scope.get("client")
|
|
82
|
+
return client[0] if client else None
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def path_params(self) -> dict[str, object]:
|
|
86
|
+
return self.scope.get("path_params", {})
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def headers(self) -> dict[str, str]:
|
|
90
|
+
"""Request headers keyed by lowercase names."""
|
|
91
|
+
return {
|
|
92
|
+
name.decode("latin-1").lower(): value.decode("latin-1")
|
|
93
|
+
for name, value in self.scope.get("headers", [])
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def cookies(self) -> dict[str, str]:
|
|
98
|
+
"""Incoming cookies parsed from the Cookie request header."""
|
|
99
|
+
cookie = SimpleCookie()
|
|
100
|
+
cookie.load(self.headers.get("cookie", ""))
|
|
101
|
+
return {name: morsel.value for name, morsel in cookie.items()}
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def query_params(self) -> dict[str, str | list[str]]:
|
|
105
|
+
parsed = parse_qs(self.scope.get("query_string", b"").decode("latin-1"), keep_blank_values=True)
|
|
106
|
+
return {key: values[0] if len(values) == 1 else values for key, values in parsed.items()}
|
|
107
|
+
|
|
108
|
+
async def body(self) -> bytes:
|
|
109
|
+
if self._body is None:
|
|
110
|
+
chunks = []
|
|
111
|
+
size = 0
|
|
112
|
+
deadline = time.monotonic() + self.body_timeout if self.body_timeout is not None else None
|
|
113
|
+
while True:
|
|
114
|
+
try:
|
|
115
|
+
if deadline is None:
|
|
116
|
+
message = await self._receive()
|
|
117
|
+
else:
|
|
118
|
+
remaining = deadline - time.monotonic()
|
|
119
|
+
if remaining <= 0:
|
|
120
|
+
raise asyncio.TimeoutError
|
|
121
|
+
message = await asyncio.wait_for(self._receive(), timeout=remaining)
|
|
122
|
+
except asyncio.TimeoutError as exc:
|
|
123
|
+
raise HTTPException(408, "Request body timed out") from exc
|
|
124
|
+
if message["type"] == "http.disconnect":
|
|
125
|
+
break
|
|
126
|
+
chunk = message.get("body", b"")
|
|
127
|
+
size += len(chunk)
|
|
128
|
+
if self.max_body_size is not None and size > self.max_body_size:
|
|
129
|
+
raise HTTPException(413, "Request body too large")
|
|
130
|
+
chunks.append(chunk)
|
|
131
|
+
if not message.get("more_body", False):
|
|
132
|
+
break
|
|
133
|
+
self._body = b"".join(chunks)
|
|
134
|
+
return self._body
|
|
135
|
+
|
|
136
|
+
async def json(self):
|
|
137
|
+
return json.loads((await self.body()).decode("utf-8"))
|
|
138
|
+
|
|
139
|
+
async def form(self) -> dict[str, str | UploadFile | list[str | UploadFile]]:
|
|
140
|
+
"""Parse URL-encoded or multipart form data, including file uploads."""
|
|
141
|
+
content_type = self.headers.get("content-type", "")
|
|
142
|
+
if content_type.lower().startswith("application/x-www-form-urlencoded"):
|
|
143
|
+
parsed = parse_qs((await self.body()).decode("utf-8"), keep_blank_values=True)
|
|
144
|
+
return {key: values[0] if len(values) == 1 else values for key, values in parsed.items()}
|
|
145
|
+
if not content_type.lower().startswith("multipart/form-data"):
|
|
146
|
+
raise HTTPException(415, "Expected form data")
|
|
147
|
+
raw = await self.body()
|
|
148
|
+
message = BytesParser(policy=policy.default).parsebytes(
|
|
149
|
+
f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode("latin-1") + raw
|
|
150
|
+
)
|
|
151
|
+
if not message.is_multipart():
|
|
152
|
+
raise HTTPException(400, "Invalid multipart form data")
|
|
153
|
+
values: dict[str, str | UploadFile | list[str | UploadFile]] = {}
|
|
154
|
+
for part in message.iter_parts():
|
|
155
|
+
name = part.get_param("name", header="content-disposition")
|
|
156
|
+
if not name:
|
|
157
|
+
continue
|
|
158
|
+
data = part.get_payload(decode=True) or b""
|
|
159
|
+
filename = part.get_filename()
|
|
160
|
+
value: str | UploadFile
|
|
161
|
+
if filename is not None:
|
|
162
|
+
value = UploadFile(filename, part.get_content_type(), data)
|
|
163
|
+
else:
|
|
164
|
+
charset = part.get_content_charset() or "utf-8"
|
|
165
|
+
try:
|
|
166
|
+
value = data.decode(charset)
|
|
167
|
+
except (LookupError, UnicodeDecodeError) as exc:
|
|
168
|
+
raise HTTPException(400, "Invalid form field encoding") from exc
|
|
169
|
+
if name in values:
|
|
170
|
+
existing = values[name]
|
|
171
|
+
if not isinstance(existing, list):
|
|
172
|
+
values[name] = [existing, value]
|
|
173
|
+
else:
|
|
174
|
+
existing.append(value)
|
|
175
|
+
else:
|
|
176
|
+
values[name] = value
|
|
177
|
+
return values
|
lenapi/response.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""HTTP response types."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from http.cookies import SimpleCookie
|
|
6
|
+
from typing import Any
|
|
7
|
+
import uuid
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _json_default(value: Any):
|
|
11
|
+
if isinstance(value, uuid.UUID):
|
|
12
|
+
return str(value)
|
|
13
|
+
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Response:
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
content: bytes | str = b"",
|
|
20
|
+
*,
|
|
21
|
+
status_code: int = 200,
|
|
22
|
+
headers: Mapping[str, str] | None = None,
|
|
23
|
+
media_type: str = "text/plain; charset=utf-8",
|
|
24
|
+
) -> None:
|
|
25
|
+
self.body = content.encode("utf-8") if isinstance(content, str) else content
|
|
26
|
+
self.status_code = status_code
|
|
27
|
+
self.headers = {"content-type": media_type, **(headers or {})}
|
|
28
|
+
self._cookies: list[str] = []
|
|
29
|
+
|
|
30
|
+
def set_cookie(
|
|
31
|
+
self,
|
|
32
|
+
key: str,
|
|
33
|
+
value: str,
|
|
34
|
+
*,
|
|
35
|
+
max_age: int | None = None,
|
|
36
|
+
expires: str | None = None,
|
|
37
|
+
path: str = "/",
|
|
38
|
+
domain: str | None = None,
|
|
39
|
+
secure: bool = False,
|
|
40
|
+
httponly: bool = False,
|
|
41
|
+
samesite: str | None = "lax",
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Add a Set-Cookie header to this response."""
|
|
44
|
+
if not key or any(char in key for char in "=;, \t\r\n"):
|
|
45
|
+
raise ValueError("Invalid cookie name")
|
|
46
|
+
if samesite is not None and samesite.lower() not in {"lax", "strict", "none"}:
|
|
47
|
+
raise ValueError("samesite must be 'lax', 'strict', 'none', or None")
|
|
48
|
+
cookie = SimpleCookie()
|
|
49
|
+
cookie[key] = value
|
|
50
|
+
morsel = cookie[key]
|
|
51
|
+
morsel["path"] = path
|
|
52
|
+
if domain is not None:
|
|
53
|
+
morsel["domain"] = domain
|
|
54
|
+
if max_age is not None:
|
|
55
|
+
morsel["max-age"] = str(max_age)
|
|
56
|
+
if expires is not None:
|
|
57
|
+
morsel["expires"] = expires
|
|
58
|
+
if secure:
|
|
59
|
+
morsel["secure"] = True
|
|
60
|
+
if httponly:
|
|
61
|
+
morsel["httponly"] = True
|
|
62
|
+
if samesite is not None:
|
|
63
|
+
morsel["samesite"] = samesite.capitalize()
|
|
64
|
+
self._cookies.append(morsel.OutputString())
|
|
65
|
+
|
|
66
|
+
def delete_cookie(self, key: str, *, path: str = "/", domain: str | None = None) -> None:
|
|
67
|
+
"""Expire a cookie by adding a matching deletion cookie."""
|
|
68
|
+
self.set_cookie(key, "", max_age=0, expires="Thu, 01 Jan 1970 00:00:00 GMT", path=path, domain=domain)
|
|
69
|
+
|
|
70
|
+
async def __call__(self, scope: dict, receive, send) -> None:
|
|
71
|
+
headers = [(k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in self.headers.items()]
|
|
72
|
+
headers.extend((b"set-cookie", value.encode("latin-1")) for value in self._cookies)
|
|
73
|
+
headers.append((b"content-length", str(len(self.body)).encode("ascii")))
|
|
74
|
+
await send({"type": "http.response.start", "status": self.status_code, "headers": headers})
|
|
75
|
+
await send({"type": "http.response.body", "body": self.body})
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class JSONResponse(Response):
|
|
79
|
+
def __init__(self, content: Any, *, status_code: int = 200, headers: Mapping[str, str] | None = None) -> None:
|
|
80
|
+
body = json.dumps(content, ensure_ascii=False, separators=(",", ":"), default=_json_default).encode("utf-8")
|
|
81
|
+
super().__init__(body, status_code=status_code, headers=headers, media_type="application/json")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class HTTPException(Exception):
|
|
85
|
+
"""Raise an HTTP error from a route or framework helper."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, status_code: int, detail: str, *, headers: Mapping[str, str] | None = None) -> None:
|
|
88
|
+
super().__init__(detail)
|
|
89
|
+
self.status_code = status_code
|
|
90
|
+
self.detail = detail
|
|
91
|
+
self.headers = headers or {}
|