oapi-gen-dishka 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- oapi_gen_dishka-0.1.0/PKG-INFO +148 -0
- oapi_gen_dishka-0.1.0/README.md +139 -0
- oapi_gen_dishka-0.1.0/pyproject.toml +39 -0
- oapi_gen_dishka-0.1.0/setup.cfg +4 -0
- oapi_gen_dishka-0.1.0/src/oapi_gen_dishka/__init__.py +5 -0
- oapi_gen_dishka-0.1.0/src/oapi_gen_dishka/_integration.py +77 -0
- oapi_gen_dishka-0.1.0/src/oapi_gen_dishka/py.typed +0 -0
- oapi_gen_dishka-0.1.0/src/oapi_gen_dishka.egg-info/PKG-INFO +148 -0
- oapi_gen_dishka-0.1.0/src/oapi_gen_dishka.egg-info/SOURCES.txt +12 -0
- oapi_gen_dishka-0.1.0/src/oapi_gen_dishka.egg-info/dependency_links.txt +1 -0
- oapi_gen_dishka-0.1.0/src/oapi_gen_dishka.egg-info/requires.txt +2 -0
- oapi_gen_dishka-0.1.0/src/oapi_gen_dishka.egg-info/top_level.txt +1 -0
- oapi_gen_dishka-0.1.0/tests/test_integration.py +416 -0
- oapi_gen_dishka-0.1.0/tests/test_starlette.py +101 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oapi-gen-dishka
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Request-scoped Dishka injection for oapi-gen handlers
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: dishka<2,>=1.10.1
|
|
8
|
+
Requires-Dist: starlette<2,>=1.0
|
|
9
|
+
|
|
10
|
+
# oapi-gen-dishka
|
|
11
|
+
|
|
12
|
+
Inject Dishka dependencies into the async methods called by an `oapi-gen` router.
|
|
13
|
+
Generated code stays unchanged. The integration reuses Dishka's native Starlette
|
|
14
|
+
request container, so operation handlers, security handlers, and ordinary Starlette
|
|
15
|
+
endpoints share the same `Scope.REQUEST` instances.
|
|
16
|
+
|
|
17
|
+
This directory is an independently buildable package. Install it into your
|
|
18
|
+
application from a local checkout:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
uv add /path/to/oapi-gen/integrations/dishka
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
The example uses the repository's complete Cats specification. Generate it into
|
|
27
|
+
your application's package:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
oapi-gen generate tests/fixtures/cats.openapi.yaml --output app/http/generated
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from contextlib import asynccontextmanager
|
|
35
|
+
from uuid import UUID, uuid4
|
|
36
|
+
|
|
37
|
+
from dishka import FromDishka, Provider, Scope, make_async_container, provide
|
|
38
|
+
from starlette.applications import Starlette
|
|
39
|
+
from oapi_gen_dishka import inject, setup_dishka
|
|
40
|
+
|
|
41
|
+
from app.http.generated import Handlers, contracts, create_router, models
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CatsRepository:
|
|
45
|
+
def __init__(self):
|
|
46
|
+
self.cats: dict[UUID, models.Cat] = {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CatsController:
|
|
50
|
+
@inject
|
|
51
|
+
async def list_cats(
|
|
52
|
+
self,
|
|
53
|
+
request: contracts.ListCatsRequest,
|
|
54
|
+
*,
|
|
55
|
+
repository: FromDishka[CatsRepository],
|
|
56
|
+
) -> contracts.ListCatsResponse:
|
|
57
|
+
cats = list(repository.cats.values())
|
|
58
|
+
return contracts.ListCatsResponse200(body=cats[: request.limit])
|
|
59
|
+
|
|
60
|
+
@inject
|
|
61
|
+
async def create_cat(
|
|
62
|
+
self,
|
|
63
|
+
request: contracts.CreateCatRequest,
|
|
64
|
+
*,
|
|
65
|
+
repository: FromDishka[CatsRepository],
|
|
66
|
+
) -> contracts.CreateCatResponse:
|
|
67
|
+
cat = models.Cat(id=uuid4(), name=request.body.name)
|
|
68
|
+
repository.cats[cat.id] = cat
|
|
69
|
+
return contracts.CreateCatResponse201()
|
|
70
|
+
|
|
71
|
+
@inject
|
|
72
|
+
async def show_cat_by_id(
|
|
73
|
+
self,
|
|
74
|
+
request: contracts.ShowCatByIdRequest,
|
|
75
|
+
*,
|
|
76
|
+
repository: FromDishka[CatsRepository],
|
|
77
|
+
) -> contracts.ShowCatByIdResponse:
|
|
78
|
+
cat = repository.cats.get(request.cat_id)
|
|
79
|
+
if cat is None:
|
|
80
|
+
return contracts.ShowCatByIdResponse404(
|
|
81
|
+
body=models.HttpError(message="Cat not found"),
|
|
82
|
+
)
|
|
83
|
+
return contracts.ShowCatByIdResponse200(body=cat)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class AppProvider(Provider):
|
|
87
|
+
# This in-memory example keeps cats between requests in one process.
|
|
88
|
+
repository = provide(CatsRepository, scope=Scope.APP)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
container = make_async_container(AppProvider())
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@asynccontextmanager
|
|
95
|
+
async def lifespan(app: Starlette):
|
|
96
|
+
try:
|
|
97
|
+
yield
|
|
98
|
+
finally:
|
|
99
|
+
await container.close()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
router = create_router(Handlers(cats=CatsController()), prefix="/api")
|
|
103
|
+
app = Starlette(lifespan=lifespan, routes=router.routes)
|
|
104
|
+
setup_dishka(container, app)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Use `Scope.REQUEST` for services or database sessions that must be recreated for
|
|
108
|
+
each request. Dishka resolves dependencies from the normal provider graph and
|
|
109
|
+
finalizes generator providers at the end of the request, including on exceptions.
|
|
110
|
+
Add Dishka's `StarletteProvider()` from `dishka.integrations.starlette` when a
|
|
111
|
+
provider needs `starlette.requests.Request`.
|
|
112
|
+
|
|
113
|
+
`@inject` also works on generated security handler methods. Use
|
|
114
|
+
`dishka.integrations.starlette.inject` on ordinary Starlette endpoints; both
|
|
115
|
+
decorators resolve from the same container after the single setup call above.
|
|
116
|
+
|
|
117
|
+
## Boundaries
|
|
118
|
+
|
|
119
|
+
- Async HTTP handlers only. WebSockets, sync containers, and background work
|
|
120
|
+
outside the request lifetime are not supported by this decorator.
|
|
121
|
+
- Call this package's `setup_dishka` once, before startup, **instead of** calling
|
|
122
|
+
`dishka.integrations.starlette.setup_dishka` separately.
|
|
123
|
+
- The controller instance passed to `Handlers` is shared. Keep request-specific
|
|
124
|
+
state in local variables and injected dependencies, not on `self`.
|
|
125
|
+
- Providers must register dependencies; `FromDishka[T]` selects the registered
|
|
126
|
+
type and does not register it automatically. Dishka components are supported.
|
|
127
|
+
- Runtime signatures omit injected parameters and retain the remaining
|
|
128
|
+
annotations. The decorator preserves the static response type but uses
|
|
129
|
+
`Callable[..., ...]` for arguments, since Python typing cannot remove arbitrary
|
|
130
|
+
`FromDishka` parameters. Annotate controller references with the generated
|
|
131
|
+
protocol when calling them directly to check request argument types:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
controller: contracts.CatsApi = CatsController()
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Development
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
uv sync --project integrations/dishka
|
|
141
|
+
uv run --project integrations/dishka pytest integrations/dishka/tests
|
|
142
|
+
uv run --project integrations/dishka ruff check integrations/dishka
|
|
143
|
+
uv run --project integrations/dishka basedpyright --project integrations/dishka
|
|
144
|
+
uv build integrations/dishka --out-dir integrations/dishka/dist
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The local development dependency on `oapi-gen` is used only for integration tests;
|
|
148
|
+
the published wheel does not depend on the generator at runtime.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# oapi-gen-dishka
|
|
2
|
+
|
|
3
|
+
Inject Dishka dependencies into the async methods called by an `oapi-gen` router.
|
|
4
|
+
Generated code stays unchanged. The integration reuses Dishka's native Starlette
|
|
5
|
+
request container, so operation handlers, security handlers, and ordinary Starlette
|
|
6
|
+
endpoints share the same `Scope.REQUEST` instances.
|
|
7
|
+
|
|
8
|
+
This directory is an independently buildable package. Install it into your
|
|
9
|
+
application from a local checkout:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv add /path/to/oapi-gen/integrations/dishka
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
The example uses the repository's complete Cats specification. Generate it into
|
|
18
|
+
your application's package:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
oapi-gen generate tests/fixtures/cats.openapi.yaml --output app/http/generated
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from contextlib import asynccontextmanager
|
|
26
|
+
from uuid import UUID, uuid4
|
|
27
|
+
|
|
28
|
+
from dishka import FromDishka, Provider, Scope, make_async_container, provide
|
|
29
|
+
from starlette.applications import Starlette
|
|
30
|
+
from oapi_gen_dishka import inject, setup_dishka
|
|
31
|
+
|
|
32
|
+
from app.http.generated import Handlers, contracts, create_router, models
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CatsRepository:
|
|
36
|
+
def __init__(self):
|
|
37
|
+
self.cats: dict[UUID, models.Cat] = {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CatsController:
|
|
41
|
+
@inject
|
|
42
|
+
async def list_cats(
|
|
43
|
+
self,
|
|
44
|
+
request: contracts.ListCatsRequest,
|
|
45
|
+
*,
|
|
46
|
+
repository: FromDishka[CatsRepository],
|
|
47
|
+
) -> contracts.ListCatsResponse:
|
|
48
|
+
cats = list(repository.cats.values())
|
|
49
|
+
return contracts.ListCatsResponse200(body=cats[: request.limit])
|
|
50
|
+
|
|
51
|
+
@inject
|
|
52
|
+
async def create_cat(
|
|
53
|
+
self,
|
|
54
|
+
request: contracts.CreateCatRequest,
|
|
55
|
+
*,
|
|
56
|
+
repository: FromDishka[CatsRepository],
|
|
57
|
+
) -> contracts.CreateCatResponse:
|
|
58
|
+
cat = models.Cat(id=uuid4(), name=request.body.name)
|
|
59
|
+
repository.cats[cat.id] = cat
|
|
60
|
+
return contracts.CreateCatResponse201()
|
|
61
|
+
|
|
62
|
+
@inject
|
|
63
|
+
async def show_cat_by_id(
|
|
64
|
+
self,
|
|
65
|
+
request: contracts.ShowCatByIdRequest,
|
|
66
|
+
*,
|
|
67
|
+
repository: FromDishka[CatsRepository],
|
|
68
|
+
) -> contracts.ShowCatByIdResponse:
|
|
69
|
+
cat = repository.cats.get(request.cat_id)
|
|
70
|
+
if cat is None:
|
|
71
|
+
return contracts.ShowCatByIdResponse404(
|
|
72
|
+
body=models.HttpError(message="Cat not found"),
|
|
73
|
+
)
|
|
74
|
+
return contracts.ShowCatByIdResponse200(body=cat)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class AppProvider(Provider):
|
|
78
|
+
# This in-memory example keeps cats between requests in one process.
|
|
79
|
+
repository = provide(CatsRepository, scope=Scope.APP)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
container = make_async_container(AppProvider())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@asynccontextmanager
|
|
86
|
+
async def lifespan(app: Starlette):
|
|
87
|
+
try:
|
|
88
|
+
yield
|
|
89
|
+
finally:
|
|
90
|
+
await container.close()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
router = create_router(Handlers(cats=CatsController()), prefix="/api")
|
|
94
|
+
app = Starlette(lifespan=lifespan, routes=router.routes)
|
|
95
|
+
setup_dishka(container, app)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Use `Scope.REQUEST` for services or database sessions that must be recreated for
|
|
99
|
+
each request. Dishka resolves dependencies from the normal provider graph and
|
|
100
|
+
finalizes generator providers at the end of the request, including on exceptions.
|
|
101
|
+
Add Dishka's `StarletteProvider()` from `dishka.integrations.starlette` when a
|
|
102
|
+
provider needs `starlette.requests.Request`.
|
|
103
|
+
|
|
104
|
+
`@inject` also works on generated security handler methods. Use
|
|
105
|
+
`dishka.integrations.starlette.inject` on ordinary Starlette endpoints; both
|
|
106
|
+
decorators resolve from the same container after the single setup call above.
|
|
107
|
+
|
|
108
|
+
## Boundaries
|
|
109
|
+
|
|
110
|
+
- Async HTTP handlers only. WebSockets, sync containers, and background work
|
|
111
|
+
outside the request lifetime are not supported by this decorator.
|
|
112
|
+
- Call this package's `setup_dishka` once, before startup, **instead of** calling
|
|
113
|
+
`dishka.integrations.starlette.setup_dishka` separately.
|
|
114
|
+
- The controller instance passed to `Handlers` is shared. Keep request-specific
|
|
115
|
+
state in local variables and injected dependencies, not on `self`.
|
|
116
|
+
- Providers must register dependencies; `FromDishka[T]` selects the registered
|
|
117
|
+
type and does not register it automatically. Dishka components are supported.
|
|
118
|
+
- Runtime signatures omit injected parameters and retain the remaining
|
|
119
|
+
annotations. The decorator preserves the static response type but uses
|
|
120
|
+
`Callable[..., ...]` for arguments, since Python typing cannot remove arbitrary
|
|
121
|
+
`FromDishka` parameters. Annotate controller references with the generated
|
|
122
|
+
protocol when calling them directly to check request argument types:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
controller: contracts.CatsApi = CatsController()
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Development
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
uv sync --project integrations/dishka
|
|
132
|
+
uv run --project integrations/dishka pytest integrations/dishka/tests
|
|
133
|
+
uv run --project integrations/dishka ruff check integrations/dishka
|
|
134
|
+
uv run --project integrations/dishka basedpyright --project integrations/dishka
|
|
135
|
+
uv build integrations/dishka --out-dir integrations/dishka/dist
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The local development dependency on `oapi-gen` is used only for integration tests;
|
|
139
|
+
the published wheel does not depend on the generator at runtime.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "oapi-gen-dishka"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Request-scoped Dishka injection for oapi-gen handlers"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
dependencies = ["dishka>=1.10.1,<2", "starlette>=1.0,<2"]
|
|
12
|
+
|
|
13
|
+
[dependency-groups]
|
|
14
|
+
dev = [
|
|
15
|
+
"basedpyright==1.39.10",
|
|
16
|
+
"httpx==0.28.1",
|
|
17
|
+
"oapi-gen",
|
|
18
|
+
"pytest==9.1.1",
|
|
19
|
+
"ruff==0.16.6",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[tool.uv.sources]
|
|
23
|
+
oapi-gen = { path = "../.." }
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.package-data]
|
|
29
|
+
oapi_gen_dishka = ["py.typed"]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
testpaths = ["tests"]
|
|
33
|
+
addopts = "-q"
|
|
34
|
+
|
|
35
|
+
[tool.basedpyright]
|
|
36
|
+
include = ["src"]
|
|
37
|
+
pythonVersion = "3.12"
|
|
38
|
+
typeCheckingMode = "standard"
|
|
39
|
+
reportMissingTypeStubs = false
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Expose the native Dishka HTTP container to nested handler calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from contextvars import ContextVar
|
|
7
|
+
from inspect import iscoroutinefunction
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
from dishka import AsyncContainer
|
|
11
|
+
from dishka.integrations.base import wrap_injection
|
|
12
|
+
from dishka.integrations.starlette import ContainerMiddleware
|
|
13
|
+
from starlette.applications import Starlette
|
|
14
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
15
|
+
|
|
16
|
+
_request_container: ContextVar[AsyncContainer] = ContextVar("oapi_gen_dishka_request_container")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _get_container(args: tuple[Any, ...], kwargs: dict[str, Any]) -> AsyncContainer:
|
|
20
|
+
try:
|
|
21
|
+
return _request_container.get()
|
|
22
|
+
except LookupError:
|
|
23
|
+
raise RuntimeError(
|
|
24
|
+
"An injected oapi-gen handler must run inside an HTTP request configured with "
|
|
25
|
+
"oapi_gen_dishka.setup_dishka(container, app)."
|
|
26
|
+
) from None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def inject[Result](
|
|
30
|
+
func: Callable[..., Result],
|
|
31
|
+
) -> Callable[..., Result]:
|
|
32
|
+
"""Inject FromDishka parameters using the active HTTP request's container.
|
|
33
|
+
|
|
34
|
+
Runtime parameter annotations and the response type are preserved. Static
|
|
35
|
+
typing allows arbitrary call arguments because injected parameters are removed;
|
|
36
|
+
use the generated handler protocol for a strictly typed calling interface.
|
|
37
|
+
"""
|
|
38
|
+
if not iscoroutinefunction(func):
|
|
39
|
+
raise TypeError("oapi_gen_dishka.inject requires an async function or method")
|
|
40
|
+
wrapped = wrap_injection(func=func, is_async=True, container_getter=_get_container)
|
|
41
|
+
return cast(Callable[..., Result], wrapped)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _RequestContextMiddleware:
|
|
45
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
46
|
+
self.app = app
|
|
47
|
+
|
|
48
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
49
|
+
if scope["type"] != "http":
|
|
50
|
+
await self.app(scope, receive, send)
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
token = _request_container.set(scope["state"]["dishka_container"])
|
|
54
|
+
try:
|
|
55
|
+
await self.app(scope, receive, send)
|
|
56
|
+
finally:
|
|
57
|
+
_request_container.reset(token)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def setup_dishka(container: AsyncContainer, app: Starlette) -> None:
|
|
61
|
+
"""Install native Dishka scope management and expose its container to handlers.
|
|
62
|
+
|
|
63
|
+
Call once, before app startup, to install the request container and handler context.
|
|
64
|
+
The application lifespan remains responsible for closing the APP container.
|
|
65
|
+
"""
|
|
66
|
+
if not isinstance(container, AsyncContainer):
|
|
67
|
+
raise TypeError("oapi_gen_dishka requires an AsyncContainer")
|
|
68
|
+
if getattr(app.state, "dishka_container", None) is not None:
|
|
69
|
+
raise RuntimeError(
|
|
70
|
+
"Dishka is already configured for this app. Call oapi_gen_dishka.setup_dishka "
|
|
71
|
+
"once, instead of the framework's native Dishka setup."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Starlette prepends middleware: native Dishka must run before this bridge.
|
|
75
|
+
app.add_middleware(_RequestContextMiddleware)
|
|
76
|
+
app.add_middleware(ContainerMiddleware)
|
|
77
|
+
app.state.dishka_container = container
|
|
File without changes
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oapi-gen-dishka
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Request-scoped Dishka injection for oapi-gen handlers
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: dishka<2,>=1.10.1
|
|
8
|
+
Requires-Dist: starlette<2,>=1.0
|
|
9
|
+
|
|
10
|
+
# oapi-gen-dishka
|
|
11
|
+
|
|
12
|
+
Inject Dishka dependencies into the async methods called by an `oapi-gen` router.
|
|
13
|
+
Generated code stays unchanged. The integration reuses Dishka's native Starlette
|
|
14
|
+
request container, so operation handlers, security handlers, and ordinary Starlette
|
|
15
|
+
endpoints share the same `Scope.REQUEST` instances.
|
|
16
|
+
|
|
17
|
+
This directory is an independently buildable package. Install it into your
|
|
18
|
+
application from a local checkout:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
uv add /path/to/oapi-gen/integrations/dishka
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
The example uses the repository's complete Cats specification. Generate it into
|
|
27
|
+
your application's package:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
oapi-gen generate tests/fixtures/cats.openapi.yaml --output app/http/generated
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from contextlib import asynccontextmanager
|
|
35
|
+
from uuid import UUID, uuid4
|
|
36
|
+
|
|
37
|
+
from dishka import FromDishka, Provider, Scope, make_async_container, provide
|
|
38
|
+
from starlette.applications import Starlette
|
|
39
|
+
from oapi_gen_dishka import inject, setup_dishka
|
|
40
|
+
|
|
41
|
+
from app.http.generated import Handlers, contracts, create_router, models
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CatsRepository:
|
|
45
|
+
def __init__(self):
|
|
46
|
+
self.cats: dict[UUID, models.Cat] = {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CatsController:
|
|
50
|
+
@inject
|
|
51
|
+
async def list_cats(
|
|
52
|
+
self,
|
|
53
|
+
request: contracts.ListCatsRequest,
|
|
54
|
+
*,
|
|
55
|
+
repository: FromDishka[CatsRepository],
|
|
56
|
+
) -> contracts.ListCatsResponse:
|
|
57
|
+
cats = list(repository.cats.values())
|
|
58
|
+
return contracts.ListCatsResponse200(body=cats[: request.limit])
|
|
59
|
+
|
|
60
|
+
@inject
|
|
61
|
+
async def create_cat(
|
|
62
|
+
self,
|
|
63
|
+
request: contracts.CreateCatRequest,
|
|
64
|
+
*,
|
|
65
|
+
repository: FromDishka[CatsRepository],
|
|
66
|
+
) -> contracts.CreateCatResponse:
|
|
67
|
+
cat = models.Cat(id=uuid4(), name=request.body.name)
|
|
68
|
+
repository.cats[cat.id] = cat
|
|
69
|
+
return contracts.CreateCatResponse201()
|
|
70
|
+
|
|
71
|
+
@inject
|
|
72
|
+
async def show_cat_by_id(
|
|
73
|
+
self,
|
|
74
|
+
request: contracts.ShowCatByIdRequest,
|
|
75
|
+
*,
|
|
76
|
+
repository: FromDishka[CatsRepository],
|
|
77
|
+
) -> contracts.ShowCatByIdResponse:
|
|
78
|
+
cat = repository.cats.get(request.cat_id)
|
|
79
|
+
if cat is None:
|
|
80
|
+
return contracts.ShowCatByIdResponse404(
|
|
81
|
+
body=models.HttpError(message="Cat not found"),
|
|
82
|
+
)
|
|
83
|
+
return contracts.ShowCatByIdResponse200(body=cat)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class AppProvider(Provider):
|
|
87
|
+
# This in-memory example keeps cats between requests in one process.
|
|
88
|
+
repository = provide(CatsRepository, scope=Scope.APP)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
container = make_async_container(AppProvider())
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@asynccontextmanager
|
|
95
|
+
async def lifespan(app: Starlette):
|
|
96
|
+
try:
|
|
97
|
+
yield
|
|
98
|
+
finally:
|
|
99
|
+
await container.close()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
router = create_router(Handlers(cats=CatsController()), prefix="/api")
|
|
103
|
+
app = Starlette(lifespan=lifespan, routes=router.routes)
|
|
104
|
+
setup_dishka(container, app)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Use `Scope.REQUEST` for services or database sessions that must be recreated for
|
|
108
|
+
each request. Dishka resolves dependencies from the normal provider graph and
|
|
109
|
+
finalizes generator providers at the end of the request, including on exceptions.
|
|
110
|
+
Add Dishka's `StarletteProvider()` from `dishka.integrations.starlette` when a
|
|
111
|
+
provider needs `starlette.requests.Request`.
|
|
112
|
+
|
|
113
|
+
`@inject` also works on generated security handler methods. Use
|
|
114
|
+
`dishka.integrations.starlette.inject` on ordinary Starlette endpoints; both
|
|
115
|
+
decorators resolve from the same container after the single setup call above.
|
|
116
|
+
|
|
117
|
+
## Boundaries
|
|
118
|
+
|
|
119
|
+
- Async HTTP handlers only. WebSockets, sync containers, and background work
|
|
120
|
+
outside the request lifetime are not supported by this decorator.
|
|
121
|
+
- Call this package's `setup_dishka` once, before startup, **instead of** calling
|
|
122
|
+
`dishka.integrations.starlette.setup_dishka` separately.
|
|
123
|
+
- The controller instance passed to `Handlers` is shared. Keep request-specific
|
|
124
|
+
state in local variables and injected dependencies, not on `self`.
|
|
125
|
+
- Providers must register dependencies; `FromDishka[T]` selects the registered
|
|
126
|
+
type and does not register it automatically. Dishka components are supported.
|
|
127
|
+
- Runtime signatures omit injected parameters and retain the remaining
|
|
128
|
+
annotations. The decorator preserves the static response type but uses
|
|
129
|
+
`Callable[..., ...]` for arguments, since Python typing cannot remove arbitrary
|
|
130
|
+
`FromDishka` parameters. Annotate controller references with the generated
|
|
131
|
+
protocol when calling them directly to check request argument types:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
controller: contracts.CatsApi = CatsController()
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Development
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
uv sync --project integrations/dishka
|
|
141
|
+
uv run --project integrations/dishka pytest integrations/dishka/tests
|
|
142
|
+
uv run --project integrations/dishka ruff check integrations/dishka
|
|
143
|
+
uv run --project integrations/dishka basedpyright --project integrations/dishka
|
|
144
|
+
uv build integrations/dishka --out-dir integrations/dishka/dist
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The local development dependency on `oapi-gen` is used only for integration tests;
|
|
148
|
+
the published wheel does not depend on the generator at runtime.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/oapi_gen_dishka/__init__.py
|
|
4
|
+
src/oapi_gen_dishka/_integration.py
|
|
5
|
+
src/oapi_gen_dishka/py.typed
|
|
6
|
+
src/oapi_gen_dishka.egg-info/PKG-INFO
|
|
7
|
+
src/oapi_gen_dishka.egg-info/SOURCES.txt
|
|
8
|
+
src/oapi_gen_dishka.egg-info/dependency_links.txt
|
|
9
|
+
src/oapi_gen_dishka.egg-info/requires.txt
|
|
10
|
+
src/oapi_gen_dishka.egg-info/top_level.txt
|
|
11
|
+
tests/test_integration.py
|
|
12
|
+
tests/test_starlette.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
oapi_gen_dishka
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import importlib
|
|
3
|
+
import inspect
|
|
4
|
+
import json
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import AsyncIterator
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Annotated
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
import pytest
|
|
16
|
+
from dishka import FromComponent, FromDishka, Provider, Scope, make_async_container, provide
|
|
17
|
+
from dishka.integrations.starlette import StarletteProvider
|
|
18
|
+
from dishka.integrations.starlette import inject as inject_starlette
|
|
19
|
+
from dishka.integrations.starlette import setup_dishka as setup_starlette_dishka
|
|
20
|
+
from oapi_gen import generate_package
|
|
21
|
+
from oapi_gen_dishka import inject, setup_dishka
|
|
22
|
+
from starlette.applications import Starlette
|
|
23
|
+
from starlette.requests import Request
|
|
24
|
+
from starlette.responses import JSONResponse
|
|
25
|
+
from starlette.routing import Route
|
|
26
|
+
from starlette.testclient import TestClient
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class RequestResource:
|
|
31
|
+
name: str
|
|
32
|
+
id: str
|
|
33
|
+
native_dependency_seen: bool = False
|
|
34
|
+
closed: bool = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ResourceProvider(Provider):
|
|
38
|
+
def __init__(self):
|
|
39
|
+
super().__init__()
|
|
40
|
+
self.resources: list[RequestResource] = []
|
|
41
|
+
|
|
42
|
+
@provide(scope=Scope.REQUEST)
|
|
43
|
+
async def resource(self, request: Request) -> AsyncIterator[RequestResource]:
|
|
44
|
+
resource = RequestResource(name=request.headers.get("X-Request", "normal"), id=uuid4().hex)
|
|
45
|
+
self.resources.append(resource)
|
|
46
|
+
try:
|
|
47
|
+
yield resource
|
|
48
|
+
finally:
|
|
49
|
+
resource.closed = True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.fixture(scope="module")
|
|
53
|
+
def generated(tmp_path_factory):
|
|
54
|
+
root = tmp_path_factory.mktemp("generated")
|
|
55
|
+
spec = {
|
|
56
|
+
"openapi": "3.1.0",
|
|
57
|
+
"info": {"title": "Dishka integration", "version": "1"},
|
|
58
|
+
"paths": {
|
|
59
|
+
"/items": {
|
|
60
|
+
"get": {
|
|
61
|
+
"operationId": "listItems",
|
|
62
|
+
"x-handler-group": "Items",
|
|
63
|
+
"security": [{"bearerAuth": []}],
|
|
64
|
+
"parameters": [
|
|
65
|
+
{"name": "fail", "in": "query", "schema": {"type": "boolean"}},
|
|
66
|
+
],
|
|
67
|
+
"responses": {
|
|
68
|
+
"200": {
|
|
69
|
+
"description": "Resource id",
|
|
70
|
+
"content": {"application/json": {"schema": {"type": "string"}}},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
"components": {"securitySchemes": {"bearerAuth": {"type": "http", "scheme": "bearer"}}},
|
|
77
|
+
}
|
|
78
|
+
source = root / "spec.json"
|
|
79
|
+
source.write_text(json.dumps(spec))
|
|
80
|
+
name = "generated_dishka_test_api"
|
|
81
|
+
generate_package(source, root / name)
|
|
82
|
+
sys.path.insert(0, str(root))
|
|
83
|
+
try:
|
|
84
|
+
yield importlib.import_module(name)
|
|
85
|
+
finally:
|
|
86
|
+
sys.path.remove(str(root))
|
|
87
|
+
for key in list(sys.modules):
|
|
88
|
+
if key == name or key.startswith(f"{name}."):
|
|
89
|
+
del sys.modules[key]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@inject
|
|
93
|
+
async def outside_probe(*, resource: FromDishka[RequestResource]) -> RequestResource:
|
|
94
|
+
return resource
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_app(generated, started=None):
|
|
98
|
+
contracts = generated.contracts
|
|
99
|
+
provider = ResourceProvider()
|
|
100
|
+
container = make_async_container(provider, StarletteProvider())
|
|
101
|
+
|
|
102
|
+
class Controller:
|
|
103
|
+
@inject
|
|
104
|
+
async def list_items(
|
|
105
|
+
self,
|
|
106
|
+
request: contracts.ListItemsRequest,
|
|
107
|
+
*,
|
|
108
|
+
resource: FromDishka[RequestResource],
|
|
109
|
+
) -> contracts.ListItemsResponse:
|
|
110
|
+
assert request.security_context is resource
|
|
111
|
+
assert resource.native_dependency_seen
|
|
112
|
+
assert not resource.closed
|
|
113
|
+
if request.fail:
|
|
114
|
+
raise ValueError("handler failed")
|
|
115
|
+
if resource.name == "cancel":
|
|
116
|
+
started.set()
|
|
117
|
+
await asyncio.Event().wait()
|
|
118
|
+
await asyncio.sleep(0)
|
|
119
|
+
# Nested injection must still see this request's resource after yielding.
|
|
120
|
+
assert await outside_probe() is resource
|
|
121
|
+
return contracts.ListItemsResponse200(body=f"{resource.name}:{resource.id}")
|
|
122
|
+
|
|
123
|
+
class Security:
|
|
124
|
+
@inject
|
|
125
|
+
async def handle_bearer_auth(
|
|
126
|
+
self,
|
|
127
|
+
context: object | None,
|
|
128
|
+
operation_id: str,
|
|
129
|
+
credential: contracts.BearerAuthSecurity,
|
|
130
|
+
*,
|
|
131
|
+
resource: FromDishka[RequestResource],
|
|
132
|
+
) -> object:
|
|
133
|
+
assert operation_id == "listItems"
|
|
134
|
+
assert context is None
|
|
135
|
+
if credential.token != "accepted":
|
|
136
|
+
raise contracts.SecurityRejected("denied")
|
|
137
|
+
return resource
|
|
138
|
+
|
|
139
|
+
@asynccontextmanager
|
|
140
|
+
async def lifespan(app):
|
|
141
|
+
try:
|
|
142
|
+
yield
|
|
143
|
+
finally:
|
|
144
|
+
await container.close()
|
|
145
|
+
|
|
146
|
+
router = generated.create_router(generated.Handlers(items=Controller()), security=Security())
|
|
147
|
+
generated_endpoint = router.routes[0].endpoint
|
|
148
|
+
|
|
149
|
+
@inject_starlette
|
|
150
|
+
async def native_endpoint(request: Request, resource: FromDishka[RequestResource]):
|
|
151
|
+
resource.native_dependency_seen = True
|
|
152
|
+
return await generated_endpoint(request)
|
|
153
|
+
|
|
154
|
+
app = Starlette(
|
|
155
|
+
lifespan=lifespan,
|
|
156
|
+
routes=[Route("/items", native_endpoint), *router.routes[1:]],
|
|
157
|
+
)
|
|
158
|
+
setup_dishka(container, app)
|
|
159
|
+
return app, container, provider, Controller
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_generated_handlers_share_native_scope_with_security_and_dependencies(generated):
|
|
163
|
+
app, _, provider, controller = build_app(generated)
|
|
164
|
+
with TestClient(app) as client:
|
|
165
|
+
first = client.get("/items", headers={"Authorization": "Bearer accepted"})
|
|
166
|
+
second = client.get("/items", headers={"Authorization": "Bearer accepted"})
|
|
167
|
+
assert first.status_code == second.status_code == 200
|
|
168
|
+
assert first.json() != second.json()
|
|
169
|
+
assert len(provider.resources) == 2
|
|
170
|
+
assert all(resource.closed for resource in provider.resources)
|
|
171
|
+
operation = client.get("/openapi.json").json()["paths"]["/items"]["get"]
|
|
172
|
+
assert [parameter["name"] for parameter in operation["parameters"]] == ["fail"]
|
|
173
|
+
|
|
174
|
+
signature = inspect.signature(controller().list_items)
|
|
175
|
+
assert list(signature.parameters) == ["request"]
|
|
176
|
+
assert signature.parameters["request"].annotation is generated.contracts.ListItemsRequest
|
|
177
|
+
assert signature.return_annotation is generated.contracts.ListItemsResponse
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_parallel_http_requests_are_isolated(generated):
|
|
181
|
+
async def scenario():
|
|
182
|
+
app, container, provider, _ = build_app(generated)
|
|
183
|
+
try:
|
|
184
|
+
async with httpx.AsyncClient(
|
|
185
|
+
transport=httpx.ASGITransport(app=app),
|
|
186
|
+
base_url="http://test",
|
|
187
|
+
) as client:
|
|
188
|
+
responses = await asyncio.gather(
|
|
189
|
+
*(
|
|
190
|
+
client.get(
|
|
191
|
+
"/items",
|
|
192
|
+
headers={"Authorization": "Bearer accepted", "X-Request": str(index)},
|
|
193
|
+
)
|
|
194
|
+
for index in range(10)
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
assert all(response.status_code == 200 for response in responses)
|
|
198
|
+
assert [response.json().split(":")[0] for response in responses] == list(
|
|
199
|
+
map(str, range(10))
|
|
200
|
+
)
|
|
201
|
+
assert len({response.json().split(":")[1] for response in responses}) == 10
|
|
202
|
+
assert len(provider.resources) == 10
|
|
203
|
+
assert all(resource.closed for resource in provider.resources)
|
|
204
|
+
with pytest.raises(RuntimeError, match="inside an HTTP request"):
|
|
205
|
+
await outside_probe()
|
|
206
|
+
finally:
|
|
207
|
+
await container.close()
|
|
208
|
+
|
|
209
|
+
asyncio.run(scenario())
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_failures_finalize_dependencies_and_reset_context(generated):
|
|
213
|
+
async def scenario():
|
|
214
|
+
app, container, provider, _ = build_app(generated)
|
|
215
|
+
try:
|
|
216
|
+
async with httpx.AsyncClient(
|
|
217
|
+
transport=httpx.ASGITransport(app=app),
|
|
218
|
+
base_url="http://test",
|
|
219
|
+
) as client:
|
|
220
|
+
with pytest.raises(ValueError, match="handler failed"):
|
|
221
|
+
await client.get(
|
|
222
|
+
"/items?fail=true", headers={"Authorization": "Bearer accepted"}
|
|
223
|
+
)
|
|
224
|
+
assert provider.resources[-1].closed
|
|
225
|
+
with pytest.raises(RuntimeError, match="inside an HTTP request"):
|
|
226
|
+
await outside_probe()
|
|
227
|
+
denied = await client.get("/items", headers={"Authorization": "Bearer denied"})
|
|
228
|
+
assert denied.status_code == 401
|
|
229
|
+
assert provider.resources[-1].closed
|
|
230
|
+
success = await client.get("/items", headers={"Authorization": "Bearer accepted"})
|
|
231
|
+
assert success.status_code == 200
|
|
232
|
+
assert all(resource.closed for resource in provider.resources)
|
|
233
|
+
with pytest.raises(RuntimeError, match="inside an HTTP request"):
|
|
234
|
+
await outside_probe()
|
|
235
|
+
finally:
|
|
236
|
+
await container.close()
|
|
237
|
+
|
|
238
|
+
asyncio.run(scenario())
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_cancellation_finalizes_request_resources(generated):
|
|
242
|
+
async def scenario():
|
|
243
|
+
started = asyncio.Event()
|
|
244
|
+
app, container, provider, _ = build_app(generated, started)
|
|
245
|
+
try:
|
|
246
|
+
async with httpx.AsyncClient(
|
|
247
|
+
transport=httpx.ASGITransport(app=app),
|
|
248
|
+
base_url="http://test",
|
|
249
|
+
) as client:
|
|
250
|
+
task = asyncio.create_task(
|
|
251
|
+
client.get(
|
|
252
|
+
"/items",
|
|
253
|
+
headers={"Authorization": "Bearer accepted", "X-Request": "cancel"},
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
try:
|
|
257
|
+
await asyncio.wait_for(started.wait(), timeout=5)
|
|
258
|
+
finally:
|
|
259
|
+
task.cancel()
|
|
260
|
+
with pytest.raises(asyncio.CancelledError):
|
|
261
|
+
await task
|
|
262
|
+
assert len(provider.resources) == 1
|
|
263
|
+
assert provider.resources[0].closed
|
|
264
|
+
with pytest.raises(RuntimeError, match="inside an HTTP request"):
|
|
265
|
+
await outside_probe()
|
|
266
|
+
finally:
|
|
267
|
+
await container.close()
|
|
268
|
+
|
|
269
|
+
asyncio.run(scenario())
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_dishka_components_are_supported():
|
|
273
|
+
class ComponentProvider(Provider):
|
|
274
|
+
@provide(scope=Scope.REQUEST)
|
|
275
|
+
def resource(self) -> RequestResource:
|
|
276
|
+
return RequestResource(name="component", id="component-id")
|
|
277
|
+
|
|
278
|
+
@inject
|
|
279
|
+
async def handler(
|
|
280
|
+
*,
|
|
281
|
+
resource: Annotated[RequestResource, FromComponent("other")],
|
|
282
|
+
) -> str:
|
|
283
|
+
return resource.name
|
|
284
|
+
|
|
285
|
+
container = make_async_container(ComponentProvider().to_component("other"))
|
|
286
|
+
|
|
287
|
+
@asynccontextmanager
|
|
288
|
+
async def lifespan(app):
|
|
289
|
+
try:
|
|
290
|
+
yield
|
|
291
|
+
finally:
|
|
292
|
+
await container.close()
|
|
293
|
+
|
|
294
|
+
async def endpoint(request: Request):
|
|
295
|
+
return JSONResponse(await handler())
|
|
296
|
+
|
|
297
|
+
app = Starlette(lifespan=lifespan, routes=[Route("/", endpoint)])
|
|
298
|
+
|
|
299
|
+
setup_dishka(container, app)
|
|
300
|
+
with TestClient(app) as client:
|
|
301
|
+
assert client.get("/").json() == "component"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@pytest.mark.parametrize("native_first", [False, True])
|
|
305
|
+
def test_setup_rejects_duplicate_scope_management(native_first):
|
|
306
|
+
async def scenario():
|
|
307
|
+
container = make_async_container(Provider())
|
|
308
|
+
app = Starlette()
|
|
309
|
+
try:
|
|
310
|
+
(setup_starlette_dishka if native_first else setup_dishka)(container, app)
|
|
311
|
+
middleware_count = len(app.user_middleware)
|
|
312
|
+
with pytest.raises(RuntimeError, match="already configured"):
|
|
313
|
+
setup_dishka(container, app)
|
|
314
|
+
assert len(app.user_middleware) == middleware_count
|
|
315
|
+
finally:
|
|
316
|
+
await container.close()
|
|
317
|
+
|
|
318
|
+
asyncio.run(scenario())
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def test_inject_rejects_sync_functions():
|
|
322
|
+
with pytest.raises(TypeError, match="async function or method"):
|
|
323
|
+
inject(lambda: None)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def test_decorated_handlers_implement_generated_protocol(generated, tmp_path):
|
|
327
|
+
code = """
|
|
328
|
+
from dishka import FromDishka
|
|
329
|
+
from oapi_gen_dishka import inject
|
|
330
|
+
from generated_dishka_test_api import contracts
|
|
331
|
+
|
|
332
|
+
class Controller:
|
|
333
|
+
@inject
|
|
334
|
+
async def list_items(
|
|
335
|
+
self,
|
|
336
|
+
request: contracts.ListItemsRequest,
|
|
337
|
+
*,
|
|
338
|
+
dependency: FromDishka[object],
|
|
339
|
+
) -> contracts.ListItemsResponse:
|
|
340
|
+
return contracts.ListItemsResponse200(body=str(request.fail))
|
|
341
|
+
|
|
342
|
+
handler: contracts.ItemsApi = Controller()
|
|
343
|
+
handlers = contracts.Handlers(items=Controller())
|
|
344
|
+
|
|
345
|
+
async def call() -> contracts.ListItemsResponse:
|
|
346
|
+
return await handler.list_items(
|
|
347
|
+
request=contracts.ListItemsRequest(fail=None, security_context=None),
|
|
348
|
+
)
|
|
349
|
+
"""
|
|
350
|
+
source = tmp_path / "example.py"
|
|
351
|
+
source.write_text(code)
|
|
352
|
+
config = tmp_path / "pyrightconfig.json"
|
|
353
|
+
config.write_text(
|
|
354
|
+
json.dumps(
|
|
355
|
+
{
|
|
356
|
+
"include": ["example.py"],
|
|
357
|
+
"pythonVersion": "3.12",
|
|
358
|
+
"typeCheckingMode": "standard",
|
|
359
|
+
"extraPaths": [
|
|
360
|
+
str(Path(generated.__file__).parents[1]),
|
|
361
|
+
str(Path(__file__).resolve().parents[1] / "src"),
|
|
362
|
+
],
|
|
363
|
+
}
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
command = [
|
|
367
|
+
sys.executable,
|
|
368
|
+
"-m",
|
|
369
|
+
"basedpyright",
|
|
370
|
+
"--project",
|
|
371
|
+
str(config),
|
|
372
|
+
"--pythonpath",
|
|
373
|
+
sys.executable,
|
|
374
|
+
"--outputjson",
|
|
375
|
+
]
|
|
376
|
+
result = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
377
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
378
|
+
|
|
379
|
+
source.write_text(
|
|
380
|
+
code + '\nasync def invalid() -> None:\n await handler.list_items("wrong")\n'
|
|
381
|
+
)
|
|
382
|
+
result = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
383
|
+
assert result.returncode == 1, result.stdout + result.stderr
|
|
384
|
+
diagnostics = json.loads(result.stdout)["generalDiagnostics"]
|
|
385
|
+
assert [diagnostic["rule"] for diagnostic in diagnostics] == ["reportArgumentType"]
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def test_readme_example_runs_with_complete_generated_api(tmp_path, monkeypatch):
|
|
389
|
+
integration_root = Path(__file__).resolve().parents[1]
|
|
390
|
+
repo_root = integration_root.parents[1]
|
|
391
|
+
example = (
|
|
392
|
+
integration_root.joinpath("README.md")
|
|
393
|
+
.read_text()
|
|
394
|
+
.split("```python\n", 1)[1]
|
|
395
|
+
.split("```", 1)[0]
|
|
396
|
+
)
|
|
397
|
+
generate_package(
|
|
398
|
+
repo_root / "tests/fixtures/cats.openapi.yaml", tmp_path / "app/http/generated"
|
|
399
|
+
)
|
|
400
|
+
monkeypatch.syspath_prepend(str(tmp_path))
|
|
401
|
+
try:
|
|
402
|
+
namespace = {}
|
|
403
|
+
exec(compile(example, "README.md", "exec"), namespace)
|
|
404
|
+
with TestClient(namespace["app"]) as client:
|
|
405
|
+
assert client.get("/api/cats").json() == []
|
|
406
|
+
assert client.post("/api/cats", json={"name": "Mittens"}).status_code == 201
|
|
407
|
+
cats = client.get("/api/cats?limit=1").json()
|
|
408
|
+
assert len(cats) == 1
|
|
409
|
+
assert cats[0]["name"] == "Mittens"
|
|
410
|
+
assert client.get(f"/api/cats/{cats[0]['id']}").json() == cats[0]
|
|
411
|
+
assert client.get(f"/api/cats/{uuid4()}").status_code == 404
|
|
412
|
+
assert client.get("/api/cats?limit=0").status_code == 422
|
|
413
|
+
finally:
|
|
414
|
+
for name in list(sys.modules):
|
|
415
|
+
if name == "app" or name.startswith("app."):
|
|
416
|
+
del sys.modules[name]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import importlib
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from dishka import FromDishka, Provider, Scope, make_async_container, provide
|
|
9
|
+
from dishka.integrations.starlette import StarletteProvider
|
|
10
|
+
from oapi_gen import generate_package
|
|
11
|
+
from oapi_gen_dishka import inject, setup_dishka
|
|
12
|
+
from starlette.applications import Starlette
|
|
13
|
+
from starlette.requests import Request
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_starlette_scopes_are_shared_isolated_and_closed(tmp_path: Path, monkeypatch):
|
|
17
|
+
specification = {
|
|
18
|
+
"openapi": "3.1.0",
|
|
19
|
+
"info": {"title": "Scope test", "version": "1"},
|
|
20
|
+
"paths": {
|
|
21
|
+
"/value": {
|
|
22
|
+
"get": {
|
|
23
|
+
"operationId": "test",
|
|
24
|
+
"security": [{"key": []}],
|
|
25
|
+
"responses": {
|
|
26
|
+
"200": {
|
|
27
|
+
"description": "value",
|
|
28
|
+
"content": {"application/json": {"schema": {"type": "string"}}},
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"components": {
|
|
35
|
+
"securitySchemes": {"key": {"type": "apiKey", "in": "header", "name": "X-Key"}}
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
source = tmp_path / "spec.json"
|
|
39
|
+
source.write_text(json.dumps(specification))
|
|
40
|
+
generate_package(source, tmp_path / "generated_starlette_dishka")
|
|
41
|
+
monkeypatch.syspath_prepend(str(tmp_path))
|
|
42
|
+
generated = importlib.import_module("generated_starlette_dishka")
|
|
43
|
+
contracts = importlib.import_module("generated_starlette_dishka.contracts")
|
|
44
|
+
resources = []
|
|
45
|
+
|
|
46
|
+
class Resource:
|
|
47
|
+
def __init__(self, name):
|
|
48
|
+
self.name = name
|
|
49
|
+
self.closed = False
|
|
50
|
+
|
|
51
|
+
class Services(Provider):
|
|
52
|
+
@provide(scope=Scope.REQUEST)
|
|
53
|
+
async def resource(self, request: Request) -> AsyncIterator[Resource]:
|
|
54
|
+
resource = Resource(request.headers["X-Key"])
|
|
55
|
+
resources.append(resource)
|
|
56
|
+
try:
|
|
57
|
+
yield resource
|
|
58
|
+
finally:
|
|
59
|
+
resource.closed = True
|
|
60
|
+
|
|
61
|
+
class Security:
|
|
62
|
+
@inject
|
|
63
|
+
async def handle_key(
|
|
64
|
+
self, context, operation_id, credential, resource: FromDishka[Resource]
|
|
65
|
+
):
|
|
66
|
+
assert resource.name == credential.api_key
|
|
67
|
+
return resource
|
|
68
|
+
|
|
69
|
+
class Controller:
|
|
70
|
+
@inject
|
|
71
|
+
async def test(self, request, resource: FromDishka[Resource]):
|
|
72
|
+
await asyncio.sleep(0)
|
|
73
|
+
assert request.security_context is resource
|
|
74
|
+
assert not resource.closed
|
|
75
|
+
if resource.name == "fail":
|
|
76
|
+
raise ValueError("failed")
|
|
77
|
+
return contracts.TestResponse200(body=resource.name)
|
|
78
|
+
|
|
79
|
+
async def scenario():
|
|
80
|
+
container = make_async_container(Services(), StarletteProvider())
|
|
81
|
+
router = generated.create_router(
|
|
82
|
+
generated.Handlers(default=Controller()), security=Security()
|
|
83
|
+
)
|
|
84
|
+
app = Starlette(routes=router.routes)
|
|
85
|
+
setup_dishka(container, app)
|
|
86
|
+
try:
|
|
87
|
+
async with httpx.AsyncClient(
|
|
88
|
+
transport=httpx.ASGITransport(app, raise_app_exceptions=False),
|
|
89
|
+
base_url="http://test",
|
|
90
|
+
) as client:
|
|
91
|
+
responses = await asyncio.gather(
|
|
92
|
+
*[client.get("/value", headers={"X-Key": str(i)}) for i in range(10)]
|
|
93
|
+
)
|
|
94
|
+
assert [response.json() for response in responses] == [str(i) for i in range(10)]
|
|
95
|
+
assert (await client.get("/value", headers={"X-Key": "fail"})).status_code == 500
|
|
96
|
+
assert len(resources) == 11
|
|
97
|
+
assert all(resource.closed for resource in resources)
|
|
98
|
+
finally:
|
|
99
|
+
await container.close()
|
|
100
|
+
|
|
101
|
+
asyncio.run(scenario())
|