Repository: apiflask/apiflask (python)

Issue:
OpenAPI generation failed with flask_pyoidc
### Discussed in https://github.com/apiflask/apiflask/discussions/405

<div type='discussions-op-text'>

<sup>Originally posted by **EleazarLibelium** February 20, 2023</sup>
Good morning, thank you for the project and for the effort.

I've been using apiflask as the main library for my Rest API for a few months now.

I would like to use it together with the flask extension flask-pyoidc to implement security using keycloak but when I run it and open the url of the API it crashes.

My code is as follows:
main:
```python
import os

import flask
from flask_pyoidc.user_session import UserSession

import config
from flask import jsonify, redirect, Flask
from flask_cors import CORS
from apiflask import APIFlask
from loguru import logger

from src.auth import auth
from src.auth.auth import oidc

# Flask application
application = APIFlask(__name__, docs_path='/swagger/', title='Testing Keycloak Api', version=config.VERSION)
#application = Flask(__name__)

cors = CORS(application, resources={r"*": {"origins": "*"}})
# Flask configuration
application.config['ENV'] = config.ENVIRONMENT_MODE
application.config['SECRET_KEY'] = config.SECRET_KEY
application.config['OIDC_REDIRECT_URI'] = config.REDIRECT_URI
application.config['JSON_SORT_KEYS'] = False
application.config['JSONIFY_PRETTYPRINT_REGULAR'] = True
application.config['OPENAPI_VERSION'] = '3.0.0'


# TODO MAKE THIS WORK!
oidc.init_app(application)


@application.route('/')
@oidc.oidc_auth('default')  # endpoint will require login
def index():
    user_session = UserSession(flask.session)
    return jsonify(access_token=user_session.access_token,
                   id_token=user_session.id_token,
                   userinfo=user_session.userinfo)


if __name__ == "__main__":
    logger.debug('Starting application...')
    logger.debug("Blueprints: {}".format(application.blueprints.items()))
    application.run(config.REST_URL, config.REST_PORT, threaded=True, debug=True)

```

auth:
```python
from flask import jsonify
from loguru import logger
from apiflask import HTTPTokenAuth, APIBlueprint

# FLASK_PYOIDC SECURITY
from flask_pyoidc.provider_configuration import ClientMetadata
from flask_pyoidc.provider_configuration import ProviderConfiguration
from flask_pyoidc import OIDCAuthentication

from src import config

client_metadata = ClientMetadata(client_id=config.CLIENT_ID, client_secret=config.CLIENT_SECRET,
                                 post_logout_redirect_uris=['http://localhost:10011/logout'])

provider_config = ProviderConfiguration(issuer=config.ISSUER,
                                        client_metadata=client_metadata)
oidc = OIDCAuthentication({'default': provider_config})
```

config:
```python
import os

# Application Config
ENVIRONMENT_MODE = os.getenv('ENVIRONMENT_MODE', 'production')
VERSION = os.getenv('VERSION', 'v1.0.0')
SECRET_KEY = os.getenv('SECRET_KEY', 'SUPER-SECRET')

DEVELOPMENT_SERVER = os.getenv("SERVERIP", "localhost")

# Security Config
# PROVIDER & CLIENT
OPENID_REALM = 'test'
ISSUER = os.getenv('ISSUER', f'http://localhost:28080/auth/realms/{OPENID_REALM}')
REDIRECT_URI = os.getenv('REDIRECT_URI', 'http://localhost:10011/api/v1/redirect')
CLIENT_ID = os.getenv('CLIENT_ID', 'test')
CLIENT_SECRET = os.getenv('CLIENT_SECRET', 'test')
SECURE_API = os.getenv('SECURE_API', 'True')
MAX_SESSION_TIME_MINUTES = 1440
SUPER_ADMIN_USERNAME = 'admin'

# GUNICORN CONFIG
REST_URL = os.getenv('REST_URL', '0.0.0.0')
REST_PORT = os.getenv('REST_PORT', '10012')
WEB_UI = os.getenv('WEB_UI', f'http://localhost:{REST_PORT}')
bind = "{}:{}".format(REST_URL, REST_PORT)
workers = 1
threads = 4
timeout = 120

```

