Repository: apiflask/apiflask (python)

Issue:
Using class-based views with Flask 2.2 causes TypeError: view() takes 0 positional arguments but 1 was given
Error when using class-based views for compliance documentation

```
from flask.views import MethodView
from apiflask import APIFlask

app = APIFlask(__name__)


@app.route('/pets/<int:pet_id>', endpoint='pet')
class Pet(MethodView):

    def get(self, pet_id):
        return {'message': 'OK'}

    def delete(self, pet_id):
        return '', 204

app.run(debug=True)
```

Environment:

- Python version: 3.10
- Flask version: 2.2.2
- APIFlask version: 1.1.2


Pull request description:
Fix and enhance the class-based view support
- Create apiflask.views module
- Raise error if using MethodView from flask.views
- Refactor the code to collect spec from view classes
- Remove the Flask<2.2 restriction


fixes #341

<!--
If needed, ensure each step in the checklist below is complete. If only docs were changed, these aren't relevant and can be removed.
-->

Checklist:

- [x] Add tests that demonstrate the correct behavior of the change. Tests should fail without the change.
- [x] Add or update relevant docs, in the `docs` folder and in code docstring.
- [x] Add an entry in `CHANGES.md` summarizing the change and linking to the issue and your username.
- [x] Add `*Version changed*` or `*Version added*` note in any relevant docs and docstring.
- [x] Run `pytest` and `tox`, no tests failed.


Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 5d3fc80..53d815f 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -5,6 +5,8 @@
 
 Released: -
 
