Add reusable component schemas to kaya-openapi
CI / Build Pip package (push) Successful in 7m57s

Operations could only inline schemas: the generated document had no
components section, so #/components/... references dangled. OpenAPIMixin
and generate_spec now accept a components mapping (merged verbatim into
the document) and kaya.openapi exports schema_ref() to build
#/components/schemas/<name> references. Component references are
validated at generation time and unknown ones raise ValueError.
This commit is contained in:
2026-10-14 20:37:14 +08:00
parent e73c4b2c18
commit ea82ac44e9
6 changed files with 254 additions and 7 deletions
+43
View File
@@ -69,6 +69,48 @@ object, so any valid OpenAPI 3.1 construct can be used:
`parameters` entries whose `name` and `in` match an auto-generated path
parameter override it; all others are appended.
## Reusable schemas (`components`)
Schemas can be defined once and referenced from operation fragments: pass a
`components` mapping to the mixin and use `schema_ref` in `@operation`.
```python
from kaya.openapi import OpenAPIMixin, operation, schema_ref
USER = {
'type': 'object',
'required': ['id'],
'properties': {'id': {'type': 'integer'}, 'name': {'type': 'string'}},
}
USER_PAGE = {
'type': 'object',
'properties': {'results': {'type': 'array', 'items': schema_ref('User')}},
}
app = KayaApp(mixins=[OpenAPIMixin(
title='My API',
version='1.0.0',
components={'schemas': {'User': USER, 'UserPage': USER_PAGE}},
)])
@app.GET('/users')
@operation(summary='List users',
responses={200: {
'description': 'A page of users',
'content': {'application/json': {'schema': schema_ref('UserPage')}},
}})
async def list_users(ctx: HttpContext) -> None:
...
```
`components` is merged verbatim (as a copy) into the generated document under
`components`, so Swagger UI lists the named schemas. Every
`#/components/<section>/<name>` reference — in operations or between
components — is validated when the document is generated: a missing section or
entry raises `ValueError`. Reflected references (`./other.json#/...`, absolute
URLs) are left alone.
## Configuration
```python
@@ -81,6 +123,7 @@ OpenAPIMixin(
servers=[{'url': 'https://api.example.com'}],
openapi_version='3.1.0',
include_self=False, # include spec/docs endpoints in the document
components=None, # reusable OpenAPI components (e.g. schemas)
)
```
@@ -1,10 +1,11 @@
from ._metadata import operation
from ._mixin import OpenAPIMixin
from ._spec import generate_spec
from ._spec import generate_spec, schema_ref
__all__ = [
'OpenAPIMixin',
'generate_spec',
'operation',
'schema_ref',
]
@@ -25,7 +25,9 @@ def operation(summary: Optional[str] = None,
: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.
OpenAPI document, so any valid OpenAPI 3.1 construct can be used. ``$ref``
fragments may point at the reusable schemas registered through the
``components`` argument of :class:`~kaya.openapi.OpenAPIMixin`.
Example::
@@ -55,6 +55,11 @@ class OpenAPIMixin(KayaMixin):
bodies and response schemas to individual handlers; the handler docstring
is used as the operation description when no explicit one is given.
Reusable schemas are supplied through ``components`` and referenced from
operation fragments with :func:`~kaya.openapi.schema_ref`; every
``#/components/...`` reference is validated when the document is
generated.
Example::
openapi = OpenAPIMixin(title='My API', version='1.0.0')
@@ -74,7 +79,8 @@ class OpenAPIMixin(KayaMixin):
docs_path: str = '/docs',
servers: Optional[Sequence[Mapping[str, Any]]] = None,
openapi_version: str = '3.1.0',
include_self: bool = False) -> None:
include_self: bool = False,
components: Optional[Mapping[str, Any]] = None) -> None:
self._title = title
self._version = version
self._description = description
@@ -83,6 +89,7 @@ class OpenAPIMixin(KayaMixin):
self._servers = servers
self._openapi_version = openapi_version
self._include_self = include_self
self._components = components
def apply(self, app: KayaApp) -> None:
@app.GET(self._spec_path)
@@ -107,4 +114,5 @@ class OpenAPIMixin(KayaMixin):
description=self._description,
servers=self._servers,
exclude_paths=exclude_paths,
openapi_version=self._openapi_version)
openapi_version=self._openapi_version,
components=self._components)
@@ -1,7 +1,7 @@
from copy import deepcopy
from dataclasses import dataclass
from inspect import getdoc
from typing import AbstractSet, Any, Mapping, Optional, Sequence
from typing import AbstractSet, Any, Dict, Iterator, Mapping, Optional, Sequence
from kaya.core import HttpMethod, KayaApp
from kaya.core._path_handler import PathHandler
@@ -22,6 +22,17 @@ STANDARD_METHODS: Sequence[HttpMethod] = (
)
def schema_ref(name: str) -> Dict[str, str]:
"""Return a reference to the component schema ``name``.
The reference resolves to ``#/components/schemas/<name>`` in the
generated document; the schema itself is supplied through the
``components`` argument of :func:`generate_spec` (or
:class:`~kaya.openapi.OpenAPIMixin`).
"""
return {'$ref': f'#/components/schemas/{name}'}
@dataclass
class _Route:
raw_path: str
@@ -122,13 +133,67 @@ def _build_operation(params: Sequence[Mapping[str, Any]],
return operation
def _unescape_pointer_token(token: str) -> str:
"""Decode one JSON Pointer reference token (``~1`` → ``/``, ``~0`` → ``~``)."""
return token.replace('~1', '/').replace('~0', '~')
def _iter_refs(node: Any) -> Iterator[str]:
"""Yield every ``$ref`` string found anywhere under ``node``."""
if isinstance(node, Mapping):
ref = node.get('$ref')
if isinstance(ref, str):
yield ref
for value in node.values():
yield from _iter_refs(value)
elif isinstance(node, (list, tuple)):
for value in node:
yield from _iter_refs(value)
def _validate_component_refs(spec: Mapping[str, Any]) -> None:
"""Fail fast on references to missing ``components`` entries.
Every ``#/components/<section>/<name>`` reference found in the operations
or in the components themselves must resolve against the document;
references in any other form (relative files, absolute URLs, other
pointers) are ignored. Raises :class:`ValueError` naming the offending
reference and the operation it appears in.
"""
components = spec.get('components')
known: Mapping[str, Any] = components if isinstance(components, Mapping) else {}
def check(node: Any, location: str) -> None:
for ref in _iter_refs(node):
if not ref.startswith('#/components/'):
continue
parts = [_unescape_pointer_token(part) for part in ref[len('#/components/'):].split('/')]
if len(parts) < 2:
raise ValueError(f'invalid component reference {ref!r} in {location}')
section, name = parts[0], parts[1]
bucket = known.get(section)
if not isinstance(bucket, Mapping) or name not in bucket:
raise ValueError(f'unknown component reference {ref!r} in {location}')
paths = spec.get('paths')
if isinstance(paths, Mapping):
for path, item in paths.items():
if not isinstance(item, Mapping):
continue
for method, operation in item.items():
check(operation, f'{method.upper()} {path}')
if isinstance(components, Mapping):
check(components, 'components')
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]:
openapi_version: str = '3.1.0',
components: Optional[Mapping[str, Any]] = None) -> 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
@@ -147,6 +212,9 @@ def generate_spec(app: KayaApp,
: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
:param components: OpenAPI ``components`` object merged verbatim into the
document; references to ``#/components/<section>/<name>`` must resolve
against it (e.g. :func:`schema_ref` for reusable schemas)
:return: the OpenAPI document as a JSON-serializable mapping
"""
routes: list[_Route] = []
@@ -174,4 +242,7 @@ def generate_spec(app: KayaApp,
}
if servers:
spec['servers'] = [dict(server) for server in servers]
if components:
spec['components'] = deepcopy(dict(components))
_validate_component_refs(spec)
return spec
+123 -1
View File
@@ -6,7 +6,7 @@ import httpx
from pwo import async_test
from kaya.core import HttpContext, HttpMethod, KayaApp, WebSocket
from kaya.openapi import OpenAPIMixin, operation
from kaya.openapi import OpenAPIMixin, generate_spec, operation, schema_ref
class OpenAPITest(unittest.TestCase):
@@ -163,5 +163,127 @@ class OpenAPITest(unittest.TestCase):
self.assertIn('/spec.json', response.text)
class ComponentsTest(unittest.TestCase):
"""Reusable schemas through ``components`` and ``schema_ref``."""
async def _spec_of(self, app: KayaApp) -> Mapping[str, Any]:
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('/openapi.json')
self.assertEqual(200, response.status_code)
return json.loads(response.text)
def test_schema_ref_helper(self) -> None:
self.assertEqual({'$ref': '#/components/schemas/User'}, schema_ref('User'))
@async_test
async def test_components_merged_and_refs_preserved(self) -> None:
user = {'type': 'object', 'properties': {'id': {'type': 'integer'}}}
page = {'type': 'object',
'properties': {'results': {'type': 'array', 'items': schema_ref('User')}}}
app = KayaApp(mixins=[OpenAPIMixin(
title='Components API',
version='1.0.0',
components={'schemas': {'User': user, 'UserPage': page}},
)])
@app.GET('/users')
@operation(responses={200: {
'description': 'A page of users',
'content': {'application/json': {'schema': schema_ref('UserPage')}},
}})
async def list_users(ctx: HttpContext) -> None:
await ctx.send_str(200, '[]')
spec = await self._spec_of(app)
self.assertEqual({'schemas': {'User': user, 'UserPage': page}}, spec['components'])
schema = spec['paths']['/users']['get']['responses']['200']['content']['application/json']['schema']
self.assertEqual(schema_ref('UserPage'), schema)
@async_test
async def test_request_body_ref_preserved(self) -> None:
app = KayaApp(mixins=[OpenAPIMixin(
title='Components API',
version='1.0.0',
components={'schemas': {'User': {'type': 'object'}}},
)])
@app.POST('/users')
@operation(request_body={'required': True,
'content': {'application/json': {'schema': schema_ref('User')}}},
responses={201: {'description': 'Created'}})
async def create_user(ctx: HttpContext) -> None:
await ctx.send_str(201, '{}')
spec = await self._spec_of(app)
schema = spec['paths']['/users']['post']['requestBody']['content']['application/json']['schema']
self.assertEqual(schema_ref('User'), schema)
@async_test
async def test_default_spec_has_no_components(self) -> None:
app = KayaApp(mixins=[OpenAPIMixin(title='Plain API', version='1.0.0')])
@app.GET('/plain')
async def plain(ctx: HttpContext) -> None:
await ctx.send_str(200, 'ok')
spec = await self._spec_of(app)
self.assertNotIn('components', spec)
def test_unknown_schema_ref_raises(self) -> None:
app = KayaApp()
@app.GET('/broken')
@operation(responses={200: {
'description': 'x',
'content': {'application/json': {'schema': schema_ref('Missing')}},
}})
async def broken(ctx: HttpContext) -> None:
await ctx.send_str(200, 'x')
with self.assertRaises(ValueError) as caught:
generate_spec(app, title='Broken', version='1.0.0',
components={'schemas': {'User': {}}})
message = str(caught.exception)
self.assertIn('#/components/schemas/Missing', message)
self.assertIn('GET /broken', message)
def test_component_to_component_ref_validates(self) -> None:
app = KayaApp()
spec = generate_spec(app, title='Nested', version='1.0.0', components={'schemas': {
'User': {'type': 'object'},
'Page': {'type': 'object',
'properties': {'results': {'type': 'array', 'items': schema_ref('User')}}},
}})
self.assertIn('components', spec)
def test_external_refs_are_ignored(self) -> None:
app = KayaApp()
@app.GET('/external')
@operation(responses={200: {
'description': 'x',
'content': {'application/json': {'schema': {'$ref': './other.json#/Thing'}}},
}})
async def external(ctx: HttpContext) -> None:
await ctx.send_str(200, 'x')
spec = generate_spec(app, title='External', version='1.0.0')
self.assertNotIn('components', spec)
def test_generic_component_section_validated(self) -> None:
app = KayaApp()
@app.GET('/p')
@operation(parameters=[{'name': 'q', 'in': 'query',
'schema': {'$ref': '#/components/parameters/Missing'}}])
async def p(ctx: HttpContext) -> None:
await ctx.send_str(200, 'x')
with self.assertRaises(ValueError) as caught:
generate_spec(app, title='P', version='1.0.0', components={'parameters': {}})
self.assertIn('#/components/parameters/Missing', str(caught.exception))
if __name__ == '__main__':
unittest.main()