Skip to content

Commit eae5d7c

Browse files
committed
refactored route context to make dynamic route parameter computation
1 parent f0eb068 commit eae5d7c

File tree

5 files changed

+121
-27
lines changed

5 files changed

+121
-27
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Django Ninja Extra is a powerful extension for [Django Ninja](https://django-nin
1818
- 📝 **Type Safety**: Comprehensive type hints for better development experience
1919
- 🎯 **Django Integration**: Seamless integration with Django's ecosystem
2020
- 📚 **OpenAPI Support**: Automatic API documentation with Swagger/ReDoc
21+
- 🔒 **API Throttling**: Rate limiting for your API
2122

2223
### Extra Features
2324
- 🏗️ **Class-Based Controllers**:

docs/index.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@
88

99
# Django Ninja Extra
1010

11-
## Overview
12-
1311
Django Ninja Extra is a powerful extension for [Django Ninja](https://django-ninja.rest-framework.com) that enhances your Django REST API development experience. It introduces class-based views and advanced features while maintaining the high performance and simplicity of Django Ninja. Whether you're building a small API or a large-scale application, Django Ninja Extra provides the tools you need for clean, maintainable, and efficient API development.
1412

1513
## Features
@@ -20,6 +18,7 @@ Django Ninja Extra is a powerful extension for [Django Ninja](https://django-nin
2018
- 📝 **Type Safety**: Comprehensive type hints for better development experience
2119
- 🎯 **Django Integration**: Seamless integration with Django's ecosystem
2220
- 📚 **OpenAPI Support**: Automatic API documentation with Swagger/ReDoc
21+
- 🔒 **API Throttling**: Rate limiting for your API
2322

2423
### Extra Features
2524
- 🏗️ **Class-Based Controllers**:
@@ -43,6 +42,7 @@ Django Ninja Extra is a powerful extension for [Django Ninja](https://django-nin
4342
- Reusable components
4443

4544
## Requirements
45+
4646
- Python >= 3.6
4747
- Django >= 2.1
4848
- Pydantic >= 1.6
@@ -161,7 +161,7 @@ class UserController:
161161

162162
Access your API's interactive documentation at `/api/docs`:
163163

164-
![Swagger UI](docs/images/ui_swagger_preview_readme.gif)
164+
![Swagger UI](/images/ui_swagger_preview_readme.gif)
165165

166166
## Learning Resources
167167

Lines changed: 84 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,104 @@
1-
from typing import Any, List, Optional, Union
1+
from typing import TYPE_CHECKING, Any, List, Optional, Union
22

3+
import pydantic
4+
from django.core.exceptions import ImproperlyConfigured
35
from django.http import HttpResponse
46
from django.http.request import HttpRequest
7+
from ninja.errors import ValidationError
58
from ninja.types import DictStrAny
6-
from pydantic import BaseModel as PydanticModel
7-
from pydantic import Field
89

10+
from ninja_extra.details import ViewSignature
911
from ninja_extra.types import PermissionType
1012

13+
if TYPE_CHECKING:
14+
from ninja_extra.main import NinjaExtraAPI
1115

12-
class RouteContext(PydanticModel):
16+
17+
class RouteContext:
1318
"""
1419
APIController Context which will be available to the class instance when handling request
1520
"""
1621

17-
class Config:
18-
arbitrary_types_allowed = True
22+
permission_classes: PermissionType
23+
request: Union[Any, HttpRequest, None]
24+
response: Union[Any, HttpResponse, None]
25+
args: List[Any]
26+
kwargs: DictStrAny
27+
28+
def __init__(
29+
self,
30+
request: HttpRequest,
31+
args: Optional[List[Any]] = None,
32+
permission_classes: Optional[PermissionType] = None,
33+
kwargs: Optional[DictStrAny] = None,
34+
response: Optional[HttpResponse] = None,
35+
api: Optional["NinjaExtraAPI"] = None,
36+
view_signature: Optional[ViewSignature] = None,
37+
):
38+
self.request = request
39+
self.response = response
40+
self.args: List[Any] = args or []
41+
self.kwargs: DictStrAny = kwargs or {}
42+
self.permission_classes: PermissionType = permission_classes or []
43+
self.api = api
44+
self.view_signature = view_signature
45+
self._has_computed_route_parameters = False
46+
47+
@property
48+
def has_computed_route_parameters(self) -> bool:
49+
return self._has_computed_route_parameters
50+
51+
def compute_route_parameters(
52+
self,
53+
) -> None:
54+
if self.view_signature is None or self.api is None:
55+
raise ImproperlyConfigured(
56+
"view_signature and api are required. "
57+
"Or you are taking an approach that is not supported "
58+
"RouteContext to compute route parameters."
59+
)
60+
61+
if self._has_computed_route_parameters:
62+
return
63+
64+
values, errors = {}, []
65+
for model in self.view_signature.models:
66+
try:
67+
data = model.resolve(self.request, self.api, self.kwargs)
68+
values.update(data)
69+
except pydantic.ValidationError as e:
70+
items = []
71+
for i in e.errors(include_url=False):
72+
i["loc"] = (
73+
model.__ninja_param_source__,
74+
) + model.__ninja_flatten_map_reverse__.get(i["loc"], i["loc"])
75+
# removing pydantic hints
76+
del i["input"] # type: ignore
77+
if (
78+
"ctx" in i
79+
and "error" in i["ctx"]
80+
and isinstance(i["ctx"]["error"], Exception)
81+
):
82+
i["ctx"]["error"] = str(i["ctx"]["error"])
83+
items.append(dict(i))
84+
errors.extend(items)
85+
86+
if errors:
87+
raise ValidationError(errors)
88+
89+
if self.view_signature.response_arg:
90+
values[self.view_signature.response_arg] = self.response
1991

20-
permission_classes: PermissionType = Field([])
21-
request: Union[Any, HttpRequest, None] = None
22-
response: Union[Any, HttpResponse, None] = None
23-
args: List[Any] = Field([])
24-
kwargs: DictStrAny = Field({})
92+
self.kwargs.update(values)
93+
self._has_computed_route_parameters = True
2594

2695

2796
def get_route_execution_context(
2897
request: HttpRequest,
2998
temporal_response: Optional[HttpResponse] = None,
3099
permission_classes: Optional[PermissionType] = None,
100+
api: Optional["NinjaExtraAPI"] = None,
101+
view_signature: Optional[ViewSignature] = None,
31102
*args: Any,
32103
**kwargs: Any,
33104
) -> RouteContext:
@@ -39,6 +110,8 @@ def get_route_execution_context(
39110
"kwargs": kwargs,
40111
"response": temporal_response,
41112
"args": args,
113+
"api": api,
114+
"view_signature": view_signature,
42115
}
43116
context = RouteContext(**init_kwargs) # type:ignore[arg-type]
44117
return context

ninja_extra/operation.py

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -193,9 +193,12 @@ def _prep_run(
193193

194194
def run(self, request: HttpRequest, **kw: Any) -> HttpResponseBase:
195195
try:
196-
temporal_response = self.api.create_temporal_response(request)
197196
with self._prep_run(
198-
request, temporal_response=temporal_response, **kw
197+
request,
198+
temporal_response=self.api.create_temporal_response(request),
199+
api=self.api,
200+
view_signature=self.signature,
201+
**kw,
199202
) as ctx:
200203
error = self._run_checks(request)
201204
if error:
@@ -205,12 +208,15 @@ def run(self, request: HttpRequest, **kw: Any) -> HttpResponseBase:
205208
if route_function:
206209
route_function.run_permission_check(ctx)
207210

208-
values = self._get_values(request, kw, temporal_response)
209-
ctx.kwargs.update(values)
210-
result = self.view_func(request, **values)
211+
if not ctx.has_computed_route_parameters:
212+
ctx.compute_route_parameters()
213+
214+
result = self.view_func(request, **ctx.kwargs)
215+
assert ctx.response is not None
211216
_processed_results = self._result_to_response(
212-
request, result, temporal_response
217+
request, result, ctx.response
213218
)
219+
214220
return _processed_results
215221
except Exception as e:
216222
if isinstance(e, TypeError) and "required positional argument" in str(
@@ -321,9 +327,12 @@ async def _prep_run( # type:ignore
321327

322328
async def run(self, request: HttpRequest, **kw: Any) -> HttpResponseBase: # type: ignore
323329
try:
324-
temporal_response = self.api.create_temporal_response(request)
325330
async with self._prep_run(
326-
request, temporal_response=temporal_response, **kw
331+
request,
332+
temporal_response=self.api.create_temporal_response(request),
333+
api=self.api,
334+
view_signature=self.signature,
335+
**kw,
327336
) as ctx:
328337
error = await self._run_checks(request)
329338
if error:
@@ -333,12 +342,15 @@ async def run(self, request: HttpRequest, **kw: Any) -> HttpResponseBase: # typ
333342
if route_function:
334343
await route_function.async_run_check_permissions(ctx) # type: ignore[attr-defined]
335344

336-
values = await self._get_values(request, kw, temporal_response) # type: ignore
337-
ctx.kwargs.update(values)
338-
result = await self.view_func(request, **values)
345+
if not ctx.has_computed_route_parameters:
346+
ctx.compute_route_parameters()
347+
348+
result = await self.view_func(request, **ctx.kwargs)
349+
assert ctx.response is not None
339350
_processed_results = await self._result_to_response(
340-
request, result, temporal_response
351+
request, result, ctx.response
341352
)
353+
342354
return cast(HttpResponseBase, _processed_results)
343355
except Exception as e:
344356
return self.api.on_exception(request, e)

tests/test_controller.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,15 @@ def test_controller_base_get_object_or_exception_works(self):
165165
group_instance = Group.objects.create(name="_groupowner")
166166

167167
controller_object = SomeController()
168-
context = RouteContext(request=Mock(), permission_classes=[AllowAny])
168+
context = RouteContext(
169+
request=Mock(),
170+
permission_classes=[AllowAny],
171+
response=None,
172+
args=[],
173+
kwargs={},
174+
api=None,
175+
view_signature=None,
176+
)
169177
controller_object.context = context
170178
with patch.object(
171179
AllowAny, "has_object_permission", return_value=True

0 commit comments

Comments
 (0)