Skip to content

Commit 5abe7b7

Browse files
amirbekazimovpre-commit-ci[bot]sobolevn
authored
Initial support for OPTIONS method (#127)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: sobolevn <[email protected]>
1 parent d1dca4f commit 5abe7b7

File tree

12 files changed

+390
-25
lines changed

12 files changed

+390
-25
lines changed

django_modern_rest/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
from django_modern_rest.components import (
55
Headers as Headers,
66
)
7+
from django_modern_rest.components import (
8+
Path as Path,
9+
)
710
from django_modern_rest.components import (
811
Query as Query,
912
)
@@ -15,6 +18,12 @@
1518
from django_modern_rest.endpoint import validate as validate
1619
from django_modern_rest.headers import HeaderDescription as HeaderDescription
1720
from django_modern_rest.headers import NewHeader as NewHeader
21+
from django_modern_rest.meta import (
22+
AsyncMetaMixin as AsyncMetaMixin,
23+
)
24+
from django_modern_rest.meta import (
25+
MetaMixin as MetaMixin,
26+
)
1827
from django_modern_rest.response import APIError as APIError
1928
from django_modern_rest.response import (
2029
ResponseDescription as ResponseDescription,

django_modern_rest/components.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
_QueryT = TypeVar('_QueryT')
1515
_BodyT = TypeVar('_BodyT')
1616
_HeadersT = TypeVar('_HeadersT')
17+
_PathT = TypeVar('_PathT')
1718

1819

1920
class ComponentParser:
@@ -159,3 +160,31 @@ def provide_context_data(
159160
**kwargs: Any,
160161
) -> Any:
161162
return request.headers
163+
164+
165+
class Path(ComponentParser, Generic[_PathT]):
166+
"""
167+
Parses the url part of the request.
168+
169+
# TODO: example
170+
171+
If your controller class inherits from ``Path`` - then you can access
172+
parsed paths parameters as ``self.parsed_path`` attribute.
173+
174+
It is way stricter than the original Django's routing system.
175+
For example, django allows to
176+
"""
177+
178+
parsed_path: _PathT
179+
context_name: ClassVar[str] = 'parsed_path'
180+
181+
@override
182+
def provide_context_data(
183+
self,
184+
serializer: type[BaseSerializer],
185+
model: Any,
186+
request: HttpRequest,
187+
*args: Any,
188+
**kwargs: Any,
189+
) -> Any:
190+
return kwargs

django_modern_rest/controller.py

Lines changed: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from http import HTTPStatus
1+
from http import HTTPMethod, HTTPStatus
22
from typing import (
33
Any,
44
ClassVar,
@@ -83,10 +83,14 @@ class Controller(View, Generic[_SerializerT_co]): # noqa: WPS214
8383
controller_validator_cls: ClassVar[type[ControllerValidator]] = (
8484
ControllerValidator
8585
)
86+
# str and not HTTPMethod, because of `meta` method:
8687
api_endpoints: ClassVar[dict[str, Endpoint]]
8788
validate_responses: ClassVar[bool | Empty] = EmptyObj
8889
responses: ClassVar[list[ResponseDescription]] = []
8990
responses_from_components: ClassVar[bool] = True
91+
http_methods: ClassVar[frozenset[str]] = frozenset(
92+
{method.name.lower() for method in HTTPMethod} - {'options'} | {'meta'},
93+
)
9094

9195
# Internal API:
9296
_component_parsers: ClassVar[list[_ComponentParserSpec]]
@@ -116,9 +120,12 @@ def __init_subclass__(cls) -> None:
116120
]
117121
cls.serializer_context = cls.serializer_context_cls(cls)
118122
cls.api_endpoints = {
119-
meth: cls.endpoint_cls(func, controller_cls=cls)
123+
# Rename `meta` back to `options`:
124+
'options' if meth == 'meta' else meth: cls.endpoint_cls(
125+
getattr(cls, meth),
126+
controller_cls=cls,
127+
)
120128
for meth in cls.existing_http_methods()
121-
if (func := getattr(cls, meth)) is not getattr(View, meth, None)
122129
}
123130
cls._is_async = cls.controller_validator_cls()(cls)
124131

@@ -218,6 +225,90 @@ def http_method_not_allowed(
218225
'use `handle_method_not_allowed` instead',
219226
)
220227

228+
@override
229+
@deprecated(
230+
# It is not actually deprecated, but type checkers have no other
231+
# ways to raise custom errors.
232+
'Please do not use `options` method with `django-modern-rest`, '
233+
'define your own `meta` method instead',
234+
)
235+
def options(
236+
self,
237+
request: HttpRequest,
238+
*args: Any,
239+
**kwargs: Any,
240+
) -> HttpResponse:
241+
"""
242+
Do not use, define your own `meta` method instead.
243+
244+
Django's `View.options` has incompatible signature with
245+
``django-modern-rest``. It would be a typing error
246+
to define something like:
247+
248+
.. warning::
249+
250+
Don't do this!
251+
252+
.. code:: python
253+
254+
>>> from http import HTTPStatus
255+
>>> from django_modern_rest import Controller, validate
256+
>>> from django_modern_rest.plugins.pydantic import (
257+
... PydanticSerializer,
258+
... )
259+
>>> class MyController(Controller[PydanticSerializer]):
260+
... @validate(
261+
... ResponseDescription(
262+
... None,
263+
... status_code=HTTPStatus.NO_CONTENT,
264+
... ),
265+
... )
266+
... def options(self) -> HttpResponse: # <- typing problem
267+
... ...
268+
269+
That's why instead of ``options`` you should define
270+
our own ``meta`` method:
271+
272+
.. code:: python
273+
274+
>>> class MyController(Controller[PydanticSerializer]):
275+
... @validate(
276+
... ResponseDescription(
277+
... None,
278+
... status_code=HTTPStatus.NO_CONTENT,
279+
... ),
280+
... )
281+
... def meta(self) -> HttpResponse:
282+
... allow = ','.join(
283+
... method.upper() for method in self.http_methods
284+
... )
285+
... return self.to_response(
286+
... None,
287+
... status_code=HTTPStatus.NO_CONTENT,
288+
... headers={'Allow': allow},
289+
... )
290+
291+
.. note::
292+
293+
By default ``meta`` method is not provided for you.
294+
If you want to support ``OPTIONS`` http method
295+
with the default implementation, use:
296+
297+
.. code:: python
298+
299+
>>> from django_modern_rest import MetaMixin
300+
301+
>>> class ControllerWithMeta(
302+
... MetaMixin,
303+
... Controller[PydanticSerializer],
304+
... ): ...
305+
306+
"""
307+
raise NotImplementedError(
308+
'Please do not use `options` method with `django-modern-rest`, '
309+
'define your own `meta` method instead',
310+
)
311+
221312
@classmethod
222313
def handle_method_not_allowed(
223314
cls,
@@ -251,7 +342,7 @@ def existing_http_methods(cls) -> set[str]:
251342
"""Returns and caches what HTTP methods are implemented in this view."""
252343
return {
253344
method
254-
for method in cls.http_method_names
345+
for method in cls.http_methods
255346
if getattr(cls, method, None) is not None
256347
}
257348

django_modern_rest/endpoint.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ async def decorator(
153153
)
154154
# Return response:
155155
try:
156-
func_result = await func(self._controller, *args, **kwargs)
156+
func_result = await func(self._controller)
157157
except APIError as exc: # pyright: ignore[reportUnknownVariableType]
158158
func_result = self._controller.to_error(
159159
exc.raw_data, # pyright: ignore[reportUnknownMemberType]
@@ -188,7 +188,7 @@ def decorator(
188188
)
189189
# Return response:
190190
try:
191-
func_result = func(self._controller, *args, **kwargs)
191+
func_result = func(self._controller)
192192
except APIError as exc: # pyright: ignore[reportUnknownVariableType]
193193
func_result = self._controller.to_error(
194194
exc.raw_data, # pyright: ignore[reportUnknownMemberType]

django_modern_rest/meta.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from http import HTTPStatus
2+
from typing import TYPE_CHECKING, Final
3+
4+
from django.http import HttpResponse
5+
6+
from django_modern_rest.endpoint import validate
7+
from django_modern_rest.headers import HeaderDescription
8+
from django_modern_rest.response import ResponseDescription
9+
from django_modern_rest.validation import validate_method_name
10+
11+
if TYPE_CHECKING:
12+
from django_modern_rest.controller import Controller
13+
from django_modern_rest.serialization import BaseSerializer
14+
15+
16+
_OptionsResponse: Final = ResponseDescription(
17+
None,
18+
status_code=HTTPStatus.NO_CONTENT,
19+
headers={'Allow': HeaderDescription()},
20+
)
21+
22+
23+
class MetaMixin:
24+
"""
25+
Mixing that provides default ``meta`` method or ``OPTIONS`` http method.
26+
27+
Use it for sync controllers.
28+
29+
It just returns the list of allowed methods.
30+
Use it as a mixin with
31+
the :class:`django_modern_rest.controller.Controller` type:
32+
33+
.. code:: python
34+
35+
>>> from django_modern_rest import Controller, MetaMixin
36+
>>> from django_modern_rest.plugins.pydantic import PydanticSerializer
37+
38+
>>> class SupportsOptionsHttpMethod(
39+
... MetaMixin,
40+
... Controller[PydanticSerializer],
41+
... ): ...
42+
43+
"""
44+
45+
__slots__ = ()
46+
47+
@validate(_OptionsResponse)
48+
def meta(self) -> HttpResponse:
49+
"""Default sync implementation for ``OPTIONS`` http method."""
50+
return _meta_impl(self) # type: ignore[arg-type]
51+
52+
53+
class AsyncMetaMixin:
54+
"""
55+
Mixing that provides default ``meta`` method or ``OPTIONS`` http method.
56+
57+
Use it for async controllers.
58+
59+
It just returns the list of allowed methods.
60+
Use it as a mixin with
61+
the :class:`django_modern_rest.controller.Controller` type:
62+
63+
.. code:: python
64+
65+
>>> from django_modern_rest import Controller, AsyncMetaMixin
66+
>>> from django_modern_rest.plugins.pydantic import PydanticSerializer
67+
68+
>>> class SupportsOptionsHttpMethod(
69+
... AsyncMetaMixin,
70+
... Controller[PydanticSerializer],
71+
... ): ...
72+
73+
"""
74+
75+
__slots__ = ()
76+
77+
@validate(_OptionsResponse)
78+
async def meta(self) -> HttpResponse:
79+
"""Default async implementation for ``OPTIONS`` http method."""
80+
return _meta_impl(self) # type: ignore[arg-type]
81+
82+
83+
def _meta_impl(controller: 'Controller[BaseSerializer]') -> HttpResponse:
84+
allow = ', '.join(
85+
validate_method_name(method).value
86+
for method in sorted(controller.existing_http_methods())
87+
)
88+
return controller.to_response(
89+
None,
90+
status_code=HTTPStatus.NO_CONTENT,
91+
headers={'Allow': allow},
92+
)

django_modern_rest/routing.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def compose_controllers(
3737
*extra: type['Controller[_SerializerT]'],
3838
) -> type[View]:
3939
"""Combines several controllers with different http methods into one url."""
40+
# TODO: validate `meta` composition. Because right now it is not correct.
4041
controllers = [first_controller, second_controller, *extra]
4142
is_all_async = _validate_controllers_composition(controllers)
4243

@@ -45,6 +46,8 @@ def compose_controllers(
4546
method_mapping = _build_method_mapping(views)
4647

4748
class ComposedControllerView(View): # noqa: WPS431
49+
original_controllers = controllers
50+
4851
@override
4952
def dispatch(
5053
self,
@@ -75,7 +78,6 @@ def _validate_controllers_composition(
7578
# We know that there are at least 2 controllers as this point:
7679
is_async = bool(controllers[0].view_is_async)
7780
serializer = controllers[0].serializer
78-
7981
for controller in controllers:
8082
if controller.view_is_async is not is_async:
8183
raise ValueError(
@@ -96,15 +98,13 @@ def _build_method_mapping(
9698
) -> Mapping[str, _ViewFunc]:
9799
method_mapping: dict[str, _ViewFunc] = {}
98100
for controller, view in views:
99-
controller_methods = controller.existing_http_methods() - {'options'}
101+
controller_methods = controller.existing_http_methods()
100102
if not controller_methods:
101103
raise ValueError(
102104
f'Controller {controller} must have at least one endpoint '
103105
'to be composed',
104106
)
105107
method_intersection = method_mapping.keys() & controller_methods
106-
# TODO: decide what to do with default `options` method.
107-
# Do we need it? Maybe it should be removed?
108108
if method_intersection:
109109
raise ValueError(
110110
f'Controllers have {method_intersection!r} common methods, '

django_modern_rest/validation.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,8 @@ class ControllerValidator:
232232

233233
def __call__(self, controller: 'type[Controller[BaseSerializer]]') -> bool:
234234
"""Run the validation."""
235+
# TODO: validate that sync controller have `MetaMixin`
236+
# and async ones have `AsyncMetaMixin`
235237
self._validate_components(controller)
236238
return self._validate_endpoints(controller)
237239

@@ -395,6 +397,7 @@ def __call__(
395397
"""Do the validation."""
396398
return_annotation = parse_return_annotation(func)
397399
method = validate_method_name(func.__name__)
400+
func.__name__ = str(method).lower() # we can change it :)
398401
endpoint = str(func)
399402
# TODO: validate contoller's definition.
400403
# Questions: how? when? one time?
@@ -611,6 +614,8 @@ def validate_method_name(func_name: str) -> HTTPMethod:
611614
try: # noqa: WPS229
612615
if func_name != func_name.lower():
613616
raise ValueError # noqa: TRY301
617+
if func_name == 'meta':
618+
return HTTPMethod.OPTIONS
614619
return HTTPMethod(func_name.upper())
615620
except ValueError:
616621
raise EndpointMetadataError(

0 commit comments

Comments
 (0)