Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
400ea5a
feat(routing): override default responders via on_request()
gespyrop Apr 6, 2025
83963a8
feat(routing): on_request() supports suffix
gespyrop Apr 11, 2025
d5226d8
Merge branch 'master' into allow-on-request
vytas7 Apr 22, 2025
bfe66a0
Merge branch 'master' into allow-on-request
gespyrop Apr 23, 2025
316ab7b
Merge branch 'master' into allow-on-request
vytas7 Apr 29, 2025
5bde80c
docs(routing): improve wording + fix a misspelling
vytas7 May 17, 2025
c49a0c2
docs(routing): Comment for bound methods in tests
gespyrop May 20, 2025
d7f9814
Merge branch 'master' into allow-on-request
vytas7 May 24, 2025
54e6d5c
refactor: `allow_on_request` ➡️ `default_to_on_request`
vytas7 May 25, 2025
4883534
Merge branch 'master' into allow-on-request
vytas7 May 25, 2025
5ab6358
docs(routing): on_request example and explanation
gespyrop May 28, 2025
a618a30
fix(routing): Decorate default responders at runtime when default_to_…
gespyrop May 29, 2025
cebd52f
fix(routing): Unit tests for decorated default responders with suffix
gespyrop May 29, 2025
a110dce
add newline
gespyrop May 30, 2025
4922d15
Merge branch 'master' into allow-on-request
vytas7 Jun 6, 2025
bc14286
Merge branch 'master' into allow-on-request
vytas7 Jun 20, 2025
34220b0
Merge branch 'master' into allow-on-request
vytas7 Jul 13, 2025
2e2f192
Merge branch 'master' into allow-on-request
vytas7 Aug 1, 2025
89f4857
Merge branch 'master' into allow-on-request
vytas7 Aug 11, 2025
9dc715e
Merge branch 'master' into allow-on-request
vytas7 Sep 20, 2025
3f15c97
chore: clean up an incomplete master merge
vytas7 Sep 20, 2025
30518a0
docs(routing): misc touchups
vytas7 Sep 20, 2025
fe5a230
Merge branch 'master' into allow-on-request
vytas7 Oct 7, 2025
e9f458d
Merge branch 'master' into allow-on-request
vytas7 Oct 8, 2025
d52b201
fix(routing): Add module attribute for enabling decorating default re…
gespyrop Oct 20, 2025
de11a9c
fix(routing): Skip overriding on_websocket with default responders
gespyrop Oct 20, 2025
8c97088
Merge branch 'master' into allow-on-request
vytas7 Oct 21, 2025
ef3145e
Merge branch 'master' into allow-on-request
vytas7 Nov 12, 2025
845e624
Merge branch 'master' into allow-on-request
vytas7 Jan 14, 2026
dd6dbec
Merge branch 'master' into allow-on-request
vytas7 Jan 17, 2026
eb171b9
Merge branch 'master' into allow-on-request
vytas7 Jan 18, 2026
2f78999
Merge branch 'master' into allow-on-request
vytas7 Jan 19, 2026
3a29b18
Merge branch 'master' into allow-on-request
vytas7 Jan 21, 2026
fe183a7
Merge branch 'master' into allow-on-request
vytas7 Jan 25, 2026
8121694
docs(towncrier): tweak the proposed newsfragment
vytas7 Jan 25, 2026
6f64549
refactor: clean up and tweak stuff
vytas7 Jan 25, 2026
de280ef
refactor: bikeshed a cls link
vytas7 Jan 25, 2026
d9b14dc
refactor(hooks): DRY on_request regex
vytas7 Jan 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/_newsfragments/2071.newandimproved.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
A new router option, :attr:`~.CompiledRouterOptions.default_to_on_request`, was
added for providing a default responder by implementing ``on_request()`` on
resources (the new option is disabled by default). When enabled,
``on_request()`` is used as the default responder for every unimplemented HTTP
verb except ``on_options()`` (and the special ``on_websocket`` handler).

