Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(routing): override default responders via on_request() #2446

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

gespyrop
Copy link

@gespyrop gespyrop commented Apr 6, 2025

Add an option to CompiledRouterOptions that allows for overriding the default responders by implementing on_request() in the resource class

Closes #2071

Summary of Changes

Added a new router option (allow_on_request) in falcon.routing.compiled.CompiledRouterOptions that allows for providing a default responder by defining on_request() on the resource. This option is disabled by default. If enabled, on_request() is set as the responder for every unimplemented method except for on_options(). If the option is disabled or on_request() is not provided in the resource, the default responder for "405 Method Not Allowed" is used.

Related Issues

Pull Request Checklist

This is just a reminder about the most common mistakes. Please make sure that you tick all appropriate boxes. But please read our contribution guide at least once; it will save you a few review cycles!

If an item doesn't apply to your pull request, check it anyway to make it apparent that there's nothing to do.

  • Applied changes to both WSGI and ASGI code paths and interfaces (where applicable).
  • Added tests for changed code.
  • Prefixed code comments with GitHub nick and an appropriate prefix.
  • Coding style is consistent with the rest of the framework.
  • Updated documentation for changed code.
    • Added docstrings for any new classes, functions, or modules.
    • Updated docstrings for any modifications to existing code.
    • Updated both WSGI and ASGI docs (where applicable).
    • Added references to new classes, functions, or modules to the relevant RST file under docs/.
    • Updated all relevant supporting documentation files under docs/.
    • A copyright notice is included at the top of any new modules (using your own name or the name of your organization).
    • Changed/added classes/methods/functions have appropriate versionadded, versionchanged, or deprecated directives.
  • Changes (and possible deprecations) have towncrier news fragments under docs/_newsfragments/, with the file name format {issue_number}.{fragment_type}.rst. (Run towncrier --draft to ensure it renders correctly.)

If you have any questions to any of the points above, just submit and ask! This checklist is here to help you, not to deter you from contributing!

PR template inspired by the attrs project.

Add an option to CompiledRouterOptions that allows for overriding the default responders by implementing on_request() in the resource class

Closes falconry#2071
Copy link

codecov bot commented Apr 6, 2025

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 100.00%. Comparing base (c820626) to head (400ea5a).

Additional details and impacted files
@@            Coverage Diff            @@
##            master     #2446   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           64        64           
  Lines         7747      7754    +7     
  Branches      1072      1074    +2     
=========================================
+ Hits          7747      7754    +7     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Member

@vytas7 vytas7 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, that is a great start! 💯

I have still haven't explored all the ramifications of the proposed design, but see some early comments inline.


__slots__ = ('converters', 'allow_on_request')

def __init__(self, allow_on_request: bool = False) -> None:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should just follow the existing pattern(s), and always initialize "options" objects with default settings.
Then, the user can customize this option as you have shown.

object.__setattr__(
self,
'converters',
ConverterDict((name, converter) for name, converter in converters.BUILTIN),
)

self.allow_on_request = allow_on_request
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a different name in mind, but that wasn't too great either... Let me think if I can come up with anything better 😅

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naming things is not my forte, but I will also try to think of a better name 😅


for method, responder in method_map.items():
if method not in ('GET', 'OPTIONS'):
assert responder == resource.on_request
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let us use comparison is, and is not, respectively, because we are comparing function pointers (PyObject* pointers on CPython), not values.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was researching why using is for the comparison was failing. Turns out that the values in method_map are of two types:

  • function, when created by utilities in responders.py
  • bound method, when retrieved by the resource using getattr(resource, responder_name)

Since, a new instance method object is created in the second case, comparison with is does not work. I thought of using responder.__func__ to compare with the original function object but this would not work for the first case.

Should I add different checks depending on the responder's type?

resource, method_map, __, __ = router.find('/default')

for method, responder in method_map.items():
assert responder != resource.on_request
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is not

default_responder = None

if self._options.allow_on_request:
default_responder = getattr(resource, 'on_request', None)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to pay heed to the suffix preference here too.

Could you also add a test where the default responder is picked up from on_request_suffix?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of course, I will implement this.
I also need to make sure that a SuffixedMethodNotFoundError is not raised in case the resource contains only on_request_suffix.

app.router_options.allow_on_request = True

Note:
This option does not override `on_options()`.
Copy link
Member

@vytas7 vytas7 Apr 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This docstring looks great, let's just move it to a different place similar to how other router options are documented.

Also, here it would be helpful to provide suggestions what to do if one wants to actually use on_request even for on_options (you can show both variants, what you wrote on the issue, as well as aliasing that I suggested).

default_responder = getattr(resource, 'on_request', None)

set_default_responders(
method_map, asgi=asgi, default_responder=default_responder
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the below _require_coroutine_responders(...) would catch the case where you provide a wrong "colour" of the default responder, but maybe it would be useful to add a # NOTE(gespyrop): ... explaining this.

Let's also add unit tests where the definition on_request() does not match the "colour" of other responder methods.

@@ -99,8 +108,11 @@ 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 not default_responder:
Copy link
Member

@vytas7 vytas7 Apr 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may not work correctly in the edge cases where default_responder evaluates as falsy in a boolean test.
Let's refuse to guess, and write if default_responder is None: ... instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Allow to override the default responders via on_request()
2 participants