Skip to content

src/genro_asgi/applications/openapi.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 """OpenApiApplication: a RoutedApplication that exposes REST + OpenAPI + docs.16 17 ``OpenApiApplication`` wraps an API surface — either the app's own18 ``@route`` methods (direct mode) or an external ``RoutingClass`` attached19 under ``api_name`` (mounted mode) — and adds a ``_meta`` sub-tree with three20 introspection endpoints:21 22 - ``_meta/schema_json`` — the OpenAPI 3.1 document of the API;23 - ``_meta/docs`` — a Swagger-UI page pointing at ``_meta/schema_json``;24 - ``_meta/index`` — an HTML splash linking to the docs.25 26 The schema is built STANDALONE from the app's own router27 (``router_openapi(app.route)``): there is no dependency on a ``_server``28 application (that surface belongs to a later macro). The mounted routing29 class is linked as an eager ``instance`` branch, so it inherits the app30 router's plugins — pydantic among them — and its handler signatures are31 captured into the neutral ``params``/``result`` blocks the32 ``OpenAPITranslator`` reads; in direct mode the same plugins reach the app's33 own router through the server's plugin arming (``PluginMixin`` config).34 35 The docs and splash HTML live in dedicated resource files next to this module36 and are read at USE time (never at import): a swap of the template file takes37 effect without re-importing the package.38 39 Kwargs peeled by the cooperative ``__init__`` (D16): ``routing_class`` (a40 ``RoutingClass`` instance to mount), ``module`` (``"pkg.mod:ClassName"``41 import path, an alternative to ``routing_class``), ``docs`` (documentation42 style — ``"swagger"`` or ``"off"``) and ``api_name`` (the segment the mounted43 class is attached under, default ``"api"``). The rest flows down the chain44 (``db_name`` to ``RoutedApplication``, ``code``/``mount`` to45 ``BaseApplication``).46 """47 48 from __future__ import annotations49 50 from pathlib import Path51 from typing import Any, ClassVar52 53 from genro_routes import RoutingClass, route54 55 from ..exceptions import HTTPNotFound56 from ..plugins.openapi import router_openapi57 from ..routed_application import RoutedApplication58 59 __all__ = ["OpenApiApplication"]60 61 RESOURCES_DIR = Path(__file__).parent / "resources"62 63 64 class OpenApiApplication(RoutedApplication):65     """Expose an API surface as REST + OpenAPI 3.1 with a Swagger docs page.66 67     Two ways to supply the API:68 69     - direct mode — subclass and write ``@route`` methods on the app itself;70       endpoints sit at the app root (``/{app}/endpoint``);71     - mounted mode — pass ``routing_class=`` (or ``module=``); the class is72       attached under ``api_name`` (``/{app}/{api_name}/endpoint``).73 74     Meta endpoints are attached under ``_meta`` in both modes.75     """76 77     openapi_info: ClassVar[dict[str, Any]] = {}78 79     def __init__(self, **kwargs: Any) -> None:80         self._docs_style: str = kwargs.pop("docs", "swagger")81         self._api_name: str = kwargs.pop("api_name", "api")82         routing_class: RoutingClass | None = kwargs.pop("routing_class", None)83         module: str | None = kwargs.pop("module", None)84         self._mounted_info: dict[str, Any] = {}85         super().__init__(**kwargs)86         self.route.add_branches({"name": "_meta", "instance": OpenApiMeta(self)})87         if routing_class is None and module:88             routing_class = self._import_routing_class(module)89         if routing_class is not None:90             self._mount_routing_class(routing_class)91 92     @property93     def docs_style(self) -> str:94         """Documentation style — ``"swagger"`` or ``"off"``."""95         return self._docs_style96 97     @property98     def api_name(self) -> str:99         """Path segment the mounted routing class is attached under."""100         return self._api_name101 102     @property103     def api_info(self) -> dict[str, Any]:104         """OpenAPI info dict: the mounted class's, else the app's, else empty."""105         return self._mounted_info or self.openapi_info or {}106 107     def schema_filters(self) -> dict[str, Any]:108         """Node filters forwarded to ``router_openapi`` when building the schema.109 110         Empty by default (the whole router). A subclass whose router carries the111         ``channel`` plugin overrides this to select the REST-facing channel so112         the schema stays visible (the ``McpOpenApiApplication`` bridge).113         """114         return {}115 116     def _mount_routing_class(self, routing_class: RoutingClass) -> None:117         """Mount the routing class under ``api_name`` as an eager branch.118 119         The instance is linked immediately (the ``instance`` branch form), so it120         inherits the app router's plugins — pydantic among them (fixed on every121         router), which captures the handler signatures into the neutral122         ``params``/``result`` blocks the OpenAPI translator reads.123         """124         self.route.add_branches({"name": self.api_name, "instance": routing_class})125         info = getattr(routing_class, "openapi_info", None)126         if info:127             self._mounted_info = info128 129 130 class OpenApiMeta(RoutingClass):131     """Meta endpoints of an ``OpenApiApplication``, attached under ``_meta``."""132 133     def __init__(self, application: OpenApiApplication) -> None:134         self.application = application135 136     @route()137     def schema_json(self) -> dict[str, Any]:138         """Return the OpenAPI 3.1 document for the app's routes (standalone)."""139         app = self.application140         info = app.api_info141         paths_data = router_openapi(app.route, **app.schema_filters())142         return {143             "openapi": "3.1.0",144             "servers": [{"url": f"/{app.mount}" if app.mount else "/"}],145             "info": {146                 "title": info.get("title", type(app).__name__),147                 "version": info.get("version", "1.0.0"),148                 "description": info.get("description", ""),149             },150             **paths_data,151         }152 153     @route(media_type="text/html")154     def docs(self) -> str:155         """Serve the Swagger-UI page pointing at the app's own schema endpoint."""156         app = self.application157         if app.docs_style == "off":158             raise HTTPNotFound("documentation disabled")159         mount = app.mount160         schema_url = f"/{mount}/_meta/schema_json" if mount else "/_meta/schema_json"161         title = app.api_info.get("title", "API")162         template = (RESOURCES_DIR / "swagger.html").read_text()163         return template.format(title=title, schema_url=schema_url)164 165     @route(media_type="text/html")166     def index(self) -> str:167         """Serve the splash page with a link to the docs."""168         app = self.application169         info = app.api_info170         title = info.get("title", type(app).__name__)171         version = info.get("version", "")172         description = info.get("description", "")173         mount = app.mount174         docs_url = f"/{mount}/_meta/docs" if mount else "/_meta/docs"175         version_html = f"<p>Version: {version}</p>" if version else ""176         desc_html = f"<p>{description}</p>" if description else ""177         template = (RESOURCES_DIR / "openapi_index.html").read_text()178         return template.format(179             title=title,180             version_html=version_html,181             desc_html=desc_html,182             docs_url=docs_url,183         )