When the option is disabled, or the ``on_request()`` method is not implemented,
the default responder for
:class:`"405 Method Not Allowed" <falcon.HTTPMethodNotAllowed>` is used.
5 changes: 5 additions & 0 deletions docs/api/hooks.rst
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,8 @@ After Hooks
-----------

.. autofunction:: falcon.after

Configuration of Hooks
----------------------

.. autodata:: falcon.hooks.decorate_on_request
7 changes: 7 additions & 0 deletions falcon/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
'MEDIA_PNG',
'MEDIA_TEXT',
'MEDIA_URLENCODED',
'TRUE_STRINGS',
'FALSE_STRINGS',
'MEDIA_XML',
'MEDIA_YAML',
'SINGLETON_HEADERS',
Expand Down Expand Up @@ -48,6 +50,11 @@
compatibility with Falcon 3.x.
"""

TRUE_STRINGS = frozenset(['true', 'True', 't', 'yes', 'y', '1', 'on'])
"""String values that are interpreted as boolean ``True``."""
FALSE_STRINGS = frozenset(['false', 'False', 'f', 'no', 'n', '0', 'off'])
"""Similar to :attr:`TRUE_STRINGS`, the values corresponding to boolean ``False``."""

# RFC 7231, 5789 methods
HTTP_METHODS = [
'CONNECT',
Expand Down
66 changes: 66 additions & 0 deletions falcon/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from functools import wraps
from inspect import getmembers
from inspect import iscoroutinefunction
import os
import re
from typing import (
Any,
Expand All @@ -29,8 +30,10 @@
TypeVar,
Union,
)
import warnings

from falcon.constants import COMBINED_METHODS
from falcon.constants import TRUE_STRINGS
from falcon.util.misc import get_argnames
from falcon.util.sync import _wrap_non_coroutine_unsafe

Expand All @@ -52,6 +55,35 @@
_DECORABLE_METHOD_NAME = re.compile(
r'^on_({})(_\w+)?$'.format('|'.join(method.lower() for method in COMBINED_METHODS))
)
_DECORABLE_ON_REQUEST_METHOD_NAME = re.compile(r'^on_request(_\w+)?$')

_ON_REQUEST_SKIPPED_WARNING = (
'Skipping decoration of default responder {responder_name!r} on resource '
'{resource_name!r}. To enable decorating default responders with '
'class-level hooks, set falcon.hooks.decorate_on_request to True '
'(or set the environment variable FALCON_DECORATE_ON_REQUEST=1).'
)

decorate_on_request = os.environ.get('FALCON_DECORATE_ON_REQUEST', '0') in TRUE_STRINGS
"""Apply class-level hooks to ``on_request`` (and ``on_request_{suffix}``) methods.

This module-level attribute is disabled by default; wrapping default responders
with class-level hooks can be enabled by setting the value of
`decorate_on_request` to ``True``::

import falcon.hooks
falcon.hooks.decorate_on_request = True

The value of this attribute must be patched before importing a module where
resource classes are actually decorated. In the case setting this value
beforehand is not possible, wrapping default responders with class-level hooks
can also be enabled by setting the ``FALCON_DECORATE_ON_REQUEST`` environment
variable to a truthy value. For example:

.. code:: bash

