Compare commits
5
Commits
release/0.0.1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a68d10868
|
||
|
|
87d1b0acb1
|
||
|
|
dfc5d70eec
|
||
|
|
08370c4963
|
||
|
|
01a54e3fb5
|
@@ -48,6 +48,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
.venv/bin/python -m mypy -p kaya.oidc
|
.venv/bin/python -m mypy -p kaya.oidc
|
||||||
.venv/bin/python -m unittest discover -s packages/kaya-oidc/tests
|
.venv/bin/python -m unittest discover -s packages/kaya-oidc/tests
|
||||||
|
- name: Check kaya-openapi
|
||||||
|
run: |
|
||||||
|
.venv/bin/python -m mypy -p kaya.openapi
|
||||||
|
.venv/bin/python -m unittest discover -s packages/kaya-openapi/tests
|
||||||
- name: Publish kaya-core artifacts
|
- name: Publish kaya-core artifacts
|
||||||
env:
|
env:
|
||||||
TWINE_REPOSITORY_URL: ${{ vars.PYPI_REGISTRY_URL }}
|
TWINE_REPOSITORY_URL: ${{ vars.PYPI_REGISTRY_URL }}
|
||||||
@@ -96,3 +100,11 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
.venv/bin/pyproject-build packages/kaya-oidc
|
.venv/bin/pyproject-build packages/kaya-oidc
|
||||||
.venv/bin/twine upload --repository gitea packages/kaya-oidc/dist/*.whl packages/kaya-oidc/dist/*.tar.gz
|
.venv/bin/twine upload --repository gitea packages/kaya-oidc/dist/*.whl packages/kaya-oidc/dist/*.tar.gz
|
||||||
|
- name: Publish kaya-openapi artifacts
|
||||||
|
env:
|
||||||
|
TWINE_REPOSITORY_URL: ${{ vars.PYPI_REGISTRY_URL }}
|
||||||
|
TWINE_USERNAME: ${{ vars.PUBLISHER_USERNAME }}
|
||||||
|
TWINE_PASSWORD: ${{ secrets.PUBLISHER_TOKEN }}
|
||||||
|
run: |
|
||||||
|
.venv/bin/pyproject-build packages/kaya-openapi
|
||||||
|
.venv/bin/twine upload --repository gitea packages/kaya-openapi/dist/*.whl packages/kaya-openapi/dist/*.tar.gz
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ This repository is a monorepo for the Kaya framework. The code is split into ind
|
|||||||
- **kaya-session-redis** — Redis-backed session storage (`packages/kaya-session-redis/`)
|
- **kaya-session-redis** — Redis-backed session storage (`packages/kaya-session-redis/`)
|
||||||
- **kaya-session-memcache** — memcached-backed session storage (`packages/kaya-session-memcache/`)
|
- **kaya-session-memcache** — memcached-backed session storage (`packages/kaya-session-memcache/`)
|
||||||
- **kaya-oidc** — OpenID Connect authentication (`packages/kaya-oidc/`)
|
- **kaya-oidc** — OpenID Connect authentication (`packages/kaya-oidc/`)
|
||||||
|
- **kaya-openapi** — automatic OpenAPI specification generation (`packages/kaya-openapi/`)
|
||||||
|
|
||||||
Additional `kaya-*` packages can be added as new directories under `packages/`.
|
Additional `kaya-*` packages can be added as new directories under `packages/`.
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ pip install --index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/si
|
|||||||
Install the packages in development mode:
|
Install the packages in development mode:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -e packages/kaya-core -e packages/kaya-rsgi -e packages/kaya-session -e packages/kaya-session-redis -e packages/kaya-session-memcache -e packages/kaya-oidc
|
pip install -e packages/kaya-core -e packages/kaya-rsgi -e packages/kaya-session -e packages/kaya-session-redis -e packages/kaya-session-memcache -e packages/kaya-oidc -e packages/kaya-openapi
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the example:
|
Run the example:
|
||||||
@@ -44,6 +45,7 @@ python -m unittest discover -s packages/kaya-session/tests
|
|||||||
python -m unittest discover -s packages/kaya-session-redis/tests
|
python -m unittest discover -s packages/kaya-session-redis/tests
|
||||||
python -m unittest discover -s packages/kaya-session-memcache/tests
|
python -m unittest discover -s packages/kaya-session-memcache/tests
|
||||||
python -m unittest discover -s packages/kaya-oidc/tests
|
python -m unittest discover -s packages/kaya-oidc/tests
|
||||||
|
python -m unittest discover -s packages/kaya-openapi/tests
|
||||||
```
|
```
|
||||||
|
|
||||||
## Static analysis
|
## Static analysis
|
||||||
@@ -52,9 +54,10 @@ python -m unittest discover -s packages/kaya-oidc/tests
|
|||||||
mypy -p kaya.core
|
mypy -p kaya.core
|
||||||
mypy -p kaya.rsgi
|
mypy -p kaya.rsgi
|
||||||
mypy -p kaya.session
|
mypy -p kaya.session
|
||||||
mypy -p kaya.session_redis
|
mypy -p kaya.session.redis
|
||||||
mypy -p kaya.session_memcache
|
mypy -p kaya.session.memcache
|
||||||
mypy -p kaya.oidc
|
mypy -p kaya.oidc
|
||||||
|
mypy -p kaya.openapi
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building packages
|
## Building packages
|
||||||
@@ -66,4 +69,5 @@ python -m build packages/kaya-session
|
|||||||
python -m build packages/kaya-session-redis
|
python -m build packages/kaya-session-redis
|
||||||
python -m build packages/kaya-session-memcache
|
python -m build packages/kaya-session-memcache
|
||||||
python -m build packages/kaya-oidc
|
python -m build packages/kaya-oidc
|
||||||
|
python -m build packages/kaya-openapi
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from kaya.core import HttpContext, KayaApp
|
||||||
|
from kaya.openapi import OpenAPIMixin, operation
|
||||||
|
|
||||||
|
app = KayaApp(mixins=[OpenAPIMixin(
|
||||||
|
title='Greeting API',
|
||||||
|
version='1.0.0',
|
||||||
|
description='Example API documented with kaya-openapi',
|
||||||
|
)])
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET('/hello')
|
||||||
|
async def hello(ctx: HttpContext) -> None:
|
||||||
|
"""Say hello to the world."""
|
||||||
|
await ctx.send_str(200, 'Hello World')
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET('/hello/${name}')
|
||||||
|
@operation(summary='Greet someone',
|
||||||
|
tags=['greetings'],
|
||||||
|
responses={200: {'description': 'A personalized greeting'}})
|
||||||
|
async def hello_name(ctx: HttpContext, name: str) -> None:
|
||||||
|
await ctx.send_str(200, f'Hello {name}')
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET('/square/${x:int}')
|
||||||
|
@operation(summary='Compute the square of a number', tags=['math'])
|
||||||
|
async def square(ctx: HttpContext, x: int) -> None:
|
||||||
|
await ctx.send_str(200, str(x * x))
|
||||||
|
|
||||||
|
# serve with an ASGI/RSGI server, e.g.:
|
||||||
|
# granian --interface rsgi example.openapi:app
|
||||||
|
# then open http://localhost:8000/docs to browse the API
|
||||||
@@ -92,6 +92,16 @@ class Tree:
|
|||||||
result = child
|
result = child
|
||||||
key = leaf
|
key = leaf
|
||||||
while key is not None:
|
while key is not None:
|
||||||
|
existing = self._find_equivalent_matcher(result, key)
|
||||||
|
if existing is not None:
|
||||||
|
result = existing
|
||||||
|
key = next(it, None)
|
||||||
|
continue
|
||||||
|
child = result.children.get(key)
|
||||||
|
if child is not None:
|
||||||
|
result = child
|
||||||
|
key = next(it, None)
|
||||||
|
continue
|
||||||
new_node = self.parse(key, result)
|
new_node = self.parse(key, result)
|
||||||
if isinstance(new_node, Node):
|
if isinstance(new_node, Node):
|
||||||
result.children[key] = new_node
|
result.children[key] = new_node
|
||||||
@@ -109,6 +119,44 @@ class Tree:
|
|||||||
def _supports_method(node: Node | PathMatcher, method: HttpMethod) -> bool:
|
def _supports_method(node: Node | PathMatcher, method: HttpMethod) -> bool:
|
||||||
return None in node.supported_methods or method in node.supported_methods
|
return None in node.supported_methods or method in node.supported_methods
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _matcher_identity(leaf: str) -> Optional[Tuple[str, str]]:
|
||||||
|
start = index_of_with_escape(leaf, '${', '\\', 0)
|
||||||
|
if start >= 0:
|
||||||
|
start += 2
|
||||||
|
end = leaf.index('}', start + 2)
|
||||||
|
definition = leaf[start:end]
|
||||||
|
try:
|
||||||
|
colon = definition.index(':')
|
||||||
|
except ValueError:
|
||||||
|
colon = None
|
||||||
|
if colon is None:
|
||||||
|
name = definition
|
||||||
|
kind = 'str'
|
||||||
|
else:
|
||||||
|
name = definition[:colon]
|
||||||
|
kind = definition[colon + 1:]
|
||||||
|
if kind not in ('str', 'int'):
|
||||||
|
raise ValueError(f"Unknown kind: '{kind}'")
|
||||||
|
return (kind, name)
|
||||||
|
if index_of_with_escape(leaf, '*', '\\', 0) >= 0:
|
||||||
|
return ('glob', leaf)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _find_equivalent_matcher(self, node: Node | PathMatcher, leaf: str) -> Optional[PathMatcher]:
|
||||||
|
identity = self._matcher_identity(leaf)
|
||||||
|
if identity is None:
|
||||||
|
return None
|
||||||
|
kind, key = identity
|
||||||
|
for existing in node.path_matchers:
|
||||||
|
if kind == 'str' and isinstance(existing, StrMatcher) and existing.name == key:
|
||||||
|
return existing
|
||||||
|
if kind == 'int' and isinstance(existing, IntMatcher) and existing.name == key:
|
||||||
|
return existing
|
||||||
|
if kind == 'glob' and isinstance(existing, GlobMatcher) and existing.pattern == key:
|
||||||
|
return existing
|
||||||
|
return None
|
||||||
|
|
||||||
def _check_matcher_conflict(self, node: Node | PathMatcher, method: Optional[HttpMethod]) -> None:
|
def _check_matcher_conflict(self, node: Node | PathMatcher, method: Optional[HttpMethod]) -> None:
|
||||||
new_is_generic = method is None
|
new_is_generic = method is None
|
||||||
for existing in node.path_matchers:
|
for existing in node.path_matchers:
|
||||||
@@ -138,16 +186,27 @@ class Tree:
|
|||||||
callback: Callable[[Context, Unpack[Any]], Awaitable[None]],
|
callback: Callable[[Context, Unpack[Any]], Awaitable[None]],
|
||||||
recursive: bool) -> None:
|
recursive: bool) -> None:
|
||||||
class Handler(PathHandler):
|
class Handler(PathHandler):
|
||||||
|
"""PathHandler created by :meth:`Tree.register`.
|
||||||
|
|
||||||
|
The original user callback is exposed through the ``callback``
|
||||||
|
attribute so that extensions (e.g. ``kaya-openapi``) can inspect
|
||||||
|
it for metadata such as docstrings or decorator attributes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
callback: Callable[[Context, Unpack[Any]], Awaitable[None]]
|
||||||
|
|
||||||
async def handle_request(self, ctx: Context, captured: Matches) -> None:
|
async def handle_request(self, ctx: Context, captured: Matches) -> None:
|
||||||
args = Maybe.of_nullable(captured.path).map(lambda it: [it]).or_else([])
|
args = Maybe.of_nullable(captured.path).map(lambda it: [it]).or_else([])
|
||||||
await callback(ctx, *args, **captured.kwargs)
|
await self.callback(ctx, *args, **captured.kwargs)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def recursive(self) -> bool:
|
def recursive(self) -> bool:
|
||||||
return recursive
|
return recursive
|
||||||
|
|
||||||
handler = Handler()
|
handler = Handler()
|
||||||
|
# assigned as an instance attribute (not a class attribute) so that
|
||||||
|
# the function descriptor protocol does not turn it into a bound method
|
||||||
|
handler.callback = callback
|
||||||
self.add((p for p in PathIterator(path)), method, handler)
|
self.add((p for p in PathIterator(path)), method, handler)
|
||||||
|
|
||||||
def find_node(self, path: Generator[str, None, None], method: HttpMethod = HttpMethod.GET) \
|
def find_node(self, path: Generator[str, None, None], method: HttpMethod = HttpMethod.GET) \
|
||||||
|
|||||||
@@ -190,3 +190,63 @@ class AsgiTest(unittest.TestCase):
|
|||||||
'employee_id': 101325
|
'employee_id': 101325
|
||||||
}, response)
|
}, response)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_nested_param_routes(self):
|
||||||
|
app = KayaApp()
|
||||||
|
|
||||||
|
@app.GET('/restaurants/${id}')
|
||||||
|
async def restaurant(ctx: HttpContext, id: str) -> None:
|
||||||
|
await ctx.send_str(200, f"restaurant:{id}")
|
||||||
|
|
||||||
|
@app.GET('/restaurants/${id}/menu')
|
||||||
|
async def menu(ctx: HttpContext, id: str) -> None:
|
||||||
|
await ctx.send_str(200, f"menu:{id}")
|
||||||
|
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:80") as client:
|
||||||
|
r = await client.get("/restaurants/42")
|
||||||
|
self.assertEqual(200, r.status_code)
|
||||||
|
self.assertEqual("restaurant:42", r.text)
|
||||||
|
|
||||||
|
r = await client.get("/restaurants/42/menu")
|
||||||
|
self.assertEqual(200, r.status_code)
|
||||||
|
self.assertEqual("menu:42", r.text)
|
||||||
|
|
||||||
|
r = await client.get("/restaurants/42/unknown")
|
||||||
|
self.assertEqual(404, r.status_code)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_nested_param_routes_multiple_methods(self):
|
||||||
|
app = KayaApp()
|
||||||
|
|
||||||
|
@app.GET('/restaurants/${id}')
|
||||||
|
async def restaurant(ctx: HttpContext, id: str) -> None:
|
||||||
|
await ctx.send_str(200, f"restaurant:{id}")
|
||||||
|
|
||||||
|
@app.GET('/restaurants/${id}/menu')
|
||||||
|
async def menu_get(ctx: HttpContext, id: str) -> None:
|
||||||
|
await ctx.send_str(200, f"menu_get:{id}")
|
||||||
|
|
||||||
|
@app.POST('/restaurants/${id}/menu')
|
||||||
|
async def menu_post(ctx: HttpContext, id: str) -> None:
|
||||||
|
await ctx.send_str(200, f"menu_post:{id}")
|
||||||
|
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:80") as client:
|
||||||
|
r = await client.get("/restaurants/42")
|
||||||
|
self.assertEqual(200, r.status_code)
|
||||||
|
self.assertEqual("restaurant:42", r.text)
|
||||||
|
|
||||||
|
r = await client.get("/restaurants/42/menu")
|
||||||
|
self.assertEqual(200, r.status_code)
|
||||||
|
self.assertEqual("menu_get:42", r.text)
|
||||||
|
|
||||||
|
r = await client.post("/restaurants/42/menu")
|
||||||
|
self.assertEqual(200, r.status_code)
|
||||||
|
self.assertEqual("menu_post:42", r.text)
|
||||||
|
|
||||||
|
r = await client.put("/restaurants/42/menu")
|
||||||
|
self.assertEqual(404, r.status_code)
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,14 @@ class TreeTest(unittest.TestCase):
|
|||||||
tree = Tree()
|
tree = Tree()
|
||||||
tree.add((p for p in ('foo', '*')), None, self.handlers[0])
|
tree.add((p for p in ('foo', '*')), None, self.handlers[0])
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
tree.add((p for p in ('foo', '*')), None, self.handlers[1])
|
tree.add((p for p in ('foo', '*.md')), None, self.handlers[1])
|
||||||
|
|
||||||
|
def test_identical_method_agnostic_matchers_reuse(self):
|
||||||
|
tree = Tree()
|
||||||
|
tree.add((p for p in ('foo', '*')), None, self.handlers[0])
|
||||||
|
tree.add((p for p in ('foo', '*')), None, self.handlers[1])
|
||||||
|
handler = Maybe.of_nullable(tree.get_handler('/foo/bar', HttpMethod.GET)).map(lambda it: it[0]).or_none()
|
||||||
|
self.assertIs(self.handlers[0], handler)
|
||||||
|
|
||||||
def test_two_overlapping_method_specific_matchers_raise(self):
|
def test_two_overlapping_method_specific_matchers_raise(self):
|
||||||
tree = Tree()
|
tree = Tree()
|
||||||
@@ -101,3 +108,81 @@ class TreeTest(unittest.TestCase):
|
|||||||
self.assertIs(self.handlers[0], put_handler)
|
self.assertIs(self.handlers[0], put_handler)
|
||||||
self.assertIs(self.handlers[1], get_handler)
|
self.assertIs(self.handlers[1], get_handler)
|
||||||
|
|
||||||
|
def test_nested_routes_with_same_param_allowed(self):
|
||||||
|
tree = Tree()
|
||||||
|
tree.add((p for p in ('restaurants', '${id}')), HttpMethod.GET, self.handlers[0])
|
||||||
|
tree.add((p for p in ('restaurants', '${id}', 'menu')), HttpMethod.GET, self.handlers[1])
|
||||||
|
h0 = Maybe.of_nullable(tree.get_handler('/restaurants/42', HttpMethod.GET)).map(lambda it: it[0]).or_none()
|
||||||
|
h1 = Maybe.of_nullable(tree.get_handler('/restaurants/42/menu', HttpMethod.GET)).map(lambda it: it[0]).or_none()
|
||||||
|
self.assertIs(self.handlers[0], h0)
|
||||||
|
self.assertIs(self.handlers[1], h1)
|
||||||
|
|
||||||
|
def test_nested_routes_same_param_reverse_order(self):
|
||||||
|
tree = Tree()
|
||||||
|
tree.add((p for p in ('restaurants', '${id}', 'menu')), HttpMethod.GET, self.handlers[1])
|
||||||
|
tree.add((p for p in ('restaurants', '${id}')), HttpMethod.GET, self.handlers[0])
|
||||||
|
h0 = Maybe.of_nullable(tree.get_handler('/restaurants/42', HttpMethod.GET)).map(lambda it: it[0]).or_none()
|
||||||
|
h1 = Maybe.of_nullable(tree.get_handler('/restaurants/42/menu', HttpMethod.GET)).map(lambda it: it[0]).or_none()
|
||||||
|
self.assertIs(self.handlers[0], h0)
|
||||||
|
self.assertIs(self.handlers[1], h1)
|
||||||
|
|
||||||
|
def test_nested_routes_with_same_int_param_allowed(self):
|
||||||
|
tree = Tree()
|
||||||
|
tree.add((p for p in ('restaurants', '${id:int}')), HttpMethod.GET, self.handlers[0])
|
||||||
|
tree.add((p for p in ('restaurants', '${id:int}', 'menu')), HttpMethod.GET, self.handlers[1])
|
||||||
|
h0 = Maybe.of_nullable(tree.get_handler('/restaurants/42', HttpMethod.GET)).map(lambda it: it[0]).or_none()
|
||||||
|
h1 = Maybe.of_nullable(tree.get_handler('/restaurants/42/menu', HttpMethod.GET)).map(lambda it: it[0]).or_none()
|
||||||
|
self.assertIs(self.handlers[0], h0)
|
||||||
|
self.assertIs(self.handlers[1], h1)
|
||||||
|
|
||||||
|
def test_nested_routes_method_agnostic_reuses_matcher(self):
|
||||||
|
tree = Tree()
|
||||||
|
tree.add((p for p in ('restaurants', '${id}')), HttpMethod.GET, self.handlers[0])
|
||||||
|
tree.add((p for p in ('restaurants', '${id}', 'menu')), None, self.handlers[1])
|
||||||
|
h0 = Maybe.of_nullable(tree.get_handler('/restaurants/42', HttpMethod.GET)).map(lambda it: it[0]).or_none()
|
||||||
|
h1 = Maybe.of_nullable(tree.get_handler('/restaurants/42/menu', HttpMethod.POST)).map(lambda it: it[0]).or_none()
|
||||||
|
self.assertIs(self.handlers[0], h0)
|
||||||
|
self.assertIs(self.handlers[1], h1)
|
||||||
|
|
||||||
|
def test_different_param_names_still_raise(self):
|
||||||
|
tree = Tree()
|
||||||
|
tree.add((p for p in ('a', '${id}')), HttpMethod.GET, self.handlers[0])
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
tree.add((p for p in ('a', '${name}', 'x')), HttpMethod.GET, self.handlers[1])
|
||||||
|
|
||||||
|
def test_different_param_kinds_still_raise(self):
|
||||||
|
tree = Tree()
|
||||||
|
tree.add((p for p in ('a', '${id}')), HttpMethod.GET, self.handlers[0])
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
tree.add((p for p in ('a', '${id:int}', 'x')), HttpMethod.GET, self.handlers[1])
|
||||||
|
|
||||||
|
def test_nested_routes_different_methods_share_subtree(self):
|
||||||
|
tree = Tree()
|
||||||
|
tree.add((p for p in ('restaurants', '${id}', 'menu')), HttpMethod.GET, self.handlers[0])
|
||||||
|
tree.add((p for p in ('restaurants', '${id}', 'menu')), HttpMethod.POST, self.handlers[1])
|
||||||
|
h0 = Maybe.of_nullable(tree.get_handler('/restaurants/42/menu', HttpMethod.GET)).map(lambda it: it[0]).or_none()
|
||||||
|
h1 = Maybe.of_nullable(tree.get_handler('/restaurants/42/menu', HttpMethod.POST)).map(lambda it: it[0]).or_none()
|
||||||
|
self.assertIs(self.handlers[0], h0)
|
||||||
|
self.assertIs(self.handlers[1], h1)
|
||||||
|
|
||||||
|
def test_nested_routes_different_methods_reverse_order(self):
|
||||||
|
tree = Tree()
|
||||||
|
tree.add((p for p in ('restaurants', '${id}', 'menu')), HttpMethod.POST, self.handlers[1])
|
||||||
|
tree.add((p for p in ('restaurants', '${id}', 'menu')), HttpMethod.GET, self.handlers[0])
|
||||||
|
h0 = Maybe.of_nullable(tree.get_handler('/restaurants/42/menu', HttpMethod.GET)).map(lambda it: it[0]).or_none()
|
||||||
|
h1 = Maybe.of_nullable(tree.get_handler('/restaurants/42/menu', HttpMethod.POST)).map(lambda it: it[0]).or_none()
|
||||||
|
self.assertIs(self.handlers[0], h0)
|
||||||
|
self.assertIs(self.handlers[1], h1)
|
||||||
|
|
||||||
|
def test_nested_routes_combined_detail_and_menu_methods(self):
|
||||||
|
tree = Tree()
|
||||||
|
tree.add((p for p in ('restaurants', '${id}')), HttpMethod.GET, self.handlers[0])
|
||||||
|
tree.add((p for p in ('restaurants', '${id}', 'menu')), HttpMethod.GET, self.handlers[1])
|
||||||
|
tree.add((p for p in ('restaurants', '${id}', 'menu')), HttpMethod.POST, self.handlers[2])
|
||||||
|
h0 = Maybe.of_nullable(tree.get_handler('/restaurants/42', HttpMethod.GET)).map(lambda it: it[0]).or_none()
|
||||||
|
h1 = Maybe.of_nullable(tree.get_handler('/restaurants/42/menu', HttpMethod.GET)).map(lambda it: it[0]).or_none()
|
||||||
|
h2 = Maybe.of_nullable(tree.get_handler('/restaurants/42/menu', HttpMethod.POST)).map(lambda it: it[0]).or_none()
|
||||||
|
self.assertIs(self.handlers[0], h0)
|
||||||
|
self.assertIs(self.handlers[1], h1)
|
||||||
|
self.assertIs(self.handlers[2], h2)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# kaya-openapi
|
||||||
|
|
||||||
|
Automatic [OpenAPI](https://www.openapis.org/) specification generation for the
|
||||||
|
[Kaya](https://github.com/woggioni/kaya) lightweight ASGI web framework.
|
||||||
|
|
||||||
|
The package provides an `OpenAPIMixin` that inspects a `KayaApp`'s routing
|
||||||
|
tree and serves:
|
||||||
|
|
||||||
|
- an OpenAPI 3.1 JSON document (default: `GET /openapi.json`)
|
||||||
|
- a Swagger UI page to browse it interactively (default: `GET /docs`)
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from kaya.core import HttpContext, KayaApp
|
||||||
|
from kaya.openapi import OpenAPIMixin, operation
|
||||||
|
|
||||||
|
app = KayaApp(mixins=[OpenAPIMixin(title='My API', version='1.0.0')])
|
||||||
|
|
||||||
|
|
||||||
|
@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:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the app with any ASGI/RSGI server and open `http://localhost:8000/docs`.
|
||||||
|
|
||||||
|
## How routes are mapped
|
||||||
|
|
||||||
|
- Static segments and parameters are converted to OpenAPI path templating:
|
||||||
|
- `/users/${user_id}` → `/users/{user_id}` (string path parameter)
|
||||||
|
- `/users/${user_id:int}` → `/users/{user_id}` (integer path parameter)
|
||||||
|
- Wildcard routes (`*`) are **skipped**: they cannot be expressed in OpenAPI
|
||||||
|
path syntax.
|
||||||
|
- Websocket routes are **skipped**: OpenAPI does not model websockets.
|
||||||
|
- Method-agnostic routes (registered with `app.route(path)` without methods)
|
||||||
|
are documented under **all** standard HTTP methods, since they respond to
|
||||||
|
all of them.
|
||||||
|
- The mixin's own endpoints are excluded from the document unless
|
||||||
|
`include_self=True`.
|
||||||
|
|
||||||
|
The document is generated on every request to the spec endpoint, so routes
|
||||||
|
registered after the mixin is applied are always included.
|
||||||
|
|
||||||
|
## Operation metadata
|
||||||
|
|
||||||
|
The `@operation` decorator attaches OpenAPI metadata to a route handler.
|
||||||
|
All fragments are plain dicts merged verbatim into the generated operation
|
||||||
|
object, so any valid OpenAPI 3.1 construct can be used:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@operation(summary='...', # operation summary
|
||||||
|
description='...', # defaults to the handler docstring
|
||||||
|
tags=['users'],
|
||||||
|
operation_id='getUser',
|
||||||
|
request_body={...}, # OpenAPI requestBody object
|
||||||
|
responses={200: {...}}, # per-status-code response objects
|
||||||
|
parameters=[...], # extra/overriding parameter objects
|
||||||
|
deprecated=False,
|
||||||
|
hidden=False) # exclude from the document
|
||||||
|
```
|
||||||
|
|
||||||
|
`parameters` entries whose `name` and `in` match an auto-generated path
|
||||||
|
parameter override it; all others are appended.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```python
|
||||||
|
OpenAPIMixin(
|
||||||
|
title='My API', # info.title (required)
|
||||||
|
version='1.0.0', # info.version (required)
|
||||||
|
description='', # info.description
|
||||||
|
spec_path='/openapi.json', # where the JSON document is served
|
||||||
|
docs_path='/docs', # where Swagger UI is served
|
||||||
|
servers=[{'url': 'https://api.example.com'}],
|
||||||
|
openapi_version='3.1.0',
|
||||||
|
include_self=False, # include spec/docs endpoints in the document
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The document can also be generated programmatically without serving it:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from kaya.openapi import generate_spec
|
||||||
|
|
||||||
|
spec = generate_spec(app, title='My API', version='1.0.0')
|
||||||
|
```
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0", "setuptools-scm>=8"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "kaya-openapi"
|
||||||
|
dynamic = ["version"]
|
||||||
|
authors = [
|
||||||
|
{ name="Walter Oggioni", email="oggioni.walter@gmail.com" },
|
||||||
|
]
|
||||||
|
description = "Automatic OpenAPI specification generation for the Kaya lightweight ASGI web framework"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
license = "MIT"
|
||||||
|
classifiers = [
|
||||||
|
'Development Status :: 3 - Alpha',
|
||||||
|
'Topic :: Utilities',
|
||||||
|
'Intended Audience :: System Administrators',
|
||||||
|
'Intended Audience :: Developers',
|
||||||
|
'Environment :: Console',
|
||||||
|
'Programming Language :: Python :: 3',
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"kaya-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"build", "mypy", "ipdb", "twine", "httpx", "httpx-ws"
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
"Homepage" = "https://github.com/woggioni/kaya"
|
||||||
|
"Bug Tracker" = "https://github.com/woggioni/kaya/issues"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
namespaces = true
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.12"
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
show_error_codes = true
|
||||||
|
no_implicit_optional = true
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
exclude = ["scripts", "docs", "test"]
|
||||||
|
strict = true
|
||||||
|
|
||||||
|
[tool.setuptools_scm]
|
||||||
|
root = "../.."
|
||||||
|
version_file = "src/kaya/openapi/_version.py"
|
||||||
|
|
||||||
|
[tool.setuptools_scm.tag]
|
||||||
|
prefix = "release/"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from ._metadata import operation
|
||||||
|
from ._mixin import OpenAPIMixin
|
||||||
|
from ._spec import generate_spec
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'OpenAPIMixin',
|
||||||
|
'generate_spec',
|
||||||
|
'operation',
|
||||||
|
]
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
from typing import Any, Awaitable, Callable, Mapping, Optional, Sequence, TypeVar
|
||||||
|
|
||||||
|
F = TypeVar('F', bound=Callable[..., Awaitable[None]])
|
||||||
|
|
||||||
|
METADATA_ATTR = '__kaya_openapi__'
|
||||||
|
|
||||||
|
#: Metadata attached to route handler functions by :func:`operation`.
|
||||||
|
#: Values are raw OpenAPI fragments (plain dicts) merged verbatim into the
|
||||||
|
#: generated operation object.
|
||||||
|
type OperationMetadata = Mapping[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
def operation(summary: Optional[str] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
tags: Optional[Sequence[str]] = None,
|
||||||
|
request_body: Optional[Mapping[str, Any]] = None,
|
||||||
|
responses: Optional[Mapping[int | str, Mapping[str, Any]]] = None,
|
||||||
|
parameters: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||||
|
operation_id: Optional[str] = None,
|
||||||
|
deprecated: bool = False,
|
||||||
|
hidden: bool = False) -> Callable[[F], F]:
|
||||||
|
"""Attach OpenAPI metadata to a Kaya route handler.
|
||||||
|
|
||||||
|
The metadata is stored on the function itself and picked up by
|
||||||
|
: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.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
@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:
|
||||||
|
...
|
||||||
|
|
||||||
|
:param summary: short summary of the operation
|
||||||
|
:param description: longer description (defaults to the handler docstring)
|
||||||
|
:param tags: list of OpenAPI tags
|
||||||
|
:param request_body: OpenAPI ``requestBody`` object
|
||||||
|
:param responses: mapping of status code (or ``'default'``) to OpenAPI
|
||||||
|
response objects
|
||||||
|
:param parameters: extra OpenAPI parameter objects merged with the
|
||||||
|
auto-generated path parameters (entries whose ``name`` matches a path
|
||||||
|
parameter override the auto-generated one)
|
||||||
|
:param operation_id: explicit OpenAPI ``operationId``
|
||||||
|
:param deprecated: mark the operation as deprecated
|
||||||
|
:param hidden: exclude the operation from the generated specification
|
||||||
|
"""
|
||||||
|
def decorator(func: F) -> F:
|
||||||
|
metadata: dict[str, Any] = {}
|
||||||
|
if summary is not None:
|
||||||
|
metadata['summary'] = summary
|
||||||
|
if description is not None:
|
||||||
|
metadata['description'] = description
|
||||||
|
if tags is not None:
|
||||||
|
metadata['tags'] = list(tags)
|
||||||
|
if request_body is not None:
|
||||||
|
metadata['request_body'] = dict(request_body)
|
||||||
|
if responses is not None:
|
||||||
|
metadata['responses'] = {str(k): dict(v) for k, v in responses.items()}
|
||||||
|
if parameters is not None:
|
||||||
|
metadata['parameters'] = [dict(p) for p in parameters]
|
||||||
|
if operation_id is not None:
|
||||||
|
metadata['operation_id'] = operation_id
|
||||||
|
if deprecated:
|
||||||
|
metadata['deprecated'] = True
|
||||||
|
if hidden:
|
||||||
|
metadata['hidden'] = True
|
||||||
|
setattr(func, METADATA_ATTR, metadata)
|
||||||
|
return func
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def get_metadata(handler: Any) -> OperationMetadata:
|
||||||
|
"""Return the metadata attached by :func:`operation`, or an empty mapping."""
|
||||||
|
metadata = getattr(handler, METADATA_ATTR, None)
|
||||||
|
if isinstance(metadata, Mapping):
|
||||||
|
return metadata
|
||||||
|
return {}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import json
|
||||||
|
from html import escape
|
||||||
|
from typing import Any, Mapping, Optional, Sequence
|
||||||
|
|
||||||
|
from kaya.core import HttpContext, KayaApp, KayaMixin
|
||||||
|
|
||||||
|
from ._spec import generate_spec
|
||||||
|
|
||||||
|
_DOCS_PAGE = """<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{title} - API documentation</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js" crossorigin></script>
|
||||||
|
<script>
|
||||||
|
window.onload = () => {{
|
||||||
|
window.ui = SwaggerUIBundle({{
|
||||||
|
url: '{spec_path}',
|
||||||
|
dom_id: '#swagger-ui',
|
||||||
|
}});
|
||||||
|
}};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAPIMixin(KayaMixin):
|
||||||
|
"""Kaya mixin serving an auto-generated OpenAPI specification.
|
||||||
|
|
||||||
|
Registers two routes on the app:
|
||||||
|
|
||||||
|
- ``spec_path`` (default ``/openapi.json``): the OpenAPI document,
|
||||||
|
generated on each request from the application's routing tree so that
|
||||||
|
routes registered after the mixin are always included.
|
||||||
|
- ``docs_path`` (default ``/docs``): a Swagger UI page rendering the
|
||||||
|
specification (assets are loaded from a CDN).
|
||||||
|
|
||||||
|
Routes are converted as follows:
|
||||||
|
|
||||||
|
- ``${name}`` path segments become ``{name}`` string path parameters,
|
||||||
|
``${name:int}`` become integer path parameters;
|
||||||
|
- wildcard (``*``) and websocket routes are skipped, since they cannot be
|
||||||
|
expressed in OpenAPI;
|
||||||
|
- method-agnostic routes (registered with ``app.route(path)``) are
|
||||||
|
documented under all standard HTTP methods;
|
||||||
|
- the mixin's own routes are excluded unless ``include_self`` is true.
|
||||||
|
|
||||||
|
Use :func:`~kaya.openapi.operation` to attach summaries, tags, request
|
||||||
|
bodies and response schemas to individual handlers; the handler docstring
|
||||||
|
is used as the operation description when no explicit one is given.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
openapi = OpenAPIMixin(title='My API', version='1.0.0')
|
||||||
|
app = KayaApp(mixins=[openapi])
|
||||||
|
|
||||||
|
@app.GET('/users/${user_id:int}')
|
||||||
|
@operation(summary='Get a user', tags=['users'])
|
||||||
|
async def get_user(ctx: HttpContext, user_id: int) -> None:
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
title: str,
|
||||||
|
version: str,
|
||||||
|
description: str = '',
|
||||||
|
spec_path: str = '/openapi.json',
|
||||||
|
docs_path: str = '/docs',
|
||||||
|
servers: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||||
|
openapi_version: str = '3.1.0',
|
||||||
|
include_self: bool = False) -> None:
|
||||||
|
self._title = title
|
||||||
|
self._version = version
|
||||||
|
self._description = description
|
||||||
|
self._spec_path = spec_path
|
||||||
|
self._docs_path = docs_path
|
||||||
|
self._servers = servers
|
||||||
|
self._openapi_version = openapi_version
|
||||||
|
self._include_self = include_self
|
||||||
|
|
||||||
|
def apply(self, app: KayaApp) -> None:
|
||||||
|
@app.GET(self._spec_path)
|
||||||
|
async def openapi_spec(ctx: HttpContext) -> None:
|
||||||
|
spec = self.generate_spec(app)
|
||||||
|
await ctx.send_str(200,
|
||||||
|
json.dumps(spec, indent=2),
|
||||||
|
{'Content-Type': 'application/json'})
|
||||||
|
|
||||||
|
@app.GET(self._docs_path)
|
||||||
|
async def openapi_docs(ctx: HttpContext) -> None:
|
||||||
|
page = _DOCS_PAGE.format(title=escape(self._title), spec_path=self._spec_path)
|
||||||
|
await ctx.send_str(200, page, {'Content-Type': 'text/html; charset=utf-8'})
|
||||||
|
|
||||||
|
def generate_spec(self, app: KayaApp) -> Mapping[str, Any]:
|
||||||
|
"""Generate the OpenAPI document for ``app`` (also used by the
|
||||||
|
``spec_path`` endpoint on every request)."""
|
||||||
|
exclude_paths = frozenset() if self._include_self else frozenset((self._spec_path, self._docs_path))
|
||||||
|
return generate_spec(app,
|
||||||
|
title=self._title,
|
||||||
|
version=self._version,
|
||||||
|
description=self._description,
|
||||||
|
servers=self._servers,
|
||||||
|
exclude_paths=exclude_paths,
|
||||||
|
openapi_version=self._openapi_version)
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from inspect import getdoc
|
||||||
|
from typing import AbstractSet, Any, Mapping, Optional, Sequence
|
||||||
|
|
||||||
|
from kaya.core import HttpMethod, KayaApp
|
||||||
|
from kaya.core._path_handler import PathHandler
|
||||||
|
from kaya.core._path_matcher import GlobMatcher, IntMatcher, Node, PathMatcher, StrMatcher
|
||||||
|
|
||||||
|
from ._metadata import get_metadata
|
||||||
|
|
||||||
|
#: HTTP methods documented for method-agnostic routes
|
||||||
|
#: (registered with ``app.route(path)`` without explicit methods).
|
||||||
|
STANDARD_METHODS: Sequence[HttpMethod] = (
|
||||||
|
HttpMethod.GET,
|
||||||
|
HttpMethod.PUT,
|
||||||
|
HttpMethod.POST,
|
||||||
|
HttpMethod.DELETE,
|
||||||
|
HttpMethod.OPTIONS,
|
||||||
|
HttpMethod.HEAD,
|
||||||
|
HttpMethod.PATCH,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Route:
|
||||||
|
raw_path: str
|
||||||
|
openapi_path: str
|
||||||
|
method: Optional[HttpMethod]
|
||||||
|
params: Sequence[Mapping[str, Any]]
|
||||||
|
handlers: Sequence[PathHandler]
|
||||||
|
|
||||||
|
|
||||||
|
def _walk(node: Node | PathMatcher,
|
||||||
|
raw_path: str,
|
||||||
|
openapi_path: str,
|
||||||
|
params: Sequence[Mapping[str, Any]],
|
||||||
|
routes: list[_Route]) -> None:
|
||||||
|
if node.handlers:
|
||||||
|
routes.append(_Route(raw_path or '/', openapi_path or '/', None, params, list(node.handlers)))
|
||||||
|
for key, child in node.children.items():
|
||||||
|
if isinstance(key, HttpMethod):
|
||||||
|
if key is not HttpMethod.WS and child.handlers:
|
||||||
|
routes.append(_Route(raw_path or '/', openapi_path or '/', key, params, list(child.handlers)))
|
||||||
|
else:
|
||||||
|
_walk(child, f'{raw_path}/{key}', f'{openapi_path}/{key}', params, routes)
|
||||||
|
for matcher in node.path_matchers:
|
||||||
|
if isinstance(matcher, GlobMatcher):
|
||||||
|
# Wildcard routes cannot be expressed in OpenAPI path syntax
|
||||||
|
continue
|
||||||
|
if isinstance(matcher, IntMatcher):
|
||||||
|
raw_segment = f'${{{matcher.name}:int}}'
|
||||||
|
schema: Mapping[str, Any] = {'type': 'integer'}
|
||||||
|
elif isinstance(matcher, StrMatcher):
|
||||||
|
raw_segment = f'${{{matcher.name}}}'
|
||||||
|
schema = {'type': 'string'}
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
param: dict[str, Any] = {
|
||||||
|
'name': matcher.name,
|
||||||
|
'in': 'path',
|
||||||
|
'required': True,
|
||||||
|
'schema': dict(schema),
|
||||||
|
}
|
||||||
|
_walk(matcher,
|
||||||
|
f'{raw_path}/{raw_segment}',
|
||||||
|
f'{openapi_path}/{{{matcher.name}}}',
|
||||||
|
(*params, param),
|
||||||
|
routes)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_parameters(params: Sequence[Mapping[str, Any]],
|
||||||
|
extra: Any) -> list[Mapping[str, Any]]:
|
||||||
|
merged: list[Mapping[str, Any]] = [dict(p) for p in params]
|
||||||
|
if not isinstance(extra, Sequence) or isinstance(extra, (str, bytes)):
|
||||||
|
return merged
|
||||||
|
for candidate in extra:
|
||||||
|
if not isinstance(candidate, Mapping):
|
||||||
|
continue
|
||||||
|
for i, existing in enumerate(merged):
|
||||||
|
if existing.get('name') == candidate.get('name') and existing.get('in') == candidate.get('in'):
|
||||||
|
merged[i] = candidate
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
merged.append(candidate)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _build_operation(params: Sequence[Mapping[str, Any]],
|
||||||
|
handler: PathHandler) -> Optional[dict[str, Any]]:
|
||||||
|
callback = getattr(handler, 'callback', None)
|
||||||
|
metadata = get_metadata(callback) if callback is not None else {}
|
||||||
|
if metadata.get('hidden'):
|
||||||
|
return None
|
||||||
|
operation: dict[str, Any] = {}
|
||||||
|
operation_id = metadata.get('operation_id')
|
||||||
|
if operation_id is not None:
|
||||||
|
operation['operationId'] = operation_id
|
||||||
|
summary = metadata.get('summary')
|
||||||
|
if summary is not None:
|
||||||
|
operation['summary'] = summary
|
||||||
|
description = metadata.get('description')
|
||||||
|
if description is None and callback is not None:
|
||||||
|
description = getdoc(callback)
|
||||||
|
if description is not None:
|
||||||
|
operation['description'] = description
|
||||||
|
tags = metadata.get('tags')
|
||||||
|
if tags is not None:
|
||||||
|
operation['tags'] = tags
|
||||||
|
if metadata.get('deprecated'):
|
||||||
|
operation['deprecated'] = True
|
||||||
|
parameters = _merge_parameters(params, metadata.get('parameters'))
|
||||||
|
if parameters:
|
||||||
|
operation['parameters'] = parameters
|
||||||
|
request_body = metadata.get('request_body')
|
||||||
|
if request_body is not None:
|
||||||
|
operation['requestBody'] = request_body
|
||||||
|
responses = metadata.get('responses')
|
||||||
|
operation['responses'] = responses if responses is not None else {
|
||||||
|
'default': {'description': 'Successful response'},
|
||||||
|
}
|
||||||
|
return operation
|
||||||
|
|
||||||
|
|
||||||
|
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]:
|
||||||
|
"""Generate an OpenAPI specification document from a :class:`KayaApp`.
|
||||||
|
|
||||||
|
The application's routing tree is walked and every route is converted to
|
||||||
|
an OpenAPI path item:
|
||||||
|
|
||||||
|
- static segments and ``${name}`` / ``${name:int}`` parameters are mapped
|
||||||
|
to OpenAPI path templating (``{name}``);
|
||||||
|
- wildcard (``*``) and websocket routes are skipped;
|
||||||
|
- method-agnostic routes are documented under all standard HTTP methods;
|
||||||
|
- routes whose Kaya path is in ``exclude_paths`` are skipped.
|
||||||
|
|
||||||
|
:param app: the application to inspect
|
||||||
|
:param title: value of ``info.title``
|
||||||
|
:param version: value of ``info.version``
|
||||||
|
:param description: value of ``info.description``
|
||||||
|
: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
|
||||||
|
:return: the OpenAPI document as a JSON-serializable mapping
|
||||||
|
"""
|
||||||
|
routes: list[_Route] = []
|
||||||
|
_walk(app._tree.root, '', '', (), routes)
|
||||||
|
|
||||||
|
paths: dict[str, dict[str, Any]] = {}
|
||||||
|
for route in sorted(routes, key=lambda it: it.openapi_path):
|
||||||
|
if route.raw_path in exclude_paths or not route.handlers:
|
||||||
|
continue
|
||||||
|
operation = _build_operation(route.params, route.handlers[0])
|
||||||
|
if operation is None:
|
||||||
|
continue
|
||||||
|
path_item = paths.setdefault(route.openapi_path, {})
|
||||||
|
methods = STANDARD_METHODS if route.method is None else (route.method,)
|
||||||
|
for method in methods:
|
||||||
|
path_item[method.value.lower()] = deepcopy(operation)
|
||||||
|
|
||||||
|
info: dict[str, Any] = {'title': title, 'version': version}
|
||||||
|
if description:
|
||||||
|
info['description'] = description
|
||||||
|
spec: dict[str, Any] = {
|
||||||
|
'openapi': openapi_version,
|
||||||
|
'info': info,
|
||||||
|
'paths': paths,
|
||||||
|
}
|
||||||
|
if servers:
|
||||||
|
spec['servers'] = [dict(server) for server in servers]
|
||||||
|
return spec
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
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()
|
||||||
@@ -13,7 +13,7 @@ import aiomcache
|
|||||||
|
|
||||||
from kaya.core import KayaApp, HttpContext
|
from kaya.core import KayaApp, HttpContext
|
||||||
from kaya.session import SessionMixin
|
from kaya.session import SessionMixin
|
||||||
from kaya.session_memcache import MemcacheSessionStore
|
from kaya.session.memcache import MemcacheSessionStore
|
||||||
|
|
||||||
client = aiomcache.Client('127.0.0.1', 11211)
|
client = aiomcache.Client('127.0.0.1', 11211)
|
||||||
session = SessionMixin(MemcacheSessionStore(client))
|
session = SessionMixin(MemcacheSessionStore(client))
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ strict = true
|
|||||||
|
|
||||||
[tool.setuptools_scm]
|
[tool.setuptools_scm]
|
||||||
root = "../.."
|
root = "../.."
|
||||||
version_file = "src/kaya/session_memcache/_version.py"
|
version_file = "src/kaya/session/memcache/_version.py"
|
||||||
|
|
||||||
[tool.setuptools_scm.tag]
|
[tool.setuptools_scm.tag]
|
||||||
prefix = "release/"
|
prefix = "release/"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pwo import async_test
|
|||||||
|
|
||||||
from kaya.core import KayaApp, HttpContext
|
from kaya.core import KayaApp, HttpContext
|
||||||
from kaya.session import Session, SessionMixin
|
from kaya.session import Session, SessionMixin
|
||||||
from kaya.session_memcache import MemcacheSessionStore
|
from kaya.session.memcache import MemcacheSessionStore
|
||||||
|
|
||||||
|
|
||||||
class FakeClock:
|
class FakeClock:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from redis.asyncio import Redis
|
|||||||
|
|
||||||
from kaya.core import KayaApp, HttpContext
|
from kaya.core import KayaApp, HttpContext
|
||||||
from kaya.session import SessionMixin
|
from kaya.session import SessionMixin
|
||||||
from kaya.session_redis import RedisSessionStore
|
from kaya.session.redis import RedisSessionStore
|
||||||
|
|
||||||
client = Redis(host='localhost', port=6379)
|
client = Redis(host='localhost', port=6379)
|
||||||
session = SessionMixin(RedisSessionStore(client))
|
session = SessionMixin(RedisSessionStore(client))
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ strict = true
|
|||||||
|
|
||||||
[tool.setuptools_scm]
|
[tool.setuptools_scm]
|
||||||
root = "../.."
|
root = "../.."
|
||||||
version_file = "src/kaya/session_redis/_version.py"
|
version_file = "src/kaya/session/redis/_version.py"
|
||||||
|
|
||||||
[tool.setuptools_scm.tag]
|
[tool.setuptools_scm.tag]
|
||||||
prefix = "release/"
|
prefix = "release/"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pwo import async_test
|
|||||||
|
|
||||||
from kaya.core import KayaApp, HttpContext
|
from kaya.core import KayaApp, HttpContext
|
||||||
from kaya.session import Session, SessionMixin
|
from kaya.session import Session, SessionMixin
|
||||||
from kaya.session_redis import RedisSessionStore
|
from kaya.session.redis import RedisSessionStore
|
||||||
|
|
||||||
|
|
||||||
class RedisSessionStoreTest(unittest.TestCase):
|
class RedisSessionStoreTest(unittest.TestCase):
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
from pkgutil import extend_path
|
||||||
|
|
||||||
|
__path__ = extend_path(__path__, __name__)
|
||||||
|
|
||||||
from ._mixin import SessionMixin
|
from ._mixin import SessionMixin
|
||||||
from ._session import Session
|
from ._session import Session
|
||||||
from ._store import InMemorySessionStore, SessionStore
|
from ._store import InMemorySessionStore, SessionStore
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ kaya-session @ file:./packages/kaya-session
|
|||||||
kaya-session-redis @ file:./packages/kaya-session-redis
|
kaya-session-redis @ file:./packages/kaya-session-redis
|
||||||
kaya-session-memcache @ file:./packages/kaya-session-memcache
|
kaya-session-memcache @ file:./packages/kaya-session-memcache
|
||||||
kaya-oidc @ file:./packages/kaya-oidc
|
kaya-oidc @ file:./packages/kaya-oidc
|
||||||
|
kaya-openapi @ file:./packages/kaya-openapi
|
||||||
build
|
build
|
||||||
fakeredis
|
fakeredis
|
||||||
mypy
|
mypy
|
||||||
|
|||||||
@@ -90,10 +90,13 @@ file:./packages/kaya-core
|
|||||||
# via
|
# via
|
||||||
# -r requirements-dev.in
|
# -r requirements-dev.in
|
||||||
# kaya-oidc
|
# kaya-oidc
|
||||||
|
# kaya-openapi
|
||||||
# kaya-rsgi
|
# kaya-rsgi
|
||||||
# kaya-session
|
# kaya-session
|
||||||
file:./packages/kaya-oidc
|
file:./packages/kaya-oidc
|
||||||
# via -r requirements-dev.in
|
# via -r requirements-dev.in
|
||||||
|
file:./packages/kaya-openapi
|
||||||
|
# via -r requirements-dev.in
|
||||||
file:./packages/kaya-rsgi
|
file:./packages/kaya-rsgi
|
||||||
# via -r requirements-dev.in
|
# via -r requirements-dev.in
|
||||||
file:./packages/kaya-session
|
file:./packages/kaya-session
|
||||||
|
|||||||
Reference in New Issue
Block a user