Skip to content

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}>"