- New packages/kaya-openapi with OpenAPIMixin, @operation decorator, and generate_spec() that walks the routing tree - Enables kaya-core's Tree.register to expose the original handler callback as an instance attribute for metadata introspection - Registers GET /openapi.json and GET /docs (Swagger UI) routes - Supports and path parameters, docstring descriptions, @operation metadata, and excludes wildcard/WS routes - Adds example/openapi.py, updates CI, README, and requirements
168 lines
6.5 KiB
Python
168 lines
6.5 KiB
Python
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()
|