From 9a68d10868be2154671d9f26e98eb211625d74ed Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Sat, 25 Jul 2026 09:20:19 +0000 Subject: [PATCH] Add kaya-openapi package for automatic OpenAPI spec generation - New packages/kaya-openapi with OpenAPIMixin, @operation decorator, and generate_spec() that walks the routing tree - Enables kaya-core's Tree.register to expose the original handler callback as an instance attribute for metadata introspection - Registers GET /openapi.json and GET /docs (Swagger UI) routes - Supports and path parameters, docstring descriptions, @operation metadata, and excludes wildcard/WS routes - Adds example/openapi.py, updates CI, README, and requirements --- .gitea/workflows/build.yaml | 12 ++ README.md | 6 +- example/openapi.py | 32 ++++ packages/kaya-core/src/kaya/core/_tree.py | 13 +- packages/kaya-openapi/README.md | 93 +++++++++ packages/kaya-openapi/pyproject.toml | 56 ++++++ .../kaya-openapi/src/kaya/openapi/__init__.py | 10 + .../src/kaya/openapi/_metadata.py | 84 +++++++++ .../kaya-openapi/src/kaya/openapi/_mixin.py | 110 +++++++++++ .../kaya-openapi/src/kaya/openapi/_spec.py | 177 ++++++++++++++++++ .../kaya-openapi/src/kaya/openapi/py.typed | 0 packages/kaya-openapi/tests/test_openapi.py | 167 +++++++++++++++++ requirements-dev.in | 1 + requirements-dev.txt | 3 + 14 files changed, 762 insertions(+), 2 deletions(-) create mode 100644 example/openapi.py create mode 100644 packages/kaya-openapi/README.md create mode 100644 packages/kaya-openapi/pyproject.toml create mode 100644 packages/kaya-openapi/src/kaya/openapi/__init__.py create mode 100644 packages/kaya-openapi/src/kaya/openapi/_metadata.py create mode 100644 packages/kaya-openapi/src/kaya/openapi/_mixin.py create mode 100644 packages/kaya-openapi/src/kaya/openapi/_spec.py create mode 100644 packages/kaya-openapi/src/kaya/openapi/py.typed create mode 100644 packages/kaya-openapi/tests/test_openapi.py diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index f02337c..1f8547c 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -48,6 +48,10 @@ jobs: run: | .venv/bin/python -m mypy -p kaya.oidc .venv/bin/python -m unittest discover -s packages/kaya-oidc/tests + - name: Check kaya-openapi + run: | + .venv/bin/python -m mypy -p kaya.openapi + .venv/bin/python -m unittest discover -s packages/kaya-openapi/tests - name: Publish kaya-core artifacts env: TWINE_REPOSITORY_URL: ${{ vars.PYPI_REGISTRY_URL }} @@ -96,3 +100,11 @@ jobs: run: | .venv/bin/pyproject-build packages/kaya-oidc .venv/bin/twine upload --repository gitea packages/kaya-oidc/dist/*.whl packages/kaya-oidc/dist/*.tar.gz + - name: Publish kaya-openapi artifacts + env: + TWINE_REPOSITORY_URL: ${{ vars.PYPI_REGISTRY_URL }} + TWINE_USERNAME: ${{ vars.PUBLISHER_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PUBLISHER_TOKEN }} + run: | + .venv/bin/pyproject-build packages/kaya-openapi + .venv/bin/twine upload --repository gitea packages/kaya-openapi/dist/*.whl packages/kaya-openapi/dist/*.tar.gz diff --git a/README.md b/README.md index bf0d5af..061f758 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ This repository is a monorepo for the Kaya framework. The code is split into ind - **kaya-session-redis** — Redis-backed session storage (`packages/kaya-session-redis/`) - **kaya-session-memcache** — memcached-backed session storage (`packages/kaya-session-memcache/`) - **kaya-oidc** — OpenID Connect authentication (`packages/kaya-oidc/`) +- **kaya-openapi** — automatic OpenAPI specification generation (`packages/kaya-openapi/`) Additional `kaya-*` packages can be added as new directories under `packages/`. @@ -26,7 +27,7 @@ pip install --index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/si Install the packages in development mode: ```bash -pip install -e packages/kaya-core -e packages/kaya-rsgi -e packages/kaya-session -e packages/kaya-session-redis -e packages/kaya-session-memcache -e packages/kaya-oidc +pip install -e packages/kaya-core -e packages/kaya-rsgi -e packages/kaya-session -e packages/kaya-session-redis -e packages/kaya-session-memcache -e packages/kaya-oidc -e packages/kaya-openapi ``` Run the example: @@ -44,6 +45,7 @@ python -m unittest discover -s packages/kaya-session/tests python -m unittest discover -s packages/kaya-session-redis/tests python -m unittest discover -s packages/kaya-session-memcache/tests python -m unittest discover -s packages/kaya-oidc/tests +python -m unittest discover -s packages/kaya-openapi/tests ``` ## Static analysis @@ -55,6 +57,7 @@ mypy -p kaya.session mypy -p kaya.session.redis mypy -p kaya.session.memcache mypy -p kaya.oidc +mypy -p kaya.openapi ``` ## Building packages @@ -66,4 +69,5 @@ python -m build packages/kaya-session python -m build packages/kaya-session-redis python -m build packages/kaya-session-memcache python -m build packages/kaya-oidc +python -m build packages/kaya-openapi ``` diff --git a/example/openapi.py b/example/openapi.py new file mode 100644 index 0000000..92e6947 --- /dev/null +++ b/example/openapi.py @@ -0,0 +1,32 @@ +from kaya.core import HttpContext, KayaApp +from kaya.openapi import OpenAPIMixin, operation + +app = KayaApp(mixins=[OpenAPIMixin( + title='Greeting API', + version='1.0.0', + description='Example API documented with kaya-openapi', +)]) + + +@app.GET('/hello') +async def hello(ctx: HttpContext) -> None: + """Say hello to the world.""" + await ctx.send_str(200, 'Hello World') + + +@app.GET('/hello/${name}') +@operation(summary='Greet someone', + tags=['greetings'], + responses={200: {'description': 'A personalized greeting'}}) +async def hello_name(ctx: HttpContext, name: str) -> None: + await ctx.send_str(200, f'Hello {name}') + + +@app.GET('/square/${x:int}') +@operation(summary='Compute the square of a number', tags=['math']) +async def square(ctx: HttpContext, x: int) -> None: + await ctx.send_str(200, str(x * x)) + +# serve with an ASGI/RSGI server, e.g.: +# granian --interface rsgi example.openapi:app +# then open http://localhost:8000/docs to browse the API diff --git a/packages/kaya-core/src/kaya/core/_tree.py b/packages/kaya-core/src/kaya/core/_tree.py index 1ab0539..84723cc 100644 --- a/packages/kaya-core/src/kaya/core/_tree.py +++ b/packages/kaya-core/src/kaya/core/_tree.py @@ -186,16 +186,27 @@ class Tree: callback: Callable[[Context, Unpack[Any]], Awaitable[None]], recursive: bool) -> None: class Handler(PathHandler): + """PathHandler created by :meth:`Tree.register`. + + The original user callback is exposed through the ``callback`` + attribute so that extensions (e.g. ``kaya-openapi``) can inspect + it for metadata such as docstrings or decorator attributes. + """ + + callback: Callable[[Context, Unpack[Any]], Awaitable[None]] async def handle_request(self, ctx: Context, captured: Matches) -> None: args = Maybe.of_nullable(captured.path).map(lambda it: [it]).or_else([]) - await callback(ctx, *args, **captured.kwargs) + await self.callback(ctx, *args, **captured.kwargs) @property def recursive(self) -> bool: return recursive handler = Handler() + # assigned as an instance attribute (not a class attribute) so that + # the function descriptor protocol does not turn it into a bound method + handler.callback = callback self.add((p for p in PathIterator(path)), method, handler) def find_node(self, path: Generator[str, None, None], method: HttpMethod = HttpMethod.GET) \ diff --git a/packages/kaya-openapi/README.md b/packages/kaya-openapi/README.md new file mode 100644 index 0000000..1eccf86 --- /dev/null +++ b/packages/kaya-openapi/README.md @@ -0,0 +1,93 @@ +# kaya-openapi + +Automatic [OpenAPI](https://www.openapis.org/) specification generation for the +[Kaya](https://github.com/woggioni/kaya) lightweight ASGI web framework. + +The package provides an `OpenAPIMixin` that inspects a `KayaApp`'s routing +tree and serves: + +- an OpenAPI 3.1 JSON document (default: `GET /openapi.json`) +- a Swagger UI page to browse it interactively (default: `GET /docs`) + +## Usage + +```python +from kaya.core import HttpContext, KayaApp +from kaya.openapi import OpenAPIMixin, operation + +app = KayaApp(mixins=[OpenAPIMixin(title='My API', version='1.0.0')]) + + +@app.GET('/users/${user_id:int}') +@operation(summary='Get a user', + tags=['users'], + responses={ + 200: {'description': 'The user'}, + 404: {'description': 'User not found'}, + }) +async def get_user(ctx: HttpContext, user_id: int) -> None: + ... +``` + +Run the app with any ASGI/RSGI server and open `http://localhost:8000/docs`. + +## How routes are mapped + +- Static segments and parameters are converted to OpenAPI path templating: + - `/users/${user_id}` → `/users/{user_id}` (string path parameter) + - `/users/${user_id:int}` → `/users/{user_id}` (integer path parameter) +- Wildcard routes (`*`) are **skipped**: they cannot be expressed in OpenAPI + path syntax. +- Websocket routes are **skipped**: OpenAPI does not model websockets. +- Method-agnostic routes (registered with `app.route(path)` without methods) + are documented under **all** standard HTTP methods, since they respond to + all of them. +- The mixin's own endpoints are excluded from the document unless + `include_self=True`. + +The document is generated on every request to the spec endpoint, so routes +registered after the mixin is applied are always included. + +## Operation metadata + +The `@operation` decorator attaches OpenAPI metadata to a route handler. +All fragments are plain dicts merged verbatim into the generated operation +object, so any valid OpenAPI 3.1 construct can be used: + +```python +@operation(summary='...', # operation summary + description='...', # defaults to the handler docstring + tags=['users'], + operation_id='getUser', + request_body={...}, # OpenAPI requestBody object + responses={200: {...}}, # per-status-code response objects + parameters=[...], # extra/overriding parameter objects + deprecated=False, + hidden=False) # exclude from the document +``` + +`parameters` entries whose `name` and `in` match an auto-generated path +parameter override it; all others are appended. + +## Configuration + +```python +OpenAPIMixin( + title='My API', # info.title (required) + version='1.0.0', # info.version (required) + description='', # info.description + spec_path='/openapi.json', # where the JSON document is served + docs_path='/docs', # where Swagger UI is served + servers=[{'url': 'https://api.example.com'}], + openapi_version='3.1.0', + include_self=False, # include spec/docs endpoints in the document +) +``` + +The document can also be generated programmatically without serving it: + +```python +from kaya.openapi import generate_spec + +spec = generate_spec(app, title='My API', version='1.0.0') +``` diff --git a/packages/kaya-openapi/pyproject.toml b/packages/kaya-openapi/pyproject.toml new file mode 100644 index 0000000..bcdb65f --- /dev/null +++ b/packages/kaya-openapi/pyproject.toml @@ -0,0 +1,56 @@ +[build-system] +requires = ["setuptools>=61.0", "setuptools-scm>=8"] +build-backend = "setuptools.build_meta" + +[project] +name = "kaya-openapi" +dynamic = ["version"] +authors = [ + { name="Walter Oggioni", email="oggioni.walter@gmail.com" }, +] +description = "Automatic OpenAPI specification generation for the Kaya lightweight ASGI web framework" +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +classifiers = [ + 'Development Status :: 3 - Alpha', + 'Topic :: Utilities', + 'Intended Audience :: System Administrators', + 'Intended Audience :: Developers', + 'Environment :: Console', + 'Programming Language :: Python :: 3', +] + +dependencies = [ + "kaya-core", +] + +[project.optional-dependencies] +dev = [ + "build", "mypy", "ipdb", "twine", "httpx", "httpx-ws" +] + +[project.urls] +"Homepage" = "https://github.com/woggioni/kaya" +"Bug Tracker" = "https://github.com/woggioni/kaya/issues" + +[tool.setuptools.packages.find] +where = ["src"] +namespaces = true + +[tool.mypy] +python_version = "3.12" +disallow_untyped_defs = true +show_error_codes = true +no_implicit_optional = true +warn_return_any = true +warn_unused_ignores = true +exclude = ["scripts", "docs", "test"] +strict = true + +[tool.setuptools_scm] +root = "../.." +version_file = "src/kaya/openapi/_version.py" + +[tool.setuptools_scm.tag] +prefix = "release/" diff --git a/packages/kaya-openapi/src/kaya/openapi/__init__.py b/packages/kaya-openapi/src/kaya/openapi/__init__.py new file mode 100644 index 0000000..85a3d12 --- /dev/null +++ b/packages/kaya-openapi/src/kaya/openapi/__init__.py @@ -0,0 +1,10 @@ +from ._metadata import operation +from ._mixin import OpenAPIMixin +from ._spec import generate_spec + + +__all__ = [ + 'OpenAPIMixin', + 'generate_spec', + 'operation', +] diff --git a/packages/kaya-openapi/src/kaya/openapi/_metadata.py b/packages/kaya-openapi/src/kaya/openapi/_metadata.py new file mode 100644 index 0000000..85af3a0 --- /dev/null +++ b/packages/kaya-openapi/src/kaya/openapi/_metadata.py @@ -0,0 +1,84 @@ +from typing import Any, Awaitable, Callable, Mapping, Optional, Sequence, TypeVar + +F = TypeVar('F', bound=Callable[..., Awaitable[None]]) + +METADATA_ATTR = '__kaya_openapi__' + +#: Metadata attached to route handler functions by :func:`operation`. +#: Values are raw OpenAPI fragments (plain dicts) merged verbatim into the +#: generated operation object. +type OperationMetadata = Mapping[str, Any] + + +def operation(summary: Optional[str] = None, + description: Optional[str] = None, + tags: Optional[Sequence[str]] = None, + request_body: Optional[Mapping[str, Any]] = None, + responses: Optional[Mapping[int | str, Mapping[str, Any]]] = None, + parameters: Optional[Sequence[Mapping[str, Any]]] = None, + operation_id: Optional[str] = None, + deprecated: bool = False, + hidden: bool = False) -> Callable[[F], F]: + """Attach OpenAPI metadata to a Kaya route handler. + + The metadata is stored on the function itself and picked up by + :class:`~kaya.openapi.OpenAPIMixin` when generating the specification. + + All schema fragments are plain dicts inserted verbatim into the generated + OpenAPI document, so any valid OpenAPI 3.1 construct can be used. + + Example:: + + @app.GET('/users/${user_id:int}') + @operation(summary='Get a user', + tags=['users'], + responses={200: {'description': 'The user'}, + 404: {'description': 'User not found'}}) + async def get_user(ctx: HttpContext, user_id: int) -> None: + ... + + :param summary: short summary of the operation + :param description: longer description (defaults to the handler docstring) + :param tags: list of OpenAPI tags + :param request_body: OpenAPI ``requestBody`` object + :param responses: mapping of status code (or ``'default'``) to OpenAPI + response objects + :param parameters: extra OpenAPI parameter objects merged with the + auto-generated path parameters (entries whose ``name`` matches a path + parameter override the auto-generated one) + :param operation_id: explicit OpenAPI ``operationId`` + :param deprecated: mark the operation as deprecated + :param hidden: exclude the operation from the generated specification + """ + def decorator(func: F) -> F: + metadata: dict[str, Any] = {} + if summary is not None: + metadata['summary'] = summary + if description is not None: + metadata['description'] = description + if tags is not None: + metadata['tags'] = list(tags) + if request_body is not None: + metadata['request_body'] = dict(request_body) + if responses is not None: + metadata['responses'] = {str(k): dict(v) for k, v in responses.items()} + if parameters is not None: + metadata['parameters'] = [dict(p) for p in parameters] + if operation_id is not None: + metadata['operation_id'] = operation_id + if deprecated: + metadata['deprecated'] = True + if hidden: + metadata['hidden'] = True + setattr(func, METADATA_ATTR, metadata) + return func + + return decorator + + +def get_metadata(handler: Any) -> OperationMetadata: + """Return the metadata attached by :func:`operation`, or an empty mapping.""" + metadata = getattr(handler, METADATA_ATTR, None) + if isinstance(metadata, Mapping): + return metadata + return {} diff --git a/packages/kaya-openapi/src/kaya/openapi/_mixin.py b/packages/kaya-openapi/src/kaya/openapi/_mixin.py new file mode 100644 index 0000000..48e135f --- /dev/null +++ b/packages/kaya-openapi/src/kaya/openapi/_mixin.py @@ -0,0 +1,110 @@ +import json +from html import escape +from typing import Any, Mapping, Optional, Sequence + +from kaya.core import HttpContext, KayaApp, KayaMixin + +from ._spec import generate_spec + +_DOCS_PAGE = """ + + + + + {title} - API documentation + + + +
+ + + + +""" + + +class OpenAPIMixin(KayaMixin): + """Kaya mixin serving an auto-generated OpenAPI specification. + + Registers two routes on the app: + + - ``spec_path`` (default ``/openapi.json``): the OpenAPI document, + generated on each request from the application's routing tree so that + routes registered after the mixin are always included. + - ``docs_path`` (default ``/docs``): a Swagger UI page rendering the + specification (assets are loaded from a CDN). + + Routes are converted as follows: + + - ``${name}`` path segments become ``{name}`` string path parameters, + ``${name:int}`` become integer path parameters; + - wildcard (``*``) and websocket routes are skipped, since they cannot be + expressed in OpenAPI; + - method-agnostic routes (registered with ``app.route(path)``) are + documented under all standard HTTP methods; + - the mixin's own routes are excluded unless ``include_self`` is true. + + Use :func:`~kaya.openapi.operation` to attach summaries, tags, request + bodies and response schemas to individual handlers; the handler docstring + is used as the operation description when no explicit one is given. + + Example:: + + openapi = OpenAPIMixin(title='My API', version='1.0.0') + app = KayaApp(mixins=[openapi]) + + @app.GET('/users/${user_id:int}') + @operation(summary='Get a user', tags=['users']) + async def get_user(ctx: HttpContext, user_id: int) -> None: + ... + """ + + def __init__(self, + title: str, + version: str, + description: str = '', + spec_path: str = '/openapi.json', + docs_path: str = '/docs', + servers: Optional[Sequence[Mapping[str, Any]]] = None, + openapi_version: str = '3.1.0', + include_self: bool = False) -> None: + self._title = title + self._version = version + self._description = description + self._spec_path = spec_path + self._docs_path = docs_path + self._servers = servers + self._openapi_version = openapi_version + self._include_self = include_self + + def apply(self, app: KayaApp) -> None: + @app.GET(self._spec_path) + async def openapi_spec(ctx: HttpContext) -> None: + spec = self.generate_spec(app) + await ctx.send_str(200, + json.dumps(spec, indent=2), + {'Content-Type': 'application/json'}) + + @app.GET(self._docs_path) + async def openapi_docs(ctx: HttpContext) -> None: + page = _DOCS_PAGE.format(title=escape(self._title), spec_path=self._spec_path) + await ctx.send_str(200, page, {'Content-Type': 'text/html; charset=utf-8'}) + + def generate_spec(self, app: KayaApp) -> Mapping[str, Any]: + """Generate the OpenAPI document for ``app`` (also used by the + ``spec_path`` endpoint on every request).""" + exclude_paths = frozenset() if self._include_self else frozenset((self._spec_path, self._docs_path)) + return generate_spec(app, + title=self._title, + version=self._version, + description=self._description, + servers=self._servers, + exclude_paths=exclude_paths, + openapi_version=self._openapi_version) diff --git a/packages/kaya-openapi/src/kaya/openapi/_spec.py b/packages/kaya-openapi/src/kaya/openapi/_spec.py new file mode 100644 index 0000000..8ac746d --- /dev/null +++ b/packages/kaya-openapi/src/kaya/openapi/_spec.py @@ -0,0 +1,177 @@ +from copy import deepcopy +from dataclasses import dataclass +from inspect import getdoc +from typing import AbstractSet, Any, Mapping, Optional, Sequence + +from kaya.core import HttpMethod, KayaApp +from kaya.core._path_handler import PathHandler +from kaya.core._path_matcher import GlobMatcher, IntMatcher, Node, PathMatcher, StrMatcher + +from ._metadata import get_metadata + +#: HTTP methods documented for method-agnostic routes +#: (registered with ``app.route(path)`` without explicit methods). +STANDARD_METHODS: Sequence[HttpMethod] = ( + HttpMethod.GET, + HttpMethod.PUT, + HttpMethod.POST, + HttpMethod.DELETE, + HttpMethod.OPTIONS, + HttpMethod.HEAD, + HttpMethod.PATCH, +) + + +@dataclass +class _Route: + raw_path: str + openapi_path: str + method: Optional[HttpMethod] + params: Sequence[Mapping[str, Any]] + handlers: Sequence[PathHandler] + + +def _walk(node: Node | PathMatcher, + raw_path: str, + openapi_path: str, + params: Sequence[Mapping[str, Any]], + routes: list[_Route]) -> None: + if node.handlers: + routes.append(_Route(raw_path or '/', openapi_path or '/', None, params, list(node.handlers))) + for key, child in node.children.items(): + if isinstance(key, HttpMethod): + if key is not HttpMethod.WS and child.handlers: + routes.append(_Route(raw_path or '/', openapi_path or '/', key, params, list(child.handlers))) + else: + _walk(child, f'{raw_path}/{key}', f'{openapi_path}/{key}', params, routes) + for matcher in node.path_matchers: + if isinstance(matcher, GlobMatcher): + # Wildcard routes cannot be expressed in OpenAPI path syntax + continue + if isinstance(matcher, IntMatcher): + raw_segment = f'${{{matcher.name}:int}}' + schema: Mapping[str, Any] = {'type': 'integer'} + elif isinstance(matcher, StrMatcher): + raw_segment = f'${{{matcher.name}}}' + schema = {'type': 'string'} + else: + continue + param: dict[str, Any] = { + 'name': matcher.name, + 'in': 'path', + 'required': True, + 'schema': dict(schema), + } + _walk(matcher, + f'{raw_path}/{raw_segment}', + f'{openapi_path}/{{{matcher.name}}}', + (*params, param), + routes) + + +def _merge_parameters(params: Sequence[Mapping[str, Any]], + extra: Any) -> list[Mapping[str, Any]]: + merged: list[Mapping[str, Any]] = [dict(p) for p in params] + if not isinstance(extra, Sequence) or isinstance(extra, (str, bytes)): + return merged + for candidate in extra: + if not isinstance(candidate, Mapping): + continue + for i, existing in enumerate(merged): + if existing.get('name') == candidate.get('name') and existing.get('in') == candidate.get('in'): + merged[i] = candidate + break + else: + merged.append(candidate) + return merged + + +def _build_operation(params: Sequence[Mapping[str, Any]], + handler: PathHandler) -> Optional[dict[str, Any]]: + callback = getattr(handler, 'callback', None) + metadata = get_metadata(callback) if callback is not None else {} + if metadata.get('hidden'): + return None + operation: dict[str, Any] = {} + operation_id = metadata.get('operation_id') + if operation_id is not None: + operation['operationId'] = operation_id + summary = metadata.get('summary') + if summary is not None: + operation['summary'] = summary + description = metadata.get('description') + if description is None and callback is not None: + description = getdoc(callback) + if description is not None: + operation['description'] = description + tags = metadata.get('tags') + if tags is not None: + operation['tags'] = tags + if metadata.get('deprecated'): + operation['deprecated'] = True + parameters = _merge_parameters(params, metadata.get('parameters')) + if parameters: + operation['parameters'] = parameters + request_body = metadata.get('request_body') + if request_body is not None: + operation['requestBody'] = request_body + responses = metadata.get('responses') + operation['responses'] = responses if responses is not None else { + 'default': {'description': 'Successful response'}, + } + return operation + + +def generate_spec(app: KayaApp, + title: str, + version: str, + description: str = '', + servers: Optional[Sequence[Mapping[str, Any]]] = None, + exclude_paths: AbstractSet[str] = frozenset(), + openapi_version: str = '3.1.0') -> Mapping[str, Any]: + """Generate an OpenAPI specification document from a :class:`KayaApp`. + + The application's routing tree is walked and every route is converted to + an OpenAPI path item: + + - static segments and ``${name}`` / ``${name:int}`` parameters are mapped + to OpenAPI path templating (``{name}``); + - wildcard (``*``) and websocket routes are skipped; + - method-agnostic routes are documented under all standard HTTP methods; + - routes whose Kaya path is in ``exclude_paths`` are skipped. + + :param app: the application to inspect + :param title: value of ``info.title`` + :param version: value of ``info.version`` + :param description: value of ``info.description`` + :param servers: list of OpenAPI server objects + :param exclude_paths: Kaya paths (e.g. ``/openapi.json``) to omit + :param openapi_version: OpenAPI version to declare + :return: the OpenAPI document as a JSON-serializable mapping + """ + routes: list[_Route] = [] + _walk(app._tree.root, '', '', (), routes) + + paths: dict[str, dict[str, Any]] = {} + for route in sorted(routes, key=lambda it: it.openapi_path): + if route.raw_path in exclude_paths or not route.handlers: + continue + operation = _build_operation(route.params, route.handlers[0]) + if operation is None: + continue + path_item = paths.setdefault(route.openapi_path, {}) + methods = STANDARD_METHODS if route.method is None else (route.method,) + for method in methods: + path_item[method.value.lower()] = deepcopy(operation) + + info: dict[str, Any] = {'title': title, 'version': version} + if description: + info['description'] = description + spec: dict[str, Any] = { + 'openapi': openapi_version, + 'info': info, + 'paths': paths, + } + if servers: + spec['servers'] = [dict(server) for server in servers] + return spec diff --git a/packages/kaya-openapi/src/kaya/openapi/py.typed b/packages/kaya-openapi/src/kaya/openapi/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/kaya-openapi/tests/test_openapi.py b/packages/kaya-openapi/tests/test_openapi.py new file mode 100644 index 0000000..f833d10 --- /dev/null +++ b/packages/kaya-openapi/tests/test_openapi.py @@ -0,0 +1,167 @@ +import json +import unittest +from typing import Any, Mapping, Sequence + +import httpx +from pwo import async_test + +from kaya.core import HttpContext, HttpMethod, KayaApp, WebSocket +from kaya.openapi import OpenAPIMixin, operation + + +class OpenAPITest(unittest.TestCase): + app: KayaApp + + def setUp(self) -> None: + self.app = KayaApp(mixins=[OpenAPIMixin(title='Test API', version='1.2.3')]) + + @self.app.GET('/hello') + async def hello(ctx: HttpContext) -> None: + """Say hello.""" + await ctx.send_str(200, 'Hello World!') + + @self.app.GET('/users/${user_id:int}') + @operation(summary='Get a user', + tags=['users'], + responses={ + 200: {'description': 'The user'}, + 404: {'description': 'User not found'}, + }) + async def get_user(ctx: HttpContext, user_id: int) -> None: + await ctx.send_str(200, str(user_id)) + + @self.app.POST('/users/${name}') + async def create_user(ctx: HttpContext, name: str) -> None: + await ctx.send_str(201, name) + + @self.app.route('/ping') + async def ping(ctx: HttpContext) -> None: + await ctx.send_str(200, 'pong') + + @self.app.GET('/files/*', recursive=True) + async def serve_file(ctx: HttpContext, path: Sequence[str]) -> None: + await ctx.send_str(200, '/'.join(path)) + + @self.app.GET('/internal/health') + @operation(hidden=True) + async def health(ctx: HttpContext) -> None: + await ctx.send_str(200, 'ok') + + @self.app.websocket('/echo') + async def echo(ws: WebSocket) -> None: + await ws.accept() + await ws.close() + + async def _get_spec(self) -> Mapping[str, Any]: + transport = httpx.ASGITransport(app=self.app) + async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: + response = await client.get('/openapi.json') + self.assertEqual(200, response.status_code) + self.assertEqual('application/json', response.headers['Content-Type']) + return json.loads(response.text) + + @async_test + async def test_spec_endpoint(self) -> None: + spec = await self._get_spec() + self.assertEqual('3.1.0', spec['openapi']) + self.assertEqual({'title': 'Test API', 'version': '1.2.3'}, spec['info']) + self.assertIn('paths', spec) + + @async_test + async def test_static_route_with_docstring(self) -> None: + spec = await self._get_spec() + hello = spec['paths']['/hello']['get'] + self.assertEqual('Say hello.', hello['description']) + self.assertIn('responses', hello) + + @async_test + async def test_int_path_parameter(self) -> None: + spec = await self._get_spec() + operation = spec['paths']['/users/{user_id}']['get'] + self.assertEqual('Get a user', operation['summary']) + self.assertEqual(['users'], operation['tags']) + self.assertEqual({ + '200': {'description': 'The user'}, + '404': {'description': 'User not found'}, + }, operation['responses']) + self.assertEqual( + [{'name': 'user_id', 'in': 'path', 'required': True, 'schema': {'type': 'integer'}}], + operation['parameters']) + + @async_test + async def test_str_path_parameter(self) -> None: + spec = await self._get_spec() + operation = spec['paths']['/users/{name}']['post'] + self.assertEqual( + [{'name': 'name', 'in': 'path', 'required': True, 'schema': {'type': 'string'}}], + operation['parameters']) + + @async_test + async def test_method_agnostic_route(self) -> None: + spec = await self._get_spec() + path_item = spec['paths']['/ping'] + for method in ('get', 'put', 'post', 'delete', 'options', 'head', 'patch'): + self.assertIn(method, path_item) + + @async_test + async def test_excluded_routes(self) -> None: + spec = await self._get_spec() + paths = spec['paths'] + # wildcard routes cannot be expressed in OpenAPI + self.assertNotIn('/files/*', paths) + self.assertFalse(any('files' in path for path in paths)) + # websocket routes are not part of OpenAPI + self.assertNotIn('/echo', paths) + # hidden operations are skipped + self.assertNotIn('/internal/health', paths) + # the mixin's own endpoints are excluded by default + self.assertNotIn('/openapi.json', paths) + self.assertNotIn('/docs', paths) + + @async_test + async def test_docs_endpoint(self) -> None: + transport = httpx.ASGITransport(app=self.app) + async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: + response = await client.get('/docs') + self.assertEqual(200, response.status_code) + self.assertEqual('text/html; charset=utf-8', response.headers['Content-Type']) + self.assertIn('swagger-ui', response.text) + self.assertIn('/openapi.json', response.text) + + @async_test + async def test_late_registered_routes_are_included(self) -> None: + @self.app.GET('/late') + async def late(ctx: HttpContext) -> None: + await ctx.send_str(200, 'late') + + spec = await self._get_spec() + self.assertIn('/late', spec['paths']) + + @async_test + async def test_custom_paths_and_self_inclusion(self) -> None: + app = KayaApp(mixins=[OpenAPIMixin(title='Custom', + version='0.1.0', + spec_path='/spec.json', + docs_path='/swagger', + include_self=True)]) + + @app.route('/items/${item_id:int}', HttpMethod.DELETE) + async def delete_item(ctx: HttpContext, item_id: int) -> None: + await ctx.send_empty(204) + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: + response = await client.get('/spec.json') + self.assertEqual(200, response.status_code) + spec = json.loads(response.text) + self.assertIn('delete', spec['paths']['/items/{item_id}']) + self.assertIn('/spec.json', spec['paths']) + self.assertIn('/swagger', spec['paths']) + + response = await client.get('/swagger') + self.assertEqual(200, response.status_code) + self.assertIn('/spec.json', response.text) + + +if __name__ == '__main__': + unittest.main() diff --git a/requirements-dev.in b/requirements-dev.in index a98de82..49159e2 100644 --- a/requirements-dev.in +++ b/requirements-dev.in @@ -4,6 +4,7 @@ kaya-session @ file:./packages/kaya-session kaya-session-redis @ file:./packages/kaya-session-redis kaya-session-memcache @ file:./packages/kaya-session-memcache kaya-oidc @ file:./packages/kaya-oidc +kaya-openapi @ file:./packages/kaya-openapi build fakeredis mypy diff --git a/requirements-dev.txt b/requirements-dev.txt index 708a8a2..7e7c199 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -90,10 +90,13 @@ file:./packages/kaya-core # via # -r requirements-dev.in # kaya-oidc + # kaya-openapi # kaya-rsgi # kaya-session file:./packages/kaya-oidc # via -r requirements-dev.in +file:./packages/kaya-openapi + # via -r requirements-dev.in file:./packages/kaya-rsgi # via -r requirements-dev.in file:./packages/kaya-session