Skip to content

Commit ebf4b56

Browse files
authored
Merge pull request #32 from eadwinCode/controller_response_refactor
Throttled Controller
2 parents 2dc12aa + a2590ff commit ebf4b56

17 files changed

+623
-115
lines changed

ninja_extra/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@
77
OPTIONS = "OPTIONS"
88
TRACE = "TRACE"
99
ROUTE_METHODS = [POST, PUT, PATCH, DELETE, GET, HEAD, OPTIONS, TRACE]
10+
THROTTLED_FUNCTION = "__throttled_endpoint__"

ninja_extra/controllers/base.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from ninja.signature import is_async
3030
from ninja.utils import normalize_path
3131

32+
from ninja_extra.constants import THROTTLED_FUNCTION
3233
from ninja_extra.exceptions import APIException, NotFound, PermissionDenied, bad_request
3334
from ninja_extra.helper import get_function_name
3435
from ninja_extra.operation import ControllerPathView, Operation
@@ -44,10 +45,11 @@
4445
from .response import Detail, Id, Ok
4546
from .route.route_functions import AsyncRouteFunction, RouteFunction
4647

47-
if TYPE_CHECKING:
48-
from ninja_extra import NinjaExtraAPI # pragma: no cover
48+
if TYPE_CHECKING: # pragma: no cover
49+
from ninja_extra import NinjaExtraAPI
50+
from ninja_extra.throttling import BaseThrottle
4951

50-
from .route.context import RouteContext # pragma: no cover
52+
from .route.context import RouteContext
5153

5254

5355
class MissingAPIControllerDecoratorException(Exception):
@@ -115,6 +117,8 @@ def some_method_name(self):
115117
# `context` variable will change based on the route function called on the APIController
116118
# that way we can get some specific items things that belong the route function during execution
117119
context: Optional["RouteContext"] = None
120+
throttling_classes: List["BaseThrottle"] = []
121+
throttling_init_kwargs: Optional[Dict[Any, Any]] = None
118122

119123
Ok = Ok
120124
Id = Id
@@ -310,13 +314,21 @@ def tags(self, value: Union[str, List[str], None]) -> None:
310314
self._tags = tag
311315

312316
def __call__(self, cls: Type) -> Type["ControllerBase"]:
317+
from ninja_extra.throttling import throttle
318+
313319
self.auto_import = getattr(cls, "auto_import", self.auto_import)
314320
if not issubclass(cls, ControllerBase):
315321
# We force the cls to inherit from `ControllerBase` by creating another type.
316322
cls = type(cls.__name__, (ControllerBase, cls), {"_api_controller": self})
317323
else:
318324
cls._api_controller = self
319325

326+
assert isinstance(
327+
cls.throttling_classes, (list, tuple)
328+
), f"Controller[{cls.__name__}].throttling_class must be a list or tuple"
329+
has_throttling_classes = len(cls.throttling_classes) > 0
330+
throttling_init_kwargs = cls.throttling_init_kwargs or {}
331+
320332
if not self.tags:
321333
tag = str(cls.__name__).lower().replace("controller", "")
322334
self.tags = [tag]
@@ -328,6 +340,11 @@ def __call__(self, cls: Type) -> Type["ControllerBase"]:
328340
compute_api_route_function(base_cls, self)
329341

330342
for _, v in self._controller_class_route_functions.items():
343+
throttled_endpoint = v.as_view.__dict__.get(THROTTLED_FUNCTION)
344+
if not throttled_endpoint and has_throttling_classes:
345+
v.route.view_func = throttle(
346+
*cls.throttling_classes, **throttling_init_kwargs
347+
)(v.route.view_func)
331348
self._add_operation_from_route_function(v)
332349

333350
if not is_decorated_with_inject(cls.__init__):
Lines changed: 130 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,146 @@
1-
from typing import Any, Dict, List, Optional, Type, Union
1+
import sys
2+
from typing import (
3+
Any,
4+
Dict,
5+
Generic,
6+
Optional,
7+
Tuple,
8+
Type,
9+
TypeVar,
10+
Union,
11+
no_type_check,
12+
)
213

