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