$ export FALCON_DECORATE_ON_REQUEST=1
"""


def before(
Expand Down Expand Up @@ -110,6 +142,24 @@ def _before(responder_or_resource: _R) -> _R:

setattr(responder_or_resource, responder_name, do_before_all)

if _DECORABLE_ON_REQUEST_METHOD_NAME.match(responder_name):
# Only wrap default responders if decorate_on_request is set to True
if decorate_on_request:
responder = cast('Responder', responder)
do_before_all = _wrap_with_before(
responder, action, args, kwargs
)

setattr(responder_or_resource, responder_name, do_before_all)
else:
warnings.warn(
_ON_REQUEST_SKIPPED_WARNING.format(
responder_name=responder_name,
resource_name=responder_or_resource.__name__,
),
UserWarning,
)

return cast(_R, responder_or_resource)

else:
Expand Down Expand Up @@ -157,6 +207,22 @@ def _after(responder_or_resource: _R) -> _R:

setattr(responder_or_resource, responder_name, do_after_all)

if _DECORABLE_ON_REQUEST_METHOD_NAME.match(responder_name):
# Only wrap default responders if decorate_on_request is set to True
if decorate_on_request:
responder = cast('Responder', responder)
do_after_all = _wrap_with_after(responder, action, args, kwargs)

setattr(responder_or_resource, responder_name, do_after_all)
else:
warnings.warn(
_ON_REQUEST_SKIPPED_WARNING.format(
responder_name=responder_name,
resource_name=responder_or_resource.__name__,
),
UserWarning,
)

return cast(_R, responder_or_resource)

else:
Expand Down
4 changes: 2 additions & 2 deletions falcon/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
from falcon._typing import StoreArg
from falcon._typing import UnsetOr
from falcon.constants import DEFAULT_MEDIA_TYPE
from falcon.constants import FALSE_STRINGS
from falcon.constants import MEDIA_JSON
from falcon.constants import TRUE_STRINGS
from falcon.forwarded import _parse_forwarded_header
from falcon.forwarded import Forwarded
from falcon.media import Handlers
Expand All @@ -54,8 +56,6 @@

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

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

_PARAM_VALUE_DELIMITERS = {
Expand Down
82 changes: 79 additions & 3 deletions falcon/routing/compiled.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,11 @@ class can use suffixed responders to distinguish requests
resource.
"""

return map_http_methods(resource, suffix=kwargs.get('suffix', None))
return map_http_methods(
resource,
suffix=kwargs.get('suffix', None),
default_to_on_request=self._options.default_to_on_request,
)

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

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

set_default_responders(method_map, asgi=asgi)
default_responder = None

if self._options.default_to_on_request:
responder_name = 'on_request'
suffix = kwargs.get('suffix', None)

if suffix:
responder_name += '_' + suffix

default_responder = getattr(resource, responder_name, None)

# NOTE(gespyrop): We do not verify whether the default responder is
# a regular synchronous method or a coroutine since it falls under the
# general case that will be handled by _require_coroutine_responders()
# and _require_non_coroutine_responders().
set_default_responders(
method_map, asgi=asgi, default_responder=default_responder
Comment thread
vytas7 marked this conversation as resolved.
)

if asgi:
self._require_coroutine_responders(method_map)
Expand Down Expand Up @@ -937,7 +958,60 @@ class CompiledRouterOptions:
(See also: :ref:`Field Converters <routing_field_converters>`)
"""

__slots__ = ('converters',)
default_to_on_request: bool
"""Allows for providing a default responder by defining `on_request()` on
the resource. For example::

class Resource:
def on_request(self, req: Request, resp: Response) -> None:
if req.method == 'GET':
... # handle GET
elif req.method == 'POST':
... # handle post
else:
raise HTTPMethodNotAllowed(['GET', 'POST'])

app = falcon.App()
app.router_options.default_to_on_request = True

app.add_route('/resource', Resource())

This feature is disabled by default and can be enabled by::

app.router_options.default_to_on_request = True

The default responder will only handle methods for which a method-named
responder is not provided. For example, a POST request to a resource
that defines both `on_post` and `on_request` would only be handled by
`on_post`.

This option does not override `on_options()` or `on_websocket()`.
In case `on_options()` needs to be overriden, this can be done explicitly
by aliasing::

on_options = on_request

or by explicitly calling `on_request()` in `on_options()`::

def on_options(self, req, resp):
self.on_request(req, resp)

