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)