Repository: apiflask/apiflask (python)

Issue:
Subclassed `MethodView` resources cannot be added as URL rules
If you attempt to subclass an instance of `MethodView` with existing URL methods, perhaps with some extra logic, that subclassed view throws an error when `APIFlask.add_url_rule` gets called.

```
In [1]: from apiflask import APIFlask

In [2]: from flask.views import MethodView

In [3]: class Foo(MethodView):
   ...:     text = "This comes from `Foo`"
   ...: 
   ...:     def get(self):
   ...:         return self.text
   ...: 

In [4]: class FooSubclass(Foo):
   ...:     text = "This comes from `FooSubclass`"
   ...: 

In [5]: app = APIFlask('mock_app')

In [6]: app.add_url_rule('/foo', view_func=FooSubclass.as_view('foo'))
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
<ipython-input-6-82066accc4da> in <module>
----> 1 app.add_url_rule('/foo', view_func=FooSubclass.as_view('foo'))

.../lib/python3.8/site-packages/apiflask/route.py in add_url_rule(self, rule, endpoint, view_func, provide_automatic_options, **options)
     92                 # record spec for MethodView class
     93                 if hasattr(self, 'enable_openapi') and self.enable_openapi:
---> 94                     view_func = record_spec_for_view_class(view_func, view_class)  # type: ignore
     95 
     96         super(cls, self).add_url_rule(

.../lib/python3.8/site-packages/apiflask/route.py in record_spec_for_view_class(view_func, view_class)
     40         for method_name in view_class.methods:  # type: ignore
     41             # method_name: ['GET', 'POST', ...]
---> 42             method = view_class.__dict__[method_name.lower()]
     43             # collect spec info from class attribute "decorators"
     44             if hasattr(view_func, '_spec') and view_func._spec != {}:

KeyError: 'get'

In [7]: app.add_url_rule('/foo', view_func=Foo.as_view('foo'))
```

I would expect the registration of `FooSubclass` on line 8 to not fail.
This is because on [this line](https://github.com/apiflask/apiflask/blob/590975fc83186088c390b6a030c74b058e2b202b/src/apiflask/route.py#L42), the method is being accessed via `__dict__` instead of `getattr`.

I am happy to open a PR fixing this bug. Should regression tests be added in [`tests/test_app.py`](https://github.com/apiflask/apiflask/blob/main/tests/test_app.py)?

Environment:

- Python version: 3.8.18
- Flask version: 2.2.2
- APIFlask version: 2.2.1


Pull request:
fix(#618): `MethodView` with subclass
fixes #618 .

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.
- [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/src/apiflask/route.py b/src/apiflask/route.py
index 9b2c08c..e9f3591 100644
--- a/src/apiflask/route.py
+++ b/src/apiflask/route.py
@@ -39,7 +39,7 @@ def route_patch(cls):
             return view_func
         for method_name in view_class.methods:  # type: ignore
             # method_name: ['GET', 'POST', ...]
-            method = view_class.__dict__[method_name.lower()]
+            method = getattr(view_class, method_name.lower())
             # collect spec info from class attribute "decorators"
             if hasattr(view_func, '_spec') and view_func._spec != {}:
                 if not hasattr(method, '_spec'):


Test patch:
diff --git a/tests/test_route.py b/tests/test_route.py
index d15d963..3779d9c 100644
--- a/tests/test_route.py
+++ b/tests/test_route.py
@@ -241,3 +241,25 @@ def test_empty_methodview_class(app, client):
     rv = client.get('/openapi.json')
     assert rv.status_code == 200
     assert '/foo' not in rv.json['paths']
+
+
+def test_methodview_class_with_subclass(app, client):
+    class Foo(MethodView):
+        text = 'You are in Foo'
+
+        def get(self):
+            return self.text
+
+    class FooSubClass(Foo):
+        text = 'You are in FooSubClass'
+
+    app.add_url_rule('/foo', view_func=FooSubClass.as_view('foo'))
+
+    rv = client.get('/foo')
+    assert rv.status_code == 200
+    assert rv.data == b'You are in FooSubClass'
+
+    rv = client.get('/openapi.json')
+    assert rv.status_code == 200
+    osv.validate(rv.json)
+    assert rv.json['paths']['/foo']['get']['summary'] == 'Get FooSubClass'