314
from ninja import Schema
4-
from pydantic.types import UUID1, UUID3, UUID4, UUID5
5-
6-
from ninja_extra import status
7-
8-
9-
class ControllerResponseMeta(type):
10-
pass
1115

16+
from .. import status
17+
from ..schemas import DetailSchema, IdSchema, OkSchema
18+
19+
T = TypeVar("T")
20+
SCHEMA_KEY = "_schema"
21+
22+
if sys.version_info < (3, 7): # pragma: no cover
23+
from typing import GenericMeta
24+
25+
class ControllerResponseMeta(GenericMeta):
26+
@no_type_check
27+
def __new__(mcls, name: str, bases: Tuple, namespace: Dict, **kwargs: Any):
28+
args = kwargs.get("args")
29+
if args and args[0].__name__ != "T":
30+
t = args[0]
31+
origin_schema = namespace.get(SCHEMA_KEY)
32+
if origin_schema and hasattr(origin_schema, "__generic_model__"):
33+
schema = origin_schema.__generic_model__[t]
34+
else:
35+
schema = origin_schema[t]
36+
namespace[SCHEMA_KEY] = schema
37+
res = super().__new__(mcls, name, bases, namespace, **kwargs)
38+
return res
39+
40+
class GenericControllerResponse(metaclass=ControllerResponseMeta):
41+
@no_type_check
42+
def __new__(
43+
cls: Type["ControllerResponse[T]"], *args: Any, **kwargs: Any
44+
) -> "ControllerResponse[T]":
45+
46+
if cls._gorg is Generic or "_schema" not in cls.__dict__:
47+
raise TypeError(
48+
"Type Generic cannot be instantiated; "
49+
"it can be used only as a base class"
50+
)
51+
return object.__new__(cls)
52+
53+
else:
54+
_generic_types_cache: Dict[
55+
Tuple[Type[Any], Union[Any, Tuple[Any, ...]]], Type["ControllerResponse"]
56+
] = {}
57+
GenericControllerResponseT = TypeVar(
58+
"GenericControllerResponseT", bound="GenericControllerResponse"
59+
)
60+
61+
class ControllerResponseMeta(type):
62+
pass
1263

13-
class ControllerResponse(metaclass=ControllerResponseMeta):
64+
class GenericControllerResponse(metaclass=ControllerResponseMeta):
65+
def __new__(
66+
cls: Type["ControllerResponse[T]"], *args: Any, **kwargs: Any
67+
) -> "ControllerResponse[T]":
68+
69+
if "_schema" not in cls.__dict__:
70+
raise TypeError(
71+
"Type Generic cannot be instantiated; "
72+
"it can be used only as a base class"
73+
)
74+
return object.__new__(cls)
75+
76+
@no_type_check
77+
def __class_getitem__(cls: Type[GenericControllerResponseT], item: Any) -> Any:
78+
if isinstance(item, tuple):
79+
raise TypeError(
80+
"Cannot parameterize a concrete instantiation of a generic model"
81+
)
82+
83+
_key = (cls, item)
84+
_cached_value = _generic_types_cache.get(_key)
85+
if _cached_value:
86+
return _cached_value
87+
88+
if str(type(item)) == "<class 'typing.TypeVar'>":
89+
result = super().__class_getitem__(item)
90+
else:
91+
new_schema = cls.__dict__.get(SCHEMA_KEY)
92+
if hasattr(new_schema, "__generic_model__"):
93+
new_schema = new_schema.__generic_model__[item]
94+
95+
result = type(
96+
f"{cls.__name__}[{item.__name__}]", (cls,), {SCHEMA_KEY: new_schema}
97+
)
98+
_generic_types_cache[_key] = result
99+
return result
100+
101+
102+
class ControllerResponse(GenericControllerResponse, Generic[T]):
14103
status_code: int = status.HTTP_204_NO_CONTENT
15-
16-
def __init__(self, **kwargs: Any) -> None:
17-
pass
104+
_schema: Optional[T]
18105

