diff --git a/CHANGES.rst b/CHANGES.rst index e9eb70fa..6b24db27 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -81,7 +81,8 @@ Unreleased - ``helpers.total_seconds()`` is deprecated. Use ``timedelta.total_seconds()`` instead. :pr:`3962` - Add type hinting. :pr:`3973`. - +- Support using the ``route`` decorator on view classes (i.e. + ``View`` and ``MethodView`` subclasses). :issue:`3404` Version 1.1.2 ------------- diff --git a/docs/views.rst b/docs/views.rst index 1d2daec7..f3d1cdcf 100644 --- a/docs/views.rst +++ b/docs/views.rst @@ -233,3 +233,56 @@ registration code:: methods=['GET', 'PUT', 'DELETE']) register_api(UserAPI, 'user_api', '/users/', pk='user_id') + + +Use the ``route`` Decorator on View Classes +------------------------------------------- + +For simple use cases, you can use the :func:`~flask.Flask.route` decorator +as a shortcut of :meth:`~flask.views.View.as_view` class method to register +a view class:: + + from flask.views import View + + @app.route('/users/', endpoint='show_users') + class ShowUsers(View): + + def dispatch_request(self): + users = User.query.all() + return render_template('users.html', objects=users) + +Or on a method-based view class:: + + from flask.views import MethodView + + @app.route('/users/', endpoint='users') + class UserAPI(MethodView): + + def get(self): + users = User.query.all() + ... + + def post(self): + user = User.from_form_data(request.form) + ... + +You can also pass a view class to :meth:`~flask.Flask.add_url_rule` +directly:: + + from flask.views import MethodView + + class UserAPI(MethodView): + + def get(self): + users = User.query.all() + ... + + def post(self): + user = User.from_form_data(request.form) + ... + + app.add_url_rule('/users/', endpoint='users', view_func=UserAPI) + +Beware that if the ``endpoint`` argument isn't provided, the class name +will be used as the endpoint. Also, you can't pass any class arguments in +this way. diff --git a/src/flask/app.py b/src/flask/app.py index 7afb0a1e..dea12591 100644 --- a/src/flask/app.py +++ b/src/flask/app.py @@ -68,6 +68,8 @@ from .typing import TemplateGlobalCallable from .typing import TemplateTestCallable from .typing import URLDefaultCallable from .typing import URLValuePreprocessorCallable +from .typing import ViewFuncArgument +from .views import View from .wrappers import Request from .wrappers import Response @@ -1033,10 +1035,16 @@ class Flask(Scaffold): self, rule: str, endpoint: t.Optional[str] = None, - view_func: t.Optional[t.Callable] = None, + view_func: ViewFuncArgument = None, provide_automatic_options: t.Optional[bool] = None, **options: t.Any, ) -> None: + """A helper method to register a rule (and optionally a view function) + to the application. + + .. versionchanged:: 2.0.0 + Support to pass a view class as view function. + """ if endpoint is None: endpoint = _endpoint_from_view_func(view_func) # type: ignore options["endpoint"] = endpoint @@ -1078,6 +1086,8 @@ class Flask(Scaffold): rule.provide_automatic_options = provide_automatic_options # type: ignore self.url_map.add(rule) + if isinstance(view_func, type) and issubclass(view_func, View): + view_func = view_func.as_view(endpoint) if view_func is not None: old_func = self.view_functions.get(endpoint) if getattr(old_func, "_flask_sync_wrapper", False): diff --git a/src/flask/blueprints.py b/src/flask/blueprints.py index a2b6c0f5..0001db01 100644 --- a/src/flask/blueprints.py +++ b/src/flask/blueprints.py @@ -15,6 +15,8 @@ from .typing import TemplateGlobalCallable from .typing import TemplateTestCallable from .typing import URLDefaultCallable from .typing import URLValuePreprocessorCallable +from .typing import ViewFuncArgument +from .views import View if t.TYPE_CHECKING: from .app import Flask @@ -78,12 +80,15 @@ class BlueprintSetupState: self, rule: str, endpoint: t.Optional[str] = None, - view_func: t.Optional[t.Callable] = None, + view_func: ViewFuncArgument = None, **options: t.Any, ) -> None: """A helper method to register a rule (and optionally a view function) to the application. The endpoint is automatically prefixed with the blueprint's name. + + .. versionchanged:: 2.0.0 + Support to pass a view class as view function. """ if self.url_prefix is not None: if rule: @@ -96,6 +101,8 @@ class BlueprintSetupState: defaults = self.url_defaults if "defaults" in options: defaults = dict(defaults, **options.pop("defaults")) + if isinstance(view_func, type) and issubclass(view_func, View): + view_func = view_func.as_view(endpoint) self.app.add_url_rule( rule, f"{self.name_prefix}{self.blueprint.name}.{endpoint}", diff --git a/src/flask/scaffold.py b/src/flask/scaffold.py index d40dfdd8..d5b72162 100644 --- a/src/flask/scaffold.py +++ b/src/flask/scaffold.py @@ -405,9 +405,9 @@ class Scaffold: return self._method_route("PATCH", rule, options) def route(self, rule: str, **options: t.Any) -> t.Callable: - """Decorate a view function to register it with the given URL - rule and options. Calls :meth:`add_url_rule`, which has more - details about the implementation. + """Decorate a view function or view class to register it with + the given URL rule and options. Calls :meth:`add_url_rule`, which + has more details about the implementation. .. code-block:: python @@ -426,6 +426,9 @@ class Scaffold: :param rule: The URL rule string. :param options: Extra options passed to the :class:`~werkzeug.routing.Rule` object. + + .. versionchanged:: 2.0.0 + The ``route`` decorator can be used on view classes. """ def decorator(f: t.Callable) -> t.Callable: diff --git a/src/flask/typing.py b/src/flask/typing.py index 9a664e41..e583217f 100644 --- a/src/flask/typing.py +++ b/src/flask/typing.py @@ -5,6 +5,7 @@ if t.TYPE_CHECKING: from werkzeug.datastructures import Headers # noqa: F401 from wsgiref.types import WSGIApplication # noqa: F401 from .wrappers import Response # noqa: F401 + from .views import View # noqa: F401 # The possible types that are directly convertible or are a Response object. ResponseValue = t.Union[ @@ -44,3 +45,4 @@ TemplateGlobalCallable = t.Callable[[], t.Any] TemplateTestCallable = t.Callable[[t.Any], bool] URLDefaultCallable = t.Callable[[str, dict], None] URLValuePreprocessorCallable = t.Callable[[t.Optional[str], t.Optional[dict]], None] +ViewFuncArgument = t.Optional[t.Union[t.Callable, t.Type["View"]]] diff --git a/tests/test_views.py b/tests/test_views.py index 0e215252..bc9295be 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -240,3 +240,55 @@ def test_remove_method_from_parent(app, client): assert client.get("/").data == b"GET" assert client.post("/").status_code == 405 assert sorted(View.methods) == ["GET"] + + +def test_route_on_view(app): + @app.route("/", endpoint="index") + class Index(flask.views.View): + methods = ["GET", "POST"] + + def dispatch_request(self): + return flask.request.method + + common_test(app) + + +def test_route_on_methodview(app): + @app.route("/", endpoint="index") + class Index(flask.views.MethodView): + def get(self): + return "GET" + + def post(self): + return "POST" + + common_test(app) + + +def test_bp_route_on_view(app): + bp = flask.Blueprint("test", __name__) + + @bp.route("/", endpoint="index") + class Index(flask.views.View): + methods = ["GET", "POST"] + + def dispatch_request(self): + return flask.request.method + + app.register_blueprint(bp) + common_test(app) + + +def test_bp_route_on_methodview(app): + bp = flask.Blueprint("test", __name__) + + @bp.route("/", endpoint="index") + class Index(flask.views.MethodView): + def get(self): + return "GET" + + def post(self): + return "POST" + + app.register_blueprint(bp) + common_test(app)