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 )