Author SHA1 Message Date
woggioni ea82ac44e9 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.
2026-10-14 20:37:14 +08:00
6 changed files with 254 additions and 7 deletions

No files matched your search

+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 `parameters` entries whose `name` and `in` match an auto-generated path
parameter override it; all others are appended. 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 ## Configuration
```python ```python
@@ -81,6 +123,7 @@ OpenAPIMixin(
servers=[{'url': 'https://api.example.com'}], servers=[{'url': 'https://api.example.com'}],
openapi_version='3.1.0', openapi_version='3.1.0',
include_self=False, # include spec/docs endpoints in the document 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 ._metadata import operation
from ._mixin import OpenAPIMixin from ._mixin import OpenAPIMixin
from ._spec import generate_spec from ._spec import generate_spec, schema_ref
__all__ = [ __all__ = [
'OpenAPIMixin', 'OpenAPIMixin',
'generate_spec', 'generate_spec',
'operation', 'operation',
'schema_ref',
] ]
@@ -25,7 +25,9 @@ def operation(summary: Optional[str] = None,
:class:`~kaya.openapi.OpenAPIMixin` when generating the specification. :class:`~kaya.openapi.OpenAPIMixin` when generating the specification.
All schema fragments are plain dicts inserted verbatim into the generated 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:: Example::
@@ -55,6 +55,11 @@ class OpenAPIMixin(KayaMixin):
bodies and response schemas to individual handlers; the handler docstring bodies and response schemas to individual handlers; the handler docstring
is used as the operation description when no explicit one is given. 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:: Example::
openapi = OpenAPIMixin(title='My API', version='1.0.0') openapi = OpenAPIMixin(title='My API', version='1.0.0')
@@ -74,7 +79,8 @@ class OpenAPIMixin(KayaMixin):
docs_path: str = '/docs', docs_path: str = '/docs',
servers: Optional[Sequence[Mapping[str, Any]]] = None, servers: Optional[Sequence[Mapping[str, Any]]] = None,
openapi_version: str = '3.1.0', 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._title = title
self._version = version self._version = version
self._description = description self._description = description
@@ -83,6 +89,7 @@ class OpenAPIMixin(KayaMixin):
self._servers = servers self._servers = servers
self._openapi_version = openapi_version self._openapi_version = openapi_version
self._include_self = include_self self._include_self = include_self
self._components = components
def apply(self, app: KayaApp) -> None: def apply(self, app: KayaApp) -> None:
@app.GET(self._spec_path) @app.GET(self._spec_path)
@@ -107,4 +114,5 @@ class OpenAPIMixin(KayaMixin):
description=self._description, description=self._description,
servers=self._servers, servers=self._servers,
exclude_paths=exclude_paths, 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 copy import deepcopy
from dataclasses import dataclass from dataclasses import dataclass
from inspect import getdoc 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 import HttpMethod, KayaApp
from kaya.core._path_handler import PathHandler 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 @dataclass
class _Route: class _Route:
raw_path: str raw_path: str
@@ -122,13 +133,67 @@ def _build_operation(params: Sequence[Mapping[str, Any]],
return operation 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, def generate_spec(app: KayaApp,
title: str, title: str,
version: str, version: str,
description: str = '', description: str = '',
servers: Optional[Sequence[Mapping[str, Any]]] = None, servers: Optional[Sequence[Mapping[str, Any]]] = None,
exclude_paths: AbstractSet[str] = frozenset(), 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`. """Generate an OpenAPI specification document from a :class:`KayaApp`.
The application's routing tree is walked and every route is converted to 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 servers: list of OpenAPI server objects
:param exclude_paths: Kaya paths (e.g. ``/openapi.json``) to omit :param exclude_paths: Kaya paths (e.g. ``/openapi.json``) to omit
:param openapi_version: OpenAPI version to declare :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 :return: the OpenAPI document as a JSON-serializable mapping
""" """
routes: list[_Route] = [] routes: list[_Route] = []
@@ -174,4 +242,7 @@ def generate_spec(app: KayaApp,
} }
if servers: if servers:
spec['servers'] = [dict(server) for server in servers] spec['servers'] = [dict(server) for server in servers]
if components:
spec['components'] = deepcopy(dict(components))
_validate_component_refs(spec)
return spec return spec
+123 -1
View File
@@ -6,7 +6,7 @@ import httpx
from pwo import async_test from pwo import async_test
from kaya.core import HttpContext, HttpMethod, KayaApp, WebSocket 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): class OpenAPITest(unittest.TestCase):
@@ -163,5 +163,127 @@ class OpenAPITest(unittest.TestCase):
self.assertIn('/spec.json', response.text) 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__': if __name__ == '__main__':
unittest.main() unittest.main()