19106
@classmethod
20-
def get_schema(cls) -> Union[Schema, Type[Schema], Any]:
107+
def get_schema(cls) -> Union[Schema, Type[Schema], Any]: # pragma: no cover
21108
raise NotImplementedError
22109

23-
def convert_to_schema(self) -> Any:
110+
def convert_to_schema(self) -> Any: # pragma: no cover
24111
raise NotImplementedError
25112

26113

27-
class Id(ControllerResponse):
114+
class Id(ControllerResponse[T]):
28115
"""
29116
Creates a 201 response with id information
30117
{
31118
id: int| str| UUID4| UUID1| UUID3| UUID5,
32119
}
33120
Example:
34121
Id(423) ==> 201, {id: 423}
122+
OR
123+
Id[int](424) ==> 201, {id: 424}
124+
Id[UUID4]("883a1a3d-7b10-458d-bccc-f9b7219342c9")
125+
==> 201, {id: "883a1a3d-7b10-458d-bccc-f9b7219342c9"}
35126
"""
36127

128+
_schema = IdSchema[Any] # type: ignore
37129
status_code: int = status.HTTP_201_CREATED
38-
id: Union[int, str, UUID4, UUID1, UUID3, UUID5, Any]
39130

40-
def __init__(self, id: Any) -> None:
131+
def __init__(self, id: T) -> None:
41132
super(Id, self).__init__()
42133
self.id = id
43134

44-
class Id(Schema):
45-
id: Any
46-
47135
def convert_to_schema(self) -> Any:
48-
return self.Id.from_orm(self)
136+
return self._schema.from_orm(self)
49137

50138
@classmethod
51139
def get_schema(cls) -> Union[Schema, Type[Schema], Any]:
52-
return cls.Id
140+
return cls._schema
53141

54142

55-
class Ok(ControllerResponse):
143+
class Ok(ControllerResponse[T]):
56144
"""
57145
Creates a 200 response with a detail information.
58146
{
@@ -61,53 +149,57 @@ class Ok(ControllerResponse):
61149
62150
Example:
63151
Ok('Saved Successfully') ==> 200, {detail: 'Saved Successfully'}
152+
OR
153+
class ASchema(BaseModel):
154+
name: str
155+
age: int
64156
157+
OK[ASchema](ASchema(name='Eadwin', age=18)) ==> 200, {detail: {'name':'Eadwin', 'age': 18}}
65158
"""
66159

67160
status_code: int = status.HTTP_200_OK
68-
detail: Union[str, List[Dict], List[str], Dict] = "Action was successful"
161+
_schema = OkSchema[Any] # type: ignore
69162

70163
def __init__(self, message: Optional[Any] = None) -> None:
71164
super(Ok, self).__init__()
72-
self.detail = message or self.detail
73-
74-
class Ok(Schema):
75-
detail: Union[str, List[Dict], List[str], Dict]
165+
self.detail = message or "Action was successful"
76166

77167
def convert_to_schema(self) -> Any:
78-
return self.Ok.from_orm(self)
168+
return self._schema.from_orm(self)
79169

80170
@classmethod
81171
def get_schema(cls) -> Union[Schema, Type[Schema], Any]:
82-
return cls.Ok
172+
return cls._schema
83173

84174

85-
class Detail(ControllerResponse):
175+
class Detail(ControllerResponse[T]):
86176
"""
87177
Creates a custom response with detail information
88178
{
89179
detail: str| List[Dict] | List[str] | Dict,
90180
}
91181
Example:
92182
Detail('Invalid Request', 404) ==> 404, {detail: 'Invalid Request'}
183+
OR
184+
class ErrorSchema(BaseModel):
185+
message: str
186+
187+
Detail[ErrorSchema](dict(message='Bad Request'),400) ==> 400, {detail: {'message':'Bad Request'}}
93188
"""
94189

95190
status_code: int = status.HTTP_200_OK
96-
detail: Union[str, List[Dict], List[str], Dict] = dict()
191+
_schema = DetailSchema[Any] # type: ignore
97192

