src/genro_asgi/request.py¶
Source from this local checkout, regenerated when the reader rebuilds.
Line links use #L<number>; a GitHub line range opens its first line.
1 # Copyright 2025 Softwell S.r.l.2 #3 # Licensed under the Apache License, Version 2.0 (the "License");4 # you may not use this file except in compliance with the License.5 # You may obtain a copy of the License at6 #7 # https://www.apache.org/licenses/LICENSE-2.08 #9 # Unless required by applicable law or agreed to in writing, software10 # distributed under the License is distributed on an "AS IS" BASIS,11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.12 # See the License for the specific language governing permissions and13 # limitations under the License.14 15 """HTTP request: one flat class over the ASGI scope, eager body parsing.16 17 ``Request`` is HTTP-only — no transport abstraction (the WSX/message transport18 is orchestration, out of the core). It wraps the ASGI ``scope`` and, in the19 async ``init()``, reads the request once and by itself: headers and cookies20 off the scope, the query string, and the whole body pumped from ``receive``21 until ``more_body`` is false — always, whatever the content-type, so a22 request never leaves unread ASGI messages behind. genro-tytx is used ONLY as23 a serializer: header values, query values and multipart fields are hydrated24 with ``from_tytx``, an urlencoded body with ``from_qs``, a json/xml/msgpack25 body with ``from_tytx(transport=...)``. The transport → media type map lives26 in ``media_types``, the inbound content-type is resolved here in27 ``get_transport``; the protocol reading lives here and nowhere else.28 29 The body is decoded by content-type:30 31 - json / xml / msgpack (standard or ``application/vnd.tytx+*`` media type) →32 the hydrated value;33 - ``application/x-www-form-urlencoded`` → a dict via ``from_qs``;34 - ``multipart/form-data`` → a dict: text parts hydrated with ``from_tytx``,35 file parts (those carrying a ``filename``) as ``UploadedFile``; a field36 name repeated across parts collects its values in a list;37 - anything else → the raw bytes;38 - an empty body → ``None``.39 40 ``UploadedFile`` is the file a client uploaded in a multipart form: ``name``41 (the form field), ``filename`` (as sent by the client), ``content_type``42 (declared for that part) and ``data`` (the bytes, whole — no spooling, no43 streaming: the body is already resident).44 45 TYTX mode is detected from the ``X-TYTX-Transport`` header; the paired46 ``Response`` reads ``tytx_mode`` / ``tytx_transport`` to serialize the reply47 in the same transport. The owning application creates the request48 (``Request(scope, receive, application=app)``, or ``server=`` directly) and49 holds the response seam: ``self.response`` is a ``Response`` bound back to it.50 51 ``handler_kwargs()`` builds the kwargs a route handler receives: the query is52 the base; a form body — urlencoded OR multipart — is merged field by field53 (body wins on a clash, files included: ``def upload(self, title, doc)`` gets54 ``doc`` as an ``UploadedFile``); a hydrated body is passed whole as55 ``body_data``; opaque bytes as ``body_raw``; an empty body adds nothing.56 57 ``db`` is the deferred preparation layer (no ORM yet): on first access it58 resolves the server's registered handler for the owning app's ``db_name`` (else59 ``"default"``) and registers its ``closeConnection`` as a request cleanup (drained60 by the server at end of request). ``get_db(name)`` is a plain lookup with no61 cleanup registration. Auth and session ride the scope (``scope["auth"]`` — an62 ``Avatar`` or ``None`` — and ``scope["session"]``), set by the middleware chain.63 """64 65 from __future__ import annotations66 67 import email.policy68 import time69 import uuid70 from collections import defaultdict71 from email.parser import BytesParser72 from http.cookies import SimpleCookie73 from typing import TYPE_CHECKING, Any, Literal74 from urllib.parse import parse_qs75 76 from genro_tytx import from_qs, from_tytx77 78 from .response import Response79 from .session.session import Session80 81 if TYPE_CHECKING:82 from .application import BaseApplication83 from .server import BaseServer84 from .types import Receive, Scope85 86 __all__ = ["Request", "UploadedFile"]87 88 89 class UploadedFile:90 """A file uploaded in a multipart form: its form field, name, type and bytes."""91 92 __slots__ = ("name", "filename", "content_type", "data")93 94 def __init__(self, name: str, filename: str, content_type: str, data: bytes) -> None:95 self.name = name96 self.filename = filename97 self.content_type = content_type98 self.data = data99 100 def __repr__(self) -> str:101 return (102 f"<UploadedFile name={self.name!r} filename={self.filename!r} "103 f"bytes={len(self.data)}>"104 )105 106 107 class Request:108 """An ASGI HTTP request: scope wrapper with eager, TYTX-aware body parsing."""109 110 __slots__ = (111 "_scope",112 "_receive",113 "_server",114 "_application",115 "_db",116 "_headers",117 "_cookies",118 "_query",119 "_data",120 "_id",121 "_external_id",122 "_tytx_mode",123 "_tytx_transport",124 "_created_at",125 "response",126 )127 128 def __init__(129 self,130 scope: Scope,131 receive: Receive,132 *,133 server: BaseServer | None = None,134 application: BaseApplication | None = None,135 ) -> None:136 self._scope = scope137 self._receive = receive138 self._server = server139 self._application = application140 self._db: Any = None141 self._headers: dict[str, Any] = {}142 self._cookies: dict[str, str] = {}143 self._query: dict[str, Any] = {}144 self._data: Any = None145 self._id: str = ""146 self._external_id: str | None = None147 self._tytx_mode: bool = False148 self._tytx_transport: str | None = None149 self._created_at: float = time.time()150 self.response: Response = Response(request=self)151 152 async def init(self) -> None:153 """Read headers, cookies, query and body from the scope (once).154 155 Then derives TYTX mode, the request id (``x-request-id`` header or a156 fresh uuid4) and the optional client correlation id (``x-external-id``).157 """158 cookie_header = self.read_headers()159 self._cookies = self.decode_cookies(cookie_header)160 self._query = self.decode_query(self._scope.get("query_string", b"").decode("latin-1"))161 body = await self.read_body()162 self._data = self.decode_body(body, str(self.content_type or ""))163 transport = self._headers.get("x-tytx-transport")164 if transport:165 self._tytx_mode = True166 self._tytx_transport = str(transport).lower()167 request_id = self._headers.get("x-request-id")168 self._id = str(request_id) if request_id else str(uuid.uuid4())169 external_id = self._headers.get("x-external-id")170 self._external_id = str(external_id) if external_id is not None else None171 172 def read_headers(self) -> str:173 """Fill the header map off the scope and hand back the raw cookie header.174 175 Keys are lowercased and values TYTX-hydrated; ``cookie`` stays out of the176 map — it is the one header ``decode_cookies`` owns.177 """178 cookie_header = ""179 for name, value in self._scope.get("headers", []):180 key = name.decode("latin-1").lower()181 text = value.decode("latin-1")182 if key == "cookie":183 cookie_header = text184 else:185 self._headers[key] = from_tytx(text)186 return cookie_header187 188 def decode_cookies(self, cookie_header: str) -> dict[str, str]:189 """Split a ``Cookie`` header into its morsels, values TYTX-hydrated."""190 cookies: SimpleCookie = SimpleCookie()191 cookies.load(cookie_header)192 return {key: from_tytx(morsel.value) for key, morsel in cookies.items()}193 194 def decode_query(self, query_string: str) -> dict[str, Any]:195 """Split a query string, values TYTX-hydrated (a repeated key gives a list)."""196 parsed = parse_qs(query_string, keep_blank_values=True)197 return {198 key: from_tytx(values[0]) if len(values) == 1 else [from_tytx(v) for v in values]199 for key, values in parsed.items()200 }201 202 async def read_body(self) -> bytes:203 """Pump the ASGI body messages until ``more_body`` is false, joined once."""204 chunks: list[bytes] = []205 while True:206 message = await self._receive()207 chunks.append(message.get("body", b""))208 if not message.get("more_body", False):209 return b"".join(chunks)210 211 def get_transport(self, content_type: str) -> Literal["json", "xml", "msgpack"] | None:212 """The TYTX transport a content-type names, or ``None`` for the others.213 214 Substring matching, so the standard media type (``application/json``) and215 the TYTX one (``application/vnd.tytx+json``) resolve to the same transport.216 """217 if "json" in content_type:218 return "json"219 if "xml" in content_type:220 return "xml"221 if "msgpack" in content_type:222 return "msgpack"223 return None224 225 def decode_body(self, body: bytes, content_type: str) -> Any:226 """Decode the body bytes by content-type.227 228 A json/xml/msgpack body comes back hydrated, a form body (urlencoded or229 multipart) as a dict of fields, anything else as the opaque bytes it is;230 an empty body is ``None``. The media type decides case-insensitively,231 while the multipart parser gets the header as sent — its boundary is232 case-sensitive.233 """234 if not body:235 return None236 media = content_type.lower()237 transport = self.get_transport(media)238 if transport == "msgpack":239 return from_tytx(body, transport=transport)240 if transport is not None:241 return from_tytx(body.decode("utf-8"), transport=transport)242 if "x-www-form-urlencoded" in media:243 return from_qs(body.decode("latin-1"))244 if "multipart/form-data" in media:245 return self.decode_multipart(body, content_type)246 return body247 248 def decode_multipart(self, body: bytes, content_type: str) -> dict[str, Any]:249 """Split a multipart form body into its fields, keyed by form name.250 251 A part carrying a ``filename`` becomes an ``UploadedFile``, a text part is252 TYTX-hydrated, and a name repeated across parts collects a list.253 """254 raw = b"Content-Type: " + content_type.encode("latin-1") + b"\r\n\r\n" + body255 form = BytesParser(policy=email.policy.HTTP).parsebytes(raw)256 fields: defaultdict[str, list[Any]] = defaultdict(list)257 for part in form.iter_parts():258 name = part.get_param("name", header="content-disposition")259 filename = part.get_filename()260 payload = part.get_payload(decode=True)261 if filename is None:262 fields[name].append(from_tytx(payload.decode("utf-8")))263 else:264 fields[name].append(UploadedFile(name, filename, part.get_content_type(), payload))265 return {name: values[0] if len(values) == 1 else values for name, values in fields.items()}266 267 @property268 def id(self) -> str:269 """Correlation id: the ``x-request-id`` header, or a generated uuid4."""270 return self._id271 272 @property273 def method(self) -> str:274 """HTTP method (uppercased)."""275 return str(self._scope.get("method", "GET")).upper()276 277 @property278 def path(self) -> str:279 """Request path."""280 return str(self._scope.get("path", "/"))281 282 @property283 def headers(self) -> dict[str, Any]:284 """Request headers (lowercase keys), values hydrated by TYTX."""285 return self._headers286 287 @property288 def cookies(self) -> dict[str, str]:289 """Request cookies parsed from the ``Cookie`` header."""290 return self._cookies291 292 @property293 def query(self) -> dict[str, Any]:294 """Query parameters (typed via TYTX)."""295 return self._query296 297 @property298 def data(self) -> Any:299 """Parsed body: hydrated value, raw bytes, or ``None`` when empty."""300 return self._data301 302 @property303 def content_type(self) -> str | None:304 """``Content-Type`` header value, or ``None``."""305 return self._headers.get("content-type")306 307 @property308 def external_id(self) -> str | None:309 """Client-provided correlation id (``x-external-id`` header)."""310 return self._external_id311 312 @property313 def tytx_mode(self) -> bool:314 """True when the request declared a TYTX transport."""315 return self._tytx_mode316 317 @property318 def tytx_transport(self) -> str | None:319 """TYTX transport (``json``/``xml``/``msgpack``), or ``None``."""320 return self._tytx_transport321 322 @property323 def created_at(self) -> float:324 """Wall-clock timestamp captured at construction."""325 return self._created_at326 327 @property328 def age(self) -> float:329 """Seconds elapsed since construction."""330 return time.time() - self._created_at331 332 @property333 def scope(self) -> Scope:334 """The raw ASGI scope."""335 return self._scope336 337 @property338 def server(self) -> BaseServer | None:339 """The owning server (passed directly, or via the owning application)."""340 if self._server is not None:341 return self._server342 return self._application.server if self._application is not None else None343 344 @property345 def application(self) -> BaseApplication | None:346 """The application that created this request (``None`` if unbound)."""347 return self._application348 349 def avatar(self, key: str = Session.ROOT_AVATAR_KEY) -> Any:350 """The identity acting on this request under ``key`` (an ``Avatar``) or ``None``.351 352 With no argument — the root slot — it is the effective identity the auth353 chain resolved for this request: header credentials or the session's root354 avatar, read from the scope. Any other key is a sub-login, looked up in355 the session's keyed avatars (``None`` without a session).356 """357 if key == Session.ROOT_AVATAR_KEY:358 return self._scope.get("auth")359 session = self.session360 return session.avatar(key) if session is not None else None361 362 @property363 def auth_tags(self) -> list[str]:364 """Authorization tags of the current identity (empty when anonymous)."""365 avatar = self.avatar()366 return list(avatar.tags) if avatar is not None else []367 368 @property369 def session(self) -> Any:370 """The session object attached by ``SessionMiddleware``, or ``None``."""371 return self._scope.get("session")372 373 @property374 def db(self) -> Any:375 """The default db handler for the owning app, or ``None`` (lazy).376 377 Resolves ``server.databases[name]`` where ``name`` is the owning378 application's ``db_name`` attribute if set, else ``"default"``. On the379 first successful resolution it registers ``handler.closeConnection`` as a380 request cleanup (drained by the server at end of request). Returns381 ``None`` when there is no server or no handler under that name.382 383 Preparation layer only: no pooling, no transactions, no per-app registry.384 """385 if self._db is not None:386 return self._db387 server = self.server388 if server is None:389 return None390 name = getattr(self._application, "db_name", None) or "default"391 handler = server.databases.get(name)392 if handler is None:393 return None394 self._db = handler395 current = server.requests.current396 if current is not None:397 current.add_cleanup(handler.closeConnection)398 return handler399 400 def get_db(self, name: str) -> Any:401 """Look up a registered db handler by ``name`` (no cleanup registration)."""402 server = self.server403 if server is None:404 return None405 return server.databases.get(name)406 407 def handler_kwargs(self) -> dict[str, Any]:408 """Build the kwargs a route handler is called with (query + body).409 410 The query params are the base. The body adds to them by content-type,411 not by Python shape: a form body — ``x-www-form-urlencoded`` or412 ``multipart/form-data`` — arrives as a dict of hydrated fields (files413 included, as ``UploadedFile``) and is merged field by field, the body414 winning on a name clash; a hydrated body (JSON/XML/msgpack) is passed415 whole as ``body_data``; opaque bytes are passed as ``body_raw``; an416 empty body adds nothing.417 """418 kwargs: dict[str, Any] = dict(self._query)419 data = self._data420 if data is None:421 return kwargs422 content_type = (self.content_type or "").lower()423 if "x-www-form-urlencoded" in content_type or "multipart/form-data" in content_type:424 if isinstance(data, dict):425 kwargs.update(data)426 elif isinstance(data, bytes):427 kwargs["body_raw"] = data428 else:429 kwargs["body_data"] = data430 return kwargs431 432 def __repr__(self) -> str:433 return f"<Request id={self._id!r} method={self.method} path={self.path!r}>"