Skip to content

Commit f81911e

Browse files
gespyropvytas7
andauthored
feat(routing): override default responders via on_request() (#2446)
* feat(routing): override default responders via on_request() Add an option to CompiledRouterOptions that allows for overriding the default responders by implementing on_request() in the resource class Closes #2071 * feat(routing): on_request() supports suffix Support on_request_{suffix}, unit tests for wrong wrong color of the default responder, minor on_request fixes Closes #2071 * docs(routing): improve wording + fix a misspelling * docs(routing): Comment for bound methods in tests * refactor: `allow_on_request` ➡️ `default_to_on_request` * docs(routing): on_request example and explanation * fix(routing): Decorate default responders at runtime when default_to_on_request is enabled * fix(routing): Unit tests for decorated default responders with suffix * add newline * chore: clean up an incomplete master merge * docs(routing): misc touchups * fix(routing): Add module attribute for enabling decorating default responders with class-level hooks * fix(routing): Skip overriding on_websocket with default responders * docs(towncrier): tweak the proposed newsfragment * refactor: clean up and tweak stuff * refactor: bikeshed a cls link * refactor(hooks): DRY on_request regex --------- Co-authored-by: Vytautas Liuolia <vytautas.liuolia@gmail.com>
1 parent 1ff571c commit f81911e

File tree

10 files changed

+541
-10
lines changed

10 files changed

+541
-10
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
A new router option, :attr:`~.CompiledRouterOptions.default_to_on_request`, was
2+
added for providing a default responder by implementing ``on_request()`` on
3+
resources (the new option is disabled by default). When enabled,
4+
``on_request()`` is used as the default responder for every unimplemented HTTP
5+
verb except ``on_options()`` (and the special ``on_websocket`` handler).
6+
7+
When the option is disabled, or the ``on_request()`` method is not implemented,
8+
the default responder for
9+
:class:`"405 Method Not Allowed" <falcon.HTTPMethodNotAllowed>` is used.

docs/api/hooks.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,8 @@ After Hooks
164164
-----------
165165

166166
.. autofunction:: falcon.after
167+
168+
Configuration of Hooks
169+
----------------------
170+
171+
.. autodata:: falcon.hooks.decorate_on_request

falcon/constants.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
'MEDIA_PNG',
2121
'MEDIA_TEXT',
2222
'MEDIA_URLENCODED',
23+
'TRUE_STRINGS',
24+
'FALSE_STRINGS',
2325
'MEDIA_XML',
2426
'MEDIA_YAML',
2527
'SINGLETON_HEADERS',
@@ -48,6 +50,11 @@
4850
compatibility with Falcon 3.x.
4951
"""
5052

53+
TRUE_STRINGS = frozenset(['true', 'True', 't', 'yes', 'y', '1', 'on'])
54+
"""String values that are interpreted as boolean ``True``."""
55+
FALSE_STRINGS = frozenset(['false', 'False', 'f', 'no', 'n', '0', 'off'])
56+
"""Similar to :attr:`TRUE_STRINGS`, the values corresponding to boolean ``False``."""
57+
5158
# RFC 7231, 5789 methods
5259
HTTP_METHODS = [
5360
'CONNECT',

falcon/hooks.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from functools import wraps
2121
from inspect import getmembers
2222
from inspect import iscoroutinefunction
23+
import os
2324
import re
2425
from typing import (
2526
Any,
@@ -29,8 +30,10 @@
2930
TypeVar,
3031
Union,
3132
)
33+
import warnings
3234

3335
from falcon.constants import COMBINED_METHODS
36+
from falcon.constants import TRUE_STRINGS
3437
from falcon.util.misc import get_argnames
3538
from falcon.util.sync import _wrap_non_coroutine_unsafe
3639

@@ -52,6 +55,35 @@
5255
_DECORABLE_METHOD_NAME = re.compile(
5356
r'^on_({})(_\w+)?$'.format('|'.join(method.lower() for method in COMBINED_METHODS))
5457
)
58+
_DECORABLE_ON_REQUEST_METHOD_NAME = re.compile(r'^on_request(_\w+)?$')
59+
60+
_ON_REQUEST_SKIPPED_WARNING = (
61+
'Skipping decoration of default responder {responder_name!r} on resource '
62+
'{resource_name!r}. To enable decorating default responders with '
63+
'class-level hooks, set falcon.hooks.decorate_on_request to True '
64+
'(or set the environment variable FALCON_DECORATE_ON_REQUEST=1).'
65+
)
66+
67+
decorate_on_request = os.environ.get('FALCON_DECORATE_ON_REQUEST', '0') in TRUE_STRINGS
68+
"""Apply class-level hooks to ``on_request`` (and ``on_request_{suffix}``) methods.
69+
70+
This module-level attribute is disabled by default; wrapping default responders
71+
with class-level hooks can be enabled by setting the value of
72+
`decorate_on_request` to ``True``::
73+
74+
import falcon.hooks
75+
falcon.hooks.decorate_on_request = True
76+
77+
The value of this attribute must be patched before importing a module where
78+
resource classes are actually decorated. In the case setting this value
79+
beforehand is not possible, wrapping default responders with class-level hooks
80+
can also be enabled by setting the ``FALCON_DECORATE_ON_REQUEST`` environment
81+
variable to a truthy value. For example:
82+
83+
.. code:: bash
84+
85+
$ export FALCON_DECORATE_ON_REQUEST=1
86+
"""
5587

5688

5789
def before(
@@ -110,6 +142,24 @@ def _before(responder_or_resource: _R) -> _R:
110142

111143
setattr(responder_or_resource, responder_name, do_before_all)
112144

145+
if _DECORABLE_ON_REQUEST_METHOD_NAME.match(responder_name):
146+
# Only wrap default responders if decorate_on_request is set to True
147+
if decorate_on_request:
148+
responder = cast('Responder', responder)
149+
do_before_all = _wrap_with_before(
150+
responder, action, args, kwargs
151+
)
152+
153+
setattr(responder_or_resource, responder_name, do_before_all)
154+
else:
155+
warnings.warn(
156+
_ON_REQUEST_SKIPPED_WARNING.format(
157+
responder_name=responder_name,
158+
resource_name=responder_or_resource.__name__,
159+
),
160+
UserWarning,
161+
)
162+
113163
return cast(_R, responder_or_resource)
114164

115165
else:
@@ -157,6 +207,22 @@ def _after(responder_or_resource: _R) -> _R:
157207

158208
setattr(responder_or_resource, responder_name, do_after_all)
159209

210+
if _DECORABLE_ON_REQUEST_METHOD_NAME.match(responder_name):
211+
# Only wrap default responders if decorate_on_request is set to True
212+
if decorate_on_request:
213+
responder = cast('Responder', responder)
214+
do_after_all = _wrap_with_after(responder, action, args, kwargs)
215+
216+
setattr(responder_or_resource, responder_name, do_after_all)
217+
else:
218+
warnings.warn(
219+
_ON_REQUEST_SKIPPED_WARNING.format(
220+
responder_name=responder_name,
221+
resource_name=responder_or_resource.__name__,
222+
),
223+
UserWarning,
224+
)
225+
160226
return cast(_R, responder_or_resource)
161227

162228
else:

falcon/request.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@
3838
from falcon._typing import StoreArg
3939
from falcon._typing import UnsetOr
4040
from falcon.constants import DEFAULT_MEDIA_TYPE
41+
from falcon.constants import FALSE_STRINGS
4142
from falcon.constants import MEDIA_JSON
43+
from falcon.constants import TRUE_STRINGS
4244
from falcon.forwarded import _parse_forwarded_header
4345
from falcon.forwarded import Forwarded
4446
from falcon.media import Handlers
@@ -54,8 +56,6 @@
5456

5557
DEFAULT_ERROR_LOG_FORMAT = '{0:%Y-%m-%d %H:%M:%S} [FALCON] [ERROR] {1} {2}{3} => '
5658

57-
TRUE_STRINGS = frozenset(['true', 'True', 't', 'yes', 'y', '1', 'on'])
58-
FALSE_STRINGS = frozenset(['false', 'False', 'f', 'no', 'n', '0', 'off'])
5959
WSGI_CONTENT_HEADERS = frozenset(['CONTENT_TYPE', 'CONTENT_LENGTH'])
6060

6161
_PARAM_VALUE_DELIMITERS = {

falcon/routing/compiled.py

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,11 @@ class can use suffixed responders to distinguish requests
156156
resource.
157157
"""
158158

159-
return map_http_methods(resource, suffix=kwargs.get('suffix', None))
159+
return map_http_methods(
160+
resource,
161+
suffix=kwargs.get('suffix', None),
162+
default_to_on_request=self._options.default_to_on_request,
163+
)
160164

161165
def add_route( # noqa: C901
162166
self, uri_template: str, resource: object, **kwargs: Any
@@ -203,7 +207,24 @@ class can use suffixed responders to distinguish requests
203207

204208
method_map = self.map_http_methods(resource, **kwargs)
205209

206-
set_default_responders(method_map, asgi=asgi)
210+
default_responder = None
211+
212+
if self._options.default_to_on_request:
213+
responder_name = 'on_request'
214+
suffix = kwargs.get('suffix', None)
215+
216+
if suffix:
217+
responder_name += '_' + suffix
218+
219+
default_responder = getattr(resource, responder_name, None)
220+
221+
# NOTE(gespyrop): We do not verify whether the default responder is
222+
# a regular synchronous method or a coroutine since it falls under the
223+
# general case that will be handled by _require_coroutine_responders()
224+
# and _require_non_coroutine_responders().
225+
set_default_responders(
226+
method_map, asgi=asgi, default_responder=default_responder
227+
)
207228

208229
if asgi:
209230
self._require_coroutine_responders(method_map)
@@ -937,7 +958,60 @@ class CompiledRouterOptions:
937958
(See also: :ref:`Field Converters <routing_field_converters>`)
938959
"""
939960

940-
__slots__ = ('converters',)
961+
default_to_on_request: bool
962+
"""Allows for providing a default responder by defining `on_request()` on
963+
the resource. For example::
964+
965+
class Resource:
966+
def on_request(self, req: Request, resp: Response) -> None:
967+
if req.method == 'GET':
968+
... # handle GET
969+
elif req.method == 'POST':
970+
... # handle post
971+
else:
972+
raise HTTPMethodNotAllowed(['GET', 'POST'])
973+
974+
app = falcon.App()
975+
app.router_options.default_to_on_request = True
976+
977+
app.add_route('/resource', Resource())
978+
979+
This feature is disabled by default and can be enabled by::
980+
981+
app.router_options.default_to_on_request = True
982+
983+
The default responder will only handle methods for which a method-named
984+
responder is not provided. For example, a POST request to a resource
985+
that defines both `on_post` and `on_request` would only be handled by
986+
`on_post`.
987+
988+
This option does not override `on_options()` or `on_websocket()`.
989+
In case `on_options()` needs to be overriden, this can be done explicitly
990+
by aliasing::
991+
992+
on_options = on_request
993+
994+
or by explicitly calling `on_request()` in `on_options()`::
995+
996+
def on_options(self, req, resp):
997+
self.on_request(req, resp)
998+
999+
Note:
1000+
In order for this option to take effect, it must be enabled before
1001+
calling :meth:`.CompiledRouter.add_route`.
1002+
1003+
Warning:
1004+
Class-level hooks do not wrap default responders by default. Wrapping
1005+
default responders with class-level hooks can be enabled by setting
1006+
the value of :data:`falcon.hooks.decorate_on_request` to ``True``::
1007+
1008+
import falcon.hooks
1009+
falcon.hooks.decorate_on_request = True
1010+
1011+
.. versionadded:: 4.3
1012+
"""
1013+
1014+
__slots__ = ('converters', 'default_to_on_request')
9411015

9421016
def __init__(self) -> None:
9431017
object.__setattr__(
@@ -946,6 +1020,8 @@ def __init__(self) -> None:
9461020
ConverterDict((name, converter) for name, converter in converters.BUILTIN),
9471021
)
9481022

1023+
self.default_to_on_request = False
1024+
9491025
def __setattr__(self, name: str, value: Any) -> None:
9501026
if name == 'converters':
9511027
raise AttributeError('Cannot set "converters", please update it in place.')

falcon/routing/util.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
from falcon import responders
2323

2424
if TYPE_CHECKING:
25+
from falcon._typing import AsgiResponderCallable
2526
from falcon._typing import MethodDict
27+
from falcon._typing import ResponderCallable
2628

2729

2830
class SuffixedMethodNotFoundError(Exception):
@@ -31,7 +33,9 @@ def __init__(self, message: str) -> None:
3133
self.message = message
3234

3335

34-
def map_http_methods(resource: object, suffix: str | None = None) -> MethodDict:
36+
def map_http_methods(
37+
resource: object, suffix: str | None = None, default_to_on_request: bool = False
38+
) -> MethodDict:
3539
"""Map HTTP methods (e.g., GET, POST) to methods of a resource object.
3640
3741
Args:
@@ -46,6 +50,11 @@ def map_http_methods(resource: object, suffix: str | None = None) -> MethodDict:
4650
a suffix is provided, Falcon will map GET requests to
4751
``on_get_{suffix}()``, POST requests to ``on_post_{suffix}()``,
4852
etc.
53+
default_to_on_request (bool): If True, it prevents a
54+
``SuffixedMethodNotFoundError`` from being raised on resources
55+
defining ``on_request_{suffix}()``.
56+
(See also: :ref:`CompiledRouterOptions <compiled_router_options>`.)
57+
4958
5059
Returns:
5160
dict: A mapping of HTTP methods to explicitly defined resource responders.
@@ -69,23 +78,36 @@ def map_http_methods(resource: object, suffix: str | None = None) -> MethodDict:
6978
if callable(responder):
7079
method_map[method] = responder
7180

81+
has_default_responder = default_to_on_request and hasattr(
82+
resource, f'on_request_{suffix}'
83+
)
84+
7285
# If suffix is specified and doesn't map to any methods, raise an error
73-
if suffix and not method_map:
86+
if suffix and not method_map and not has_default_responder:
7487
raise SuffixedMethodNotFoundError(
7588
'No responders found for the specified suffix'
7689
)
7790

7891
return method_map
7992

8093

81-
def set_default_responders(method_map: MethodDict, asgi: bool = False) -> None:
94+
def set_default_responders(
95+
method_map: MethodDict,
96+
asgi: bool = False,
97+
default_responder: ResponderCallable | AsgiResponderCallable | None = None,
98+
) -> None:
8299
"""Map HTTP methods not explicitly defined on a resource to default responders.
83100
84101
Args:
85102
method_map: A dict with HTTP methods mapped to responders explicitly
86103
defined in a resource.
87104
asgi (bool): ``True`` if using an ASGI app, ``False`` otherwise
88105
(default ``False``).
106+
default_responder: An optional default responder for unimplemented
107+
resource methods (default: ``None``). If not provided, a new
108+
responder for
109+
:class:`"405 Method Not Allowed" <falcon.HTTPMethodNotAllowed>`
110+
is constructed.
89111
"""
90112

91113
# Attach a resource for unsupported HTTP methods
@@ -99,8 +121,18 @@ def set_default_responders(method_map: MethodDict, asgi: bool = False) -> None:
99121
method_map['OPTIONS'] = opt_responder # type: ignore[assignment]
100122
allowed_methods.append('OPTIONS')
101123

102-
na_responder = responders.create_method_not_allowed(allowed_methods, asgi=asgi)
124+
if 'WEBSOCKET' not in method_map:
125+
# Explicitly assign 405 Method Not Allowed to avoid
126+
# using the default responder for WEBSOCKET
127+
method_map['WEBSOCKET'] = responders.create_method_not_allowed(
128+
allowed_methods, asgi=asgi
129+
) # type: ignore[assignment]
130+
131+
if default_responder is None:
132+
default_responder = responders.create_method_not_allowed(
133+
allowed_methods, asgi=asgi
134+
)
103135

104136
for method in constants.COMBINED_METHODS:
105137
if method not in method_map:
106-
method_map[method] = na_responder # type: ignore[assignment]
138+
method_map[method] = default_responder # type: ignore[assignment]

0 commit comments

Comments
 (0)