diff --git a/packages/kaya-openapi/README.md b/packages/kaya-openapi/README.md index 1eccf86..f34e4d0 100644 --- a/packages/kaya-openapi/README.md +++ b/packages/kaya-openapi/README.md @@ -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/
/` 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) ) ``` diff --git a/packages/kaya-openapi/src/kaya/openapi/__init__.py b/packages/kaya-openapi/src/kaya/openapi/__init__.py index 85a3d12..05cf5ec 100644 --- a/packages/kaya-openapi/src/kaya/openapi/__init__.py +++ b/packages/kaya-openapi/src/kaya/openapi/__init__.py @@ -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', ] diff --git a/packages/kaya-openapi/src/kaya/openapi/_metadata.py b/packages/kaya-openapi/src/kaya/openapi/_metadata.py index 85af3a0..a4133cc 100644 --- a/packages/kaya-openapi/src/kaya/openapi/_metadata.py +++ b/packages/kaya-openapi/src/kaya/openapi/_metadata.py @@ -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:: diff --git a/packages/kaya-openapi/src/kaya/openapi/_mixin.py b/packages/kaya-openapi/src/kaya/openapi/_mixin.py index 48e135f..3858e1f 100644 --- a/packages/kaya-openapi/src/kaya/openapi/_mixin.py +++ b/packages/kaya-openapi/src/kaya/openapi/_mixin.py @@ -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) diff --git a/packages/kaya-openapi/src/kaya/openapi/_spec.py b/packages/kaya-openapi/src/kaya/openapi/_spec.py index 8ac746d..956b156 100644 --- a/packages/kaya-openapi/src/kaya/openapi/_spec.py +++ b/packages/kaya-openapi/src/kaya/openapi/_spec.py @@ -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/`` 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/
/`` 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/
/`` 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 diff --git a/packages/kaya-openapi/tests/test_openapi.py b/packages/kaya-openapi/tests/test_openapi.py index f833d10..e17b4b4 100644 --- a/packages/kaya-openapi/tests/test_openapi.py +++ b/packages/kaya-openapi/tests/test_openapi.py @@ -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()