Source code for genro_asgi.applications.openapi

# Copyright 2025 Softwell S.r.l.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""OpenApiApplication: a RoutedApplication that exposes REST + OpenAPI + docs.

``OpenApiApplication`` wraps an API surface — either the app's own
``@route`` methods (direct mode) or an external ``RoutingClass`` attached
under ``api_name`` (mounted mode) — and adds a ``_meta`` sub-tree with three
introspection endpoints:

- ``_meta/schema_json`` — the OpenAPI 3.1 document of the API;
- ``_meta/docs`` — a Swagger-UI page pointing at ``_meta/schema_json``;
- ``_meta/index`` — an HTML splash linking to the docs.

The schema is built STANDALONE from the app's own router
(``router_openapi(app.route)``): there is no dependency on a ``_server``
application (that surface belongs to a later macro). The mounted routing
class is linked as an eager ``instance`` branch, so it inherits the app
router's plugins — pydantic among them — and its handler signatures are
captured into the neutral ``params``/``result`` blocks the
``OpenAPITranslator`` reads; in direct mode the same plugins reach the app's
own router through the server's plugin arming (``PluginMixin`` config).

The docs and splash HTML live in dedicated resource files next to this module
and are read at USE time (never at import): a swap of the template file takes
effect without re-importing the package.

Kwargs peeled by the cooperative ``__init__`` (D16): ``routing_class`` (a
``RoutingClass`` instance to mount), ``module`` (``"pkg.mod:ClassName"``
import path, an alternative to ``routing_class``), ``docs`` (documentation
style — ``"swagger"`` or ``"off"``) and ``api_name`` (the segment the mounted
class is attached under, default ``"api"``). The rest flows down the chain
(``db_name`` to ``RoutedApplication``, ``mount_name`` to ``BaseApplication``).
"""

from __future__ import annotations

from pathlib import Path
from typing import Any, ClassVar

from genro_routes import RoutingClass, route

from ..exceptions import HTTPNotFound
from ..plugins.openapi import router_openapi
from ..routed_application import RoutedApplication

__all__ = ["OpenApiApplication"]

RESOURCES_DIR = Path(__file__).parent / "resources"


[docs] class OpenApiApplication(RoutedApplication): """Expose an API surface as REST + OpenAPI 3.1 with a Swagger docs page. Two ways to supply the API: - direct mode — subclass and write ``@route`` methods on the app itself; endpoints sit at the app root (``/{app}/endpoint``); - mounted mode — pass ``routing_class=`` (or ``module=``); the class is attached under ``api_name`` (``/{app}/{api_name}/endpoint``). Meta endpoints are attached under ``_meta`` in both modes. """ openapi_info: ClassVar[dict[str, Any]] = {} def __init__(self, **kwargs: Any) -> None: self._docs_style: str = kwargs.pop("docs", "swagger") self._api_name: str = kwargs.pop("api_name", "api") routing_class: RoutingClass | None = kwargs.pop("routing_class", None) module: str | None = kwargs.pop("module", None) self._mounted_info: dict[str, Any] = {} super().__init__(**kwargs) self.route.add_branches({"name": "_meta", "instance": OpenApiMeta(self)}) if routing_class is None and module: routing_class = self._import_routing_class(module) if routing_class is not None: self._mount_routing_class(routing_class) @property def docs_style(self) -> str: """Documentation style — ``"swagger"`` or ``"off"``.""" return self._docs_style @property def api_name(self) -> str: """Path segment the mounted routing class is attached under.""" return self._api_name @property def api_info(self) -> dict[str, Any]: """OpenAPI info dict: the mounted class's, else the app's, else empty.""" return self._mounted_info or self.openapi_info or {}
[docs] def schema_filters(self) -> dict[str, Any]: """Node filters forwarded to ``router_openapi`` when building the schema. Empty by default (the whole router). A subclass whose router carries the ``channel`` plugin overrides this to select the REST-facing channel so the schema stays visible (the ``McpOpenApiApplication`` bridge). """ return {}
def _mount_routing_class(self, routing_class: RoutingClass) -> None: """Mount the routing class under ``api_name`` as an eager branch. The instance is linked immediately (the ``instance`` branch form), so it inherits the app router's plugins — pydantic among them (fixed on every router), which captures the handler signatures into the neutral ``params``/``result`` blocks the OpenAPI translator reads. """ self.route.add_branches({"name": self.api_name, "instance": routing_class}) info = getattr(routing_class, "openapi_info", None) if info: self._mounted_info = info
class OpenApiMeta(RoutingClass): """Meta endpoints of an ``OpenApiApplication``, attached under ``_meta``.""" def __init__(self, application: OpenApiApplication) -> None: self.application = application @route() def schema_json(self) -> dict[str, Any]: """Return the OpenAPI 3.1 document for the app's routes (standalone).""" app = self.application info = app.api_info paths_data = router_openapi(app.route, **app.schema_filters()) return { "openapi": "3.1.0", "info": { "title": info.get("title", type(app).__name__), "version": info.get("version", "1.0.0"), "description": info.get("description", ""), }, **paths_data, } @route(media_type="text/html") def docs(self) -> str: """Serve the Swagger-UI page pointing at the app's own schema endpoint.""" app = self.application if app.docs_style == "off": raise HTTPNotFound("documentation disabled") mount_name = app.mount_name schema_url = f"/{mount_name}/_meta/schema_json" if mount_name else "/_meta/schema_json" title = app.api_info.get("title", "API") template = (RESOURCES_DIR / "swagger.html").read_text() return template.format(title=title, schema_url=schema_url) @route(media_type="text/html") def index(self) -> str: """Serve the splash page with a link to the docs.""" app = self.application info = app.api_info title = info.get("title", type(app).__name__) version = info.get("version", "") description = info.get("description", "") mount_name = app.mount_name docs_url = f"/{mount_name}/_meta/docs" if mount_name else "/_meta/docs" version_html = f"<p>Version: {version}</p>" if version else "" desc_html = f"<p>{description}</p>" if description else "" template = (RESOURCES_DIR / "openapi_index.html").read_text() return template.format( title=title, version_html=version_html, desc_html=desc_html, docs_url=docs_url, ) if __name__ == "__main__": class DemoApi(OpenApiApplication): openapi_info = {"title": "Demo", "version": "1.0.0"} @route() def add(self, x: int = 0, y: int = 0) -> dict[str, int]: return {"sum": x + y} app = DemoApi() assert app.api_name == "api" assert app.docs_style == "swagger" assert app.api_info["title"] == "Demo" meta = app.route.node("/_meta/schema_json") assert meta.error is None, meta.error doc = meta() assert doc["openapi"] == "3.1.0" assert doc["info"]["title"] == "Demo"