+- Add `apiflask.views.MethodView` to replace `flask.views.MethodView`, raise error if
+  using `flask.views.MethodView` ([issue #341][issue_341]).
 - Add `Enum` field from marshmallow 3.18.
 - Fix OpenAPI spec generating for path parameters when path schema is provided ([issue #350][issue_350]).
 - Add `spec_plugins` param to `APIFlask` class to support using custom apispec plugins ([issue #349][issue_349]).
@@ -22,6 +24,7 @@ Released: -
 [issue_369]: https://github.com/apiflask/apiflask/issues/369
 [issue_379]: https://github.com/apiflask/apiflask/issues/379
 [issue_373]: https://github.com/apiflask/apiflask/issues/373
+[issue_341]: https://github.com/apiflask/apiflask/issues/341
 
 
 ## Version 1.1.3
diff --git a/README.md b/README.md
index 24b67ed..8e18f1d 100644
--- a/README.md
+++ b/README.md
@@ -113,7 +113,7 @@ def update_pet(pet_id, data):
 from apiflask import APIFlask, Schema, abort
 from apiflask.fields import Integer, String
 from apiflask.validators import Length, OneOf
-from flask.views import MethodView
+from apiflask.views import MethodView
 
 app = APIFlask(__name__)
 
@@ -232,6 +232,7 @@ APIFlask is a thin wrapper on top of Flask. You only need to remember the follow
 
 - When creating an application instance, use `APIFlask` instead of `Flask`.
 - When creating a blueprint instance, use `APIBlueprint` instead of `Blueprint`.
+- When creating a class-based view, use `apiflask.views.MethodView` instead of `flask.views.MethodView`.
 - The `abort()` function from APIFlask (`apiflask.abort`) returns JSON error response.
 
 For a minimal Flask application:
diff --git a/docs/migrating.md b/docs/migrating.md
index 993ede3..4ae9f05 100644
--- a/docs/migrating.md
+++ b/docs/migrating.md
@@ -81,7 +81,7 @@ APIFlask support to use the `MethodView`-based view class, for example:
 
 ```python
 from apiflask import APIFlask, Schema, input, output
-from flask.views import MethodView
+from apiflask.views import MethodView
 
 # ...
 
diff --git a/docs/usage.md b/docs/usage.md
index 0929889..b786b8f 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -1022,8 +1022,8 @@ You can create a group of routes under the same URL rule with the `MethodView` c
 Here is a simple example:
 
 ```python
-from flask.views import MethodView
 from apiflask import APIFlask
+from apiflask.views import MethodView
 
 app = APIFlask(__name__)
 
diff --git a/examples/cbv/app.py b/examples/cbv/app.py
index 71c0247..fc6e2b0 100644
--- a/examples/cbv/app.py
+++ b/examples/cbv/app.py
@@ -1,7 +1,7 @@
 from apiflask import APIFlask, Schema, abort
 from apiflask.fields import Integer, String
 from apiflask.validators import Length, OneOf
-from flask.views import MethodView
+from apiflask.views import MethodView
 
 app = APIFlask(__name__)
 
diff --git a/setup.cfg b/setup.cfg
index 1cd48b1..dc5aa73 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -119,3 +119,4 @@ addopts = --cov=apiflask --cov-branch --cov-report=term-missing
 [coverage:run]
 omit =
     src/apiflask/_decorators.py
+    src/apiflask/views.py
diff --git a/setup.py b/setup.py
index 6446546..ab06938 100644
--- a/setup.py
+++ b/setup.py
@@ -4,7 +4,7 @@ from setuptools import setup
 setup(
     name='APIFlask',
     install_requires=[
-        'flask >= 1.1.0, < 2.2.0',
+        'flask >= 1.1.0',
         'flask-marshmallow >= 0.12.0',
         'webargs >= 6',
         'flask-httpauth >= 4',
diff --git a/src/apiflask/app.py b/src/apiflask/app.py
index 7767c0b..a6fa4b9 100644
--- a/src/apiflask/app.py
+++ b/src/apiflask/app.py
@@ -19,7 +19,7 @@ from flask import render_template_string
 from flask.config import ConfigAttribute
 try:
     from flask.globals import request_ctx  # type: ignore
-except ImportError:
+except ImportError:  # pragma: no cover
     from flask.globals import _request_ctx_stack
     request_ctx = None  # type: ignore
 from flask.wrappers import Response
@@ -436,8 +436,9 @@ class APIFlask(APIScaffold, Flask):
         view_function = self.view_functions[rule.endpoint]
         if hasattr(self, 'ensure_sync'):  # pragma: no cover
             view_function = self.ensure_sync(view_function)
-        if rule.endpoint == 'static':
+        if rule.endpoint == 'static' or hasattr(view_function, '_only_kwargs'):
             # app static route only accepts keyword arguments, see flask#3762
+            # view classes created by Flask only accept keyword arguments
             return view_function(**req.view_args)  # type: ignore
         else:
             return view_function(*req.view_args.values())  # type: ignore
diff --git a/src/apiflask/route.py b/src/apiflask/route.py
index 46ef229..4047199 100644
--- a/src/apiflask/route.py
+++ b/src/apiflask/route.py
@@ -1,12 +1,14 @@
 import typing as t
 
-from flask.views import MethodView
+from flask.views import MethodView as FlaskMethodView
+from flask.views import View as FlaskView
 
 from .openapi import get_path_description
 from .openapi import get_path_summary
 from .types import ViewClassType
 from .types import ViewFuncOrClassType
 from .types import ViewFuncType
+from .views import MethodView
 
 
 def route_patch(cls):
@@ -34,6 +36,8 @@ def route_patch(cls):
         view_func._method_spec = {}
         if not hasattr(view_func, '_spec'):
             view_func._spec = {}
+        if not view_class.methods:  # no methods defined
+            return view_func
         for method_name in view_class.methods:  # type: ignore
             # method_name: ['GET', 'POST', ...]
             method = view_class.__dict__[method_name.lower()]
@@ -64,6 +68,7 @@ def route_patch(cls):
         rule: str,
         endpoint: t.Optional[str] = None,
         view_func: t.Optional[ViewFuncOrClassType] = None,
+        provide_automatic_options: t.Optional[bool] = None,
         **options: t.Any,
     ):
         """Record the spec for view classes before calling the actual `add_url_rule` method.
@@ -72,31 +77,40 @@ def route_patch(cls):
         a view function created by `ViewClass.as_view()`. It only accepts a view class when
         using the route decorator on a view class.
         """
-        view_class: ViewClassType
-        is_view_class: bool = False
+        if isinstance(view_func, type):
+            # call as_view() for MethodView passed with @route
+            if endpoint is None:
+                endpoint = view_func.__name__
+            view_func = view_func.as_view(endpoint)  # type: ignore
 
         if hasattr(view_func, 'view_class'):
+            # view function created with MethodViewClass.as_view()
             view_class = view_func.view_class  # type: ignore
-            # a function returned by MethodViewClass.as_view()
-            is_view_class = True
-            if not isinstance(view_class, type(MethodView)):
-                # avoid recording spec for view class
-                is_view_class = False
-                # avoid auto response being added to bare view
+            if not issubclass(view_class, MethodView):
+                # skip View-based class
                 view_func._spec = {'hide': True}  # type: ignore
+            else:
+                # record spec for MethodView class
+                if hasattr(self, 'enable_openapi') and self.enable_openapi:
+                    view_func = record_spec_for_view_class(view_func, view_class)  # type: ignore
 
-        elif isinstance(view_func, type(MethodView)):
-            # a MethodView class passed with the route decorator
-            is_view_class = True
-            view_class = view_func  # type: ignore
-            if endpoint is None:
-                endpoint = view_class.__name__
-            view_func = view_class.as_view(endpoint)
+            # view func created by Flask's View only accpets keyword arguments
+            if issubclass(view_class, FlaskView):
+                view_func._only_kwargs = True  # type: ignore
 
-        if is_view_class and hasattr(self, 'enable_openapi') and self.enable_openapi:
-            view_func = record_spec_for_view_class(view_func, view_class)  # type: ignore
+            if issubclass(view_class, FlaskMethodView):
+                raise RuntimeError(
+                    'APIFlask only supports generating OpenAPI spec for view classes created '
+                    'with apiflask.views.MethodView (`from apiflask.views import MethodView`).',
+                )
 
-        super(cls, self).add_url_rule(rule, endpoint, view_func, **options)
+        super(cls, self).add_url_rule(
+            rule,
+            endpoint,
+            view_func,
+            provide_automatic_options=provide_automatic_options,
+            **options
+        )
 
     cls.add_url_rule = add_url_rule
     return cls
diff --git a/src/apiflask/scaffold.py b/src/apiflask/scaffold.py
index 9049c1e..e7e4a45 100644
--- a/src/apiflask/scaffold.py
+++ b/src/apiflask/scaffold.py
@@ -7,7 +7,6 @@ import flask
 from flask import current_app
 from flask import jsonify
 from flask import Response
-from flask.views import MethodView
 from marshmallow import ValidationError as MarshmallowValidationError
 from webargs.flaskparser import FlaskParser as BaseFlaskParser
 from webargs.multidictproxy import MultiDictProxy
@@ -23,6 +22,7 @@ from .types import OpenAPISchemaType
 from .types import RequestType
 from .types import ResponseReturnValueType
 from .types import SchemaType
+from .views import MethodView
 
 BODY_LOCATIONS = ['json', 'files', 'form', 'form_and_files', 'json_or_form']
 SUPPORTED_LOCATIONS = BODY_LOCATIONS + [
diff --git a/src/apiflask/types.py b/src/apiflask/types.py
index 8c156a5..492f560 100644
--- a/src/apiflask/types.py
+++ b/src/apiflask/types.py
@@ -8,7 +8,6 @@ else:  # pragma: no cover
 
 if t.TYPE_CHECKING:  # pragma: no cover
     from flask.wrappers import Response  # noqa: F401
-    from flask.views import View  # noqa: F401
     from werkzeug.datastructures import Headers  # noqa: F401
     from _typeshed.wsgi import WSGIApplication  # noqa: F401
     from .fields import Field  # noqa: F401
@@ -16,29 +15,38 @@ if t.TYPE_CHECKING:  # pragma: no cover
     from .security import HTTPBasicAuth  # noqa: F401
     from .security import HTTPTokenAuth  # noqa: F401
     from .exceptions import HTTPError  # noqa: F401
+    from .views import View  # noqa: F401
 
 
 DecoratedType = t.TypeVar('DecoratedType', bound=t.Callable[..., t.Any])
 RequestType = t.TypeVar('RequestType')
 
 ResponseBodyType = t.Union[
-    str, bytes, list, t.Dict[str, t.Any], t.Generator[str, None, None], 'Response'
+    str,
+    bytes,
+    t.List[t.Any],
+    # Only dict is actually accepted, but Mapping allows for TypedDict.
+    t.Mapping[str, t.Any],
+    t.Iterator[str],
+    t.Iterator[bytes],
+    'Response',
 ]
+
 ResponseStatusType = t.Union[str, int]
 _HeaderName = str
 _HeaderValue = t.Union[str, t.List[str], t.Tuple[str, ...]]
 ResponseHeaderType = t.Union[
     t.Dict[_HeaderName, _HeaderValue],
     t.Mapping[_HeaderName, _HeaderValue],
-    t.List[t.Tuple[_HeaderName, _HeaderValue]],
+    t.Sequence[t.Tuple[_HeaderName, _HeaderValue]],
     'Headers'
 ]
 ResponseReturnValueType = t.Union[
     ResponseBodyType,
-    t.Tuple[ResponseBodyType, ResponseStatusType],
     t.Tuple[ResponseBodyType, ResponseHeaderType],
+    t.Tuple[ResponseBodyType, ResponseStatusType],
     t.Tuple[ResponseBodyType, ResponseStatusType, ResponseHeaderType],
-    'WSGIApplication'
+    'WSGIApplication',
 ]
 SpecCallbackType = t.Callable[[t.Union[dict, str]], t.Union[dict, str]]
 ErrorCallbackType = t.Callable[['HTTPError'], ResponseReturnValueType]
@@ -51,6 +59,11 @@ TagsType = t.Union[t.List[str], t.List[t.Dict[str, t.Any]]]
 ViewClassType = t.Type['View']
 ViewFuncOrClassType = t.Union[t.Callable, ViewClassType]
 
+RouteCallableType = t.Union[
+    t.Callable[..., ResponseReturnValueType],
+    t.Callable[..., t.Awaitable[ResponseReturnValueType]],
+]
+
 
 class PaginationType(Protocol):
     page: int
diff --git a/src/apiflask/views.py b/src/apiflask/views.py
new file mode 100644
index 0000000..f85e26c
--- /dev/null
+++ b/src/apiflask/views.py
@@ -0,0 +1,189 @@
+# From Flask, see the NOTICE file for the license details.
+import typing as t
+
+from flask import current_app
+from flask import request
+
+from .types import ResponseReturnValueType
+from .types import RouteCallableType
+
+
+http_method_funcs = frozenset(
+    ['get', 'post', 'head', 'options', 'delete', 'put', 'trace', 'patch']
+)
+
+
+class View:
+    """Flask's `View` with some modifications for API support.
+
+    Note: APIFlask only support generating OpenAPI spec for
+    `MethodView`-based classes.
+
+    Set `methods` on the class to change what methods the view
+    accepts.
+
+    Set `decorators` on the class to apply a list of decorators to
+    the generated view function. Decorators applied to the class itself
+    will not be applied to the generated view function!
+
+    Set `init_every_request` to `False` for efficiency, unless
+    you need to store request-global data on `self`.
+    """
+
+    #: The methods this view is registered for. Uses the same default
+    #: (`['GET', 'HEAD', 'OPTIONS']`) as `route` and
+    #: `add_url_rule` by default.
+    methods: t.ClassVar[t.Optional[t.Collection[str]]] = None
+
+    #: Control whether the `OPTIONS` method is handled automatically.
+    #: Uses the same default (`True`) as `route` and
+    #: `add_url_rule` by default.
+    provide_automatic_options: t.ClassVar[t.Optional[bool]] = None
+
+    #: A list of decorators to apply, in order, to the generated view
+    #: function. Remember that `@decorator` syntax is applied bottom
+    #: to top, so the first decorator in the list would be the bottom
+    #: decorator.
+    #:
+    #: *Version added: Flask 0.8*
+    decorators: t.ClassVar[t.List[t.Callable]] = []
+
+    #: Create a new instance of this view class for every request by
+    #: default. If a view subclass sets this to `False`, the same
+    #: instance is used for every request.
+    #:
+    #: A single instance is more efficient, especially if complex setup
+    #: is done during init. However, storing data on `self` is no
+    #: longer safe across requests, and :data:`~flask.g` should be used
+    #: instead.
+    #:
+    #: *Version added: Flask 2.2*
+    init_every_request: t.ClassVar[bool] = True
+
+    def dispatch_request(self) -> ResponseReturnValueType:
+        """The actual view function behavior. Subclasses must override
+        this and return a valid response. Any variables from the URL
+        rule are passed as keyword arguments.
+        """
+        raise NotImplementedError()
+
+    @classmethod
+    def as_view(
+        cls, name: str, *class_args: t.Any, **class_kwargs: t.Any
+    ) -> RouteCallableType:
+        """Convert the class into a view function that can be registered
+        for a route.
+
+        By default, the generated view will create a new instance of the
+        view class for every request and call its
+        `dispatch_request` method. If the view class sets
+        `init_every_request` to `False`, the same instance will
+        be used for every request.
+
+        The arguments passed to this method are forwarded to the view
+        class `__init__` method.
+
+        *Version added: Flask 2.2*
+            Added the `init_every_request` class attribute.
+        """
+        if cls.init_every_request:
+
+            def view(*args: t.Any, **kwargs: t.Any) -> ResponseReturnValueType:
+                self = view.view_class(  # type: ignore[attr-defined]
+                    *class_args, **class_kwargs
+                )
+                if hasattr(current_app, 'ensure_sync'):
+                    return current_app.ensure_sync(  # type: ignore
+                        self.dispatch_request
+                    )(*args, **kwargs)
+                return self.dispatch_request(*args, **kwargs)  # type: ignore
+
+        else:
+            self = cls(*class_args, **class_kwargs)
+
+            def view(*args: t.Any, **kwargs: t.Any) -> ResponseReturnValueType:
+                if hasattr(current_app, 'ensure_sync'):
+                    return current_app.ensure_sync(  # type: ignore
+                        self.dispatch_request
+                    )(*args, **kwargs)
+                return self.dispatch_request(*args, **kwargs)
+
+        if cls.decorators:
+            view.__name__ = name
+            view.__module__ = cls.__module__
+            for decorator in cls.decorators:
+                view = decorator(view)
+
+        # We attach the view class to the view function for two reasons:
+        # first of all it allows us to easily figure out what class-based
+        # view this thing came from, secondly it's also used for instantiating
+        # the view class so you can actually replace it with something else
+        # for testing purposes and debugging.
+        view.view_class = cls  # type: ignore
+        view.__name__ = name
+        view.__doc__ = cls.__doc__
+        view.__module__ = cls.__module__
+        view.methods = cls.methods  # type: ignore
+        view.provide_automatic_options = cls.provide_automatic_options  # type: ignore
+        return view
+
+
+class MethodView(View):
+    """Dispatches request methods to the corresponding instance methods.
+    For example, if you implement a `get` method, it will be used to
+    handle `GET` requests.
+
+    This can be useful for defining a REST API.
+
+    `methods` is automatically set based on the methods defined on
+    the class.
+
+    Example:
+
+    ```python
+    class CounterAPI(MethodView):
+
+        @app.output(CounterSchema)
+        def get(self):
+            return str(session.get('counter', 0))
+
+        @app.input(CounterSchema)
+        def post(self):
+            session['counter'] = session.get('counter', 0) + 1
+            return redirect(url_for('counter'))
+
+    app.add_url_rule(
+        '/counter', view_func=CounterAPI.as_view('counter')
+    )
+    ```
+    """
+
+    def __init_subclass__(cls, **kwargs: t.Any) -> None:
+        super().__init_subclass__(**kwargs)
+
+        if 'methods' not in cls.__dict__:
+            methods = set()
+
+            for base in cls.__bases__:
+                if getattr(base, 'methods', None):
+                    methods.update(base.methods)  # type: ignore[attr-defined]
+
+            for key in http_method_funcs:
+                if hasattr(cls, key):
+                    methods.add(key.upper())
+
+            if methods:
+                cls.methods = methods
+
+    def dispatch_request(self, *args: t.Any, **kwargs: t.Any) -> ResponseReturnValueType:
+        meth = getattr(self, request.method.lower(), None)
+
+        # If the request method is HEAD and we don't have a handler for it
+        # retry with GET.
+        if meth is None and request.method == 'HEAD':
+            meth = getattr(self, 'get', None)
+
+        assert meth is not None, f'Unimplemented method {request.method!r}'
+        if hasattr(current_app, 'ensure_sync'):
+            meth = current_app.ensure_sync(meth)
+        return meth(*args, **kwargs)  # type: ignore


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 48e259a..ff0f747 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -46,7 +46,7 @@ jobs:
           pip install -U setuptools
           python -m pip install -U pip
       - name: cache mypy
-        uses: actions/cache@v3.2.2
+        uses: actions/cache@v3.0.11
         with:
           path: ./.mypy_cache
           key: mypy|${{ matrix.python }}|${{ hashFiles('setup.cfg') }}
diff --git a/tests/test_app.py b/tests/test_app.py
index aa84af7..ff7c0cc 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -1,7 +1,6 @@
 import pytest
 from apispec import BasePlugin
 from flask import Blueprint
-from flask.views import MethodView
 from openapi_spec_validator import validate_spec
 
 from .schemas import Bar
@@ -12,6 +11,7 @@ from apiflask import APIFlask
 from apiflask import Schema
 from apiflask.fields import Integer
 from apiflask.fields import String
+from apiflask.views import MethodView
 
 
 def test_app_init(app):
diff --git a/tests/test_blueprint.py b/tests/test_blueprint.py
index 6b0cd9c..f2dfe3f 100644
--- a/tests/test_blueprint.py
+++ b/tests/test_blueprint.py
@@ -1,9 +1,9 @@
-from flask.views import MethodView
 from openapi_spec_validator import validate_spec
 
 from apiflask import APIBlueprint
 from apiflask.security import HTTPBasicAuth
 from apiflask.security import HTTPTokenAuth
+from apiflask.views import MethodView
 
 
 def test_blueprint_object():
diff --git a/tests/test_decorator_auth_required.py b/tests/test_decorator_auth_required.py
index d5ba5ad..886b50d 100644
--- a/tests/test_decorator_auth_required.py
+++ b/tests/test_decorator_auth_required.py
@@ -1,9 +1,9 @@
-from flask.views import MethodView
 from openapi_spec_validator import validate_spec
 
 from apiflask import APIBlueprint
 from apiflask.security import HTTPBasicAuth
 from apiflask.security import HTTPTokenAuth
+from apiflask.views import MethodView
 
 
 def test_auth_required(app, client):
diff --git a/tests/test_decorator_doc.py b/tests/test_decorator_doc.py
index 182739a..5ed71b0 100644
--- a/tests/test_decorator_doc.py
+++ b/tests/test_decorator_doc.py
@@ -1,8 +1,8 @@
 import pytest
-from flask.views import MethodView
 from openapi_spec_validator import validate_spec
 
 from .schemas import Foo
+from apiflask.views import MethodView
 
 
 def test_doc_summary_and_description(app, client):
diff --git a/tests/test_decorator_input.py b/tests/test_decorator_input.py
index cf4230b..62663d0 100644
--- a/tests/test_decorator_input.py
+++ b/tests/test_decorator_input.py
@@ -1,7 +1,6 @@
 import io
 
 import pytest
-from flask.views import MethodView
 from openapi_spec_validator import validate_spec
 from werkzeug.datastructures import FileStorage
 
@@ -13,6 +12,7 @@ from .schemas import Form
 from .schemas import FormAndFiles
 from .schemas import Query
 from apiflask.fields import String
+from apiflask.views import MethodView
 
 
 def test_input(app, client):
diff --git a/tests/test_decorator_output.py b/tests/test_decorator_output.py
index 178a731..d45ea66 100644
--- a/tests/test_decorator_output.py
+++ b/tests/test_decorator_output.py
@@ -1,10 +1,10 @@
 from flask import make_response
-from flask.views import MethodView
 from openapi_spec_validator import validate_spec
 
 from .schemas import Foo
 from .schemas import Query
 from apiflask.fields import String
+from apiflask.views import MethodView
 
 
 def test_output(app, client):
diff --git a/tests/test_route.py b/tests/test_route.py
index c6cf84a..d1e766a 100644
--- a/tests/test_route.py
+++ b/tests/test_route.py
@@ -1,11 +1,13 @@
 import pytest
-from flask.views import MethodView
-from flask.views import View
+from flask.views import MethodView as FlaskMethodView
+from flask.views import View as FlaskView
 from openapi_spec_validator import validate_spec
 
 from .schemas import Foo
 from apiflask import APIBlueprint
 from apiflask import HTTPTokenAuth
+from apiflask.views import MethodView
+from apiflask.views import View
 
 
 @pytest.mark.parametrize('method', ['get', 'post', 'put', 'patch', 'delete'])
@@ -165,7 +167,7 @@ def test_add_url_rule_with_method_view(app, client):
             """Create foo"""
             return 'post'
 
-    app.add_url_rule('/', view_func=Foo, methods=['GET', 'POST'])
+    app.add_url_rule('/', view_func=Foo.as_view('foo'), methods=['GET', 'POST'])
 
     rv = client.get('/')
     assert rv.data == b'get'
@@ -215,17 +217,35 @@ def test_view_endpoint_contains_dot(app, client):
     assert rv.json['paths']['/']['get']
 
 
-def test_add_url_rule_with_view_as_view(app, client):
-    """Flask Views should be ignored by add_url_rule"""
-    class Foo(View):
+@pytest.mark.parametrize('view_class', [View, FlaskView])
+def test_add_url_rule_skip_collecting_spec_from_view_class(app, client, view_class):
+    class Foo(view_class):
         def dispatch_request(self):
+            return 'Hello'
+
+    app.add_url_rule('/foo', view_func=Foo.as_view('foo'))
+
+    rv = client.get('/openapi.json')
+    assert rv.status_code == 200
+    assert '/foo' not in rv.json['paths']
+
+    rv = client.get('/foo')
+    assert rv.status_code == 200
+
+
+def test_runtime_error_on_flask_methodview_class(app):
+    with pytest.raises(RuntimeError):
+        class Foo(FlaskMethodView):
             pass
 
-    foo_view = Foo.as_view('foo')
+        app.add_url_rule('/foo', view_func=Foo.as_view('foo'))
 
-    app.add_url_rule('/foo', view_func=foo_view)
 
+def test_empty_methodview_class(app, client):
+    class Foo(MethodView):
+        pass
+
+    app.add_url_rule('/foo', view_func=Foo.as_view('foo'))
     rv = client.get('/openapi.json')
     assert rv.status_code == 200
-
     assert '/foo' not in rv.json['paths']
diff --git a/tests/test_settings_auto_behaviour.py b/tests/test_settings_auto_behaviour.py
index 46a2730..bc1ab62 100644
--- a/tests/test_settings_auto_behaviour.py
+++ b/tests/test_settings_auto_behaviour.py
@@ -1,11 +1,11 @@
 import pytest
-from flask.views import MethodView
 from openapi_spec_validator import validate_spec
 
 from .schemas import Foo
 from .schemas import Query
 from apiflask import APIBlueprint
 from apiflask.security import HTTPBasicAuth
+from apiflask.views import MethodView
 
 
 def test_auto_tags(app, client):