Error message displayed when running the application and opening the api in the browser.
```bash
/home/eleazar/PycharmProjects/test-project/venv/bin/python /home/eleazar/PycharmProjects/test-project/src/main.py 
2023-02-20 14:06:55.433 | DEBUG    | __main__:<module>:33 - Starting application...
2023-02-20 14:06:55.434 | DEBUG    | __main__:<module>:34 - Blueprints: dict_items([('openapi', <Blueprint 'openapi'>)])
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on all addresses (0.0.0.0)
 * Running on http://127.0.0.1:10012
 * Running on http://192.168.1.32:10012
Press CTRL+C to quit
 * Restarting with stat
 * Serving Flask app 'main'
 * Debug mode: on
2023-02-20 14:06:55.905 | DEBUG    | __main__:<module>:33 - Starting application...
2023-02-20 14:06:55.905 | DEBUG    | __main__:<module>:34 - Blueprints: dict_items([('openapi', <Blueprint 'openapi'>)])
 * Debugger is active!
 * Debugger PIN: 230-173-614
127.0.0.1 - - [20/Feb/2023 14:06:58] "GET /swagger/ HTTP/1.1" 200 -
127.0.0.1 - - [20/Feb/2023 14:06:58] "GET /openapi.json HTTP/1.1" 500 -
Traceback (most recent call last):
  File "/home/eleazar/PycharmProjects/test-project/venv/lib/python3.8/site-packages/flask/app.py", line 2551, in __call__
    return self.wsgi_app(environ, start_response)
  File "/home/eleazar/PycharmProjects/test-project/venv/lib/python3.8/site-packages/flask/app.py", line 2531, in wsgi_app
    response = self.handle_exception(e)
  File "/home/eleazar/PycharmProjects/test-project/venv/lib/python3.8/site-packages/flask_cors/extension.py", line 165, in wrapped_function
    return cors_after_request(app.make_response(f(*args, **kwargs)))
  File "/home/eleazar/PycharmProjects/test-project/venv/lib/python3.8/site-packages/flask/app.py", line 2528, in wsgi_app
    response = self.full_dispatch_request()
  File "/home/eleazar/PycharmProjects/test-project/venv/lib/python3.8/site-packages/flask/app.py", line 1825, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/home/eleazar/PycharmProjects/test-project/venv/lib/python3.8/site-packages/flask_cors/extension.py", line 165, in wrapped_function
    return cors_after_request(app.make_response(f(*args, **kwargs)))
  File "/home/eleazar/PycharmProjects/test-project/venv/lib/python3.8/site-packages/flask/app.py", line 1823, in full_dispatch_request
    rv = self.dispatch_request()
  File "/home/eleazar/PycharmProjects/test-project/venv/lib/python3.8/site-packages/apiflask/app.py", line 446, in dispatch_request
    return view_function(*req.view_args.values())  # type: ignore
  File "/home/eleazar/PycharmProjects/test-project/venv/lib/python3.8/site-packages/apiflask/app.py", line 601, in spec
    response = jsonify(self._get_spec('json'))
  File "/home/eleazar/PycharmProjects/test-project/venv/lib/python3.8/site-packages/apiflask/app.py", line 687, in _get_spec
    self._spec = self._generate_spec().to_dict()
  File "/home/eleazar/PycharmProjects/test-project/venv/lib/python3.8/site-packages/apiflask/app.py", line 938, in _generate_spec
    view_func._spec = {'response': default_response}
AttributeError: 'method' object has no attribute '_spec'

```

Am I making a mistake? If there is a way to do this that I am not currently able to see, please show me how.
</div>

Pull request description:
Bypass OpenAPI spec generation for view methods
Some Flask extensions use add_url_rule to create routes from methods.

Related PR:

- https://github.com/apiflask/apiflask/pull/375
- https://github.com/apiflask/apiflask/pull/347

<!--
For features and bug fixes, before opening a PR, please open an issue describing
the bug or feature the PR will address. You can skip this step if it's a typo fix.

Replace this comment with a description of the change. Describe how it
addresses the linked issue.
-->

<!--
Link to relevant issues or previous PRs, one per line. Use "fixes" to
automatically close an issue.
-->

fixes #406

<!--
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 an entry in `CHANGES.md` summarizing the change and linking to the issue.
- [x] Run `pytest` and `tox`, no tests failed.


Patch:
diff --git a/CHANGES.md b/CHANGES.md
index c6586a1..0392a6c 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -2,6 +2,10 @@
 
 Released: -
 
+- Bypass OpenAPI spec generation for view methods ([issue #406][issue_406]).
+
+[issue_406]: https://github.com/apiflask/apiflask/issues/406
+
 
 ## Version 1.2.2
 
diff --git a/src/apiflask/app.py b/src/apiflask/app.py
index c780126..b0d481c 100644
--- a/src/apiflask/app.py
+++ b/src/apiflask/app.py
@@ -1,3 +1,4 @@
+import inspect
 import json
 import re
 import sys
@@ -934,7 +935,7 @@ class APIFlask(APIScaffold, Flask):
                         continue
             # add a default 200 response for bare views
             if not hasattr(view_func, '_spec'):
-                if self.config['AUTO_200_RESPONSE']:
+                if not inspect.ismethod(view_func) and self.config['AUTO_200_RESPONSE']:
                     view_func._spec = {'response': default_response}
                 else:
                     continue  # pragma: no cover


Test patch:
diff --git a/tests/test_openapi_basic.py b/tests/test_openapi_basic.py
index d247b4d..6dbcd46 100644
--- a/tests/test_openapi_basic.py
+++ b/tests/test_openapi_basic.py
@@ -74,6 +74,18 @@ def test_spec_bypass_endpoints(app):
     assert '/docs/oauth2-redirect' not in spec['paths']
 
 
+def test_spec_bypass_methods(app):
+
+    class Foo:
+        def bar(self):
+            pass
+
+    app.add_url_rule('/foo', 'foo', Foo().bar)
+
+    spec = app._get_spec()
+    assert '/foo' not in spec['paths']
+
+
 def test_spec_attribute(app):
     spec = app._get_spec()