Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea82ac44e9
|
No files matched your search
@@ -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
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user