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
+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()