98193
def __init__(
99194
self, message: Optional[Any] = None, status_code: int = status.HTTP_200_OK
100195
) -> None:
101196
super(Detail, self).__init__()
102-
self.detail = message or self.detail
197+
self.detail = message or "Action was successful"
103198
self.status_code = status_code or self.status_code
104199

105-
class Detail(Schema):
106-
detail: Union[str, List[Dict], List[str], Dict]
107-
108200
def convert_to_schema(self) -> Any:
109-
return self.Detail.from_orm(self)
201+
return self._schema.from_orm(self)
110202

111203
@classmethod
112204
def get_schema(cls) -> Union[Schema, Type[Schema], Any]:
113-
return cls.Detail
205+
return cls._schema

ninja_extra/generic.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,47 @@
1-
from typing import Any, Dict, no_type_check
1+
from typing import Any, Dict, Optional, Tuple, Type, cast, no_type_check
2+
3+
_generic_types_registry: Dict[Tuple[Type["GenericType"], Type], Type] = {}
24

35

46
class GenericModelMeta(type):
5-
registry: Dict = {}
7+
def _get_name(self: Type["GenericType"], wrap_type: Type) -> str: # type: ignore
8+
_name = f"{self._generic_base_name or self.__name__}"
9+
if hasattr(wrap_type, "__name__"):
10+
_name += f"[{str(wrap_type.__name__)}]"
11+
return _name
12+
13+
def __getitem__(self: Type["GenericType"], wraps: Any) -> Any: # type: ignore
14+
if (self, wraps) not in _generic_types_registry:
15+
new_generic_type = cast(Type, self().get_generic_type(wraps))
16+
new_generic_type.__generic_model__ = self
17+
new_generic_type.__name__ = self._get_name(wraps)
618

7-
def __getitem__(self, wraps: Any) -> Any:
8-
if (self, wraps) not in self.__class__.registry:
9-
self.__class__.registry[self, wraps] = self().get_generic_type(wraps)
10-
return self.__class__.registry[self, wraps]
19+
_generic_types_registry[self, wraps] = new_generic_type
20+
return _generic_types_registry[self, wraps]
1121

1222

1323
@no_type_check
1424
class GenericType(metaclass=GenericModelMeta):
25+
_generic_base_name: Optional[str] = None
1526
"""
1627
Get a wrapper class that mimic python 3.8 generic support to python3.6, 3.7
1728
Examples
1829
--------
1930
Create a Class to reference the generic model to be create:
20-
>>> class ObjectA(GenericType):
31+
>>> class ObjectA(GenericType, generic_base_name="A"):
2132
>>> def get_generic_type(self, wrap_type):
2233
>>> class ObjectAGeneric(self):
2334
>>> item: wrap_type
24-
>>> ObjectAGeneric.__name__ = (
25-
>>> f"{self.__class__.__name__}[{str(wrap_type.__name__).capitalize()}]"
26-
>>> )
2735
>>> return ObjectAGeneric
2836
2937
Usage:
3038
>>> class Pass: ...
3139
>>> object_generic_type = ObjectA[Pass]
3240
"""
41+
42+
def __init_subclass__(cls, **kwargs: Any) -> None:
43+
cls._generic_base_name = kwargs.get("generic_base_name")
44+
return super().__init_subclass__()
45+
46+
def get_generic_type(self, wrap_type: Any) -> Any: # pragma: no cover
47+
raise NotImplementedError

ninja_extra/schemas/__init__.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
from .response import (
2+
DetailSchema,
3+
IdSchema,
24
NinjaPaginationResponseSchema,
5+
OkSchema,
36
PaginatedResponseSchema,
47
RouteParameter,
58
)
69

7-
__all__ = ["PaginatedResponseSchema", "RouteParameter", "NinjaPaginationResponseSchema"]
10+
__all__ = [
11+
"PaginatedResponseSchema",
12+
"RouteParameter",
13+
"NinjaPaginationResponseSchema",
14+
"IdSchema",
15+
"OkSchema",
16+
"DetailSchema",
17+
]

0 commit comments

Comments
 (0)