Skip to content

src/genro_asgi/asgi_server.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 """AsgiServer — the shipped mono-process server composition (D22, D6, D16).16 17 ``AsgiServer`` stacks every core capability mixin over ``BaseServer`` in one18 MRO (``CommunicationMixin, AuthMixin, SessionMixin, MiddlewareMixin,19 PluginMixin, StorageMixin, TaskMixin, BaseServer``): the complete mono-process20 async server of D22. ``TaskMixin`` sits after ``StorageMixin`` (it needs21 ``server.storage``) and before ``BaseServer`` (its lifespan hook must wrap the22 base ``Lifespan``). The future internal (worker) server simply composes the SAME23 base WITHOUT the auth mixin (D6 by construction — the base never learned about24 the chain).25 26 The server is SELF-CONFIGURING: ``AsgiServer(config=source)`` builds its own27 read door — a ``ConfigurationHandler`` over a ``config.py`` path, a recipe class,28 a recipe instance or a ready handler — derives its constructor kwargs from it and29 then runs the ordinary D16 cooperative chain. Nothing materializes a server from30 the outside; the class that needs the values reads them. Explicitly passed31 kwargs WIN over the configured ones, wholesale per kwarg32 (``AsgiServer(config=Recipe, port=0)`` serves the recipe's site on an33 OS-assigned port), and the handler stays reachable as ``server.config`` — the34 read door applications delegate to. A bare ``AsgiServer(...)`` has35 ``config is None`` and behaves exactly as before.36 37 Its cooperative ``__init__`` peels the kwargs the frozen Macro 1 ``BaseServer``38 does not accept — ``host``/``port``/``external_url`` plus ``server_app`` (the39 login-surface values of the ``authentication`` section) — and forwards40 everything else (``applications``, ``auth``, ``session_store``/``session_ttl``,41 ``middleware``/``middleware_registry``, ``plugins``/``plugin_registry``,42 ``storage``/``storage_key``, ``parent``) down the D16 chain. The peeled43 ``host``/``port`` become the defaults of ``serve``, so a configured server44 serves on its configured address unless the caller overrides it.45 46 ``host``/``port`` are the LISTENER; ``external_url`` is the server's PUBLIC47 base address — the two differ behind a proxy and answer different questions.48 The listener says where to bind; the public address is what the server calls49 itself when it hands its own URL to a third party. Only one consumer needs it50 today (an OIDC provider is given an absolute ``redirect_uri``, RFC 674951 §3.1.2), and it is DECLARED rather than derived from a request: the URI must52 match the one registered with the provider — a deployment fact known to53 whoever installs — and deriving it from the client-supplied ``Host`` would54 build a value the provider then rejects. Missing it with a provider55 configured is a boot error (``_check_oidc_external_url``), not an opaque56 provider error at the first login.57 58 Once the chain has run, ``__init__`` registers the automatic ``_server`` app59 (``_register_server_app``, D4 "automatic, not configured"): a hand-built60 ``AsgiServer(applications=[...])`` exposes ``/_server/...`` exactly like a61 configured one, and no configuration path special-cases it. The configured62 databases are registered right after, over the live server.63 """64 65 from __future__ import annotations66 67 from pathlib import Path68 from typing import Any69 70 from genro_builders.builder import BuilderBase71 72 from .applications.server_app import ServerApplication73 from .auth import AuthMixin74 from .communication import CommunicationMixin75 from .config.elements import AsgiServerGrammar76 from .config.default_config import DefaultConfig77 from .config.handler import ConfigurationHandler78 from .db import AsgiDbHandlerBase79 from .middleware import MiddlewareMixin80 from .plugin_mixin import PluginMixin81 from .server import BaseServer82 from .session import SessionMixin83 from .storage_mixin import StorageMixin84 from .tasks import TaskMixin85 86 __all__ = ["AsgiServer"]87 88 ConfigSource = str | Path | type | BuilderBase | ConfigurationHandler89 90 91 class AsgiServer(92     CommunicationMixin,93     AuthMixin,94     SessionMixin,95     MiddlewareMixin,96     PluginMixin,97     StorageMixin,98     TaskMixin,99     BaseServer,100 ):101     """The shipped composition: communication + auth + sessions + chain + plugins + storage + base.102 103     Constructor kwargs peeled here: ``config`` — the configuration source this104     server reads itself from — ``host`` and ``port`` (the ``serve`` defaults),105     ``external_url`` (the public base address, trailing slash stripped) and106     ``server_app`` (the login-surface values forwarded to the automatically107     registered ``_server`` app). Every other kwarg flows to the capability108     mixins and the base (D16 cooperative init).109     """110 111     grammar: type = AsgiServerGrammar112 113     def __init__(self, config: ConfigSource | None = None, **kwargs: Any) -> None:114         self._config = self._build_config(config)115         if self.config is not None:116             kwargs = {**self._configured_kwargs(self.config), **kwargs}117         self._config_host: str | None = kwargs.pop("host", None)118         self._config_port: int | None = kwargs.pop("port", None)119         external_url: str | None = kwargs.pop("external_url", None)120         self._external_url: str | None = external_url.rstrip("/") if external_url else None121         self._server_app_kwargs: dict[str, Any] = kwargs.pop("server_app", {})122         super().__init__(**kwargs)123         self._register_server_app()124         self._check_oidc_external_url()125         if self.config is not None:126             self._register_configured_databases(self.config)127 128     def _build_config(self, config: ConfigSource | None) -> ConfigurationHandler | None:129         """The read door over ``config``: a ready handler passes through, anything130         else (a ``config.py`` path, a recipe class, a recipe instance) is wrapped131         in one over the parent layers. ``None`` — a hand-built server — has no132         configuration at all.133 134         The site recipe is the TOP layer: ``DefaultConfig.parents_for()`` puts the135         package defaults under it, plus the defaults source the recipe itself136         declares (``default_config``). A handler handed in ready-made keeps137         whatever layering it was built with — its owner already decided.138 139         A ``config.py`` path is imported ONCE, here: the loaded class both140         answers ``default_config`` and becomes the handler's source, so a141         module-body side effect fires a single time per boot and the class the142         parents were computed from is the class the handler builds."""143         if config is None or isinstance(config, ConfigurationHandler):144             return config145         defaults = DefaultConfig()146         if isinstance(config, (str, Path)):147             config = defaults.recipe_class(config)148         return ConfigurationHandler(config, parents=defaults.parents_for(config))149 150     def _configured_kwargs(self, config: ConfigurationHandler) -> dict[str, Any]:151         """The constructor kwargs the configuration declares.152 153         One helper of the read door per section, each mapped to the kwarg the154         owning class peels; a section the recipe omits contributes nothing, so155         the composition's own defaults apply. ``applications`` are instantiated156         HERE — the recipe named the classes and their kwargs, and a recipe error157         surfaces as a boot error instead of a broken server.158         """159         kwargs: dict[str, Any] = config.server_kwargs()160         kwargs.update(config.identity_kwargs())161         for name, value in (162             ("middleware", config.middleware_config()),163             ("auth", config.auth_entries()),164             ("plugins", config.plugins_config()),165         ):166             if value is not None:167                 kwargs[name] = value168         storage = config.storage_config()169         if storage is not None:170             kwargs["storage"], kwargs["storage_key"] = storage171         server_app = config.server_app_kwargs()172         if server_app:173             kwargs["server_app"] = server_app174         entries, default = config.applications()175         kwargs["applications"] = [app_class(**app_kwargs) for app_class, app_kwargs in entries]176         if default is not None:177             kwargs["default"] = default178         return kwargs179 180     def _register_configured_databases(self, config: ConfigurationHandler) -> None:181         """Build and register the configured database handlers over the live server.182 183         Each descriptor becomes ``db_handler_class(db_class(**params))``,184         registered by its ``code``; the default handler class is185         ``AsgiDbHandlerBase``. It runs after the cooperative chain because186         ``add_database`` needs the server, not its kwargs.187         """188         for descriptor in config.databases():189             db_class = descriptor["db_class"]190             handler_class = descriptor["db_handler_class"] or AsgiDbHandlerBase191             self.add_database(192                 descriptor["code"], handler_class(db_class(**descriptor["params"]))193             )194 195     @property196     def config(self) -> ConfigurationHandler | None:197         """The read door over this server's configuration (``None`` when built bare).198 199         Callable as ``server.config("server.host")`` — the four-layer read stack200         of the ``ConfigurationHandler`` — and the door applications delegate to201         with their own ``applications.<code>.`` prefix.202         """203         return self._config204 205     def _register_server_app(self) -> None:206         """Register the automatic ``_server`` app (D4) unless one is already there.207 208         Runs at the end of ``__init__``, after the composed applications are209         registered, so the guard only matters when the composition already210         carries a ``_server`` app (idempotent). The peeled ``server_app``211         kwargs (the ``authentication`` login surface: ``login`` policy, ``oidc``212         providers) are forwarded here — the app peels them.213         """214         if "_server" not in self.applications:215             self.register_application(ServerApplication(**self._server_app_kwargs))216 217     def _check_oidc_external_url(self) -> None:218         """Refuse to boot when a provider is configured without ``external_url``.219 220         Runs right after the ``_server`` registration, the first moment both facts are221         known — the app carries the configured providers, the server carries its222         public address — and covers the configured and the hand-built server with223         one check. An OIDC provider is handed the ABSOLUTE ``redirect_uri`` it224         must send the browser back to; without a public base address that URI225         cannot be built, so the configuration is incomplete and the server says226         so loudly instead of failing at the first login attempt with a227         provider-side error.228         """229         providers = getattr(self.applications.get("_server"), "oidc_providers", None)230         if providers and self.external_url is None:231             codes = ", ".join(sorted(providers))232             raise ValueError(233                 f"oidc provider(s) {codes} configured but the server has no "234                 "external_url: OIDC needs the public base URL to build the "235                 "absolute redirect_uri (set server(external_url=...))"236             )237 238     @property239     def login_enabled(self) -> bool:240         """True when the ``_server`` app carries a registered auth method.241 242         The challenge negotiation (``ErrorMiddleware``) reads this to decide243         whether a 401 becomes a login redirect (browser) or a ``login_url``244         body (API). It reflects live state: ``ServerApplication`` registers the245         password method at construction, so its server has a login surface.246         """247         server_app = self.applications.get("_server")248         section = getattr(server_app, "auth_section", None)249         return bool(section is not None and section.methods)250 251     @property252     def config_host(self) -> str | None:253         """The host from the config's ``server`` section (``None`` if unset)."""254         return self._config_host255 256     @property257     def config_port(self) -> int | None:258         """The port from the config's ``server`` section (``None`` if unset)."""259         return self._config_port260 261     @property262     def external_url(self) -> str | None:263         """The server's public base URL, without a trailing slash (``None`` if unset).264 265         What the server calls ITSELF when it hands its own address to a third266         party — distinct from the ``host``/``port`` it binds to, which differ267         behind a proxy. Declared in the config's ``server`` section; the only268         consumer today is the OIDC ``redirect_uri``, which must be absolute.269         """270         return self._external_url271 272     def serve(self, host: str | None = None, port: int | None = None) -> None:273         """Boot uvicorn, defaulting host/port to the configured values.274 275         The caller's explicit ``host``/``port`` win; otherwise the config's276         ``server`` section is used, falling back to the ``BaseServer`` defaults277         (``127.0.0.1`` and an OS-assigned port).278         """279         resolved_host = host if host is not None else (self.config_host or "127.0.0.1")280         resolved_port = port if port is not None else (self.config_port if self.config_port is not None else 0)281         super().serve(host=resolved_host, port=resolved_port)