Note:
In order for this option to take effect, it must be enabled before
calling :meth:`.CompiledRouter.add_route`.

Warning:
Class-level hooks do not wrap default responders by default. Wrapping
default responders with class-level hooks can be enabled by setting
the value of :data:`falcon.hooks.decorate_on_request` to ``True``::

import falcon.hooks
falcon.hooks.decorate_on_request = True

.. versionadded:: 4.3
"""

__slots__ = ('converters', 'default_to_on_request')

def __init__(self) -> None:
object.__setattr__(
Expand All @@ -946,6 +1020,8 @@ def __init__(self) -> None:
ConverterDict((name, converter) for name, converter in converters.BUILTIN),
)

self.default_to_on_request = False

def __setattr__(self, name: str, value: Any) -> None:
if name == 'converters':
raise AttributeError('Cannot set "converters", please update it in place.')
Expand Down
42 changes: 37 additions & 5 deletions falcon/routing/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@
from falcon import responders

if TYPE_CHECKING:
from falcon._typing import AsgiResponderCallable
from falcon._typing import MethodDict
from falcon._typing import ResponderCallable


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


def map_http_methods(resource: object, suffix: str | None = None) -> MethodDict:
def map_http_methods(
resource: object, suffix: str | None = None, default_to_on_request: bool = False
) -> MethodDict:
"""Map HTTP methods (e.g., GET, POST) to methods of a resource object.

Args:
Expand All @@ -46,6 +50,11 @@ def map_http_methods(resource: object, suffix: str | None = None) -> MethodDict:
a suffix is provided, Falcon will map GET requests to
``on_get_{suffix}()``, POST requests to ``on_post_{suffix}()``,
etc.
default_to_on_request (bool): If True, it prevents a
``SuffixedMethodNotFoundError`` from being raised on resources
defining ``on_request_{suffix}()``.
(See also: :ref:`CompiledRouterOptions <compiled_router_options>`.)


Returns:
dict: A mapping of HTTP methods to explicitly defined resource responders.
Expand All @@ -69,23 +78,36 @@ def map_http_methods(resource: object, suffix: str | None = None) -> MethodDict:
if callable(responder):
method_map[method] = responder

has_default_responder = default_to_on_request and hasattr(
resource, f'on_request_{suffix}'
)

# If suffix is specified and doesn't map to any methods, raise an error
if suffix and not method_map:
if suffix and not method_map and not has_default_responder:
raise SuffixedMethodNotFoundError(
'No responders found for the specified suffix'
)

return method_map


def set_default_responders(method_map: MethodDict, asgi: bool = False) -> None:
def set_default_responders(
method_map: MethodDict,
asgi: bool = False,
default_responder: ResponderCallable | AsgiResponderCallable | None = None,
) -> None:
"""Map HTTP methods not explicitly defined on a resource to default responders.

Args:
method_map: A dict with HTTP methods mapped to responders explicitly
defined in a resource.
asgi (bool): ``True`` if using an ASGI app, ``False`` otherwise
(default ``False``).
default_responder: An optional default responder for unimplemented
resource methods (default: ``None``). If not provided, a new
responder for
:class:`"405 Method Not Allowed" <falcon.HTTPMethodNotAllowed>`
is constructed.
"""

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

na_responder = responders.create_method_not_allowed(allowed_methods, asgi=asgi)
if 'WEBSOCKET' not in method_map:
# Explicitly assign 405 Method Not Allowed to avoid
# using the default responder for WEBSOCKET
method_map['WEBSOCKET'] = responders.create_method_not_allowed(
allowed_methods, asgi=asgi
) # type: ignore[assignment]

if default_responder is None:
default_responder = responders.create_method_not_allowed(
allowed_methods, asgi=asgi
)

for method in constants.COMBINED_METHODS:
if method not in method_map:
method_map[method] = na_responder # type: ignore[assignment]
method_map[method] = default_responder # type: ignore[assignment]
Loading