From ca70b16ff5e5f9c1a321c629db771968d94e8f0d Mon Sep 17 00:00:00 2001 From: Robbe Sneyders Date: Tue, 22 Mar 2022 22:26:41 +0100 Subject: [PATCH 01/20] Drop aiohttp support (#1491) --- ARCHITECTURE.rst | 3 +- connexion/__init__.py | 8 - connexion/apis/abstract.py | 38 +- connexion/apis/aiohttp_api.py | 447 ------------------ connexion/apis/flask_api.py | 2 - connexion/apps/aiohttp_app.py | 99 ---- connexion/cli.py | 10 - connexion/security/__init__.py | 5 - .../aiohttp_security_handler_factory.py | 39 -- docs/quickstart.rst | 9 - .../enforcedefaults_aiohttp/README.rst | 16 - .../enforcedefaults-api.yaml | 39 -- .../enforcedefaults.py | 56 --- .../openapi3/helloworld_aiohttp/README.rst | 11 - examples/openapi3/helloworld_aiohttp/hello.py | 14 - .../openapi/helloworld-api.yaml | 30 -- .../openapi3/reverseproxy_aiohttp/README.rst | 58 --- examples/openapi3/reverseproxy_aiohttp/app.py | 82 ---- .../openapi3/reverseproxy_aiohttp/nginx.conf | 38 -- .../reverseproxy_aiohttp/openapi.yaml | 18 - setup.py | 10 - tests/aiohttp/test_aiohttp_api_secure.py | 161 ------- tests/aiohttp/test_aiohttp_app.py | 160 ------- tests/aiohttp/test_aiohttp_datetime.py | 49 -- tests/aiohttp/test_aiohttp_errors.py | 137 ------ tests/aiohttp/test_aiohttp_multipart.py | 118 ----- tests/aiohttp/test_aiohttp_reverse_proxy.py | 129 ----- tests/aiohttp/test_aiohttp_simple_api.py | 377 --------------- tests/aiohttp/test_get_response.py | 172 ------- tests/conftest.py | 10 - tests/fakeapi/aiohttp_handlers.py | 153 ------ tests/fakeapi/aiohttp_handlers_async_def.py | 5 - tests/fixtures/aiohttp/datetime_support.yaml | 60 --- .../aiohttp/openapi_empty_base_path.yaml | 28 -- tests/fixtures/aiohttp/openapi_multipart.yaml | 132 ------ tests/fixtures/aiohttp/openapi_secure.yaml | 99 ---- tests/fixtures/aiohttp/openapi_simple.yaml | 35 -- .../aiohttp/swagger_empty_base_path.yaml | 29 -- tests/fixtures/aiohttp/swagger_secure.yaml | 40 -- tests/fixtures/aiohttp/swagger_simple.yaml | 203 -------- .../aiohttp/swagger_simple_async_def.yaml | 21 - tests/test_cli.py | 40 -- tox.ini | 8 +- 43 files changed, 8 insertions(+), 3190 deletions(-) delete mode 100644 connexion/apis/aiohttp_api.py delete mode 100644 connexion/apps/aiohttp_app.py delete mode 100644 connexion/security/aiohttp_security_handler_factory.py delete mode 100644 examples/openapi3/enforcedefaults_aiohttp/README.rst delete mode 100644 examples/openapi3/enforcedefaults_aiohttp/enforcedefaults-api.yaml delete mode 100755 examples/openapi3/enforcedefaults_aiohttp/enforcedefaults.py delete mode 100644 examples/openapi3/helloworld_aiohttp/README.rst delete mode 100755 examples/openapi3/helloworld_aiohttp/hello.py delete mode 100644 examples/openapi3/helloworld_aiohttp/openapi/helloworld-api.yaml delete mode 100644 examples/openapi3/reverseproxy_aiohttp/README.rst delete mode 100755 examples/openapi3/reverseproxy_aiohttp/app.py delete mode 100644 examples/openapi3/reverseproxy_aiohttp/nginx.conf delete mode 100644 examples/openapi3/reverseproxy_aiohttp/openapi.yaml delete mode 100644 tests/aiohttp/test_aiohttp_api_secure.py delete mode 100644 tests/aiohttp/test_aiohttp_app.py delete mode 100644 tests/aiohttp/test_aiohttp_datetime.py delete mode 100644 tests/aiohttp/test_aiohttp_errors.py delete mode 100644 tests/aiohttp/test_aiohttp_multipart.py delete mode 100644 tests/aiohttp/test_aiohttp_reverse_proxy.py delete mode 100644 tests/aiohttp/test_aiohttp_simple_api.py delete mode 100644 tests/aiohttp/test_get_response.py delete mode 100755 tests/fakeapi/aiohttp_handlers.py delete mode 100644 tests/fakeapi/aiohttp_handlers_async_def.py delete mode 100644 tests/fixtures/aiohttp/datetime_support.yaml delete mode 100644 tests/fixtures/aiohttp/openapi_empty_base_path.yaml delete mode 100644 tests/fixtures/aiohttp/openapi_multipart.yaml delete mode 100644 tests/fixtures/aiohttp/openapi_secure.yaml delete mode 100644 tests/fixtures/aiohttp/openapi_simple.yaml delete mode 100644 tests/fixtures/aiohttp/swagger_empty_base_path.yaml delete mode 100644 tests/fixtures/aiohttp/swagger_secure.yaml delete mode 100644 tests/fixtures/aiohttp/swagger_simple.yaml delete mode 100644 tests/fixtures/aiohttp/swagger_simple_async_def.yaml diff --git a/ARCHITECTURE.rst b/ARCHITECTURE.rst index 9cf6a0704..f14a35fed 100644 --- a/ARCHITECTURE.rst +++ b/ARCHITECTURE.rst @@ -11,8 +11,7 @@ This document describes the high-level architecture of Connexion. Apps ---- -A Connexion ``App`` or application wraps a specific framework application (currently Flask or -AioHttp) and exposes a standardized interface for users to create and configure their Connexion +A Connexion ``App`` or application wraps a specific framework application (currently Flask) and exposes a standardized interface for users to create and configure their Connexion application. While a Connexion app implements the WSGI interface, it only acts ass a pass-through and doesn't diff --git a/connexion/__init__.py b/connexion/__init__.py index 789eff529..db5741d01 100755 --- a/connexion/__init__.py +++ b/connexion/__init__.py @@ -38,13 +38,5 @@ App = FlaskApp Api = FlaskApi -try: - from .apis.aiohttp_api import AioHttpApi - from .apps.aiohttp_app import AioHttpApp -except ImportError as e: # pragma: no cover - _aiohttp_not_installed_error = not_installed_error(e) - AioHttpApi = _aiohttp_not_installed_error - AioHttpApp = _aiohttp_not_installed_error - # This version is replaced during release process. __version__ = '2020.0.dev1' diff --git a/connexion/apis/abstract.py b/connexion/apis/abstract.py index 8027d7524..6c0b4fe00 100644 --- a/connexion/apis/abstract.py +++ b/connexion/apis/abstract.py @@ -7,7 +7,6 @@ import pathlib import sys import typing as t -import warnings from enum import Enum from ..decorators.produces import NoContent @@ -19,7 +18,6 @@ from ..options import ConnexionOptions from ..resolver import Resolver from ..spec import Specification -from ..utils import is_json_mimetype MODULE_PATH = pathlib.Path(__file__).absolute().parent.parent SWAGGER_UI_URL = 'ui' @@ -256,7 +254,6 @@ def get_response(self, response, mimetype=None, request=None): """ This method converts a handler response to a framework response. This method should just retrieve response from handler then call `cls._get_response`. - It is mainly here to handle AioHttp async handler. :param response: A response to cast (tuple, framework response, etc). :param mimetype: The response mimetype. :type mimetype: Union[None, str] @@ -348,18 +345,7 @@ def _response_from_handler( def get_connexion_response(cls, response, mimetype=None): """ Cast framework dependent response to ConnexionResponse used for schema validation """ if isinstance(response, ConnexionResponse): - # If body in ConnexionResponse is not byte, it may not pass schema validation. - # In this case, rebuild response with aiohttp to have consistency - if response.body is None or isinstance(response.body, bytes): - return response - else: - response = cls._build_response( - data=response.body, - mimetype=mimetype, - content_type=response.content_type, - headers=response.headers, - status_code=response.status_code - ) + return response if not cls._is_framework_response(response): response = cls._response_from_handler(response, mimetype) @@ -430,27 +416,9 @@ def _prepare_body_and_status_code(cls, data, mimetype, status_code=None, extra_c return body, status_code, mimetype @classmethod + @abc.abstractmethod def _serialize_data(cls, data, mimetype): - # TODO: Harmonize with flask_api. Currently this is the backwards compatible with aiohttp_api._cast_body. - if not isinstance(data, bytes): - if isinstance(mimetype, str) and is_json_mimetype(mimetype): - body = cls.jsonifier.dumps(data) - elif isinstance(data, str): - body = data - else: - warnings.warn( - "Implicit (aiohttp) serialization with str() will change in the next major version. " - "This is triggered because a non-JSON response body is being stringified. " - "This will be replaced by something that is mimetype-specific and may " - "serialize some things as JSON or throw an error instead of silently " - "stringifying unknown response bodies. " - "Please make sure to specify media/mime types in your specs.", - FutureWarning # a Deprecation targeted at application users. - ) - body = str(data) - else: - body = data - return body, mimetype + pass def json_loads(self, data): return self.jsonifier.loads(data) diff --git a/connexion/apis/aiohttp_api.py b/connexion/apis/aiohttp_api.py deleted file mode 100644 index 7462cfc72..000000000 --- a/connexion/apis/aiohttp_api.py +++ /dev/null @@ -1,447 +0,0 @@ -""" -This module defines an AioHttp Connexion API which implements translations between AioHttp and -Connexion requests / responses. -""" - -import asyncio -import logging -import re -import traceback -from contextlib import suppress -from http import HTTPStatus -from urllib.parse import parse_qs - -import aiohttp_jinja2 -import jinja2 -from aiohttp import web -from aiohttp.web_exceptions import HTTPNotFound, HTTPPermanentRedirect -from aiohttp.web_middlewares import normalize_path_middleware -from werkzeug.exceptions import HTTPException as werkzeug_HTTPException - -from connexion.apis.abstract import AbstractAPI -from connexion.exceptions import ProblemException -from connexion.handlers import AuthErrorHandler -from connexion.jsonifier import JSONEncoder, Jsonifier -from connexion.lifecycle import ConnexionRequest, ConnexionResponse -from connexion.problem import problem -from connexion.security import AioHttpSecurityHandlerFactory -from connexion.utils import yamldumper - -logger = logging.getLogger('connexion.apis.aiohttp_api') - - -def _generic_problem(http_status: HTTPStatus, exc: Exception = None): - extra = None - if exc is not None: - loop = asyncio.get_event_loop() - if loop.get_debug(): - tb = None - with suppress(Exception): - tb = traceback.format_exc() - if tb: - extra = {"traceback": tb} - - return problem( - status=http_status.value, - title=http_status.phrase, - detail=http_status.description, - ext=extra, - ) - - -@web.middleware -async def problems_middleware(request, handler): - try: - response = await handler(request) - except ProblemException as exc: - response = problem(status=exc.status, detail=exc.detail, title=exc.title, - type=exc.type, instance=exc.instance, headers=exc.headers, ext=exc.ext) - except (werkzeug_HTTPException, _HttpNotFoundError) as exc: - response = problem(status=exc.code, title=exc.name, detail=exc.description) - except web.HTTPError as exc: - if exc.text == f"{exc.status}: {exc.reason}": - detail = HTTPStatus(exc.status).description - else: - detail = exc.text - response = problem(status=exc.status, title=exc.reason, detail=detail) - except ( - web.HTTPException, # eg raised HTTPRedirection or HTTPSuccessful - asyncio.CancelledError, # skipped in default web_protocol - ): - # leave this to default handling in aiohttp.web_protocol.RequestHandler.start() - raise - except asyncio.TimeoutError as exc: - # overrides 504 from aiohttp.web_protocol.RequestHandler.start() - logger.debug('Request handler timed out.', exc_info=exc) - response = _generic_problem(HTTPStatus.GATEWAY_TIMEOUT, exc) - except Exception as exc: - # overrides 500 from aiohttp.web_protocol.RequestHandler.start() - logger.exception('Error handling request', exc_info=exc) - response = _generic_problem(HTTPStatus.INTERNAL_SERVER_ERROR, exc) - - if isinstance(response, ConnexionResponse): - response = await AioHttpApi.get_response(response) - return response - - -class AioHttpApi(AbstractAPI): - def __init__(self, *args, **kwargs): - # NOTE we use HTTPPermanentRedirect (308) because - # clients sometimes turn POST requests into GET requests - # on 301, 302, or 303 - # see https://tools.ietf.org/html/rfc7538 - trailing_slash_redirect = normalize_path_middleware( - append_slash=True, - redirect_class=HTTPPermanentRedirect - ) - self.subapp = web.Application( - middlewares=[ - problems_middleware, - trailing_slash_redirect - ] - ) - AbstractAPI.__init__(self, *args, **kwargs) - - aiohttp_jinja2.setup( - self.subapp, - loader=jinja2.FileSystemLoader( - str(self.options.openapi_console_ui_from_dir) - ) - ) - middlewares = self.options.as_dict().get('middlewares', []) - self.subapp.middlewares.extend(middlewares) - - @staticmethod - def make_security_handler_factory(pass_context_arg_name): - """ Create default SecurityHandlerFactory to create all security check handlers """ - return AioHttpSecurityHandlerFactory(pass_context_arg_name) - - def _set_base_path(self, base_path): - AbstractAPI._set_base_path(self, base_path) - self._api_name = AioHttpApi.normalize_string(self.base_path) - - @staticmethod - def normalize_string(string): - return re.sub(r'[^a-zA-Z0-9]', '_', string.strip('/')) - - def _base_path_for_prefix(self, request): - """ - returns a modified basePath which includes the incoming request's - path prefix. - """ - base_path = self.base_path - if not request.path.startswith(self.base_path): - prefix = request.path.split(self.base_path)[0] - base_path = prefix + base_path - return base_path - - def _spec_for_prefix(self, request): - """ - returns a spec with a modified basePath / servers block - which corresponds to the incoming request path. - This is needed when behind a path-altering reverse proxy. - """ - base_path = self._base_path_for_prefix(request) - return self.specification.with_base_path(base_path).raw - - def add_openapi_json(self): - """ - Adds openapi json to {base_path}/openapi.json - (or {base_path}/swagger.json for swagger2) - """ - logger.debug('Adding spec json: %s/%s', self.base_path, - self.options.openapi_spec_path) - self.subapp.router.add_route( - 'GET', - self.options.openapi_spec_path, - self._get_openapi_json - ) - - def add_openapi_yaml(self): - """ - Adds openapi json to {base_path}/openapi.json - (or {base_path}/swagger.json for swagger2) - """ - if not self.options.openapi_spec_path.endswith("json"): - return - - openapi_spec_path_yaml = \ - self.options.openapi_spec_path[:-len("json")] + "yaml" - logger.debug('Adding spec yaml: %s/%s', self.base_path, - openapi_spec_path_yaml) - self.subapp.router.add_route( - 'GET', - openapi_spec_path_yaml, - self._get_openapi_yaml - ) - - async def _get_openapi_json(self, request): - return web.Response( - status=200, - content_type='application/json', - body=self.jsonifier.dumps(self._spec_for_prefix(request)) - ) - - async def _get_openapi_yaml(self, request): - return web.Response( - status=200, - content_type='text/yaml', - body=yamldumper(self._spec_for_prefix(request)) - ) - - def add_swagger_ui(self): - """ - Adds swagger ui to {base_path}/ui/ - """ - console_ui_path = self.options.openapi_console_ui_path.strip().rstrip('/') - logger.debug('Adding swagger-ui: %s%s/', - self.base_path, - console_ui_path) - - for path in ( - console_ui_path + '/', - console_ui_path + '/index.html', - ): - self.subapp.router.add_route( - 'GET', - path, - self._get_swagger_ui_home - ) - - if self.options.openapi_console_ui_config is not None: - self.subapp.router.add_route( - 'GET', - console_ui_path + '/swagger-ui-config.json', - self._get_swagger_ui_config - ) - - # we have to add an explicit redirect instead of relying on the - # normalize_path_middleware because we also serve static files - # from this dir (below) - - async def redirect(request): - raise web.HTTPMovedPermanently( - location=self.base_path + console_ui_path + '/' - ) - - self.subapp.router.add_route( - 'GET', - console_ui_path, - redirect - ) - - # this route will match and get a permission error when trying to - # serve index.html, so we add the redirect above. - self.subapp.router.add_static( - console_ui_path, - path=str(self.options.openapi_console_ui_from_dir), - name='swagger_ui_static' - ) - - @aiohttp_jinja2.template('index.j2') - async def _get_swagger_ui_home(self, req): - base_path = self._base_path_for_prefix(req) - template_variables = { - 'openapi_spec_url': (base_path + self.options.openapi_spec_path), - **self.options.openapi_console_ui_index_template_variables, - } - if self.options.openapi_console_ui_config is not None: - template_variables['configUrl'] = 'swagger-ui-config.json' - return template_variables - - async def _get_swagger_ui_config(self, req): - return web.Response( - status=200, - content_type='text/json', - body=self.jsonifier.dumps(self.options.openapi_console_ui_config) - ) - - def add_auth_on_not_found(self, security, security_definitions): - """ - Adds a 404 error handler to authenticate and only expose the 404 status if the security validation pass. - """ - logger.debug('Adding path not found authentication') - not_found_error = AuthErrorHandler( - self, _HttpNotFoundError(), - security=security, - security_definitions=security_definitions - ) - endpoint_name = f"{self._api_name}_not_found" - self.subapp.router.add_route( - '*', - '/{not_found_path}', - not_found_error.function, - name=endpoint_name - ) - - def _add_operation_internal(self, method, path, operation): - method = method.upper() - operation_id = operation.operation_id or path - - logger.debug('... Adding %s -> %s', method, operation_id, - extra=vars(operation)) - - handler = operation.function - endpoint_name = '{}_{}_{}'.format( - self._api_name, - AioHttpApi.normalize_string(path), - method.lower() - ) - self.subapp.router.add_route( - method, path, handler, name=endpoint_name - ) - - if not path.endswith('/'): - self.subapp.router.add_route( - method, path + '/', handler, name=endpoint_name + '_' - ) - - @classmethod - async def get_request(cls, req): - """Convert aiohttp request to connexion - - :param req: instance of aiohttp.web.Request - :return: connexion request instance - :rtype: ConnexionRequest - """ - url = str(req.url) - - logger.debug( - 'Getting data and status code', - extra={ - # has_body | can_read_body report if - # body has been read or not - # body_exists refers to underlying stream of data - 'body_exists': req.body_exists, - 'can_read_body': req.can_read_body, - 'content_type': req.content_type, - 'url': url, - }, - ) - - query = parse_qs(req.rel_url.query_string) - headers = req.headers - body = None - - # Note: if request is not 'application/x-www-form-urlencoded' nor 'multipart/form-data', - # then `post_data` will be left an empty dict and the stream will not be consumed. - post_data = await req.post() - - files = {} - form = {} - - if post_data: - logger.debug('Reading multipart data from request') - for k, v in post_data.items(): - if isinstance(v, web.FileField): - if k in files: - # if multiple files arrive under the same name in the - # request, downstream requires that we put them all into - # a list under the same key in the files dict. - if isinstance(files[k], list): - files[k].append(v) - else: - files[k] = [files[k], v] - else: - files[k] = v - else: - # put normal fields as an array, that's how werkzeug does that for Flask - # and that's what Connexion expects in its processing functions - form[k] = [v] - body = b'' - else: - logger.debug('Reading data from request') - body = await req.read() - - return ConnexionRequest(url=url, - method=req.method.lower(), - path_params=dict(req.match_info), - query=query, - headers=headers, - body=body, - json_getter=lambda: cls.jsonifier.loads(body), - form=form, - files=files, - context=req, - cookies=req.cookies) - - @classmethod - async def get_response(cls, response, mimetype=None, request=None): - """Get response. - This method is used in the lifecycle decorators - - :type response: aiohttp.web.StreamResponse | (Any,) | (Any, int) | (Any, dict) | (Any, int, dict) - :rtype: aiohttp.web.Response - """ - while asyncio.iscoroutine(response): - response = await response - - url = str(request.url) if request else '' - - return cls._get_response(response, mimetype=mimetype, extra_context={"url": url}) - - @classmethod - def _is_framework_response(cls, response): - """ Return True if `response` is a framework response class """ - return isinstance(response, web.StreamResponse) - - @classmethod - def _framework_to_connexion_response(cls, response, mimetype): - """ Cast framework response class to ConnexionResponse used for schema validation """ - body = None - if hasattr(response, "body"): # StreamResponse and FileResponse don't have body - body = response.body - return ConnexionResponse( - status_code=response.status, - mimetype=mimetype, - content_type=response.content_type, - headers=response.headers, - body=body - ) - - @classmethod - def _connexion_to_framework_response(cls, response, mimetype, extra_context=None): - """ Cast ConnexionResponse to framework response class """ - return cls._build_response( - mimetype=response.mimetype or mimetype, - status_code=response.status_code, - content_type=response.content_type, - headers=response.headers, - data=response.body, - extra_context=extra_context, - ) - - @classmethod - def _build_response(cls, data, mimetype, content_type=None, headers=None, status_code=None, extra_context=None): - if cls._is_framework_response(data): - raise TypeError("Cannot return web.StreamResponse in tuple. Only raw data can be returned in tuple.") - - data, status_code, serialized_mimetype = cls._prepare_body_and_status_code(data=data, mimetype=mimetype, status_code=status_code, extra_context=extra_context) - - if isinstance(data, str): - text = data - body = None - else: - text = None - body = data - - content_type = content_type or mimetype or serialized_mimetype - return web.Response(body=body, text=text, headers=headers, status=status_code, content_type=content_type) - - @classmethod - def _set_jsonifier(cls): - cls.jsonifier = Jsonifier(cls=JSONEncoder) - - -class _HttpNotFoundError(HTTPNotFound): - def __init__(self): - self.name = 'Not Found' - self.description = ( - 'The requested URL was not found on the server. ' - 'If you entered the URL manually please check your spelling and ' - 'try again.' - ) - self.code = type(self).status_code - self.empty_body = True - - HTTPNotFound.__init__(self, reason=self.name) diff --git a/connexion/apis/flask_api.py b/connexion/apis/flask_api.py index 70803e97f..6207bf4e0 100644 --- a/connexion/apis/flask_api.py +++ b/connexion/apis/flask_api.py @@ -199,8 +199,6 @@ def _build_response(cls, mimetype, content_type=None, headers=None, status_code= @classmethod def _serialize_data(cls, data, mimetype): - # TODO: harmonize flask and aiohttp serialization when mimetype=None or mimetype is not JSON - # (cases where it might not make sense to jsonify the data) if (isinstance(mimetype, str) and is_json_mimetype(mimetype)): body = cls.jsonifier.dumps(data) elif not (isinstance(data, bytes) or isinstance(data, str)): diff --git a/connexion/apps/aiohttp_app.py b/connexion/apps/aiohttp_app.py deleted file mode 100644 index b179ce0b5..000000000 --- a/connexion/apps/aiohttp_app.py +++ /dev/null @@ -1,99 +0,0 @@ -""" -This module defines an AioHttpApp, a Connexion application to wrap an AioHttp application. -""" - -import logging -import pathlib -import pkgutil -import sys - -from aiohttp import web - -from ..apis.aiohttp_api import AioHttpApi -from ..exceptions import ConnexionException -from .abstract import AbstractApp - -logger = logging.getLogger('connexion.aiohttp_app') - - -class AioHttpApp(AbstractApp): - - def __init__(self, import_name, only_one_api=False, **kwargs): - super().__init__(import_name, AioHttpApi, server='aiohttp', **kwargs) - self._only_one_api = only_one_api - self._api_added = False - - def create_app(self): - return web.Application(**self.server_args) - - def get_root_path(self): - mod = sys.modules.get(self.import_name) - if mod is not None and hasattr(mod, '__file__'): - return pathlib.Path(mod.__file__).resolve().parent - - loader = pkgutil.get_loader(self.import_name) - filepath = None - - if hasattr(loader, 'get_filename'): - filepath = loader.get_filename(self.import_name) - - if filepath is None: - raise RuntimeError(f"Invalid import name '{self.import_name}'") - - return pathlib.Path(filepath).resolve().parent - - def set_errors_handlers(self): - pass - - def add_api(self, specification, **kwargs): - if self._only_one_api: - if self._api_added: - raise ConnexionException( - "an api was already added, " - "create a new app with 'only_one_api=False' " - "to add more than one api" - ) - else: - self.app = self._get_api(specification, kwargs).subapp - self._api_added = True - return self.app - - api = self._get_api(specification, kwargs) - try: - self.app.add_subapp(api.base_path, api.subapp) - except ValueError: - raise ConnexionException( - "aiohttp doesn't allow to set empty base_path ('/'), " - "use non-empty instead, e.g /api" - ) - - return api - - def _get_api(self, specification, kwargs): - return super().add_api(specification, **kwargs) - - def run(self, port=None, server=None, debug=None, host=None, **options): - if port is not None: - self.port = port - elif self.port is None: - self.port = 5000 - - self.server = server or self.server - self.host = host or self.host or '0.0.0.0' - - if debug is not None: - self.debug = debug - - logger.debug('Starting %s HTTP server..', self.server, extra=vars(self)) - - if self.server == 'aiohttp': - logger.info('Listening on %s:%s..', self.host, self.port) - - access_log = options.pop('access_log', None) - - if options.pop('use_default_access_log', None): - access_log = logger - - web.run_app(self.app, port=self.port, host=self.host, access_log=access_log, **options) - else: - raise Exception(f'Server {self.server} not recognized') diff --git a/connexion/cli.py b/connexion/cli.py index 9ad894791..ab0b998a7 100644 --- a/connexion/cli.py +++ b/connexion/cli.py @@ -16,20 +16,16 @@ logger = logging.getLogger('connexion.cli') CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) FLASK_APP = 'flask' -AIOHTTP_APP = 'aiohttp' AVAILABLE_SERVERS = { 'flask': [FLASK_APP], 'gevent': [FLASK_APP], 'tornado': [FLASK_APP], - 'aiohttp': [AIOHTTP_APP] } AVAILABLE_APPS = { FLASK_APP: 'connexion.apps.flask_app.FlaskApp', - AIOHTTP_APP: 'connexion.apps.aiohttp_app.AioHttpApp' } DEFAULT_SERVERS = { FLASK_APP: FLASK_APP, - AIOHTTP_APP: AIOHTTP_APP } @@ -153,12 +149,6 @@ def run(spec_file, ) raise click.UsageError(message) - if app_framework == AIOHTTP_APP: - try: - import aiohttp # NOQA - except Exception: - fatal_error('aiohttp library is not installed') - logging_level = logging.WARN if verbose > 0: logging_level = logging.INFO diff --git a/connexion/security/__init__.py b/connexion/security/__init__.py index 2404ea47c..7d3325937 100644 --- a/connexion/security/__init__.py +++ b/connexion/security/__init__.py @@ -16,8 +16,3 @@ from .flask_security_handler_factory import FlaskSecurityHandlerFactory except ImportError as err: # pragma: no cover FlaskSecurityHandlerFactory = not_installed_error(err) - -try: - from .aiohttp_security_handler_factory import AioHttpSecurityHandlerFactory -except ImportError as err: # pragma: no cover - AioHttpSecurityHandlerFactory = not_installed_error(err) diff --git a/connexion/security/aiohttp_security_handler_factory.py b/connexion/security/aiohttp_security_handler_factory.py deleted file mode 100644 index 6568f646c..000000000 --- a/connexion/security/aiohttp_security_handler_factory.py +++ /dev/null @@ -1,39 +0,0 @@ -""" -This module defines an aiohttp-specific SecurityHandlerFactory. -""" - -import logging - -import aiohttp - -from .async_security_handler_factory import AbstractAsyncSecurityHandlerFactory - -logger = logging.getLogger('connexion.api.security') - - -class AioHttpSecurityHandlerFactory(AbstractAsyncSecurityHandlerFactory): - def __init__(self, pass_context_arg_name): - super().__init__(pass_context_arg_name=pass_context_arg_name) - self.client_session = None - - def get_token_info_remote(self, token_info_url): - """ - Return a function which will call `token_info_url` to retrieve token info. - - Returned function must accept oauth token in parameter. - It must return a token_info dict in case of success, None otherwise. - - :param token_info_url: Url to get information about the token - :type token_info_url: str - :rtype: types.FunctionType - """ - async def wrapper(token): - if not self.client_session: - # Must be created in a coroutine - self.client_session = aiohttp.ClientSession() - headers = {'Authorization': f'Bearer {token}'} - token_request = await self.client_session.get(token_info_url, headers=headers, timeout=5) - if token_request.status != 200: - return None - return token_request.json() - return wrapper diff --git a/docs/quickstart.rst b/docs/quickstart.rst index bcc7ffb96..ec8a4b986 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -96,15 +96,6 @@ to ``tornado`` or ``gevent``: app = connexion.FlaskApp(__name__, port = 8080, specification_dir='openapi/', server='tornado') -Connexion has the ``aiohttp`` framework as server backend too: - -.. code-block:: python - - import connexion - - app = connexion.AioHttpApp(__name__, port = 8080, specification_dir='openapi/') - - .. _Jinja2: http://jinja.pocoo.org/ .. _Tornado: http://www.tornadoweb.org/en/stable/ .. _gevent: http://www.gevent.org/ diff --git a/examples/openapi3/enforcedefaults_aiohttp/README.rst b/examples/openapi3/enforcedefaults_aiohttp/README.rst deleted file mode 100644 index 906316116..000000000 --- a/examples/openapi3/enforcedefaults_aiohttp/README.rst +++ /dev/null @@ -1,16 +0,0 @@ -======================== -Custom Validator Example -======================== - -In this example we fill-in non-provided properties with their defaults. -Validator code is based on example from `python-jsonschema docs`_. - -Running: - -.. code-block:: bash - - $ ./enforcedefaults.py - -Now open your browser and go to http://localhost:8080/v1/ui/ to see the Swagger -UI. If you send a ``POST`` request with empty body ``{}``, you should receive -echo with defaults filled-in. diff --git a/examples/openapi3/enforcedefaults_aiohttp/enforcedefaults-api.yaml b/examples/openapi3/enforcedefaults_aiohttp/enforcedefaults-api.yaml deleted file mode 100644 index 02d00ebca..000000000 --- a/examples/openapi3/enforcedefaults_aiohttp/enforcedefaults-api.yaml +++ /dev/null @@ -1,39 +0,0 @@ -openapi: '3.0.0' -info: - version: '1' - title: Custom Validator Example -servers: - - url: http://localhost:8080/{basePath} - variables: - basePath: - default: api -paths: - /echo: - post: - description: Echo passed data - operationId: enforcedefaults.echo - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Data' - responses: - '200': - description: Data with defaults filled in by validator - default: - description: Unexpected error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' -components: - schemas: - Data: - type: object - properties: - foo: - type: string - default: foo - Error: - type: string diff --git a/examples/openapi3/enforcedefaults_aiohttp/enforcedefaults.py b/examples/openapi3/enforcedefaults_aiohttp/enforcedefaults.py deleted file mode 100755 index cedfcc184..000000000 --- a/examples/openapi3/enforcedefaults_aiohttp/enforcedefaults.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 - -import connexion -import jsonschema -import six -from connexion.decorators.validation import RequestBodyValidator -from connexion.json_schema import Draft4RequestValidator - - -async def echo(body): - return body - - -# via https://python-jsonschema.readthedocs.io/ -def extend_with_set_default(validator_class): - validate_properties = validator_class.VALIDATORS['properties'] - - def set_defaults(validator, properties, instance, schema): - for property, subschema in six.iteritems(properties): - if 'default' in subschema: - instance.setdefault(property, subschema['default']) - - for error in validate_properties( - validator, properties, instance, schema): - yield error - - return jsonschema.validators.extend( - validator_class, {'properties': set_defaults}) - -DefaultsEnforcingDraft4Validator = extend_with_set_default(Draft4RequestValidator) - - -class DefaultsEnforcingRequestBodyValidator(RequestBodyValidator): - def __init__(self, *args, **kwargs): - super(DefaultsEnforcingRequestBodyValidator, self).__init__( - *args, validator=DefaultsEnforcingDraft4Validator, **kwargs) - - -validator_map = { - 'body': DefaultsEnforcingRequestBodyValidator -} - - -if __name__ == '__main__': - app = connexion.AioHttpApp( - __name__, - port=8080, - specification_dir='.', - options={'swagger_ui': True} - ) - app.add_api( - 'enforcedefaults-api.yaml', - arguments={'title': 'Hello World Example'}, - validator_map=validator_map, - ) - app.run() diff --git a/examples/openapi3/helloworld_aiohttp/README.rst b/examples/openapi3/helloworld_aiohttp/README.rst deleted file mode 100644 index b154571d2..000000000 --- a/examples/openapi3/helloworld_aiohttp/README.rst +++ /dev/null @@ -1,11 +0,0 @@ -=================== -Hello World Example -=================== - -Running: - -.. code-block:: bash - - $ ./hello.py - -Now open your browser and go to http://localhost:9090/v1.0/ui/ to see the Swagger UI. diff --git a/examples/openapi3/helloworld_aiohttp/hello.py b/examples/openapi3/helloworld_aiohttp/hello.py deleted file mode 100755 index c7e2e020e..000000000 --- a/examples/openapi3/helloworld_aiohttp/hello.py +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env python3 - -import connexion -from aiohttp import web - - -async def post_greeting(name): - return web.Response(text=f'Hello {name}') - - -if __name__ == '__main__': - app = connexion.AioHttpApp(__name__, port=9090, specification_dir='openapi/') - app.add_api('helloworld-api.yaml', arguments={'title': 'Hello World Example'}) - app.run() diff --git a/examples/openapi3/helloworld_aiohttp/openapi/helloworld-api.yaml b/examples/openapi3/helloworld_aiohttp/openapi/helloworld-api.yaml deleted file mode 100644 index 214dd151d..000000000 --- a/examples/openapi3/helloworld_aiohttp/openapi/helloworld-api.yaml +++ /dev/null @@ -1,30 +0,0 @@ -openapi: "3.0.0" - -info: - title: Hello World - version: "1.0" -servers: - - url: http://localhost:9090/v1.0 - -paths: - /greeting/{name}: - post: - summary: Generate greeting - description: Generates a greeting message. - operationId: hello.post_greeting - responses: - 200: - description: greeting response - content: - text/plain: - schema: - type: string - example: "hello dave!" - parameters: - - name: name - in: path - description: Name of the person to greet. - required: true - schema: - type: string - example: "dave" diff --git a/examples/openapi3/reverseproxy_aiohttp/README.rst b/examples/openapi3/reverseproxy_aiohttp/README.rst deleted file mode 100644 index 37107cf7c..000000000 --- a/examples/openapi3/reverseproxy_aiohttp/README.rst +++ /dev/null @@ -1,58 +0,0 @@ -===================== -Reverse Proxy Example -===================== - -This example demonstrates how to run a connexion application behind a path-altering reverse proxy. - -You can either set the path in your app, or set the ``X-Forwarded-Path`` header. - -Running: - -.. code-block:: bash - - $ sudo pip3 install --upgrade connexion[swagger-ui] aiohttp-remotes - $ ./app.py - -Now open your browser and go to http://localhost:8080/reverse_proxied/ui/ to see the Swagger UI. - - -You can also use the ``X-Forwarded-Path`` header to modify the reverse proxy path. -For example: - -.. code-block:: bash - - curl -H "X-Forwarded-Path: /banana/" http://localhost:8080/openapi.json - - { - "servers" : [ - { - "url" : "banana" - } - ], - "paths" : { - "/hello" : { - "get" : { - "responses" : { - "200" : { - "description" : "hello", - "content" : { - "text/plain" : { - "schema" : { - "type" : "string" - } - } - } - } - }, - "operationId" : "app.hello", - "summary" : "say hi" - } - } - }, - "openapi" : "3.0.0", - "info" : { - "version" : "1.0", - "title" : "Path-Altering Reverse Proxy Example" - } - } - diff --git a/examples/openapi3/reverseproxy_aiohttp/app.py b/examples/openapi3/reverseproxy_aiohttp/app.py deleted file mode 100755 index 973ae1b3d..000000000 --- a/examples/openapi3/reverseproxy_aiohttp/app.py +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env python3 -''' -example of aiohttp connexion running behind a path-altering reverse-proxy -''' - -import json -import logging - -import connexion -from aiohttp import web -from aiohttp_remotes.exceptions import RemoteError, TooManyHeaders -from aiohttp_remotes.x_forwarded import XForwardedBase -from yarl import URL - -X_FORWARDED_PATH = "X-Forwarded-Path" - - -class XPathForwarded(XForwardedBase): - - def __init__(self, num=1): - self._num = num - - def get_forwarded_path(self, headers): - forwarded_host = headers.getall(X_FORWARDED_PATH, []) - if len(forwarded_host) > 1: - raise TooManyHeaders(X_FORWARDED_PATH) - return forwarded_host[0] if forwarded_host else None - - @web.middleware - async def middleware(self, request, handler): - logging.warning( - "this demo is not secure by default!! " - "You'll want to make sure these headers are coming from your proxy, " - "and not directly from users on the web!" - ) - try: - overrides = {} - headers = request.headers - - forwarded_for = self.get_forwarded_for(headers) - if forwarded_for: - overrides['remote'] = str(forwarded_for[-self._num]) - - proto = self.get_forwarded_proto(headers) - if proto: - overrides['scheme'] = proto[-self._num] - - host = self.get_forwarded_host(headers) - if host is not None: - overrides['host'] = host - - prefix = self.get_forwarded_path(headers) - if prefix is not None: - prefix = '/' + prefix.strip('/') + '/' - request_path = URL(request.path.lstrip('/')) - overrides['rel_url'] = URL(prefix).join(request_path) - - request = request.clone(**overrides) - - return await handler(request) - except RemoteError as exc: - exc.log(request) - await self.raise_error(request) - - -def hello(request): - ret = { - "host": request.host, - "scheme": request.scheme, - "path": request.path, - "_href": str(request.url) - } - return web.Response(text=json.dumps(ret), status=200) - - -if __name__ == '__main__': - app = connexion.AioHttpApp(__name__) - app.add_api('openapi.yaml', pass_context_arg_name='request') - aio = app.app - reverse_proxied = XPathForwarded() - aio.middlewares.append(reverse_proxied.middleware) - app.run(port=8080) diff --git a/examples/openapi3/reverseproxy_aiohttp/nginx.conf b/examples/openapi3/reverseproxy_aiohttp/nginx.conf deleted file mode 100644 index 98ba4cd12..000000000 --- a/examples/openapi3/reverseproxy_aiohttp/nginx.conf +++ /dev/null @@ -1,38 +0,0 @@ -worker_processes 1; -error_log stderr; -daemon off; -pid nginx.pid; - -events { - worker_connections 1024; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - sendfile on; - - keepalive_timeout 65; - - ssl_protocols TLSv1 TLSv1.1 TLSv1.2; - ssl_prefer_server_ciphers on; - access_log access.log; - server { - - listen localhost:9000; - - location /reverse_proxied/ { - # Define the location of the proxy server to send the request to - proxy_pass http://localhost:8080/; - # Add prefix header - proxy_set_header X-Forwarded-Path /reverse_proxied/; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Host $host:$server_port; - proxy_set_header X-Forwarded-Server $host; - proxy_set_header X-Forwarded-Port $server_port; - proxy_set_header X-Forwarded-Proto http; - } - - } -} diff --git a/examples/openapi3/reverseproxy_aiohttp/openapi.yaml b/examples/openapi3/reverseproxy_aiohttp/openapi.yaml deleted file mode 100644 index 90d062560..000000000 --- a/examples/openapi3/reverseproxy_aiohttp/openapi.yaml +++ /dev/null @@ -1,18 +0,0 @@ -openapi: 3.0.0 -info: - title: Path-Altering Reverse Proxy Example - version: '1.0' -servers: - - url: /api -paths: - /hello: - get: - summary: say hi - operationId: app.hello - responses: - '200': - description: hello - content: - text/plain: - schema: - type: string diff --git a/setup.py b/setup.py index 9b8d7b72e..dedef4ec3 100755 --- a/setup.py +++ b/setup.py @@ -36,11 +36,6 @@ def read_version(package): 'flask>=1.0.4,<3', 'itsdangerous>=0.24', ] -aiohttp_require = [ - 'aiohttp>=2.3.10,<4', - 'aiohttp-jinja2>=0.14.0,<2', - 'MarkupSafe>=0.23', -] tests_require = [ 'decorator>=5,<6', @@ -51,10 +46,6 @@ def read_version(package): swagger_ui_require ] -tests_require.extend(aiohttp_require) -tests_require.append('pytest-aiohttp') -tests_require.append('aiohttp-remotes') - docs_require = [ 'sphinx-autoapi==1.8.1' ] @@ -108,7 +99,6 @@ def readme(): 'tests': tests_require, 'flask': flask_require, 'swagger-ui': swagger_ui_require, - 'aiohttp': aiohttp_require, 'docs': docs_require }, cmdclass={'test': PyTest}, diff --git a/tests/aiohttp/test_aiohttp_api_secure.py b/tests/aiohttp/test_aiohttp_api_secure.py deleted file mode 100644 index 32d14f4da..000000000 --- a/tests/aiohttp/test_aiohttp_api_secure.py +++ /dev/null @@ -1,161 +0,0 @@ -import base64 -from unittest.mock import MagicMock - -import pytest -from connexion import AioHttpApp - - -class FakeAioHttpClientResponse: - def __init__(self, status_code, data): - """ - :type status_code: int - :type data: dict - """ - self.status = status_code - self.data = data - self.ok = status_code == 200 - - async def json(self): - return self.data - - -@pytest.fixture -def oauth_aiohttp_client(monkeypatch): - async def fake_get(url, params=None, headers=None, timeout=None): - """ - :type url: str - :type params: dict| None - """ - headers = headers or {} - assert url == "https://oauth.example/token_info" - token = headers.get('Authorization', 'invalid').split()[-1] - if token in ["100", "has_myscope"]: - return FakeAioHttpClientResponse(200, {"uid": "test-user", "scope": ["myscope"]}) - elif token in ["200", "has_wrongscope"]: - return FakeAioHttpClientResponse(200, {"uid": "test-user", "scope": ["wrongscope"]}) - elif token == "has_myscope_otherscope": - return FakeAioHttpClientResponse(200, {"uid": "test-user", "scope": ["myscope", "otherscope"]}) - elif token in ["300", "is_not_invalid"]: - return FakeAioHttpClientResponse(404, {}) - elif token == "has_scopes_in_scopes_with_s": - return FakeAioHttpClientResponse(200, {"uid": "test-user", "scopes": ["myscope", "otherscope"]}) - else: - raise AssertionError('Not supported test token ' + token) - - client_instance = MagicMock() - client_instance.get = fake_get - monkeypatch.setattr('aiohttp.ClientSession', MagicMock(return_value=client_instance)) - - -async def test_auth_all_paths(oauth_aiohttp_client, aiohttp_api_spec_dir, aiohttp_client): - app = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir, - debug=True, auth_all_paths=True) - app.add_api('swagger_secure.yaml') - - app_client = await aiohttp_client(app.app) - - get_inexistent_endpoint = await app_client.get( - '/v1.0/does-not-exist-valid-token', - headers={'Authorization': 'Bearer 100'} - ) - assert get_inexistent_endpoint.status == 404 - assert get_inexistent_endpoint.content_type == 'application/problem+json' - - get_inexistent_endpoint = await app_client.get( - '/v1.0/does-not-exist-no-token' - ) - assert get_inexistent_endpoint.status == 401 - assert get_inexistent_endpoint.content_type == 'application/problem+json' - - -@pytest.mark.parametrize('spec', ['swagger_secure.yaml', 'openapi_secure.yaml']) -async def test_secure_app(oauth_aiohttp_client, aiohttp_api_spec_dir, aiohttp_client, spec): - """ - Test common authentication method between Swagger 2 and OpenApi 3 - """ - app = AioHttpApp(__name__, port=5001, specification_dir=aiohttp_api_spec_dir, debug=True) - app.add_api(spec) - app_client = await aiohttp_client(app.app) - - response = await app_client.get('/v1.0/all_auth') - assert response.status == 401 - assert response.content_type == 'application/problem+json' - - response = await app_client.get('/v1.0/all_auth', headers={'Authorization': 'Bearer 100'}) - assert response.status == 200 - assert (await response.json()) == {"scope": ['myscope'], "uid": 'test-user'} - - response = await app_client.get('/v1.0/all_auth', headers={'authorization': 'Bearer 100'}) - assert response.status == 200, "Authorization header in lower case should be accepted" - assert (await response.json()) == {"scope": ['myscope'], "uid": 'test-user'} - - response = await app_client.get('/v1.0/all_auth', headers={'AUTHORIZATION': 'Bearer 100'}) - assert response.status == 200, "Authorization header in upper case should be accepted" - assert (await response.json()) == {"scope": ['myscope'], "uid": 'test-user'} - - basic_header = 'Basic ' + base64.b64encode(b'username:username').decode('ascii') - response = await app_client.get('/v1.0/all_auth', headers={'Authorization': basic_header}) - assert response.status == 200 - assert (await response.json()) == {"uid": 'username'} - - basic_header = 'Basic ' + base64.b64encode(b'username:wrong').decode('ascii') - response = await app_client.get('/v1.0/all_auth', headers={'Authorization': basic_header}) - assert response.status == 401, "Wrong password should trigger unauthorized" - assert response.content_type == 'application/problem+json' - - response = await app_client.get('/v1.0/all_auth', headers={'X-API-Key': '{"foo": "bar"}'}) - assert response.status == 200 - assert (await response.json()) == {"foo": "bar"} - - -async def test_bearer_secure(aiohttp_api_spec_dir, aiohttp_client): - """ - Test authentication method specific to OpenApi 3 - """ - app = AioHttpApp(__name__, port=5001, specification_dir=aiohttp_api_spec_dir, debug=True) - app.add_api('openapi_secure.yaml') - app_client = await aiohttp_client(app.app) - - bearer_header = 'Bearer {"scope": ["myscope"], "uid": "test-user"}' - response = await app_client.get('/v1.0/bearer_auth', headers={'Authorization': bearer_header}) - assert response.status == 200 - assert (await response.json()) == {"scope": ['myscope'], "uid": 'test-user'} - - -async def test_async_secure(aiohttp_api_spec_dir, aiohttp_client): - app = AioHttpApp(__name__, port=5001, specification_dir=aiohttp_api_spec_dir, debug=True) - app.add_api('openapi_secure.yaml', pass_context_arg_name='request') - app_client = await aiohttp_client(app.app) - - response = await app_client.get('/v1.0/async_auth') - assert response.status == 401 - assert response.content_type == 'application/problem+json' - - bearer_header = 'Bearer {"scope": ["myscope"], "uid": "test-user"}' - response = await app_client.get('/v1.0/async_auth', headers={'Authorization': bearer_header}) - assert response.status == 200 - assert (await response.json()) == {"scope": ['myscope'], "uid": 'test-user'} - - bearer_header = 'Bearer {"scope": ["myscope", "other_scope"], "uid": "test-user"}' - response = await app_client.get('/v1.0/async_auth', headers={'Authorization': bearer_header}) - assert response.status == 403, "async_scope_validation should deny access if scopes are not strictly the same" - - basic_header = 'Basic ' + base64.b64encode(b'username:username').decode('ascii') - response = await app_client.get('/v1.0/async_auth', headers={'Authorization': basic_header}) - assert response.status == 200 - assert (await response.json()) == {"uid": 'username'} - - basic_header = 'Basic ' + base64.b64encode(b'username:wrong').decode('ascii') - response = await app_client.get('/v1.0/async_auth', headers={'Authorization': basic_header}) - assert response.status == 401, "Wrong password should trigger unauthorized" - assert response.content_type == 'application/problem+json' - - response = await app_client.get('/v1.0/all_auth', headers={'X-API-Key': '{"foo": "bar"}'}) - assert response.status == 200 - assert (await response.json()) == {"foo": "bar"} - - bearer_header = 'Bearer {"scope": ["myscope"], "uid": "test-user"}' - response = await app_client.get('/v1.0/async_bearer_auth', headers={'Authorization': bearer_header}) - assert response.status == 200 - assert (await response.json()) == {"scope": ['myscope'], "uid": 'test-user'} diff --git a/tests/aiohttp/test_aiohttp_app.py b/tests/aiohttp/test_aiohttp_app.py deleted file mode 100644 index 845b63477..000000000 --- a/tests/aiohttp/test_aiohttp_app.py +++ /dev/null @@ -1,160 +0,0 @@ -import logging -import os -import pathlib -from unittest import mock - -import pytest -from connexion import AioHttpApp -from connexion.exceptions import ConnexionException - -from conftest import TEST_FOLDER - - -@pytest.fixture -def web_run_app_mock(monkeypatch): - mock_ = mock.MagicMock() - monkeypatch.setattr('connexion.apps.aiohttp_app.web.run_app', mock_) - return mock_ - - -@pytest.fixture -def sys_modules_mock(monkeypatch): - monkeypatch.setattr('connexion.apps.aiohttp_app.sys.modules', {}) - - -def test_app_run(web_run_app_mock, aiohttp_api_spec_dir): - app = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir, - debug=True) - app.run(use_default_access_log=True) - logger = logging.getLogger('connexion.aiohttp_app') - assert web_run_app_mock.call_args_list == [ - mock.call(app.app, port=5001, host='0.0.0.0', access_log=logger) - ] - - -def test_app_run_new_port(web_run_app_mock, aiohttp_api_spec_dir): - app = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir, - debug=True) - app.run(port=5002) - assert web_run_app_mock.call_args_list == [ - mock.call(app.app, port=5002, host='0.0.0.0', access_log=None) - ] - - -def test_app_run_default_port(web_run_app_mock, aiohttp_api_spec_dir): - app = AioHttpApp(__name__, - specification_dir=aiohttp_api_spec_dir, - debug=True) - app.run() - assert web_run_app_mock.call_args_list == [ - mock.call(app.app, port=5000, host='0.0.0.0', access_log=None) - ] - - -def test_app_run_debug(web_run_app_mock, aiohttp_api_spec_dir): - app = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir) - app.add_api('swagger_simple.yaml') - app.run(debug=True) - assert web_run_app_mock.call_args_list == [ - mock.call(app.app, port=5001, host='0.0.0.0', access_log=None) - ] - - -def test_app_run_access_log(web_run_app_mock, aiohttp_api_spec_dir): - app = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir, - debug=True) - logger = logging.getLogger('connexion.aiohttp_app') - app.run(access_log=logger) - assert web_run_app_mock.call_args_list == [ - mock.call(app.app, port=5001, host='0.0.0.0', access_log=logger) - ] - - -def test_app_run_server_error(web_run_app_mock, aiohttp_api_spec_dir): - app = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir) - - with pytest.raises(Exception) as exc_info: - app.run(server='other') - - assert exc_info.value.args == ('Server other not recognized',) - - -def test_app_get_root_path_return_Path(aiohttp_api_spec_dir): - app = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir) - assert isinstance(app.get_root_path(), pathlib.Path) == True - - -def test_app_get_root_path_exists(aiohttp_api_spec_dir): - app = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir) - assert app.get_root_path().exists() == True - - -def test_app_get_root_path(aiohttp_api_spec_dir): - app = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir) - root_path = app.get_root_path() - assert str(root_path).endswith(os.path.join('tests', 'aiohttp')) == True - - -def test_app_get_root_path_not_in_sys_modules(sys_modules_mock, aiohttp_api_spec_dir): - app = AioHttpApp('connexion', port=5001, - specification_dir=aiohttp_api_spec_dir) - root_path = app.get_root_path() - assert str(root_path).endswith(os.sep + 'connexion') == True - - -def test_app_get_root_path_invalid(sys_modules_mock, aiohttp_api_spec_dir): - with pytest.raises(RuntimeError) as exc_info: - AioHttpApp('error__', port=5001, - specification_dir=aiohttp_api_spec_dir) - - assert exc_info.value.args == ("Invalid import name 'error__'",) - - -def test_app_with_empty_base_path_error(aiohttp_api_spec_dir): - spec_dir = '..' / aiohttp_api_spec_dir.relative_to(TEST_FOLDER) - app = AioHttpApp(__name__, port=5001, - specification_dir=spec_dir, - debug=True) - with pytest.raises(ConnexionException) as exc_info: - app.add_api('swagger_empty_base_path.yaml') - - assert exc_info.value.args == ( - "aiohttp doesn't allow to set empty base_path ('/'), " - "use non-empty instead, e.g /api", - ) - - -def test_app_with_empty_base_path_and_only_one_api(aiohttp_api_spec_dir): - spec_dir = '..' / aiohttp_api_spec_dir.relative_to(TEST_FOLDER) - app = AioHttpApp(__name__, port=5001, - specification_dir=spec_dir, - debug=True, - only_one_api=True) - api = app.add_api('swagger_empty_base_path.yaml') - assert api is app.app - - -def test_app_add_two_apis_error_with_only_one_api(aiohttp_api_spec_dir): - spec_dir = '..' / aiohttp_api_spec_dir.relative_to(TEST_FOLDER) - app = AioHttpApp(__name__, port=5001, - specification_dir=spec_dir, - debug=True, - only_one_api=True) - app.add_api('swagger_empty_base_path.yaml') - - with pytest.raises(ConnexionException) as exc_info: - app.add_api('swagger_empty_base_path.yaml') - - assert exc_info.value.args == ( - "an api was already added, " - "create a new app with 'only_one_api=False' " - "to add more than one api", - ) diff --git a/tests/aiohttp/test_aiohttp_datetime.py b/tests/aiohttp/test_aiohttp_datetime.py deleted file mode 100644 index 6de12bf0f..000000000 --- a/tests/aiohttp/test_aiohttp_datetime.py +++ /dev/null @@ -1,49 +0,0 @@ -from connexion import AioHttpApp - -try: - import ujson as json -except ImportError: - import json - - -async def test_swagger_json(aiohttp_api_spec_dir, aiohttp_client): - """ Verify the swagger.json file is returned for default setting passed to app. """ - app = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir, - debug=True) - app.add_api('datetime_support.yaml') - - app_client = await aiohttp_client(app.app) - swagger_json = await app_client.get('/v1.0/openapi.json') - spec_data = await swagger_json.json() - - def get_value(data, path): - for part in path.split('.'): - data = data.get(part) - assert data, f"No data in part '{part}' of '{path}'" - return data - - example = get_value(spec_data, 'paths./datetime.get.responses.200.content.application/json.schema.example.value') - assert example in [ - '2000-01-23T04:56:07.000008+00:00', # PyYAML 5.3 - '2000-01-23T04:56:07.000008Z' - ] - example = get_value(spec_data, 'paths./date.get.responses.200.content.application/json.schema.example.value') - assert example == '2000-01-23' - example = get_value(spec_data, 'paths./uuid.get.responses.200.content.application/json.schema.example.value') - assert example == 'a7b8869c-5f24-4ce0-a5d1-3e44c3663aa9' - - resp = await app_client.get('/v1.0/datetime') - assert resp.status == 200 - json_data = await resp.json() - assert json_data == {'value': '2000-01-02T03:04:05.000006Z'} - - resp = await app_client.get('/v1.0/date') - assert resp.status == 200 - json_data = await resp.json() - assert json_data == {'value': '2000-01-02'} - - resp = await app_client.get('/v1.0/uuid') - assert resp.status == 200 - json_data = await resp.json() - assert json_data == {'value': 'e7ff66d0-3ec2-4c4e-bed0-6e4723c24c51'} diff --git a/tests/aiohttp/test_aiohttp_errors.py b/tests/aiohttp/test_aiohttp_errors.py deleted file mode 100644 index 4d620c6f8..000000000 --- a/tests/aiohttp/test_aiohttp_errors.py +++ /dev/null @@ -1,137 +0,0 @@ -import asyncio - -import pytest -from connexion import AioHttpApp -from connexion.apis.aiohttp_api import HTTPStatus - -import aiohttp.test_utils - - -def is_valid_problem_json(json_body): - return all(key in json_body for key in ["type", "title", "detail", "status"]) - - -@pytest.fixture -def aiohttp_app(problem_api_spec_dir): - app = AioHttpApp(__name__, port=5001, - specification_dir=problem_api_spec_dir, - debug=True) - options = {"validate_responses": True} - app.add_api('openapi.yaml', validate_responses=True, pass_context_arg_name='request_ctx', options=options) - return app - - -async def test_aiohttp_problems_404(aiohttp_app, aiohttp_client): - # TODO: This is a based on test_errors.test_errors(). That should be refactored - # so that it is parameterized for all web frameworks. - app_client = await aiohttp_client(aiohttp_app.app) # type: aiohttp.test_utils.TestClient - - greeting404 = await app_client.get('/v1.0/greeting') # type: aiohttp.ClientResponse - assert greeting404.content_type == 'application/problem+json' - assert greeting404.status == 404 - error404 = await greeting404.json() - assert is_valid_problem_json(error404) - assert error404['type'] == 'about:blank' - assert error404['title'] == 'Not Found' - assert error404['detail'] == HTTPStatus(404).description - assert error404['status'] == 404 - assert 'instance' not in error404 - - -async def test_aiohttp_problems_405(aiohttp_app, aiohttp_client): - # TODO: This is a based on test_errors.test_errors(). That should be refactored - # so that it is parameterized for all web frameworks. - app_client = await aiohttp_client(aiohttp_app.app) # type: aiohttp.test_utils.TestClient - - get_greeting = await app_client.get('/v1.0/greeting/jsantos') # type: aiohttp.ClientResponse - assert get_greeting.content_type == 'application/problem+json' - assert get_greeting.status == 405 - error405 = await get_greeting.json() - assert is_valid_problem_json(error405) - assert error405['type'] == 'about:blank' - assert error405['title'] == 'Method Not Allowed' - assert error405['detail'] == HTTPStatus(405).description - assert error405['status'] == 405 - assert 'instance' not in error405 - - -async def test_aiohttp_problems_500(aiohttp_app, aiohttp_client): - # TODO: This is a based on test_errors.test_errors(). That should be refactored - # so that it is parameterized for all web frameworks. - app_client = await aiohttp_client(aiohttp_app.app) # type: aiohttp.test_utils.TestClient - - get500 = await app_client.get('/v1.0/except') # type: aiohttp.ClientResponse - assert get500.content_type == 'application/problem+json' - assert get500.status == 500 - error500 = await get500.json() - assert is_valid_problem_json(error500) - assert error500['type'] == 'about:blank' - assert error500['title'] == 'Internal Server Error' - assert error500['detail'] == HTTPStatus(500).description - assert error500['status'] == 500 - assert 'instance' not in error500 - - -async def test_aiohttp_problems_418(aiohttp_app, aiohttp_client): - # TODO: This is a based on test_errors.test_errors(). That should be refactored - # so that it is parameterized for all web frameworks. - app_client = await aiohttp_client(aiohttp_app.app) # type: aiohttp.test_utils.TestClient - - get_problem = await app_client.get('/v1.0/problem') # type: aiohttp.ClientResponse - assert get_problem.content_type == 'application/problem+json' - assert get_problem.status == 418 - assert get_problem.headers['x-Test-Header'] == 'In Test' - error_problem = await get_problem.json() - assert is_valid_problem_json(error_problem) - assert error_problem['type'] == 'http://www.example.com/error' - assert error_problem['title'] == 'Some Error' - assert error_problem['detail'] == 'Something went wrong somewhere' - assert error_problem['status'] == 418 - assert error_problem['instance'] == 'instance1' - - -async def test_aiohttp_problems_misc(aiohttp_app, aiohttp_client): - # TODO: This is a based on test_errors.test_errors(). That should be refactored - # so that it is parameterized for all web frameworks. - app_client = await aiohttp_client(aiohttp_app.app) # type: aiohttp.test_utils.TestClient - - problematic_json = await app_client.get( - '/v1.0/json_response_with_undefined_value_to_serialize') # type: aiohttp.ClientResponse - assert problematic_json.content_type == 'application/problem+json' - assert problematic_json.status == 500 - problematic_json_body = await problematic_json.json() - assert is_valid_problem_json(problematic_json_body) - - custom_problem = await app_client.get('/v1.0/customized_problem_response') # type: aiohttp.ClientResponse - assert custom_problem.content_type == 'application/problem+json' - assert custom_problem.status == 403 - problem_body = await custom_problem.json() - assert is_valid_problem_json(problem_body) - assert 'amount' in problem_body - - problem_as_exception = await app_client.get('/v1.0/problem_exception_with_extra_args') # type: aiohttp.ClientResponse - assert problem_as_exception.content_type == "application/problem+json" - assert problem_as_exception.status == 400 - problem_as_exception_body = await problem_as_exception.json() - assert is_valid_problem_json(problem_as_exception_body) - assert 'age' in problem_as_exception_body - assert problem_as_exception_body['age'] == 30 - - -@pytest.mark.skip(reason="aiohttp_api.get_connexion_response uses _cast_body " - "to stringify the dict directly instead of using json.dumps. " - "This differs from flask usage, where there is no _cast_body.") -async def test_aiohttp_problem_with_text_content_type(aiohttp_app, aiohttp_client): - app_client = await aiohttp_client(aiohttp_app.app) # type: aiohttp.test_utils.TestClient - - get_problem2 = await app_client.get('/v1.0/other_problem') # type: aiohttp.ClientResponse - assert get_problem2.content_type == 'application/problem+json' - assert get_problem2.status == 418 - error_problem2 = await get_problem2.json() - assert is_valid_problem_json(error_problem2) - assert error_problem2['type'] == 'about:blank' - assert error_problem2['title'] == 'Some Error' - assert error_problem2['detail'] == 'Something went wrong somewhere' - assert error_problem2['status'] == 418 - assert error_problem2['instance'] == 'instance1' - diff --git a/tests/aiohttp/test_aiohttp_multipart.py b/tests/aiohttp/test_aiohttp_multipart.py deleted file mode 100644 index 7f80b7388..000000000 --- a/tests/aiohttp/test_aiohttp_multipart.py +++ /dev/null @@ -1,118 +0,0 @@ -import os -from pathlib import Path - -import pytest -from connexion import AioHttpApp - -import aiohttp - -try: - import ujson as json -except ImportError: - import json - - -@pytest.fixture -def aiohttp_app(aiohttp_api_spec_dir): - app = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir, - debug=True) - app.add_api( - 'openapi_multipart.yaml', - validate_responses=True, - strict_validation=True, - pythonic_params=True, - pass_context_arg_name='request_ctx', - ) - return app - - -async def test_single_file_upload(aiohttp_app, aiohttp_client): - app_client = await aiohttp_client(aiohttp_app.app) - - resp = await app_client.post( - '/v1.0/upload_file', - data=aiohttp.FormData(fields=[('myfile', open(__file__, 'rb'))])(), - ) - - data = await resp.json() - assert resp.status == 200 - assert data['fileName'] == f'{__name__}.py' - assert data['myfile_content'] == Path(__file__).read_bytes().decode('utf8') - - -async def test_many_files_upload(aiohttp_app, aiohttp_client): - app_client = await aiohttp_client(aiohttp_app.app) - - dir_name = os.path.dirname(__file__) - files_field = [ - ('myfiles', open(f'{dir_name}/{file_name}', 'rb')) \ - for file_name in sorted(os.listdir(dir_name)) if file_name.endswith('.py') - ] - - form_data = aiohttp.FormData(fields=files_field) - - resp = await app_client.post( - '/v1.0/upload_files', - data=form_data(), - ) - - data = await resp.json() - - assert resp.status == 200 - assert data['files_count'] == len(files_field) - assert data['myfiles_content'] == [ - Path(f'{dir_name}/{file_name}').read_bytes().decode('utf8') \ - for file_name in sorted(os.listdir(dir_name)) if file_name.endswith('.py') - ] - - -async def test_mixed_multipart_single_file(aiohttp_app, aiohttp_client): - app_client = await aiohttp_client(aiohttp_app.app) - - form_data = aiohttp.FormData() - form_data.add_field('dir_name', os.path.dirname(__file__)) - form_data.add_field('myfile', open(__file__, 'rb')) - - resp = await app_client.post( - '/v1.0/mixed_single_file', - data=form_data(), - ) - - data = await resp.json() - - assert resp.status == 200 - assert data['dir_name'] == os.path.dirname(__file__) - assert data['fileName'] == f'{__name__}.py' - assert data['myfile_content'] == Path(__file__).read_bytes().decode('utf8') - - - -async def test_mixed_multipart_many_files(aiohttp_app, aiohttp_client): - app_client = await aiohttp_client(aiohttp_app.app) - - dir_name = os.path.dirname(__file__) - files_field = [ - ('myfiles', open(f'{dir_name}/{file_name}', 'rb')) \ - for file_name in sorted(os.listdir(dir_name)) if file_name.endswith('.py') - ] - - form_data = aiohttp.FormData(fields=files_field) - form_data.add_field('dir_name', os.path.dirname(__file__)) - form_data.add_field('test_count', str(len(files_field))) - - resp = await app_client.post( - '/v1.0/mixed_many_files', - data=form_data(), - ) - - data = await resp.json() - - assert resp.status == 200 - assert data['dir_name'] == os.path.dirname(__file__) - assert data['test_count'] == len(files_field) - assert data['files_count'] == len(files_field) - assert data['myfiles_content'] == [ - Path(f'{dir_name}/{file_name}').read_bytes().decode('utf8') \ - for file_name in sorted(os.listdir(dir_name)) if file_name.endswith('.py') - ] diff --git a/tests/aiohttp/test_aiohttp_reverse_proxy.py b/tests/aiohttp/test_aiohttp_reverse_proxy.py deleted file mode 100644 index 42091e46f..000000000 --- a/tests/aiohttp/test_aiohttp_reverse_proxy.py +++ /dev/null @@ -1,129 +0,0 @@ -import asyncio - -from aiohttp_remotes.exceptions import RemoteError, TooManyHeaders -from aiohttp_remotes.x_forwarded import XForwardedBase -from connexion import AioHttpApp -from yarl import URL - -from aiohttp import web - -X_FORWARDED_PATH = "X-Forwarded-Path" - - -class XPathForwarded(XForwardedBase): - - def __init__(self, num=1): - self._num = num - - def get_forwarded_path(self, headers): - forwarded_host = headers.getall(X_FORWARDED_PATH, []) - if len(forwarded_host) > 1: - raise TooManyHeaders(X_FORWARDED_PATH) - return forwarded_host[0] if forwarded_host else None - - @web.middleware - async def middleware(self, request, handler): - try: - overrides = {} - headers = request.headers - - forwarded_for = self.get_forwarded_for(headers) - if forwarded_for: - overrides['remote'] = str(forwarded_for[-self._num]) - - proto = self.get_forwarded_proto(headers) - if proto: - overrides['scheme'] = proto[-self._num] - - host = self.get_forwarded_host(headers) - if host is not None: - overrides['host'] = host - - prefix = self.get_forwarded_path(headers) - if prefix is not None: - prefix = '/' + prefix.strip('/') + '/' - request_path = URL(request.path.lstrip('/')) - overrides['rel_url'] = URL(prefix).join(request_path) - - request = request.clone(**overrides) - - return await handler(request) - except RemoteError as exc: - exc.log(request) - await self.raise_error(request) - - - async def test_swagger_json_behind_proxy(simple_api_spec_dir, aiohttp_client): - """ Verify the swagger.json file is returned with base_path updated - according to X-Forwarded-Path header. """ - app = AioHttpApp(__name__, port=5001, - specification_dir=simple_api_spec_dir, - debug=True) - api = app.add_api('swagger.yaml') - - aio = app.app - reverse_proxied = XPathForwarded() - aio.middlewares.append(reverse_proxied.middleware) - - app_client = await aiohttp_client(app.app) - headers = {'X-Forwarded-Path': '/behind/proxy'} - - swagger_ui = await app_client.get('/v1.0/ui/', headers=headers) - assert swagger_ui.status == 200 - assert b'url = "/behind/proxy/v1.0/swagger.json"' in ( - await swagger_ui.read() - ) - - swagger_json = await app_client.get('/v1.0/swagger.json', headers=headers) - assert swagger_json.status == 200 - assert swagger_json.headers.get('Content-Type') == 'application/json' - json_ = await swagger_json.json() - - assert api.specification.raw['basePath'] == '/v1.0', \ - "Original specifications should not have been changed" - - assert json_.get('basePath') == '/behind/proxy/v1.0', \ - "basePath should contains original URI" - - json_['basePath'] = api.specification.raw['basePath'] - assert api.specification.raw == json_, \ - "Only basePath should have been updated" - - - async def test_openapi_json_behind_proxy(simple_api_spec_dir, aiohttp_client): - """ Verify the swagger.json file is returned with base_path updated - according to X-Forwarded-Path header. """ - app = AioHttpApp(__name__, port=5001, - specification_dir=simple_api_spec_dir, - debug=True) - - api = app.add_api('openapi.yaml') - - aio = app.app - reverse_proxied = XPathForwarded() - aio.middlewares.append(reverse_proxied.middleware) - - app_client = await aiohttp_client(app.app) - headers = {'X-Forwarded-Path': '/behind/proxy'} - - swagger_ui = await app_client.get('/v1.0/ui/', headers=headers) - assert swagger_ui.status == 200 - assert b'url: "/behind/proxy/v1.0/openapi.json"' in ( - await swagger_ui.read() - ) - - swagger_json = await app_client.get('/v1.0/openapi.json', headers=headers) - assert swagger_json.status == 200 - assert swagger_json.headers.get('Content-Type') == 'application/json' - json_ = await swagger_json.json() - - assert json_.get('servers', [{}])[0].get('url') == '/behind/proxy/v1.0', \ - "basePath should contains original URI" - - url = api.specification.raw.get('servers', [{}])[0].get('url') - assert url != '/behind/proxy/v1.0', \ - "Original specifications should not have been changed" - - json_['servers'] = api.specification.raw.get('servers') - assert api.specification.raw == json_, \ - "Only there servers block should have been updated" diff --git a/tests/aiohttp/test_aiohttp_simple_api.py b/tests/aiohttp/test_aiohttp_simple_api.py deleted file mode 100644 index c48468362..000000000 --- a/tests/aiohttp/test_aiohttp_simple_api.py +++ /dev/null @@ -1,377 +0,0 @@ -import sys - -import pytest -import yaml -from connexion import AioHttpApp - -from conftest import TEST_FOLDER - -try: - import ujson as json -except ImportError: - import json - - -@pytest.fixture -def aiohttp_app(aiohttp_api_spec_dir): - app = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir, - debug=True) - options = {"validate_responses": True} - app.add_api('swagger_simple.yaml', validate_responses=True, pass_context_arg_name='request_ctx', options=options) - return app - - -async def test_app(aiohttp_app, aiohttp_client): - # Create the app and run the test_app testcase below. - app_client = await aiohttp_client(aiohttp_app.app) - get_bye = await app_client.get('/v1.0/bye/jsantos') - assert get_bye.status == 200 - assert (await get_bye.read()) == b'Goodbye jsantos' - - -async def test_app_with_relative_path(aiohttp_api_spec_dir, aiohttp_client): - # Create the app with a relative path and run the test_app testcase below. - app = AioHttpApp(__name__, port=5001, - specification_dir='..' / - aiohttp_api_spec_dir.relative_to(TEST_FOLDER), - debug=True) - app.add_api('swagger_simple.yaml') - app_client = await aiohttp_client(app.app) - get_bye = await app_client.get('/v1.0/bye/jsantos') - assert get_bye.status == 200 - assert (await get_bye.read()) == b'Goodbye jsantos' - - -async def test_swagger_json(aiohttp_api_spec_dir, aiohttp_client): - """ Verify the swagger.json file is returned for default setting passed to app. """ - app = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir, - debug=True) - api = app.add_api('swagger_simple.yaml') - - app_client = await aiohttp_client(app.app) - swagger_json = await app_client.get('/v1.0/swagger.json') - - assert swagger_json.status == 200 - json_ = await swagger_json.json() - assert api.specification.raw == json_ - - -async def test_swagger_yaml(aiohttp_api_spec_dir, aiohttp_client): - """ Verify the swagger.yaml file is returned for default setting passed to app. """ - app = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir, - debug=True) - api = app.add_api('swagger_simple.yaml') - - app_client = await aiohttp_client(app.app) - spec_response = await app_client.get('/v1.0/swagger.yaml') - data_ = await spec_response.read() - - assert spec_response.status == 200 - assert api.specification.raw == yaml.load(data_, yaml.FullLoader) - - -async def test_no_swagger_json(aiohttp_api_spec_dir, aiohttp_client): - """ Verify the swagger.json file is not returned when set to False when creating app. """ - options = {"swagger_json": False} - app = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir, - options=options, - debug=True) - app.add_api('swagger_simple.yaml') - - app_client = await aiohttp_client(app.app) - swagger_json = await app_client.get('/v1.0/swagger.json') # type: flask.Response - assert swagger_json.status == 404 - - -async def test_no_swagger_yaml(aiohttp_api_spec_dir, aiohttp_client): - """ Verify the swagger.json file is not returned when set to False when creating app. """ - options = {"swagger_json": False} - app = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir, - options=options, - debug=True) - app.add_api('swagger_simple.yaml') - - app_client = await aiohttp_client(app.app) - spec_response = await app_client.get('/v1.0/swagger.yaml') # type: flask.Response - assert spec_response.status == 404 - - -async def test_swagger_ui(aiohttp_api_spec_dir, aiohttp_client): - app = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir, - debug=True) - app.add_api('swagger_simple.yaml') - - app_client = await aiohttp_client(app.app) - swagger_ui = await app_client.get('/v1.0/ui') - assert swagger_ui.status == 200 - assert swagger_ui.url.path == '/v1.0/ui/' - assert b'url = "/v1.0/swagger.json"' in (await swagger_ui.read()) - - swagger_ui = await app_client.get('/v1.0/ui/') - assert swagger_ui.status == 200 - assert b'url = "/v1.0/swagger.json"' in (await swagger_ui.read()) - - -async def test_swagger_ui_config_json(aiohttp_api_spec_dir, aiohttp_client): - """ Verify the swagger-ui-config.json file is returned for swagger_ui_config option passed to app. """ - swagger_ui_config = {"displayOperationId": True} - options = {"swagger_ui_config": swagger_ui_config} - app = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir, - options=options, - debug=True) - api = app.add_api('swagger_simple.yaml') - - app_client = await aiohttp_client(app.app) - swagger_ui_config_json = await app_client.get('/v1.0/ui/swagger-ui-config.json') - json_ = await swagger_ui_config_json.read() - - assert swagger_ui_config_json.status == 200 - assert swagger_ui_config == json.loads(json_) - - -async def test_no_swagger_ui_config_json(aiohttp_api_spec_dir, aiohttp_client): - """ Verify the swagger-ui-config.json file is not returned when the swagger_ui_config option not passed to app. """ - app = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir, - debug=True) - app.add_api('swagger_simple.yaml') - - app_client = await aiohttp_client(app.app) - swagger_ui_config_json = await app_client.get('/v1.0/ui/swagger-ui-config.json') - assert swagger_ui_config_json.status == 404 - - -async def test_swagger_ui_index(aiohttp_api_spec_dir, aiohttp_client): - app = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir, - debug=True) - app.add_api('openapi_secure.yaml') - - app_client = await aiohttp_client(app.app) - swagger_ui = await app_client.get('/v1.0/ui/index.html') - assert swagger_ui.status == 200 - assert b'url: "/v1.0/openapi.json"' in (await swagger_ui.read()) - assert b'swagger-ui-config.json' not in (await swagger_ui.read()) - - -async def test_swagger_ui_index_with_config(aiohttp_api_spec_dir, aiohttp_client): - swagger_ui_config = {"displayOperationId": True} - options = {"swagger_ui_config": swagger_ui_config} - app = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir, - options=options, - debug=True) - app.add_api('openapi_secure.yaml') - - app_client = await aiohttp_client(app.app) - swagger_ui = await app_client.get('/v1.0/ui/index.html') - assert swagger_ui.status == 200 - assert b'configUrl: "swagger-ui-config.json"' in (await swagger_ui.read()) - - -async def test_pythonic_path_param(aiohttp_api_spec_dir, aiohttp_client): - app = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir, - debug=True) - app.add_api('openapi_simple.yaml', pythonic_params=True) - - app_client = await aiohttp_client(app.app) - pythonic = await app_client.get('/v1.0/pythonic/100') - assert pythonic.status == 200 - j = await pythonic.json() - assert j['id_'] == 100 - - -async def test_cookie_param(aiohttp_api_spec_dir, aiohttp_client): - app = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir, - debug=True) - app.add_api('openapi_simple.yaml', pass_context_arg_name="request") - - app_client = await aiohttp_client(app.app) - response = await app_client.get('/v1.0/test-cookie-param', headers={"Cookie": "test_cookie=hello"}) - assert response.status == 200 - j = await response.json() - assert j['cookie_value'] == "hello" - - -async def test_swagger_ui_static(aiohttp_api_spec_dir, aiohttp_client): - app = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir, - debug=True) - app.add_api('swagger_simple.yaml') - - app_client = await aiohttp_client(app.app) - swagger_ui = await app_client.get('/v1.0/ui/lib/swagger-oauth.js') - assert swagger_ui.status == 200 - - app_client = await aiohttp_client(app.app) - swagger_ui = await app_client.get('/v1.0/ui/swagger-ui.min.js') - assert swagger_ui.status == 200 - - -async def test_no_swagger_ui(aiohttp_api_spec_dir, aiohttp_client): - options = {"swagger_ui": False} - app = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir, - options=options, debug=True) - app.add_api('swagger_simple.yaml') - - app_client = await aiohttp_client(app.app) - swagger_ui = await app_client.get('/v1.0/ui/') - assert swagger_ui.status == 404 - - app2 = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir, - debug=True) - options = {"swagger_ui": False} - app2.add_api('swagger_simple.yaml', options=options) - app2_client = await aiohttp_client(app.app) - swagger_ui2 = await app2_client.get('/v1.0/ui/') - assert swagger_ui2.status == 404 - - -async def test_middlewares(aiohttp_api_spec_dir, aiohttp_client): - async def middleware(app, handler): - async def middleware_handler(request): - response = (await handler(request)) - response.body += b' middleware' - return response - - return middleware_handler - - options = {"middlewares": [middleware]} - app = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir, - debug=True, options=options) - app.add_api('swagger_simple.yaml') - app_client = await aiohttp_client(app.app) - get_bye = await app_client.get('/v1.0/bye/jsantos') - assert get_bye.status == 200 - assert (await get_bye.read()) == b'Goodbye jsantos middleware' - - -async def test_response_with_str_body(aiohttp_app, aiohttp_client): - # Create the app and run the test_app testcase below. - app_client = await aiohttp_client(aiohttp_app.app) - get_bye = await app_client.get('/v1.0/aiohttp_str_response') - assert get_bye.status == 200 - assert (await get_bye.read()) == b'str response' - - -async def test_response_with_non_str_and_non_json_body(aiohttp_app, aiohttp_client): - app_client = await aiohttp_client(aiohttp_app.app) - get_bye = await app_client.get( - '/v1.0/aiohttp_non_str_non_json_response' - ) - assert get_bye.status == 200 - assert (await get_bye.read()) == b'1234' - - -async def test_response_with_bytes_body(aiohttp_app, aiohttp_client): - # Create the app and run the test_app testcase below. - app_client = await aiohttp_client(aiohttp_app.app) - get_bye = await app_client.get('/v1.0/aiohttp_bytes_response') - assert get_bye.status == 200 - assert (await get_bye.read()) == b'bytes response' - - -async def test_validate_responses(aiohttp_app, aiohttp_client): - app_client = await aiohttp_client(aiohttp_app.app) - get_bye = await app_client.get('/v1.0/aiohttp_validate_responses') - assert get_bye.status == 200 - assert (await get_bye.json()) == {"validate": True} - - -async def test_get_users(aiohttp_client, aiohttp_app): - app_client = await aiohttp_client(aiohttp_app.app) - resp = await app_client.get('/v1.0/users') - assert resp.url.path == '/v1.0/users/' # followed redirect - assert resp.status == 200 - - json_data = await resp.json() - assert json_data == \ - [{'name': 'John Doe', 'id': 1}, {'name': 'Nick Carlson', 'id': 2}] - - -async def test_create_user(aiohttp_client, aiohttp_app): - app_client = await aiohttp_client(aiohttp_app.app) - user = {'name': 'Maksim'} - resp = await app_client.post('/v1.0/users', json=user, headers={'Content-type': 'application/json'}) - assert resp.status == 201 - - -async def test_access_request_context(aiohttp_client, aiohttp_app): - app_client = await aiohttp_client(aiohttp_app.app) - resp = await app_client.post('/v1.0/aiohttp_access_request_context/') - assert resp.status == 204 - - -async def test_query_parsing_simple(aiohttp_client, aiohttp_app): - expected_query = 'query' - - app_client = await aiohttp_client(aiohttp_app.app) - resp = await app_client.get( - '/v1.0/aiohttp_query_parsing_str', - params={ - 'query': expected_query, - }, - ) - assert resp.status == 200 - - json_data = await resp.json() - assert json_data == {'query': expected_query} - - -async def test_query_parsing_array(aiohttp_client, aiohttp_app): - expected_query = ['queryA', 'queryB'] - - app_client = await aiohttp_client(aiohttp_app.app) - resp = await app_client.get( - '/v1.0/aiohttp_query_parsing_array', - params={ - 'query': ','.join(expected_query), - }, - ) - assert resp.status == 200 - - json_data = await resp.json() - assert json_data == {'query': expected_query} - - -async def test_query_parsing_array_multi(aiohttp_client, aiohttp_app): - expected_query = ['queryA', 'queryB', 'queryC'] - query_str = '&'.join(['query=%s' % q for q in expected_query]) - - app_client = await aiohttp_client(aiohttp_app.app) - resp = await app_client.get( - '/v1.0/aiohttp_query_parsing_array_multi?%s' % query_str, - ) - assert resp.status == 200 - - json_data = await resp.json() - assert json_data == {'query': expected_query} - - -if sys.version_info[0:2] >= (3, 5): - @pytest.fixture - def aiohttp_app_async_def(aiohttp_api_spec_dir): - app = AioHttpApp(__name__, port=5001, - specification_dir=aiohttp_api_spec_dir, - debug=True) - app.add_api('swagger_simple_async_def.yaml', validate_responses=True) - return app - - - async def test_validate_responses_async_def(aiohttp_app_async_def, aiohttp_client): - app_client = await aiohttp_client(aiohttp_app_async_def.app) - get_bye = await app_client.get('/v1.0/aiohttp_validate_responses') - assert get_bye.status == 200 - assert (await get_bye.read()) == b'{"validate": true}' diff --git a/tests/aiohttp/test_get_response.py b/tests/aiohttp/test_get_response.py deleted file mode 100644 index 13ccdfcb6..000000000 --- a/tests/aiohttp/test_get_response.py +++ /dev/null @@ -1,172 +0,0 @@ -import json - -import pytest -from connexion.apis.aiohttp_api import AioHttpApi -from connexion.lifecycle import ConnexionResponse - -from aiohttp import web - - -@pytest.fixture(scope='module') -def api(aiohttp_api_spec_dir): - yield AioHttpApi(specification=aiohttp_api_spec_dir / 'swagger_secure.yaml') - - -async def test_get_response_from_aiohttp_response(api): - response = await api.get_response(web.Response(text='foo', status=201, headers={'X-header': 'value'})) - assert isinstance(response, web.Response) - assert response.status == 201 - assert response.body == b'foo' - assert response.content_type == 'text/plain' - assert dict(response.headers) == {'Content-Type': 'text/plain; charset=utf-8', 'X-header': 'value'} - - -async def test_get_response_from_aiohttp_stream_response(api): - response = await api.get_response(web.StreamResponse(status=201, headers={'X-header': 'value'})) - assert isinstance(response, web.StreamResponse) - assert response.status == 201 - assert response.content_type == 'application/octet-stream' - assert dict(response.headers) == {'X-header': 'value'} - - -async def test_get_response_from_connexion_response(api): - response = await api.get_response(ConnexionResponse(status_code=201, mimetype='text/plain', body='foo', headers={'X-header': 'value'})) - assert isinstance(response, web.Response) - assert response.status == 201 - assert response.body == b'foo' - assert response.content_type == 'text/plain' - assert dict(response.headers) == {'Content-Type': 'text/plain; charset=utf-8', 'X-header': 'value'} - - -async def test_get_response_from_string(api): - response = await api.get_response('foo') - assert isinstance(response, web.Response) - assert response.status == 200 - assert response.body == b'foo' - assert response.content_type == 'text/plain' - assert dict(response.headers) == {'Content-Type': 'text/plain; charset=utf-8'} - - -async def test_get_response_from_string_tuple(api): - response = await api.get_response(('foo',)) - assert isinstance(response, web.Response) - assert response.status == 200 - assert response.body == b'foo' - assert response.content_type == 'text/plain' - assert dict(response.headers) == {'Content-Type': 'text/plain; charset=utf-8'} - - -async def test_get_response_from_string_status(api): - response = await api.get_response(('foo', 201)) - assert isinstance(response, web.Response) - assert response.status == 201 - assert response.body == b'foo' - assert response.content_type == 'text/plain' - assert dict(response.headers) == {'Content-Type': 'text/plain; charset=utf-8'} - - -async def test_get_response_from_string_headers(api): - response = await api.get_response(('foo', {'X-header': 'value'})) - assert isinstance(response, web.Response) - assert response.status == 200 - assert response.body == b'foo' - assert response.content_type == 'text/plain' - assert dict(response.headers) == {'Content-Type': 'text/plain; charset=utf-8', 'X-header': 'value'} - - -async def test_get_response_from_string_status_headers(api): - response = await api.get_response(('foo', 201, {'X-header': 'value'})) - assert isinstance(response, web.Response) - assert response.status == 201 - assert response.body == b'foo' - assert response.content_type == 'text/plain' - assert dict(response.headers) == {'Content-Type': 'text/plain; charset=utf-8', 'X-header': 'value'} - - -async def test_get_response_from_tuple_error(api): - with pytest.raises(TypeError) as e: - await api.get_response((web.Response(text='foo', status=201, headers={'X-header': 'value'}), 200)) - assert str(e.value) == "Cannot return web.StreamResponse in tuple. Only raw data can be returned in tuple." - - -async def test_get_response_from_dict(api): - response = await api.get_response({'foo': 'bar'}) - assert isinstance(response, web.Response) - assert response.status == 200 - # odd, yes. but backwards compatible. see test_response_with_non_str_and_non_json_body in tests/aiohttp/test_aiohttp_simple_api.py - # TODO: This should be made into JSON when aiohttp and flask serialization can be harmonized. - assert response.body == b"{'foo': 'bar'}" - assert response.content_type == 'text/plain' - assert dict(response.headers) == {'Content-Type': 'text/plain; charset=utf-8'} - - -async def test_get_response_from_dict_json(api): - response = await api.get_response({'foo': 'bar'}, mimetype='application/json') - assert isinstance(response, web.Response) - assert response.status == 200 - assert json.loads(response.body.decode()) == {"foo": "bar"} - assert response.content_type == 'application/json' - assert dict(response.headers) == {'Content-Type': 'application/json; charset=utf-8'} - - -async def test_get_response_no_data(api): - response = await api.get_response(None, mimetype='application/json') - assert isinstance(response, web.Response) - assert response.status == 204 - assert response.body is None - assert response.content_type == 'application/json' - assert dict(response.headers) == {'Content-Type': 'application/json'} - - -async def test_get_response_binary_json(api): - response = await api.get_response(b'{"foo":"bar"}', mimetype='application/json') - assert isinstance(response, web.Response) - assert response.status == 200 - assert json.loads(response.body.decode()) == {"foo": "bar"} - assert response.content_type == 'application/json' - assert dict(response.headers) == {'Content-Type': 'application/json'} - - -async def test_get_response_binary_no_mimetype(api): - response = await api.get_response(b'{"foo":"bar"}') - assert isinstance(response, web.Response) - assert response.status == 200 - assert response.body == b'{"foo":"bar"}' - assert response.content_type == 'application/octet-stream' - assert dict(response.headers) == {} - - -async def test_get_connexion_response_from_aiohttp_response(api): - response = api.get_connexion_response(web.Response(text='foo', status=201, headers={'X-header': 'value'})) - assert isinstance(response, ConnexionResponse) - assert response.status_code == 201 - assert response.body == b'foo' - assert response.content_type == 'text/plain' - assert dict(response.headers) == {'Content-Type': 'text/plain; charset=utf-8', 'X-header': 'value'} - - -async def test_get_connexion_response_from_connexion_response(api): - response = api.get_connexion_response(ConnexionResponse(status_code=201, content_type='text/plain', body='foo', headers={'X-header': 'value'})) - assert isinstance(response, ConnexionResponse) - assert response.status_code == 201 - assert response.body == b'foo' - assert response.content_type == 'text/plain' - assert dict(response.headers) == {'Content-Type': 'text/plain; charset=utf-8', 'X-header': 'value'} - - -async def test_get_connexion_response_from_tuple(api): - response = api.get_connexion_response(('foo', 201, {'X-header': 'value'})) - assert isinstance(response, ConnexionResponse) - assert response.status_code == 201 - assert response.body == b'foo' - assert response.content_type == 'text/plain' - assert dict(response.headers) == {'Content-Type': 'text/plain; charset=utf-8', 'X-header': 'value'} - - -async def test_get_connexion_response_from_aiohttp_stream_response(api): - response = api.get_connexion_response(web.StreamResponse(status=201, headers={'X-header': 'value'})) - assert isinstance(response, ConnexionResponse) - assert response.status_code == 201 - assert response.body == None - assert response.content_type == 'application/octet-stream' - assert dict(response.headers) == {'X-header': 'value'} diff --git a/tests/conftest.py b/tests/conftest.py index fe3ebb4ec..07368b389 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -78,11 +78,6 @@ def simple_api_spec_dir(): return FIXTURES_FOLDER / 'simple' -@pytest.fixture(scope='session') -def aiohttp_api_spec_dir(): - return FIXTURES_FOLDER / 'aiohttp' - - @pytest.fixture def problem_api_spec_dir(): return FIXTURES_FOLDER / 'problem' @@ -227,8 +222,3 @@ def bad_operations_app(request): return build_app_from_fixture('bad_operations', request.param, resolver_error=501) - -if sys.version_info < (3, 5, 3) and sys.version_info[0] == 3: - @pytest.fixture - def aiohttp_client(test_client): - return test_client diff --git a/tests/fakeapi/aiohttp_handlers.py b/tests/fakeapi/aiohttp_handlers.py deleted file mode 100755 index 98976f293..000000000 --- a/tests/fakeapi/aiohttp_handlers.py +++ /dev/null @@ -1,153 +0,0 @@ -#!/usr/bin/env python3 -import datetime -import uuid - -from connexion.lifecycle import ConnexionResponse - -import aiohttp -from aiohttp.web import Request -from aiohttp.web import Response as AioHttpResponse - - -async def get_bye(name): - return AioHttpResponse(text=f'Goodbye {name}') - - -async def aiohttp_str_response(): - return 'str response' - - -async def aiohttp_non_str_non_json_response(): - return 1234 - - -async def aiohttp_bytes_response(): - return b'bytes response' - - -async def aiohttp_validate_responses(): - return {"validate": True} - - -async def aiohttp_post_greeting(name, **kwargs): - data = {'greeting': f'Hello {name}'} - return data - -async def aiohttp_echo(**kwargs): - return aiohttp.web.json_response(data=kwargs, status=200) - - -async def aiohttp_access_request_context(request_ctx): - assert request_ctx is not None - assert isinstance(request_ctx, aiohttp.web.Request) - return None - - -async def aiohttp_query_parsing_str(query): - return {'query': query} - - -async def aiohttp_query_parsing_array(query): - return {'query': query} - - -async def aiohttp_query_parsing_array_multi(query): - return {'query': query} - - -USERS = [ - {"id": 1, "name": "John Doe"}, - {"id": 2, "name": "Nick Carlson"} -] - - -async def aiohttp_users_get(*args): - return aiohttp.web.json_response(data=USERS, status=200) - - -async def aiohttp_users_post(user): - if "name" not in user: - return ConnexionResponse(body={"error": "name is undefined"}, - status_code=400, - content_type='application/json') - user['id'] = len(USERS) + 1 - USERS.append(user) - return aiohttp.web.json_response(data=USERS[-1], status=201) - - -async def aiohttp_token_info(token_info): - return aiohttp.web.json_response(data=token_info) - - -async def aiohttp_all_auth(token_info): - return await aiohttp_token_info(token_info) - - -async def aiohttp_async_auth(token_info): - return await aiohttp_token_info(token_info) - - -async def aiohttp_bearer_auth(token_info): - return await aiohttp_token_info(token_info) - - -async def aiohttp_async_bearer_auth(token_info): - return await aiohttp_token_info(token_info) - - -async def get_datetime(): - return ConnexionResponse(body={'value': datetime.datetime(2000, 1, 2, 3, 4, 5, 6)}) - - -async def get_date(): - return ConnexionResponse(body={'value': datetime.date(2000, 1, 2)}) - - -async def get_uuid(): - return ConnexionResponse(body={'value': uuid.UUID(hex='e7ff66d0-3ec2-4c4e-bed0-6e4723c24c51')}) - - -async def aiohttp_multipart_single_file(myfile): - return aiohttp.web.json_response( - data={ - 'fileName': myfile.filename, - 'myfile_content': myfile.file.read().decode('utf8') - }, - ) - - -async def aiohttp_multipart_many_files(myfiles): - return aiohttp.web.json_response( - data={ - 'files_count': len(myfiles), - 'myfiles_content': [ f.file.read().decode('utf8') for f in myfiles ] - }, - ) - - -async def aiohttp_multipart_mixed_single_file(myfile, body): - dir_name = body['dir_name'] - return aiohttp.web.json_response( - data={ - 'dir_name': dir_name, - 'fileName': myfile.filename, - 'myfile_content': myfile.file.read().decode('utf8'), - }, - ) - - -async def aiohttp_multipart_mixed_many_files(myfiles, body): - dir_name = body['dir_name'] - test_count = body['test_count'] - return aiohttp.web.json_response( - data={ - 'files_count': len(myfiles), - 'dir_name': dir_name, - 'test_count': test_count, - 'myfiles_content': [ f.file.read().decode('utf8') for f in myfiles ] - }, - ) - - -async def test_cookie_param(request): - return {"cookie_value": request.cookies["test_cookie"]} diff --git a/tests/fakeapi/aiohttp_handlers_async_def.py b/tests/fakeapi/aiohttp_handlers_async_def.py deleted file mode 100644 index 173df3aa8..000000000 --- a/tests/fakeapi/aiohttp_handlers_async_def.py +++ /dev/null @@ -1,5 +0,0 @@ -from connexion.lifecycle import ConnexionResponse - - -async def aiohttp_validate_responses(): - return ConnexionResponse(body=b'{"validate": true}') diff --git a/tests/fixtures/aiohttp/datetime_support.yaml b/tests/fixtures/aiohttp/datetime_support.yaml deleted file mode 100644 index 2ee8f7477..000000000 --- a/tests/fixtures/aiohttp/datetime_support.yaml +++ /dev/null @@ -1,60 +0,0 @@ -openapi: "3.0.1" - -info: - title: "{{title}}" - version: "1.0" -servers: - - url: http://localhost:8080/v1.0 - -paths: - /datetime: - get: - summary: Generate data with date time - operationId: fakeapi.aiohttp_handlers.get_datetime - responses: - 200: - description: date time example - content: - application/json: - schema: - type: object - properties: - value: - type: string - format: date-time - example: - value: 2000-01-23T04:56:07.000008+00:00 - /date: - get: - summary: Generate data with date - operationId: fakeapi.aiohttp_handlers.get_date - responses: - 200: - description: date example - content: - application/json: - schema: - type: object - properties: - value: - type: string - format: date - example: - value: 2000-01-23 - /uuid: - get: - summary: Generate data with uuid - operationId: fakeapi.aiohttp_handlers.get_uuid - responses: - 200: - description: uuid handler - content: - application/json: - schema: - type: object - properties: - value: - type: string - format: uuid - example: - value: 'a7b8869c-5f24-4ce0-a5d1-3e44c3663aa9' diff --git a/tests/fixtures/aiohttp/openapi_empty_base_path.yaml b/tests/fixtures/aiohttp/openapi_empty_base_path.yaml deleted file mode 100644 index 505871c4d..000000000 --- a/tests/fixtures/aiohttp/openapi_empty_base_path.yaml +++ /dev/null @@ -1,28 +0,0 @@ -openapi: 3.0.0 -servers: - - url: / -info: - title: '{{title}}' - version: '1.0' -paths: - '/bye/{name}': - get: - summary: Generate goodbye - description: Generates a goodbye message. - operationId: fakeapi.aiohttp_handlers.get_bye - responses: - '200': - description: goodbye response - content: - text/plain: - schema: - type: string - default: - description: unexpected error - parameters: - - name: name - in: path - description: Name of the person to say bye. - required: true - schema: - type: string diff --git a/tests/fixtures/aiohttp/openapi_multipart.yaml b/tests/fixtures/aiohttp/openapi_multipart.yaml deleted file mode 100644 index f4374b6a3..000000000 --- a/tests/fixtures/aiohttp/openapi_multipart.yaml +++ /dev/null @@ -1,132 +0,0 @@ ---- -openapi: 3.0.0 -servers: - - url: /v1.0 -info: - title: "{{title}}" - version: "1.0" -paths: - "/upload_file": - post: - summary: Uploads single file - description: Handles multipart file upload. - operationId: fakeapi.aiohttp_handlers.aiohttp_multipart_single_file - responses: - "200": - description: OK response - content: - 'application/json': - schema: - type: object - properties: - fileName: - type: string - default: - description: unexpected error - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object - properties: - myfile: - type: string - format: binary - "/upload_files": - post: - summary: Uploads many files - description: Handles multipart file upload. - operationId: fakeapi.aiohttp_handlers.aiohttp_multipart_many_files - responses: - "200": - description: OK response - content: - 'application/json': - schema: - type: object - properties: - files_count: - type: number - default: - description: unexpected error - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object - properties: - myfiles: - type: array - items: - type: string - format: binary - "/mixed_single_file": - post: - summary: Reads multipart data - description: Handles multipart data reading - operationId: fakeapi.aiohttp_handlers.aiohttp_multipart_mixed_single_file - responses: - "200": - description: OK response - content: - 'application/json': - schema: - type: object - properties: - dir_name: - type: string - fileName: - type: string - default: - description: unexpected error - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object - properties: - dir_name: - type: string - myfile: - type: string - format: binary - "/mixed_many_files": - post: - summary: Reads multipart data - description: Handles multipart data reading - operationId: fakeapi.aiohttp_handlers.aiohttp_multipart_mixed_many_files - responses: - "200": - description: OK response - content: - 'application/json': - schema: - type: object - properties: - dir_name: - type: string - test_count: - type: number - files_count: - type: number - default: - description: unexpected error - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object - properties: - dir_name: - type: string - test_count: - type: number - myfiles: - type: array - items: - type: string - format: binary diff --git a/tests/fixtures/aiohttp/openapi_secure.yaml b/tests/fixtures/aiohttp/openapi_secure.yaml deleted file mode 100644 index 80f08d7b8..000000000 --- a/tests/fixtures/aiohttp/openapi_secure.yaml +++ /dev/null @@ -1,99 +0,0 @@ -openapi: 3.0.0 -servers: - - url: /v1.0 -info: - title: '{{title}}' - version: '1.0' -paths: - '/all_auth': - get: - summary: Test basic and oauth auth - operationId: fakeapi.aiohttp_handlers.aiohttp_all_auth - security: - - oauth: - - myscope - - basic: [] - - api_key: [] - responses: - '200': - $ref: "#/components/responses/Success" - '/async_auth': - get: - summary: Test async auth - operationId: fakeapi.aiohttp_handlers.aiohttp_async_auth - security: - - async_oauth: - - myscope - - async_basic: [] - - async_api_key: [] - responses: - '200': - $ref: "#/components/responses/Success" - '/bearer_auth': - get: - summary: Test api key auth - operationId: fakeapi.aiohttp_handlers.aiohttp_bearer_auth - security: - - bearer: [] - responses: - '200': - $ref: "#/components/responses/Success" - '/async_bearer_auth': - get: - summary: Test api key auth - operationId: fakeapi.aiohttp_handlers.aiohttp_async_bearer_auth - security: - - async_bearer: [] - responses: - '200': - $ref: "#/components/responses/Success" -components: - responses: - Success: - description: "Operation succeed" - content: - application/json: - schema: - type: object - - securitySchemes: - oauth: - type: oauth2 - x-tokenInfoUrl: 'https://oauth.example/token_info' - flows: - password: - tokenUrl: 'https://oauth.example/token' - scopes: - myscope: can do stuff - basic: - type: http - scheme: basic - x-basicInfoFunc: fakeapi.auth.fake_basic_auth - api_key: - type: apiKey - in: header - name: X-API-Key - x-apikeyInfoFunc: fakeapi.auth.fake_json_auth - bearer: - type: http - scheme: bearer - x-bearerInfoFunc: fakeapi.auth.fake_json_auth - - async_oauth: - type: oauth2 - flows: {} - x-tokenInfoFunc: fakeapi.auth.async_json_auth - x-scopeValidateFunc: fakeapi.auth.async_scope_validation - async_basic: - type: http - scheme: basic - x-basicInfoFunc: fakeapi.auth.async_basic_auth - async_api_key: - type: apiKey - in: cookie - name: X-API-Key - x-apikeyInfoFunc: fakeapi.auth.async_json_auth - async_bearer: - type: http - scheme: bearer - x-bearerInfoFunc: fakeapi.auth.async_json_auth diff --git a/tests/fixtures/aiohttp/openapi_simple.yaml b/tests/fixtures/aiohttp/openapi_simple.yaml deleted file mode 100644 index 215aeb6f1..000000000 --- a/tests/fixtures/aiohttp/openapi_simple.yaml +++ /dev/null @@ -1,35 +0,0 @@ -openapi: 3.0.0 -servers: - - url: /v1.0 -info: - title: '{{title}}' - version: '1.0' -paths: - '/pythonic/{id}': - get: - description: test overloading pythonic snake-case and builtins - operationId: fakeapi.aiohttp_handlers.aiohttp_echo - parameters: - - name: id - description: id field - in: path - required: true - schema: - type: integer - responses: - '200': - description: ok - security: [] - /test-cookie-param: - get: - summary: Test cookie parameter support. - operationId: fakeapi.aiohttp_handlers.test_cookie_param - parameters: - - name: test_cookie - in: cookie - required: true - schema: - type: string - responses: - '200': - description: OK diff --git a/tests/fixtures/aiohttp/swagger_empty_base_path.yaml b/tests/fixtures/aiohttp/swagger_empty_base_path.yaml deleted file mode 100644 index da900ee05..000000000 --- a/tests/fixtures/aiohttp/swagger_empty_base_path.yaml +++ /dev/null @@ -1,29 +0,0 @@ -swagger: "2.0" - -info: - title: "{{title}}" - version: "1.0" - -basePath: / - -paths: - /bye/{name}: - get: - summary: Generate goodbye - description: Generates a goodbye message. - operationId: fakeapi.aiohttp_handlers.get_bye - produces: - - text/plain - responses: - '200': - description: goodbye response - schema: - type: string - default: - description: "unexpected error" - parameters: - - name: name - in: path - description: Name of the person to say bye. - required: true - type: string diff --git a/tests/fixtures/aiohttp/swagger_secure.yaml b/tests/fixtures/aiohttp/swagger_secure.yaml deleted file mode 100644 index b03c51bd3..000000000 --- a/tests/fixtures/aiohttp/swagger_secure.yaml +++ /dev/null @@ -1,40 +0,0 @@ -swagger: "2.0" - -info: - title: "{{title}}" - version: "1.0" - -basePath: /v1.0 - -securityDefinitions: - oauth: - type: oauth2 - flow: password - tokenUrl: https://oauth.example/token - x-tokenInfoUrl: https://oauth.example/token_info - scopes: - myscope: can do stuff - basic: - type: basic - x-basicInfoFunc: fakeapi.auth.fake_basic_auth - api_key: - type: apiKey - in: header - name: X-API-Key - x-apikeyInfoFunc: fakeapi.auth.fake_json_auth - -security: - - oauth: - - myscope - - basic: [] - - api_key: [] -paths: - /all_auth: - get: - summary: Test different authentication - operationId: fakeapi.aiohttp_handlers.aiohttp_token_info - responses: - 200: - description: greeting response - schema: - type: object diff --git a/tests/fixtures/aiohttp/swagger_simple.yaml b/tests/fixtures/aiohttp/swagger_simple.yaml deleted file mode 100644 index 4a471c754..000000000 --- a/tests/fixtures/aiohttp/swagger_simple.yaml +++ /dev/null @@ -1,203 +0,0 @@ -swagger: "2.0" - -info: - title: "{{title}}" - version: "1.0" - -basePath: /v1.0 - -paths: - /bye/{name}: - get: - summary: Generate goodbye - description: Generates a goodbye message. - operationId: fakeapi.aiohttp_handlers.get_bye - produces: - - text/plain - responses: - '200': - description: goodbye response - schema: - type: string - default: - description: "unexpected error" - parameters: - - name: name - in: path - description: Name of the person to say bye. - required: true - type: string - - /aiohttp_str_response: - get: - summary: Return a str response - description: Test returning a str response - operationId: fakeapi.aiohttp_handlers.aiohttp_str_response - produces: - - text/plain - responses: - 200: - description: json response - schema: - type: string - - /aiohttp_non_str_non_json_response: - get: - summary: Return a non str and non json response - description: Test returning a non str and non json response - operationId: fakeapi.aiohttp_handlers.aiohttp_non_str_non_json_response - produces: - - text/plain - responses: - 200: - description: non str non json response - - /aiohttp_bytes_response: - get: - summary: Return a bytes response - description: Test returning a bytes response - operationId: fakeapi.aiohttp_handlers.aiohttp_bytes_response - produces: - - text/plain - responses: - 200: - description: bytes response - - /aiohttp_validate_responses: - get: - summary: Return a bytes response - description: Test returning a bytes response - operationId: fakeapi.aiohttp_handlers.aiohttp_validate_responses - produces: - - application/json - responses: - 200: - description: json response - schema: - type: object - - /aiohttp_access_request_context: - post: - summary: Test request context access - description: Test request context access in handlers. - operationId: fakeapi.aiohttp_handlers.aiohttp_access_request_context - responses: - 204: - description: success no content. - - /users/: - get: - summary: Test get users - description: Get test users list - operationId: fakeapi.aiohttp_handlers.aiohttp_users_get - produces: - - application/json - responses: - 200: - description: Return users - schema: - type: array - items: - $ref: '#/definitions/User' - - post: - summary: Create a new user - description: Add new user to a list of users - operationId: fakeapi.aiohttp_handlers.aiohttp_users_post - consumes: - - application/json - parameters: - - in: body - name: user - description: The user to create - schema: - $ref: '#/definitions/User' - responses: - 201: - description: json response - schema: - $ref: '#/definitions/User' - - /aiohttp_query_parsing_str: - get: - summary: Test proper parsing of query parameters - description: Tests proper parsing - operationId: fakeapi.aiohttp_handlers.aiohttp_query_parsing_str - parameters: - - in: query - name: query - description: Simple query param - type: string - required: true - responses: - 200: - description: Query parsing result - schema: - $ref: '#/definitions/SimpleQuery' - - /aiohttp_query_parsing_array: - get: - summary: Test proper parsing of query parameters - description: Tests proper parsing - operationId: fakeapi.aiohttp_handlers.aiohttp_query_parsing_array - parameters: - - in: query - name: query - description: Array like query param - type: array - items: - type: string - required: true - responses: - 200: - description: Query parsing result - schema: - $ref: '#/definitions/MultiQuery' - - /aiohttp_query_parsing_array_multi: - get: - summary: Test proper parsing of query parameters - description: Tests proper parsing - operationId: fakeapi.aiohttp_handlers.aiohttp_query_parsing_array_multi - parameters: - - in: query - name: query - description: Array like query param - type: array - items: - type: string - collectionFormat: multi - required: true - responses: - 200: - description: Query parsing result - schema: - $ref: '#/definitions/MultiQuery' - - -definitions: - SimpleQuery: - type: object - required: - - query - properties: - query: - type: string - MultiQuery: - type: object - required: - - query - properties: - query: - type: array - items: - type: string - User: - type: object - required: - - name - properties: - id: - type: number - name: - type: string diff --git a/tests/fixtures/aiohttp/swagger_simple_async_def.yaml b/tests/fixtures/aiohttp/swagger_simple_async_def.yaml deleted file mode 100644 index 74a89b39f..000000000 --- a/tests/fixtures/aiohttp/swagger_simple_async_def.yaml +++ /dev/null @@ -1,21 +0,0 @@ -swagger: "2.0" - -info: - title: "{{title}}" - version: "1.0" - -basePath: /v1.0 - -paths: - /aiohttp_validate_responses: - get: - summary: Return a bytes response - description: Test returning a bytes response - operationId: fakeapi.aiohttp_handlers_async_def.aiohttp_validate_responses - produces: - - application/json - responses: - 200: - description: json response - schema: - type: object diff --git a/tests/test_cli.py b/tests/test_cli.py index e53c1295e..a2226a1d1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -257,23 +257,6 @@ def test_run_with_wsgi_containers(mock_app_run, spec_file): assert result.exit_code == 0 -def test_run_with_aiohttp_not_installed(mock_app_run, spec_file): - import sys - aiohttp_bkp = sys.modules.pop('aiohttp', None) - sys.modules['aiohttp'] = None - - runner = CliRunner() - - # missing aiohttp - result = runner.invoke(main, - ['run', spec_file, '-f', 'aiohttp'], - catch_exceptions=False) - sys.modules['aiohttp'] = aiohttp_bkp - - assert 'aiohttp library is not installed' in result.output - assert result.exit_code == 1 - - def test_run_with_wsgi_server_and_server_opts(mock_app_run, spec_file): runner = CliRunner() @@ -284,26 +267,3 @@ def test_run_with_wsgi_server_and_server_opts(mock_app_run, spec_file): catch_exceptions=False) assert "these options are mutually exclusive" in result.output assert result.exit_code == 2 - - -def test_run_with_incompatible_server_and_default_framework(mock_app_run, spec_file): - runner = CliRunner() - - result = runner.invoke(main, - ['run', spec_file, - '-s', 'aiohttp'], - catch_exceptions=False) - assert "Invalid server 'aiohttp' for app-framework 'flask'" in result.output - assert result.exit_code == 2 - - -def test_run_with_incompatible_server_and_framework(mock_app_run, spec_file): - runner = CliRunner() - - result = runner.invoke(main, - ['run', spec_file, - '-s', 'flask', - '-f', 'aiohttp'], - catch_exceptions=False) - assert "Invalid server 'flask' for app-framework 'aiohttp'" in result.output - assert result.exit_code == 2 diff --git a/tox.ini b/tox.ini index 8b8085af5..50c7dfb4a 100644 --- a/tox.ini +++ b/tox.ini @@ -27,11 +27,11 @@ setenv=PYTHONPATH = {toxinidir}:{toxinidir} deps=pytest commands= pip install Requirements-Builder - min: requirements-builder --level=min --extras aiohttp -o {toxworkdir}/requirements-min.txt setup.py + min: requirements-builder --level=min -o {toxworkdir}/requirements-min.txt setup.py min: pip install --upgrade -r {toxworkdir}/requirements-min.txt - pypi: requirements-builder --level=pypi --extras aiohttp -o {toxworkdir}/requirements-pypi.txt setup.py + pypi: requirements-builder --level=pypi -o {toxworkdir}/requirements-pypi.txt setup.py pypi: pip install --upgrade -r {toxworkdir}/requirements-pypi.txt - dev: requirements-builder --level=dev --extras aiohttp --req=requirements-devel.txt -o {toxworkdir}/requirements-dev.txt setup.py + dev: requirements-builder --level=dev --req=requirements-devel.txt -o {toxworkdir}/requirements-dev.txt setup.py dev: pip install --upgrade -r {toxworkdir}/requirements-dev.txt python setup.py test @@ -57,7 +57,7 @@ commands=isort --thirdparty connexion --check-only --diff . basepython=python3 deps=isort==5.9.1 changedir={toxinidir}/tests -commands=isort --thirdparty aiohttp,connexion --check-only --diff . +commands=isort --thirdparty connexion --check-only --diff . [testenv:mypy] deps= From 9d3155f1bef75b46cecb64668ed91c7d4afbcfd4 Mon Sep 17 00:00:00 2001 From: Robbe Sneyders Date: Mon, 28 Mar 2022 23:54:53 +0200 Subject: [PATCH 02/20] Add Python 3.10 in favor of 3.6 (#1494) * Add Python 3.10 in favor of 3.6 * Increase lower bound requests for Python 3.10 compatibility --- .github/workflows/pipeline.yml | 2 +- setup.py | 4 ++-- tox.ini | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 413197ef1..6d85a4c77 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.6, 3.7, 3.8, 3.9] + python-version: ['3.7', '3.8', '3.9', '3.10'] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} diff --git a/setup.py b/setup.py index dedef4ec3..67333a001 100755 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ def read_version(package): 'clickclick>=1.2,<21', 'jsonschema>=2.5.1,<5', 'PyYAML>=5.1,<7', - 'requests>=2.9.1,<3', + 'requests>=2.19.1,<3', 'inflection>=0.3.1,<0.6', 'werkzeug>=1.0,<3', 'importlib-metadata>=1 ; python_version<"3.8"', @@ -105,10 +105,10 @@ def readme(): test_suite='tests', classifiers=[ 'Programming Language :: Python', - 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', 'Operating System :: OS Independent', diff --git a/tox.ini b/tox.ini index 50c7dfb4a..197650f9b 100644 --- a/tox.ini +++ b/tox.ini @@ -5,10 +5,10 @@ rst-roles=class [tox] envlist = - {py36}-{min,pypi,dev} {py37}-{min,pypi,dev} {py38}-{min,pypi,dev} {py39}-{min,pypi,dev} + {py310}-{min,pypi,dev} isort-check isort-check-examples isort-check-tests @@ -17,10 +17,10 @@ envlist = [gh-actions] python = - 3.6: py36-min,py36-pypi 3.7: py37-min,py37-pypi 3.8: py38-min,py38-pypi - 3.9: py39-min,py39-pypi,flake8,isort-check,isort-check-examples,isort-check-tests,mypy + 3.9: py39-min,py39-pypi + 3.10: py310-min,py310-pypi,flake8,isort-check,isort-check-examples,isort-check-tests,mypy [testenv] setenv=PYTHONPATH = {toxinidir}:{toxinidir} From 895d3d475aec60d6dbb5d62fdc833895559f32af Mon Sep 17 00:00:00 2001 From: Robbe Sneyders Date: Thu, 31 Mar 2022 19:24:44 +0200 Subject: [PATCH 03/20] Add empty connexion middleware (#1502) * Add empty connexion middleware * Address PR comments * Bump minimum Flask version to 2 * Update tests for changed werkzeug behavior in 2.1 https://github.com/pallets/werkzeug/issues/2352 --- connexion/apps/abstract.py | 16 ++++----- connexion/apps/flask_app.py | 20 ++++++++++- connexion/middleware/__init__.py | 1 + connexion/middleware/main.py | 61 ++++++++++++++++++++++++++++++++ setup.py | 9 ++--- tests/api/test_errors.py | 8 ++--- tests/api/test_headers.py | 6 ++-- tests/conftest.py | 33 +++++++++++++++++ tests/fakeapi/hello/__init__.py | 4 +-- 9 files changed, 136 insertions(+), 22 deletions(-) create mode 100644 connexion/middleware/__init__.py create mode 100644 connexion/middleware/main.py diff --git a/connexion/apps/abstract.py b/connexion/apps/abstract.py index 13e9bd8cf..3be47a361 100644 --- a/connexion/apps/abstract.py +++ b/connexion/apps/abstract.py @@ -55,6 +55,7 @@ def __init__(self, import_name, api_cls, port=None, specification_dir='', self.server = server self.server_args = dict() if server_args is None else server_args self.app = self.create_app() + self._apply_middleware() # we get our application root path to avoid duplicating logic self.root_path = self.get_root_path() @@ -78,6 +79,12 @@ def create_app(self): Creates the user framework application """ + @abc.abstractmethod + def _apply_middleware(self): + """ + Apply middleware to application + """ + @abc.abstractmethod def get_root_path(self): """ @@ -243,12 +250,3 @@ def run(self, port=None, server=None, debug=None, host=None, **options): # prag :type debug: bool :param options: options to be forwarded to the underlying server """ - - def __call__(self, environ, start_response): # pragma: no cover - """ - Makes the class callable to be WSGI-compliant. As Flask is used to handle requests, - this is a passthrough-call to the Flask callable class. - This is an abstraction to avoid directly referencing the app attribute from outside the - class and protect it from unwanted modification. - """ - return self.app(environ, start_response) diff --git a/connexion/apps/flask_app.py b/connexion/apps/flask_app.py index 737689a2c..0071b6cc1 100644 --- a/connexion/apps/flask_app.py +++ b/connexion/apps/flask_app.py @@ -8,12 +8,14 @@ from decimal import Decimal from types import FunctionType # NOQA +import a2wsgi import flask import werkzeug.exceptions from flask import json, signals from ..apis.flask_api import FlaskApi from ..exceptions import ProblemException +from ..middleware import ConnexionMiddleware from ..problem import problem from .abstract import AbstractApp @@ -28,8 +30,10 @@ def __init__(self, import_name, server='flask', extra_files=None, **kwargs): See :class:`~connexion.AbstractApp` for additional parameters. """ - super().__init__(import_name, FlaskApi, server=server, **kwargs) self.extra_files = extra_files or [] + self.middleware = None + + super().__init__(import_name, FlaskApi, server=server, **kwargs) def create_app(self): app = flask.Flask(self.import_name, **self.server_args) @@ -38,6 +42,14 @@ def create_app(self): app.url_map.converters['int'] = IntegerConverter return app + def _apply_middleware(self): + middlewares = [*ConnexionMiddleware.default_middlewares, + a2wsgi.WSGIMiddleware] + self.middleware = ConnexionMiddleware(self.app.wsgi_app, middlewares=middlewares) + + # Wrap with ASGI to WSGI middleware for usage with development server and test client + self.app.wsgi_app = a2wsgi.ASGIMiddleware(self.middleware) + def get_root_path(self): return pathlib.Path(self.app.root_path) @@ -147,6 +159,12 @@ def run(self, else: raise Exception(f'Server {self.server} not recognized') + def __call__(self, scope, receive, send): # pragma: no cover + """ + ASGI interface. Calls the middleware wrapped around the wsgi app. + """ + return self.middleware(scope, receive, send) + class FlaskJSONEncoder(json.JSONEncoder): def default(self, o): diff --git a/connexion/middleware/__init__.py b/connexion/middleware/__init__.py new file mode 100644 index 000000000..961969df4 --- /dev/null +++ b/connexion/middleware/__init__.py @@ -0,0 +1 @@ +from .main import ConnexionMiddleware # NOQA diff --git a/connexion/middleware/main.py b/connexion/middleware/main.py new file mode 100644 index 000000000..3cc7533bb --- /dev/null +++ b/connexion/middleware/main.py @@ -0,0 +1,61 @@ +import pathlib +import typing as t + +from starlette.types import ASGIApp, Receive, Scope, Send + + +class ConnexionMiddleware: + + default_middlewares = [ + ] + + def __init__( + self, + app: ASGIApp, + middlewares: t.Optional[t.List[t.Type[ASGIApp]]] = None + ): + """High level Connexion middleware that manages a list o middlewares wrapped around an + application. + + :param app: App to wrap middleware around. + :param middlewares: List of middlewares to wrap around app. The list should be ordered + from outer to inner middleware. + """ + if middlewares is None: + middlewares = self.default_middlewares + self.app, self.apps = self._apply_middlewares(app, middlewares) + + self._routing_middleware = None + + @staticmethod + def _apply_middlewares(app: ASGIApp, middlewares: t.List[t.Type[ASGIApp]]) \ + -> t.Tuple[ASGIApp, t.Iterable[ASGIApp]]: + """Apply all middlewares to the provided app. + + :param app: App to wrap in middlewares. + :param middlewares: List of middlewares to wrap around app. The list should be ordered + from outer to inner middleware. + + :return: App with all middlewares applied. + """ + apps = [] + for middleware in reversed(middlewares): + app = middleware(app) + apps.append(app) + return app, reversed(apps) + + def add_api( + self, + specification: t.Union[pathlib.Path, str, dict], + base_path: t.Optional[str] = None, + arguments: t.Optional[dict] = None, + ) -> None: + """Add an API to the underlying routing middleware based on a OpenAPI spec. + + :param specification: OpenAPI spec as dict or path to file. + :param base_path: Base path where to add this API. + :param arguments: Jinja arguments to replace in the spec. + """ + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + await self.app(scope, receive, send) diff --git a/setup.py b/setup.py index 67333a001..c7ec3e007 100755 --- a/setup.py +++ b/setup.py @@ -23,18 +23,19 @@ def read_version(package): 'clickclick>=1.2,<21', 'jsonschema>=2.5.1,<5', 'PyYAML>=5.1,<7', - 'requests>=2.19.1,<3', + 'requests>=2.27,<3', 'inflection>=0.3.1,<0.6', - 'werkzeug>=1.0,<3', + 'werkzeug>=2,<3', 'importlib-metadata>=1 ; python_version<"3.8"', 'packaging>=20', + 'starlette>=0.15,<1', ] swagger_ui_require = 'swagger-ui-bundle>=0.0.2,<0.1' flask_require = [ - 'flask>=1.0.4,<3', - 'itsdangerous>=0.24', + 'flask>=2,<3', + 'a2wsgi>=1.1,<2', ] tests_require = [ diff --git a/tests/api/test_errors.py b/tests/api/test_errors.py index 1ad2a8f82..6798c1028 100644 --- a/tests/api/test_errors.py +++ b/tests/api/test_errors.py @@ -44,23 +44,23 @@ def test_errors(problem_app): get_problem = app_client.get('/v1.0/problem') # type: flask.Response assert get_problem.content_type == 'application/problem+json' - assert get_problem.status_code == 418 + assert get_problem.status_code == 402 assert get_problem.headers['x-Test-Header'] == 'In Test' error_problem = json.loads(get_problem.data.decode('utf-8', 'replace')) assert error_problem['type'] == 'http://www.example.com/error' assert error_problem['title'] == 'Some Error' assert error_problem['detail'] == 'Something went wrong somewhere' - assert error_problem['status'] == 418 + assert error_problem['status'] == 402 assert error_problem['instance'] == 'instance1' get_problem2 = app_client.get('/v1.0/other_problem') # type: flask.Response assert get_problem2.content_type == 'application/problem+json' - assert get_problem2.status_code == 418 + assert get_problem2.status_code == 402 error_problem2 = json.loads(get_problem2.data.decode('utf-8', 'replace')) assert error_problem2['type'] == 'about:blank' assert error_problem2['title'] == 'Some Error' assert error_problem2['detail'] == 'Something went wrong somewhere' - assert error_problem2['status'] == 418 + assert error_problem2['status'] == 402 assert error_problem2['instance'] == 'instance1' problematic_json = app_client.get( diff --git a/tests/api/test_headers.py b/tests/api/test_headers.py index 0eb64ed63..87cdafae2 100644 --- a/tests/api/test_headers.py +++ b/tests/api/test_headers.py @@ -6,7 +6,8 @@ def test_headers_jsonifier(simple_app): response = app_client.post('/v1.0/goodday/dan', data={}) # type: flask.Response assert response.status_code == 201 - assert response.headers["Location"] == "http://localhost/my/uri" + # Default Werkzeug behavior was changed in 2.1 (https://github.com/pallets/werkzeug/issues/2352) + assert response.headers["Location"] in ["http://localhost/my/uri", "/my/uri"] def test_headers_produces(simple_app): @@ -14,7 +15,8 @@ def test_headers_produces(simple_app): response = app_client.post('/v1.0/goodevening/dan', data={}) # type: flask.Response assert response.status_code == 201 - assert response.headers["Location"] == "http://localhost/my/uri" + # Default Werkzeug behavior was changed in 2.1 (https://github.com/pallets/werkzeug/issues/2352) + assert response.headers["Location"] in ["http://localhost/my/uri", "/my/uri"] def test_header_not_returned(simple_openapi_app): diff --git a/tests/conftest.py b/tests/conftest.py index 07368b389..8144c760d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,7 @@ import pytest from connexion import App from connexion.security import FlaskSecurityHandlerFactory +from werkzeug.test import Client, EnvironBuilder logging.basicConfig(level=logging.DEBUG) @@ -31,6 +32,38 @@ def json(self): return json.loads(self.text) +def fixed_get_environ(): + """See https://github.com/pallets/werkzeug/issues/2347""" + + original_get_environ = EnvironBuilder.get_environ + + def f(self): + result = original_get_environ(self) + result.pop("HTTP_CONTENT_TYPE", None) + result.pop("HTTP_CONTENT_LENGTH", None) + return result + + return f + + +EnvironBuilder.get_environ = fixed_get_environ() + + +def buffered_open(): + """For use with ASGI middleware""" + + original_open = Client.open + + def f(*args, **kwargs): + kwargs["buffered"] = True + return original_open(*args, **kwargs) + + return f + + +Client.open = buffered_open() + + # Helper fixtures functions # ========================= diff --git a/tests/fakeapi/hello/__init__.py b/tests/fakeapi/hello/__init__.py index ba1824215..fdf76969c 100644 --- a/tests/fakeapi/hello/__init__.py +++ b/tests/fakeapi/hello/__init__.py @@ -96,7 +96,7 @@ def with_problem(): raise ProblemException(type='http://www.example.com/error', title='Some Error', detail='Something went wrong somewhere', - status=418, + status=402, instance='instance1', headers={'x-Test-Header': 'In Test'}) @@ -104,7 +104,7 @@ def with_problem(): def with_problem_txt(): raise ProblemException(title='Some Error', detail='Something went wrong somewhere', - status=418, + status=402, instance='instance1') From 41c19c11272b2878ca58a4b2a6c44b67e3d10c8c Mon Sep 17 00:00:00 2001 From: Robbe Sneyders Date: Sun, 10 Apr 2022 17:15:27 +0200 Subject: [PATCH 04/20] Extract Swagger UI functionality into middleware (#1496) * Extract swagger UI functionality from AbstractAPI * Extract Swagger UI functionality into middleware Co-authored-by: Wojciech Paciorek * Add additional docstrings Co-authored-by: Wojciech Paciorek --- connexion/apis/__init__.py | 2 +- connexion/apis/abstract.py | 111 ++++++++++----- connexion/apis/flask_api.py | 138 +------------------ connexion/apps/abstract.py | 18 ++- connexion/apps/flask_app.py | 7 +- connexion/middleware/__init__.py | 1 + connexion/middleware/base.py | 10 ++ connexion/middleware/main.py | 12 +- connexion/middleware/swagger_ui.py | 211 +++++++++++++++++++++++++++++ tests/api/test_responses.py | 2 +- 10 files changed, 335 insertions(+), 177 deletions(-) create mode 100644 connexion/middleware/base.py create mode 100644 connexion/middleware/swagger_ui.py diff --git a/connexion/apis/__init__.py b/connexion/apis/__init__.py index b1a7553d5..e7d0778bd 100644 --- a/connexion/apis/__init__.py +++ b/connexion/apis/__init__.py @@ -13,4 +13,4 @@ """ -from .abstract import AbstractAPI # NOQA +from .abstract import AbstractAPI, AbstractSwaggerUIAPI # NOQA diff --git a/connexion/apis/abstract.py b/connexion/apis/abstract.py index 6c0b4fe00..186456325 100644 --- a/connexion/apis/abstract.py +++ b/connexion/apis/abstract.py @@ -32,7 +32,84 @@ def __init__(cls, name, bases, attrs): cls._set_jsonifier() -class AbstractAPI(metaclass=AbstractAPIMeta): +class AbstractSpecAPI(metaclass=AbstractAPIMeta): + + def __init__( + self, + specification: t.Union[pathlib.Path, str, dict], + base_path: t.Optional[str] = None, + arguments: t.Optional[dict] = None, + options: t.Optional[dict] = None, + *args, + **kwargs + ): + """Base API class with only minimal behavior related to the specification.""" + logger.debug('Loading specification: %s', specification, + extra={'swagger_yaml': specification, + 'base_path': base_path, + 'arguments': arguments}) + + # Avoid validator having ability to modify specification + self.specification = Specification.load(specification, arguments=arguments) + + logger.debug('Read specification', extra={'spec': self.specification}) + + self.options = ConnexionOptions(options, oas_version=self.specification.version) + + logger.debug('Options Loaded', + extra={'swagger_ui': self.options.openapi_console_ui_available, + 'swagger_path': self.options.openapi_console_ui_from_dir, + 'swagger_url': self.options.openapi_console_ui_path}) + + self._set_base_path(base_path) + + def _set_base_path(self, base_path: t.Optional[str] = None) -> None: + if base_path is not None: + # update spec to include user-provided base_path + self.specification.base_path = base_path + self.base_path = base_path + else: + self.base_path = self.specification.base_path + + @classmethod + def _set_jsonifier(cls): + cls.jsonifier = Jsonifier() + + +class AbstractSwaggerUIAPI(AbstractSpecAPI): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if self.options.openapi_spec_available: + self.add_openapi_json() + self.add_openapi_yaml() + + if self.options.openapi_console_ui_available: + self.add_swagger_ui() + + @abc.abstractmethod + def add_openapi_json(self): + """ + Adds openapi spec to {base_path}/openapi.json + (or {base_path}/swagger.json for swagger2) + """ + + @abc.abstractmethod + def add_openapi_yaml(self): + """ + Adds openapi spec to {base_path}/openapi.yaml + (or {base_path}/swagger.yaml for swagger2) + """ + + @abc.abstractmethod + def add_swagger_ui(self): + """ + Adds swagger ui to {base_path}/ui/ + """ + + +class AbstractAPI(AbstractSpecAPI): """ Defines an abstract interface for a Swagger API """ @@ -107,12 +184,7 @@ def __init__(self, specification, base_path=None, arguments=None, self.security_handler_factory = self.make_security_handler_factory(pass_context_arg_name) - if self.options.openapi_spec_available: - self.add_openapi_json() - self.add_openapi_yaml() - - if self.options.openapi_console_ui_available: - self.add_swagger_ui() + super().__init__(specification, base_path=base_path, arguments=arguments, options=options) self.add_paths() @@ -122,27 +194,6 @@ def __init__(self, specification, base_path=None, arguments=None, self.specification.security_definitions ) - def _set_base_path(self, base_path=None): - if base_path is not None: - # update spec to include user-provided base_path - self.specification.base_path = base_path - self.base_path = base_path - else: - self.base_path = self.specification.base_path - - @abc.abstractmethod - def add_openapi_json(self): - """ - Adds openapi spec to {base_path}/openapi.json - (or {base_path}/swagger.json for swagger2) - """ - - @abc.abstractmethod - def add_swagger_ui(self): - """ - Adds swagger ui to {base_path}/ui/ - """ - @abc.abstractmethod def add_auth_on_not_found(self, security, security_definitions): """ @@ -422,7 +473,3 @@ def _serialize_data(cls, data, mimetype): def json_loads(self, data): return self.jsonifier.loads(data) - - @classmethod - def _set_jsonifier(cls): - cls.jsonifier = Jsonifier() diff --git a/connexion/apis/flask_api.py b/connexion/apis/flask_api.py index 6207bf4e0..eee16dcdf 100644 --- a/connexion/apis/flask_api.py +++ b/connexion/apis/flask_api.py @@ -4,7 +4,6 @@ """ import logging -import pathlib import warnings from typing import Any @@ -18,7 +17,7 @@ from connexion.jsonifier import Jsonifier from connexion.lifecycle import ConnexionRequest, ConnexionResponse from connexion.security import FlaskSecurityHandlerFactory -from connexion.utils import is_json_mimetype, yamldumper +from connexion.utils import is_json_mimetype logger = logging.getLogger('connexion.apis.flask_api') @@ -40,72 +39,6 @@ def _set_blueprint(self): self.blueprint = flask.Blueprint(endpoint, __name__, url_prefix=self.base_path, template_folder=str(self.options.openapi_console_ui_from_dir)) - def add_openapi_json(self): - """ - Adds spec json to {base_path}/swagger.json - or {base_path}/openapi.json (for oas3) - """ - logger.debug('Adding spec json: %s/%s', self.base_path, - self.options.openapi_spec_path) - endpoint_name = f"{self.blueprint.name}_openapi_json" - - self.blueprint.add_url_rule(self.options.openapi_spec_path, - endpoint_name, - self._handlers.get_json_spec) - - def add_openapi_yaml(self): - """ - Adds spec yaml to {base_path}/swagger.yaml - or {base_path}/openapi.yaml (for oas3) - """ - if not self.options.openapi_spec_path.endswith("json"): - return - - openapi_spec_path_yaml = \ - self.options.openapi_spec_path[:-len("json")] + "yaml" - logger.debug('Adding spec yaml: %s/%s', self.base_path, - openapi_spec_path_yaml) - endpoint_name = f"{self.blueprint.name}_openapi_yaml" - self.blueprint.add_url_rule( - openapi_spec_path_yaml, - endpoint_name, - self._handlers.get_yaml_spec - ) - - def add_swagger_ui(self): - """ - Adds swagger ui to {base_path}/ui/ - """ - console_ui_path = self.options.openapi_console_ui_path.strip('/') - logger.debug('Adding swagger-ui: %s/%s/', - self.base_path, - console_ui_path) - - if self.options.openapi_console_ui_config is not None: - config_endpoint_name = f"{self.blueprint.name}_swagger_ui_config" - config_file_url = '/{console_ui_path}/swagger-ui-config.json'.format( - console_ui_path=console_ui_path) - - self.blueprint.add_url_rule(config_file_url, - config_endpoint_name, - lambda: flask.jsonify(self.options.openapi_console_ui_config)) - - static_endpoint_name = f"{self.blueprint.name}_swagger_ui_static" - static_files_url = '/{console_ui_path}/'.format( - console_ui_path=console_ui_path) - - self.blueprint.add_url_rule(static_files_url, - static_endpoint_name, - self._handlers.console_ui_static_files) - - index_endpoint_name = f"{self.blueprint.name}_swagger_ui_index" - console_ui_url = '/{console_ui_path}/'.format( - console_ui_path=console_ui_path) - - self.blueprint.add_url_rule(console_ui_url, - index_endpoint_name, - self._handlers.console_ui_home) - def add_auth_on_not_found(self, security, security_definitions): """ Adds a 404 error handler to authenticate and only expose the 404 status if the security validation pass. @@ -127,13 +60,6 @@ def _add_operation_internal(self, method, path, operation): function = operation.function self.blueprint.add_url_rule(flask_path, endpoint_name, function, methods=[method]) - @property - def _handlers(self): - # type: () -> InternalHandlers - if not hasattr(self, '_internal_handlers'): - self._internal_handlers = InternalHandlers(self.base_path, self.options, self.specification) - return self._internal_handlers - @classmethod def get_response(cls, response, mimetype=None, request=None): """Gets ConnexionResponse instance for the operation handler @@ -267,65 +193,3 @@ def _get_context(): context = LocalProxy(_get_context) - - -class InternalHandlers: - """ - Flask handlers for internally registered endpoints. - """ - - def __init__(self, base_path, options, specification): - self.base_path = base_path - self.options = options - self.specification = specification - - def console_ui_home(self): - """ - Home page of the OpenAPI Console UI. - - :return: - """ - openapi_json_route_name = "{blueprint}.{prefix}_openapi_json" - escaped = flask_utils.flaskify_endpoint(self.base_path) - openapi_json_route_name = openapi_json_route_name.format( - blueprint=escaped, - prefix=escaped - ) - template_variables = { - 'openapi_spec_url': flask.url_for(openapi_json_route_name), - **self.options.openapi_console_ui_index_template_variables, - } - if self.options.openapi_console_ui_config is not None: - template_variables['configUrl'] = 'swagger-ui-config.json' - - # Use `render_template_string` instead of `render_template` to circumvent the flask - # template lookup mechanism and explicitly render the template of the current blueprint. - # https://github.com/zalando/connexion/issues/1289#issuecomment-884105076 - template_dir = pathlib.Path(self.options.openapi_console_ui_from_dir) - index_path = template_dir / 'index.j2' - return flask.render_template_string(index_path.read_text(), **template_variables) - - def console_ui_static_files(self, filename): - """ - Servers the static files for the OpenAPI Console UI. - - :param filename: Requested file contents. - :return: - """ - # convert PosixPath to str - static_dir = str(self.options.openapi_console_ui_from_dir) - return flask.send_from_directory(static_dir, filename) - - def get_json_spec(self): - return flask.jsonify(self._spec_for_prefix()) - - def get_yaml_spec(self): - return yamldumper(self._spec_for_prefix()), 200, {"Content-Type": "text/yaml"} - - def _spec_for_prefix(self): - """ - Modify base_path in the spec based on incoming url - This fixes problems with reverse proxies changing the path. - """ - base_path = flask.url_for(flask.request.endpoint).rsplit("/", 1)[0] - return self.specification.with_base_path(base_path).raw diff --git a/connexion/apps/abstract.py b/connexion/apps/abstract.py index 3be47a361..1a454e45a 100644 --- a/connexion/apps/abstract.py +++ b/connexion/apps/abstract.py @@ -55,7 +55,7 @@ def __init__(self, import_name, api_cls, port=None, specification_dir='', self.server = server self.server_args = dict() if server_args is None else server_args self.app = self.create_app() - self._apply_middleware() + self.middleware = self._apply_middleware() # we get our application root path to avoid duplicating logic self.root_path = self.get_root_path() @@ -153,6 +153,22 @@ def add_api(self, specification, base_path=None, arguments=None, api_options = self.options.extend(options) + self.middleware.add_api( + specification, + base_path=base_path, + arguments=arguments, + resolver=resolver, + resolver_error_handler=resolver_error_handler, + validate_responses=validate_responses, + strict_validation=strict_validation, + auth_all_paths=auth_all_paths, + debug=self.debug, + validator_map=validator_map, + pythonic_params=pythonic_params, + pass_context_arg_name=pass_context_arg_name, + options=api_options.as_dict() + ) + api = self.api_cls(specification, base_path=base_path, arguments=arguments, diff --git a/connexion/apps/flask_app.py b/connexion/apps/flask_app.py index 0071b6cc1..e88617363 100644 --- a/connexion/apps/flask_app.py +++ b/connexion/apps/flask_app.py @@ -31,7 +31,6 @@ def __init__(self, import_name, server='flask', extra_files=None, **kwargs): See :class:`~connexion.AbstractApp` for additional parameters. """ self.extra_files = extra_files or [] - self.middleware = None super().__init__(import_name, FlaskApi, server=server, **kwargs) @@ -45,10 +44,12 @@ def create_app(self): def _apply_middleware(self): middlewares = [*ConnexionMiddleware.default_middlewares, a2wsgi.WSGIMiddleware] - self.middleware = ConnexionMiddleware(self.app.wsgi_app, middlewares=middlewares) + middleware = ConnexionMiddleware(self.app.wsgi_app, middlewares=middlewares) # Wrap with ASGI to WSGI middleware for usage with development server and test client - self.app.wsgi_app = a2wsgi.ASGIMiddleware(self.middleware) + self.app.wsgi_app = a2wsgi.ASGIMiddleware(middleware) + + return middleware def get_root_path(self): return pathlib.Path(self.app.root_path) diff --git a/connexion/middleware/__init__.py b/connexion/middleware/__init__.py index 961969df4..136930c70 100644 --- a/connexion/middleware/__init__.py +++ b/connexion/middleware/__init__.py @@ -1 +1,2 @@ from .main import ConnexionMiddleware # NOQA +from .swagger_ui import SwaggerUIMiddleware # NOQA diff --git a/connexion/middleware/base.py b/connexion/middleware/base.py new file mode 100644 index 000000000..1e35c0e4a --- /dev/null +++ b/connexion/middleware/base.py @@ -0,0 +1,10 @@ +import abc +import pathlib +import typing as t + + +class AppMiddleware(abc.ABC): + + @abc.abstractmethod + def add_api(self, specification: t.Union[pathlib.Path, str, dict], **kwargs) -> None: + pass diff --git a/connexion/middleware/main.py b/connexion/middleware/main.py index 3cc7533bb..0e4a26b2c 100644 --- a/connexion/middleware/main.py +++ b/connexion/middleware/main.py @@ -1,12 +1,18 @@ import pathlib import typing as t +from starlette.exceptions import ExceptionMiddleware from starlette.types import ASGIApp, Receive, Scope, Send +from connexion.middleware.base import AppMiddleware +from connexion.middleware.swagger_ui import SwaggerUIMiddleware + class ConnexionMiddleware: default_middlewares = [ + ExceptionMiddleware, + SwaggerUIMiddleware, ] def __init__( @@ -25,8 +31,6 @@ def __init__( middlewares = self.default_middlewares self.app, self.apps = self._apply_middlewares(app, middlewares) - self._routing_middleware = None - @staticmethod def _apply_middlewares(app: ASGIApp, middlewares: t.List[t.Type[ASGIApp]]) \ -> t.Tuple[ASGIApp, t.Iterable[ASGIApp]]: @@ -49,6 +53,7 @@ def add_api( specification: t.Union[pathlib.Path, str, dict], base_path: t.Optional[str] = None, arguments: t.Optional[dict] = None, + **kwargs ) -> None: """Add an API to the underlying routing middleware based on a OpenAPI spec. @@ -56,6 +61,9 @@ def add_api( :param base_path: Base path where to add this API. :param arguments: Jinja arguments to replace in the spec. """ + for app in self.apps: + if isinstance(app, AppMiddleware): + app.add_api(specification, base_path=base_path, arguments=arguments, **kwargs) async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await self.app(scope, receive, send) diff --git a/connexion/middleware/swagger_ui.py b/connexion/middleware/swagger_ui.py new file mode 100644 index 000000000..efc3316ac --- /dev/null +++ b/connexion/middleware/swagger_ui.py @@ -0,0 +1,211 @@ +import logging +import pathlib +import re +import typing as t +from contextvars import ContextVar + +from starlette.responses import RedirectResponse +from starlette.responses import Response as StarletteResponse +from starlette.routing import Router +from starlette.staticfiles import StaticFiles +from starlette.templating import Jinja2Templates +from starlette.types import ASGIApp, Receive, Scope, Send + +from connexion.apis import AbstractSwaggerUIAPI +from connexion.jsonifier import JSONEncoder, Jsonifier +from connexion.utils import yamldumper + +from .base import AppMiddleware + +logger = logging.getLogger('connexion.middleware.swagger_ui') + + +_original_scope: ContextVar[Scope] = ContextVar('SCOPE') + + +class SwaggerUIMiddleware(AppMiddleware): + + def __init__(self, app: ASGIApp) -> None: + """Middleware that hosts a swagger UI. + + :param app: app to wrap in middleware. + """ + self.app = app + # Set default to pass unknown routes to next app + self.router = Router(default=self.default_fn) + + def add_api( + self, + specification: t.Union[pathlib.Path, str, dict], + base_path: t.Optional[str] = None, + arguments: t.Optional[dict] = None, + **kwargs + ) -> None: + """Add an API to the router based on a OpenAPI spec. + + :param specification: OpenAPI spec as dict or path to file. + :param base_path: Base path where to add this API. + :param arguments: Jinja arguments to replace in the spec. + """ + api = SwaggerUIAPI(specification, base_path=base_path, arguments=arguments, + default=self.default_fn, **kwargs) + self.router.mount(api.base_path, app=api.router) + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + _original_scope.set(scope.copy()) + await self.router(scope, receive, send) + + async def default_fn(self, _scope: Scope, receive: Receive, send: Send) -> None: + """ + Callback to call next app as default when no matching route is found. + + Unfortunately we cannot just pass the next app as default, since the router manipulates + the scope when descending into mounts, losing information about the base path. Therefore, + we use the original scope instead. + + This is caused by https://github.com/encode/starlette/issues/1336. + """ + original_scope = _original_scope.get() + await self.app(original_scope, receive, send) + + +class SwaggerUIAPI(AbstractSwaggerUIAPI): + + def __init__(self, *args, default: ASGIApp, **kwargs): + self.router = Router(default=default) + + super().__init__(*args, **kwargs) + + self._templates = Jinja2Templates( + directory=str(self.options.openapi_console_ui_from_dir) + ) + + @staticmethod + def normalize_string(string): + return re.sub(r"[^a-zA-Z0-9]", "_", string.strip("/")) + + def _base_path_for_prefix(self, request): + """ + returns a modified basePath which includes the incoming request's + path prefix. + """ + base_path = self.base_path + if not request.url.path.startswith(self.base_path): + prefix = request.url.path.split(self.base_path)[0] + base_path = prefix + base_path + return base_path + + def _spec_for_prefix(self, request): + """ + returns a spec with a modified basePath / servers block + which corresponds to the incoming request path. + This is needed when behind a path-altering reverse proxy. + """ + base_path = self._base_path_for_prefix(request) + return self.specification.with_base_path(base_path).raw + + def add_openapi_json(self): + """ + Adds openapi json to {base_path}/openapi.json + (or {base_path}/swagger.json for swagger2) + """ + logger.info( + "Adding spec json: %s/%s", self.base_path, self.options.openapi_spec_path + ) + self.router.add_route( + methods=["GET"], + path=self.options.openapi_spec_path, + endpoint=self._get_openapi_json, + ) + + def add_openapi_yaml(self): + """ + Adds openapi json to {base_path}/openapi.json + (or {base_path}/swagger.json for swagger2) + """ + if not self.options.openapi_spec_path.endswith("json"): + return + + openapi_spec_path_yaml = self.options.openapi_spec_path[: -len("json")] + "yaml" + logger.debug("Adding spec yaml: %s/%s", self.base_path, openapi_spec_path_yaml) + self.router.add_route( + methods=["GET"], + path=openapi_spec_path_yaml, + endpoint=self._get_openapi_yaml, + ) + + async def _get_openapi_json(self, request): + return StarletteResponse( + content=self.jsonifier.dumps(self._spec_for_prefix(request)), + status_code=200, + media_type="application/json", + ) + + async def _get_openapi_yaml(self, request): + return StarletteResponse( + content=yamldumper(self._spec_for_prefix(request)), + status_code=200, + media_type="text/yaml", + ) + + def add_swagger_ui(self): + """ + Adds swagger ui to {base_path}/ui/ + """ + console_ui_path = self.options.openapi_console_ui_path.strip().rstrip("/") + logger.debug("Adding swagger-ui: %s%s/", self.base_path, console_ui_path) + + for path in ( + console_ui_path + "/", + console_ui_path + "/index.html", + ): + self.router.add_route( + methods=["GET"], path=path, endpoint=self._get_swagger_ui_home + ) + + if self.options.openapi_console_ui_config is not None: + self.router.add_route( + methods=["GET"], + path=console_ui_path + "/swagger-ui-config.json", + endpoint=self._get_swagger_ui_config, + ) + + # we have to add an explicit redirect instead of relying on the + # normalize_path_middleware because we also serve static files + # from this dir (below) + + async def redirect(_request): + return RedirectResponse(url=self.base_path + console_ui_path + "/") + + self.router.add_route(methods=["GET"], path=console_ui_path, endpoint=redirect) + + # this route will match and get a permission error when trying to + # serve index.html, so we add the redirect above. + self.router.mount( + path=console_ui_path, + app=StaticFiles(directory=str(self.options.openapi_console_ui_from_dir)), + name="swagger_ui_static", + ) + + async def _get_swagger_ui_home(self, req): + base_path = self._base_path_for_prefix(req) + template_variables = { + "request": req, + "openapi_spec_url": (base_path + self.options.openapi_spec_path), + **self.options.openapi_console_ui_index_template_variables, + } + if self.options.openapi_console_ui_config is not None: + template_variables["configUrl"] = "swagger-ui-config.json" + + return self._templates.TemplateResponse("index.j2", template_variables) + + async def _get_swagger_ui_config(self, request): + return StarletteResponse( + status_code=200, + media_type="application/json", + content=self.jsonifier.dumps(self.options.openapi_console_ui_config), + ) + + @classmethod + def _set_jsonifier(cls): + cls.jsonifier = Jsonifier(cls=JSONEncoder) diff --git a/tests/api/test_responses.py b/tests/api/test_responses.py index e5a6f38be..6781f4445 100644 --- a/tests/api/test_responses.py +++ b/tests/api/test_responses.py @@ -59,7 +59,7 @@ def test_openapi_yaml_behind_proxy(reverse_proxied_app): headers=headers ) assert openapi_yaml.status_code == 200 - assert openapi_yaml.headers.get('Content-Type') == 'text/yaml' + assert openapi_yaml.headers.get('Content-Type').startswith('text/yaml') spec = yaml.load(openapi_yaml.data.decode('utf-8'), Loader=yaml.BaseLoader) if reverse_proxied_app._spec_file == 'swagger.yaml': From 7f2931037e2d1654ea9eb7515e080f31f377ba70 Mon Sep 17 00:00:00 2001 From: Robbe Sneyders Date: Tue, 12 Apr 2022 22:02:29 +0200 Subject: [PATCH 05/20] Add pre-commit hook (#1511) --- .pre-commit-config.yaml | 26 ++++++++++++++++++++++++++ README.rst | 8 ++++++++ setup.py | 4 +--- tox.ini | 33 +++++---------------------------- 4 files changed, 40 insertions(+), 31 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..1f3de91de --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,26 @@ +ci: + autoupdate_branch: "main" + autoupdate_schedule: monthly +repos: + - repo: https://github.com/PyCQA/flake8 + rev: 4.0.1 + hooks: + - id: flake8 + files: "^connexion/" + additional_dependencies: + - flake8-rst-docstrings==0.2.3 + - repo: https://github.com/PyCQA/isort + rev: 5.10.1 + hooks: + - id: isort + name: isort + files: "^connexion/" + args: ["--project", "connexion", "--check-only", "--diff"] + - id: isort + name: isort examples + files: "^examples/" + args: ["--thirdparty", "connexion", "--check-only", "--diff"] + - id: isort + name: isort tests + files: "^tests/" + args: ["--project", "conftest", "--thirdparty", "connexion", "--check-only", "--diff"] diff --git a/README.rst b/README.rst index b64d12c7d..f75fde9e9 100644 --- a/README.rst +++ b/README.rst @@ -583,6 +583,14 @@ Contributing to Connexion/TODOs We welcome your ideas, issues, and pull requests. Just follow the usual/standard GitHub practices. +For easy development, please install connexion in editable mode with the :code:`tests` extra, and +install the pre-commit hooks. + +.. code-block:: bash + + pip install -e .[tests] + pre-commit install + You can find out more about how Connexion works and where to apply your changes by having a look at our `ARCHITECTURE.rst `_. diff --git a/setup.py b/setup.py index c7ec3e007..cc14c0c02 100755 --- a/setup.py +++ b/setup.py @@ -39,10 +39,9 @@ def read_version(package): ] tests_require = [ - 'decorator>=5,<6', 'pytest>=6,<7', + 'pre-commit>=2,<3', 'pytest-cov>=2,<3', - 'testfixtures>=6,<7', *flask_require, swagger_ui_require ] @@ -92,7 +91,6 @@ def readme(): url='https://github.com/zalando/connexion', keywords='openapi oai swagger rest api oauth flask microservice framework', license='Apache License Version 2.0', - setup_requires=['flake8'], python_requires=">=3.6", install_requires=install_requires + flask_require, tests_require=tests_require, diff --git a/tox.ini b/tox.ini index 197650f9b..ad76253cf 100644 --- a/tox.ini +++ b/tox.ini @@ -9,10 +9,7 @@ envlist = {py38}-{min,pypi,dev} {py39}-{min,pypi,dev} {py310}-{min,pypi,dev} - isort-check - isort-check-examples - isort-check-tests - flake8 + pre-commit mypy [gh-actions] @@ -20,7 +17,7 @@ python = 3.7: py37-min,py37-pypi 3.8: py38-min,py38-pypi 3.9: py39-min,py39-pypi - 3.10: py310-min,py310-pypi,flake8,isort-check,isort-check-examples,isort-check-tests,mypy + 3.10: py310-min,py310-pypi,pre-commit,mypy [testenv] setenv=PYTHONPATH = {toxinidir}:{toxinidir} @@ -35,29 +32,9 @@ commands= dev: pip install --upgrade -r {toxworkdir}/requirements-dev.txt python setup.py test -[testenv:flake8] -deps= - flake8==3.9.2 - flake8-rst-docstrings==0.2.3 -commands=python setup.py flake8 - -[testenv:isort-check] -basepython=python3 -deps=isort==5.9.1 -changedir={toxinidir}/connexion -commands=isort --project connexion --check-only --diff . - -[testenv:isort-check-examples] -basepython=python3 -deps=isort==5.9.1 -changedir={toxinidir}/examples -commands=isort --thirdparty connexion --check-only --diff . - -[testenv:isort-check-tests] -basepython=python3 -deps=isort==5.9.1 -changedir={toxinidir}/tests -commands=isort --thirdparty connexion --check-only --diff . +[testenv:pre-commit] +deps=pre-commit +commands=pre-commit run --all-files --show-diff-on-failure [testenv:mypy] deps= From 84e33e5897a6a43f2a06ee5193ea958386252ce2 Mon Sep 17 00:00:00 2001 From: Robbe Sneyders Date: Tue, 19 Apr 2022 22:55:20 +0200 Subject: [PATCH 06/20] Add routing middleware (#1497) * Add routing middleware Factor out starlette BaseHTTPMiddleware Fix exceptions for starlette < 0.19 Fix docstring formatting Rename middleware/base.py to abstract.py Rework routing middleware * Clean up abstract API docstrings * Move connexion context into extensions * Allow empty middleware list --- connexion/apis/__init__.py | 3 +- connexion/apis/abstract.py | 222 +++++++++--------- connexion/apps/abstract.py | 13 +- connexion/apps/flask_app.py | 5 +- connexion/exceptions.py | 11 + connexion/lifecycle.py | 10 + connexion/middleware/__init__.py | 2 + connexion/middleware/{base.py => abstract.py} | 2 + connexion/middleware/exceptions.py | 33 +++ connexion/middleware/main.py | 6 +- connexion/middleware/routing.py | 170 ++++++++++++++ connexion/middleware/swagger_ui.py | 3 +- tests/api/test_errors.py | 1 - tests/conftest.py | 5 +- tests/fakeapi/auth.py | 12 - tests/test_middleware.py | 44 ++++ 16 files changed, 406 insertions(+), 136 deletions(-) rename connexion/middleware/{base.py => abstract.py} (65%) create mode 100644 connexion/middleware/exceptions.py create mode 100644 connexion/middleware/routing.py create mode 100644 tests/test_middleware.py diff --git a/connexion/apis/__init__.py b/connexion/apis/__init__.py index e7d0778bd..da5a4bfdc 100644 --- a/connexion/apis/__init__.py +++ b/connexion/apis/__init__.py @@ -13,4 +13,5 @@ """ -from .abstract import AbstractAPI, AbstractSwaggerUIAPI # NOQA +from .abstract import (AbstractAPI, AbstractMinimalAPI, # NOQA + AbstractSwaggerUIAPI) diff --git a/connexion/apis/abstract.py b/connexion/apis/abstract.py index 186456325..acc365106 100644 --- a/connexion/apis/abstract.py +++ b/connexion/apis/abstract.py @@ -14,7 +14,7 @@ from ..http_facts import METHODS from ..jsonifier import Jsonifier from ..lifecycle import ConnexionResponse -from ..operations import make_operation +from ..operations import AbstractOperation, make_operation from ..options import ConnexionOptions from ..resolver import Resolver from ..spec import Specification @@ -43,7 +43,14 @@ def __init__( *args, **kwargs ): - """Base API class with only minimal behavior related to the specification.""" + """Base API class with only minimal behavior related to the specification. + + :param specification: OpenAPI specification. Can be provided either as dict, or as path + to file. + :param base_path: Base path to host the API. + :param arguments: Jinja arguments to resolve in specification. + :param options: New style options dictionary. + """ logger.debug('Loading specification: %s', specification, extra={'swagger_yaml': specification, 'base_path': base_path, @@ -109,7 +116,105 @@ def add_swagger_ui(self): """ -class AbstractAPI(AbstractSpecAPI): +class AbstractMinimalAPI(AbstractSpecAPI): + + def __init__( + self, + *args, + resolver: t.Optional[Resolver] = None, + resolver_error_handler: t.Optional[t.Callable] = None, + debug: bool = False, + pass_context_arg_name: t.Optional[str] = None, + **kwargs + ) -> None: + """Minimal interface of an API, with only functionality related to routing. + + :param resolver: Callable that maps operationID to a function + :param resolver_error_handler: Callable that generates an Operation used for handling + ResolveErrors + :param debug: Flag to run in debug mode + :param pass_context_arg_name: If not None URL request handling functions with an argument + matching this name will be passed the framework's request context. + """ + super().__init__(*args, **kwargs) + self.debug = debug + self.resolver_error_handler = resolver_error_handler + + logger.debug('Security Definitions: %s', self.specification.security_definitions) + + self.resolver = resolver or Resolver() + + logger.debug('pass_context_arg_name: %s', pass_context_arg_name) + self.pass_context_arg_name = pass_context_arg_name + + self.security_handler_factory = self.make_security_handler_factory(pass_context_arg_name) + + self.add_paths() + + @staticmethod + @abc.abstractmethod + def make_security_handler_factory(pass_context_arg_name): + """ Create SecurityHandlerFactory to create all security check handlers """ + + def add_paths(self, paths: t.Optional[dict] = None) -> None: + """ + Adds the paths defined in the specification as endpoints + """ + paths = paths or self.specification.get('paths', dict()) + for path, methods in paths.items(): + logger.debug('Adding %s%s...', self.base_path, path) + + for method in methods: + if method not in METHODS: + continue + try: + self.add_operation(path, method) + except ResolverError as err: + # If we have an error handler for resolver errors, add it as an operation. + # Otherwise treat it as any other error. + if self.resolver_error_handler is not None: + self._add_resolver_error_handler(method, path, err) + else: + self._handle_add_operation_error(path, method, err.exc_info) + except Exception: + # All other relevant exceptions should be handled as well. + self._handle_add_operation_error(path, method, sys.exc_info()) + + def add_operation(self, path: str, method: str) -> None: + raise NotImplementedError + + @abc.abstractmethod + def _add_operation_internal(self, method: str, path: str, operation: AbstractOperation) -> None: + """ + Adds the operation according to the user framework in use. + It will be used to register the operation on the user framework router. + """ + + def _add_resolver_error_handler(self, method: str, path: str, err: ResolverError): + """ + Adds a handler for ResolverError for the given method and path. + """ + operation = self.resolver_error_handler( + err, + security=self.specification.security, + security_definitions=self.specification.security_definitions + ) + self._add_operation_internal(method, path, operation) + + def _handle_add_operation_error(self, path: str, method: str, exc_info: tuple): + url = f'{self.base_path}{path}' + error_msg = 'Failed to add operation for {method} {url}'.format( + method=method.upper(), + url=url) + if self.debug: + logger.exception(error_msg) + else: + logger.error(error_msg) + _type, value, traceback = exc_info + raise value.with_traceback(traceback) + + +class AbstractAPI(AbstractMinimalAPI, metaclass=AbstractAPIMeta): """ Defines an abstract interface for a Swagger API """ @@ -120,55 +225,17 @@ def __init__(self, specification, base_path=None, arguments=None, validator_map=None, pythonic_params=False, pass_context_arg_name=None, options=None, ): """ - :type specification: pathlib.Path | dict - :type base_path: str | None - :type arguments: dict | None :type validate_responses: bool :type strict_validation: bool :type auth_all_paths: bool - :type debug: bool :param validator_map: Custom validators for the types "parameter", "body" and "response". :type validator_map: dict - :param resolver: Callable that maps operationID to a function - :param resolver_error_handler: If given, a callable that generates an - Operation used for handling ResolveErrors :type resolver_error_handler: callable | None :param pythonic_params: When True CamelCase parameters are converted to snake_case and an underscore is appended to any shadowed built-ins :type pythonic_params: bool - :param options: New style options dictionary. - :type options: dict | None - :param pass_context_arg_name: If not None URL request handling functions with an argument matching this name - will be passed the framework's request context. - :type pass_context_arg_name: str | None """ - self.debug = debug self.validator_map = validator_map - self.resolver_error_handler = resolver_error_handler - - logger.debug('Loading specification: %s', specification, - extra={'swagger_yaml': specification, - 'base_path': base_path, - 'arguments': arguments, - 'auth_all_paths': auth_all_paths}) - - # Avoid validator having ability to modify specification - self.specification = Specification.load(specification, arguments=arguments) - - logger.debug('Read specification', extra={'spec': self.specification}) - - self.options = ConnexionOptions(options, oas_version=self.specification.version) - - logger.debug('Options Loaded', - extra={'swagger_ui': self.options.openapi_console_ui_available, - 'swagger_path': self.options.openapi_console_ui_from_dir, - 'swagger_url': self.options.openapi_console_ui_path}) - - self._set_base_path(base_path) - - logger.debug('Security Definitions: %s', self.specification.security_definitions) - - self.resolver = resolver or Resolver() logger.debug('Validate Responses: %s', str(validate_responses)) self.validate_responses = validate_responses @@ -179,14 +246,10 @@ def __init__(self, specification, base_path=None, arguments=None, logger.debug('Pythonic params: %s', str(pythonic_params)) self.pythonic_params = pythonic_params - logger.debug('pass_context_arg_name: %s', pass_context_arg_name) - self.pass_context_arg_name = pass_context_arg_name - - self.security_handler_factory = self.make_security_handler_factory(pass_context_arg_name) - - super().__init__(specification, base_path=base_path, arguments=arguments, options=options) - - self.add_paths() + super().__init__(specification, base_path=base_path, arguments=arguments, + resolver=resolver, auth_all_paths=auth_all_paths, + resolver_error_handler=resolver_error_handler, + debug=debug, pass_context_arg_name=pass_context_arg_name, options=options) if auth_all_paths: self.add_auth_on_not_found( @@ -200,11 +263,6 @@ def add_auth_on_not_found(self, security, security_definitions): Adds a 404 error handler to authenticate and only expose the 404 status if the security validation pass. """ - @staticmethod - @abc.abstractmethod - def make_security_handler_factory(pass_context_arg_name): - """ Create SecurityHandlerFactory to create all security check handlers """ - def add_operation(self, path, method): """ Adds one operation to the api. @@ -236,62 +294,6 @@ def add_operation(self, path, method): ) self._add_operation_internal(method, path, operation) - @abc.abstractmethod - def _add_operation_internal(self, method, path, operation): - """ - Adds the operation according to the user framework in use. - It will be used to register the operation on the user framework router. - """ - - def _add_resolver_error_handler(self, method, path, err): - """ - Adds a handler for ResolverError for the given method and path. - """ - operation = self.resolver_error_handler( - err, - security=self.specification.security, - security_definitions=self.specification.security_definitions - ) - self._add_operation_internal(method, path, operation) - - def add_paths(self, paths=None): - """ - Adds the paths defined in the specification as endpoints - - :type paths: list - """ - paths = paths or self.specification.get('paths', dict()) - for path, methods in paths.items(): - logger.debug('Adding %s%s...', self.base_path, path) - - for method in methods: - if method not in METHODS: - continue - try: - self.add_operation(path, method) - except ResolverError as err: - # If we have an error handler for resolver errors, add it as an operation. - # Otherwise treat it as any other error. - if self.resolver_error_handler is not None: - self._add_resolver_error_handler(method, path, err) - else: - self._handle_add_operation_error(path, method, err.exc_info) - except Exception: - # All other relevant exceptions should be handled as well. - self._handle_add_operation_error(path, method, sys.exc_info()) - - def _handle_add_operation_error(self, path, method, exc_info): - url = f'{self.base_path}{path}' - error_msg = 'Failed to add operation for {method} {url}'.format( - method=method.upper(), - url=url) - if self.debug: - logger.exception(error_msg) - else: - logger.error(error_msg) - _type, value, traceback = exc_info - raise value.with_traceback(traceback) - @classmethod @abc.abstractmethod def get_request(self, *args, **kwargs): diff --git a/connexion/apps/abstract.py b/connexion/apps/abstract.py index 1a454e45a..d6fe6d5bb 100644 --- a/connexion/apps/abstract.py +++ b/connexion/apps/abstract.py @@ -7,6 +7,7 @@ import logging import pathlib +from ..middleware import ConnexionMiddleware from ..options import ConnexionOptions from ..resolver import Resolver @@ -16,7 +17,7 @@ class AbstractApp(metaclass=abc.ABCMeta): def __init__(self, import_name, api_cls, port=None, specification_dir='', host=None, server=None, server_args=None, arguments=None, auth_all_paths=False, debug=None, - resolver=None, options=None, skip_error_handlers=False): + resolver=None, options=None, skip_error_handlers=False, middlewares=None): """ :param import_name: the name of the application package :type import_name: str @@ -37,6 +38,8 @@ def __init__(self, import_name, api_cls, port=None, specification_dir='', :param debug: include debugging information :type debug: bool :param resolver: Callable that maps operationID to a function + :param middlewares: Callable that maps operationID to a function + :type middlewares: list | None """ self.port = port self.host = host @@ -54,8 +57,12 @@ def __init__(self, import_name, api_cls, port=None, specification_dir='', self.server = server self.server_args = dict() if server_args is None else server_args + self.app = self.create_app() - self.middleware = self._apply_middleware() + + if middlewares is None: + middlewares = ConnexionMiddleware.default_middlewares + self.middleware = self._apply_middleware(middlewares) # we get our application root path to avoid duplicating logic self.root_path = self.get_root_path() @@ -80,7 +87,7 @@ def create_app(self): """ @abc.abstractmethod - def _apply_middleware(self): + def _apply_middleware(self, middlewares): """ Apply middleware to application """ diff --git a/connexion/apps/flask_app.py b/connexion/apps/flask_app.py index e88617363..c8da4d2c9 100644 --- a/connexion/apps/flask_app.py +++ b/connexion/apps/flask_app.py @@ -23,6 +23,7 @@ class FlaskApp(AbstractApp): + def __init__(self, import_name, server='flask', extra_files=None, **kwargs): """ :param extra_files: additional files to be watched by the reloader, defaults to the swagger specs of added apis @@ -41,8 +42,8 @@ def create_app(self): app.url_map.converters['int'] = IntegerConverter return app - def _apply_middleware(self): - middlewares = [*ConnexionMiddleware.default_middlewares, + def _apply_middleware(self, middlewares): + middlewares = [*middlewares, a2wsgi.WSGIMiddleware] middleware = ConnexionMiddleware(self.app.wsgi_app, middlewares=middlewares) diff --git a/connexion/exceptions.py b/connexion/exceptions.py index 0436b0c59..e1311f3e1 100644 --- a/connexion/exceptions.py +++ b/connexion/exceptions.py @@ -96,6 +96,17 @@ def __init__(self, title='Bad Request', detail=None): super().__init__(status=400, title=title, detail=detail) +class NotFoundProblem(ProblemException): + + description = ( + 'The requested URL was not found on the server. If you entered the URL manually please ' + 'check your spelling and try again.' + ) + + def __init__(self, title="Not Found", detail=description): + super().__init__(status=404, title=title, detail=detail) + + class UnsupportedMediaTypeProblem(ProblemException): def __init__(self, title="Unsupported Media Type", detail=None): diff --git a/connexion/lifecycle.py b/connexion/lifecycle.py index bfe741ac8..231d9d650 100644 --- a/connexion/lifecycle.py +++ b/connexion/lifecycle.py @@ -2,6 +2,8 @@ This module defines interfaces for requests and responses used in Connexion for authentication, validation, serialization, etc. """ +from starlette.requests import Request as StarletteRequest +from starlette.responses import StreamingResponse as StarletteStreamingResponse class ConnexionRequest: @@ -52,3 +54,11 @@ def __init__(self, self.body = body self.headers = headers or {} self.is_streamed = is_streamed + + +class MiddlewareRequest(StarletteRequest): + """Wraps starlette Request so it can easily be extended.""" + + +class MiddlewareResponse(StarletteStreamingResponse): + """Wraps starlette StreamingResponse so it can easily be extended.""" diff --git a/connexion/middleware/__init__.py b/connexion/middleware/__init__.py index 136930c70..302bc67c7 100644 --- a/connexion/middleware/__init__.py +++ b/connexion/middleware/__init__.py @@ -1,2 +1,4 @@ +from .abstract import AppMiddleware # NOQA from .main import ConnexionMiddleware # NOQA +from .routing import RoutingMiddleware # NOQA from .swagger_ui import SwaggerUIMiddleware # NOQA diff --git a/connexion/middleware/base.py b/connexion/middleware/abstract.py similarity index 65% rename from connexion/middleware/base.py rename to connexion/middleware/abstract.py index 1e35c0e4a..4afbc24bb 100644 --- a/connexion/middleware/base.py +++ b/connexion/middleware/abstract.py @@ -4,6 +4,8 @@ class AppMiddleware(abc.ABC): + """Middlewares that need the APIs to be registered on them should inherit from this base + class""" @abc.abstractmethod def add_api(self, specification: t.Union[pathlib.Path, str, dict], **kwargs) -> None: diff --git a/connexion/middleware/exceptions.py b/connexion/middleware/exceptions.py new file mode 100644 index 000000000..3e075781f --- /dev/null +++ b/connexion/middleware/exceptions.py @@ -0,0 +1,33 @@ +import json + +from starlette.exceptions import \ + ExceptionMiddleware as StarletteExceptionMiddleware +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import Response + +from connexion.exceptions import problem + + +class ExceptionMiddleware(StarletteExceptionMiddleware): + """Subclass of starlette ExceptionMiddleware to change handling of HTTP exceptions to + existing connexion behavior.""" + + def http_exception(self, request: Request, exc: HTTPException) -> Response: + try: + headers = exc.headers + except AttributeError: + # Starlette < 0.19 + headers = {} + + connexion_response = problem(title=exc.detail, + detail=exc.detail, + status=exc.status_code, + headers=headers) + + return Response( + content=json.dumps(connexion_response.body), + status_code=connexion_response.status_code, + media_type=connexion_response.mimetype, + headers=connexion_response.headers + ) diff --git a/connexion/middleware/main.py b/connexion/middleware/main.py index 0e4a26b2c..863abf4bb 100644 --- a/connexion/middleware/main.py +++ b/connexion/middleware/main.py @@ -1,10 +1,11 @@ import pathlib import typing as t -from starlette.exceptions import ExceptionMiddleware from starlette.types import ASGIApp, Receive, Scope, Send -from connexion.middleware.base import AppMiddleware +from connexion.middleware.abstract import AppMiddleware +from connexion.middleware.exceptions import ExceptionMiddleware +from connexion.middleware.routing import RoutingMiddleware from connexion.middleware.swagger_ui import SwaggerUIMiddleware @@ -13,6 +14,7 @@ class ConnexionMiddleware: default_middlewares = [ ExceptionMiddleware, SwaggerUIMiddleware, + RoutingMiddleware, ] def __init__( diff --git a/connexion/middleware/routing.py b/connexion/middleware/routing.py new file mode 100644 index 000000000..08e528c18 --- /dev/null +++ b/connexion/middleware/routing.py @@ -0,0 +1,170 @@ +import pathlib +import typing as t +from contextlib import contextmanager +from contextvars import ContextVar + +from starlette.requests import Request as StarletteRequest +from starlette.routing import Router +from starlette.types import ASGIApp, Receive, Scope, Send + +from connexion.apis import AbstractMinimalAPI +from connexion.exceptions import NotFoundProblem +from connexion.middleware import AppMiddleware +from connexion.operations import AbstractOperation, make_operation +from connexion.resolver import Resolver + +CONNEXION_CONTEXT = 'connexion.context' + + +_scope_receive_send: ContextVar[tuple] = ContextVar('SCOPE_RECEIVE_SEND') + + +class MiddlewareResolver(Resolver): + + def __init__(self, call_next: t.Callable) -> None: + """Resolver that resolves each operation to the provided call_next function.""" + super().__init__() + self.call_next = call_next + + def resolve_function_from_operation_id(self, operation_id: str) -> t.Callable: + return self.call_next + + +class RoutingMiddleware(AppMiddleware): + + def __init__(self, app: ASGIApp) -> None: + """Middleware that resolves the Operation for an incoming request and attaches it to the + scope. + + :param app: app to wrap in middleware. + """ + self.app = app + # Pass unknown routes to next app + self.router = Router(default=self.default_fn) + + def add_api( + self, + specification: t.Union[pathlib.Path, str, dict], + base_path: t.Optional[str] = None, + arguments: t.Optional[dict] = None, + **kwargs + ) -> None: + """Add an API to the router based on a OpenAPI spec. + + :param specification: OpenAPI spec as dict or path to file. + :param base_path: Base path where to add this API. + :param arguments: Jinja arguments to replace in the spec. + """ + kwargs.pop("resolver", None) + resolver = MiddlewareResolver(self.create_call_next()) + api = MiddlewareAPI(specification, base_path=base_path, arguments=arguments, + resolver=resolver, default=self.default_fn, **kwargs) + self.router.mount(api.base_path, app=api.router) + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + """Route request to matching operation, and attach it to the scope before calling the + next app.""" + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + _scope_receive_send.set((scope.copy(), receive, send)) + + # Needs to be set so starlette router throws exceptions instead of returning error responses + scope['app'] = self + try: + await self.router(scope, receive, send) + except ValueError: + raise NotFoundProblem + + async def default_fn(self, scope: Scope, receive: Receive, send: Send) -> None: + """Callback to call next app as default when no matching route is found.""" + original_scope, *_ = _scope_receive_send.get() + + api_base_path = scope.get('root_path', '')[len(original_scope.get('root_path', '')):] + + extensions = original_scope.setdefault('extensions', {}) + connexion_context = extensions.setdefault(CONNEXION_CONTEXT, {}) + connexion_context.update({ + 'api_base_path': api_base_path + }) + await self.app(original_scope, receive, send) + + def create_call_next(self): + + async def call_next( + operation: AbstractOperation, + request: StarletteRequest = None + ) -> None: + """Attach operation to scope and pass it to the next app""" + scope, receive, send = _scope_receive_send.get() + + api_base_path = request.scope.get('root_path', '')[len(scope.get('root_path', '')):] + + extensions = scope.setdefault('extensions', {}) + connexion_context = extensions.setdefault(CONNEXION_CONTEXT, {}) + connexion_context.update({ + 'api_base_path': api_base_path, + 'operation_id': operation.operation_id + }) + return await self.app(scope, receive, send) + + return call_next + + +class MiddlewareAPI(AbstractMinimalAPI): + + def __init__( + self, + specification: t.Union[pathlib.Path, str, dict], + base_path: t.Optional[str] = None, + arguments: t.Optional[dict] = None, + resolver: t.Optional[Resolver] = None, + default: ASGIApp = None, + resolver_error_handler: t.Optional[t.Callable] = None, + debug: bool = False, + **kwargs + ) -> None: + """API implementation on top of Starlette Router for Connexion middleware.""" + self.router = Router(default=default) + + super().__init__( + specification, + base_path=base_path, + arguments=arguments, + resolver=resolver, + resolver_error_handler=resolver_error_handler, + debug=debug + ) + + def add_operation(self, path: str, method: str) -> None: + operation = make_operation( + self.specification, + self, + path, + method, + self.resolver + ) + + @contextmanager + def patch_operation_function(): + """Patch the operation function so no decorators are set in the middleware. This + should be cleaned up by separating the APIs and Operations between the App and + middleware""" + original_operation_function = AbstractOperation.function + AbstractOperation.function = operation._resolution.function + try: + yield + finally: + AbstractOperation.function = original_operation_function + + with patch_operation_function(): + self._add_operation_internal(method, path, operation) + + def _add_operation_internal(self, method: str, path: str, operation: AbstractOperation) -> None: + self.router.add_route(path, operation.function, methods=[method]) + + @staticmethod + def make_security_handler_factory(pass_context_arg_name): + """ Create default SecurityHandlerFactory to create all security check handlers """ + pass diff --git a/connexion/middleware/swagger_ui.py b/connexion/middleware/swagger_ui.py index efc3316ac..b7543de3b 100644 --- a/connexion/middleware/swagger_ui.py +++ b/connexion/middleware/swagger_ui.py @@ -13,10 +13,9 @@ from connexion.apis import AbstractSwaggerUIAPI from connexion.jsonifier import JSONEncoder, Jsonifier +from connexion.middleware import AppMiddleware from connexion.utils import yamldumper -from .base import AppMiddleware - logger = logging.getLogger('connexion.middleware.swagger_ui') diff --git a/tests/api/test_errors.py b/tests/api/test_errors.py index 6798c1028..6c43191bd 100644 --- a/tests/api/test_errors.py +++ b/tests/api/test_errors.py @@ -27,7 +27,6 @@ def test_errors(problem_app): error405 = json.loads(get_greeting.data.decode('utf-8', 'replace')) assert error405['type'] == 'about:blank' assert error405['title'] == 'Method Not Allowed' - assert error405['detail'] == 'The method is not allowed for the requested URL.' assert error405['status'] == 405 assert 'instance' not in error405 diff --git a/tests/conftest.py b/tests/conftest.py index 8144c760d..b87672a97 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,6 @@ import json import logging import pathlib -import sys import pytest from connexion import App @@ -136,7 +135,7 @@ def json_datetime_dir(): return FIXTURES_FOLDER / 'datetime_support' -def build_app_from_fixture(api_spec_folder, spec_file='openapi.yaml', **kwargs): +def build_app_from_fixture(api_spec_folder, spec_file='openapi.yaml', middlewares=None, **kwargs): debug = True if 'debug' in kwargs: debug = kwargs['debug'] @@ -145,6 +144,7 @@ def build_app_from_fixture(api_spec_folder, spec_file='openapi.yaml', **kwargs): cnx_app = App(__name__, port=5001, specification_dir=FIXTURES_FOLDER / api_spec_folder, + middlewares=middlewares, debug=debug) cnx_app.add_api(spec_file, **kwargs) @@ -254,4 +254,3 @@ def unordered_definition_app(request): def bad_operations_app(request): return build_app_from_fixture('bad_operations', request.param, resolver_error=501) - diff --git a/tests/fakeapi/auth.py b/tests/fakeapi/auth.py index 396ee5112..2aa7bff4e 100644 --- a/tests/fakeapi/auth.py +++ b/tests/fakeapi/auth.py @@ -13,15 +13,3 @@ def fake_json_auth(token, required_scopes=None): return json.loads(token) except ValueError: return None - - -async def async_basic_auth(username, password, required_scopes=None, request=None): - return fake_basic_auth(username, password, required_scopes) - - -async def async_json_auth(token, required_scopes=None, request=None): - return fake_json_auth(token, required_scopes) - - -async def async_scope_validation(required_scopes, token_scopes, request): - return required_scopes == token_scopes diff --git a/tests/test_middleware.py b/tests/test_middleware.py new file mode 100644 index 000000000..28a0a01a9 --- /dev/null +++ b/tests/test_middleware.py @@ -0,0 +1,44 @@ +import pytest +from connexion.middleware import ConnexionMiddleware +from connexion.middleware.routing import CONNEXION_CONTEXT +from starlette.datastructures import MutableHeaders + +from conftest import SPECS, build_app_from_fixture + + +class TestMiddleware: + """Middleware to check if operation is accessible on scope.""" + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + operation_id = scope['extensions'][CONNEXION_CONTEXT]['operation_id'] + + async def patched_send(message): + if message["type"] != "http.response.start": + await send(message) + return + + message.setdefault("headers", []) + headers = MutableHeaders(scope=message) + headers["operation_id"] = operation_id + + await send(message) + + await self.app(scope, receive, patched_send) + + +@pytest.fixture(scope="session", params=SPECS) +def middleware_app(request): + middlewares = ConnexionMiddleware.default_middlewares + [TestMiddleware] + return build_app_from_fixture('simple', request.param, middlewares=middlewares) + + +def test_routing_middleware(middleware_app): + app_client = middleware_app.app.test_client() + + response = app_client.post("/v1.0/greeting/robbe") + + assert response.headers.get('operation_id') == 'fakeapi.hello.post_greeting', \ + response.status_code From 4603e0679d579e182daaef5666725949589b4d5d Mon Sep 17 00:00:00 2001 From: Robbe Sneyders Date: Wed, 27 Apr 2022 22:20:38 +0200 Subject: [PATCH 07/20] Extract security to middleware (#1514) * Extract security to middleware * Add MissingMiddleware exception * Extract add_auth_on_not_found in security middleware * Add detail kwarg to exceptions * Return JSONResponse in ExceptionMiddleware * Type dicts in SecurityMiddleware * Add trailing comma in setup.py * Rework connexion context * Improve error handling for missing security operation * Refactor default security operation * Revert "Return JSONResponse in ExceptionMiddleware" This reverts commit c1004c7500d1be7e0938eea8672325cf15355359. * Move routing context name into constant --- connexion/__init__.py | 8 - connexion/apis/abstract.py | 32 +-- connexion/apis/flask_api.py | 23 +- connexion/apps/abstract.py | 2 +- connexion/exceptions.py | 31 ++- connexion/handlers.py | 52 +--- connexion/lifecycle.py | 12 + connexion/middleware/exceptions.py | 27 +- connexion/middleware/main.py | 2 + connexion/middleware/routing.py | 15 +- connexion/middleware/security.py | 238 +++++++++++++++++ connexion/operations/__init__.py | 1 - connexion/operations/abstract.py | 28 +- connexion/operations/compat.py | 5 - connexion/operations/openapi.py | 5 - connexion/operations/secure.py | 174 ------------ connexion/operations/swagger2.py | 13 +- connexion/security/__init__.py | 12 +- .../async_security_handler_factory.py | 105 -------- .../flask_security_handler_factory.py | 40 --- .../security/security_handler_factory.py | 71 +++-- setup.py | 3 +- tests/api/test_secure_api.py | 3 - tests/conftest.py | 50 ++-- tests/decorators/test_security.py | 46 ++-- tests/fixtures/secure_api/swagger.yaml | 1 + tests/fixtures/secure_endpoint/swagger.yaml | 2 +- tests/test_middleware.py | 3 +- tests/test_mock.py | 18 -- tests/test_operation2.py | 248 +++++------------- tests/test_resolver.py | 34 --- 31 files changed, 500 insertions(+), 804 deletions(-) create mode 100644 connexion/middleware/security.py delete mode 100644 connexion/operations/compat.py delete mode 100644 connexion/operations/secure.py delete mode 100644 connexion/security/async_security_handler_factory.py delete mode 100644 connexion/security/flask_security_handler_factory.py diff --git a/connexion/__init__.py b/connexion/__init__.py index db5741d01..7c74ac210 100755 --- a/connexion/__init__.py +++ b/connexion/__init__.py @@ -7,24 +7,16 @@ specified. """ -import sys - import werkzeug.exceptions as exceptions # NOQA from .apis import AbstractAPI # NOQA from .apps import AbstractApp # NOQA from .decorators.produces import NoContent # NOQA from .exceptions import ProblemException # NOQA -# add operation for backwards compatibility -from .operations import compat from .problem import problem # NOQA from .resolver import Resolution, Resolver, RestyResolver # NOQA from .utils import not_installed_error # NOQA -full_name = f'{__package__}.operation' -sys.modules[full_name] = sys.modules[compat.__name__] - - try: from flask import request # NOQA diff --git a/connexion/apis/abstract.py b/connexion/apis/abstract.py index acc365106..28cc9aaca 100644 --- a/connexion/apis/abstract.py +++ b/connexion/apis/abstract.py @@ -140,22 +140,13 @@ def __init__( self.debug = debug self.resolver_error_handler = resolver_error_handler - logger.debug('Security Definitions: %s', self.specification.security_definitions) - self.resolver = resolver or Resolver() logger.debug('pass_context_arg_name: %s', pass_context_arg_name) self.pass_context_arg_name = pass_context_arg_name - self.security_handler_factory = self.make_security_handler_factory(pass_context_arg_name) - self.add_paths() - @staticmethod - @abc.abstractmethod - def make_security_handler_factory(pass_context_arg_name): - """ Create SecurityHandlerFactory to create all security check handlers """ - def add_paths(self, paths: t.Optional[dict] = None) -> None: """ Adds the paths defined in the specification as endpoints @@ -196,8 +187,6 @@ def _add_resolver_error_handler(self, method: str, path: str, err: ResolverError """ operation = self.resolver_error_handler( err, - security=self.specification.security, - security_definitions=self.specification.security_definitions ) self._add_operation_internal(method, path, operation) @@ -221,13 +210,11 @@ class AbstractAPI(AbstractMinimalAPI, metaclass=AbstractAPIMeta): def __init__(self, specification, base_path=None, arguments=None, validate_responses=False, strict_validation=False, resolver=None, - auth_all_paths=False, debug=False, resolver_error_handler=None, - validator_map=None, pythonic_params=False, pass_context_arg_name=None, options=None, - ): + debug=False, resolver_error_handler=None, validator_map=None, + pythonic_params=False, pass_context_arg_name=None, options=None, **kwargs): """ :type validate_responses: bool :type strict_validation: bool - :type auth_all_paths: bool :param validator_map: Custom validators for the types "parameter", "body" and "response". :type validator_map: dict :type resolver_error_handler: callable | None @@ -247,22 +234,9 @@ def __init__(self, specification, base_path=None, arguments=None, self.pythonic_params = pythonic_params super().__init__(specification, base_path=base_path, arguments=arguments, - resolver=resolver, auth_all_paths=auth_all_paths, - resolver_error_handler=resolver_error_handler, + resolver=resolver, resolver_error_handler=resolver_error_handler, debug=debug, pass_context_arg_name=pass_context_arg_name, options=options) - if auth_all_paths: - self.add_auth_on_not_found( - self.specification.security, - self.specification.security_definitions - ) - - @abc.abstractmethod - def add_auth_on_not_found(self, security, security_definitions): - """ - Adds a 404 error handler to authenticate and only expose the 404 status if the security validation pass. - """ - def add_operation(self, path, method): """ Adds one operation to the api. diff --git a/connexion/apis/flask_api.py b/connexion/apis/flask_api.py index eee16dcdf..da15bbbcd 100644 --- a/connexion/apis/flask_api.py +++ b/connexion/apis/flask_api.py @@ -8,15 +8,12 @@ from typing import Any import flask -import werkzeug.exceptions from werkzeug.local import LocalProxy from connexion.apis import flask_utils from connexion.apis.abstract import AbstractAPI -from connexion.handlers import AuthErrorHandler from connexion.jsonifier import Jsonifier from connexion.lifecycle import ConnexionRequest, ConnexionResponse -from connexion.security import FlaskSecurityHandlerFactory from connexion.utils import is_json_mimetype logger = logging.getLogger('connexion.apis.flask_api') @@ -24,11 +21,6 @@ class FlaskApi(AbstractAPI): - @staticmethod - def make_security_handler_factory(pass_context_arg_name): - """ Create default SecurityHandlerFactory to create all security check handlers """ - return FlaskSecurityHandlerFactory(pass_context_arg_name) - def _set_base_path(self, base_path): super()._set_base_path(base_path) self._set_blueprint() @@ -39,16 +31,6 @@ def _set_blueprint(self): self.blueprint = flask.Blueprint(endpoint, __name__, url_prefix=self.base_path, template_folder=str(self.options.openapi_console_ui_from_dir)) - def add_auth_on_not_found(self, security, security_definitions): - """ - Adds a 404 error handler to authenticate and only expose the 404 status if the security validation pass. - """ - logger.debug('Adding path not found authentication') - not_found_error = AuthErrorHandler(self, werkzeug.exceptions.NotFound(), security=security, - security_definitions=security_definitions) - endpoint_name = f"{self.blueprint.name}_not_found" - self.blueprint.add_url_rule('/', endpoint_name, not_found_error.function) - def _add_operation_internal(self, method, path, operation): operation_id = operation.operation_id logger.debug('... Adding %s -> %s', method.upper(), operation_id, @@ -156,9 +138,10 @@ def get_request(cls, *args, **params): :rtype: ConnexionRequest """ - context_dict = {} - setattr(flask._request_ctx_stack.top, 'connexion_context', context_dict) flask_request = flask.request + scope = flask_request.environ['asgi.scope'] + context_dict = scope.get('extensions', {}).get('connexion_context', {}) + setattr(flask._request_ctx_stack.top, 'connexion_context', context_dict) request = ConnexionRequest( flask_request.url, flask_request.method, diff --git a/connexion/apps/abstract.py b/connexion/apps/abstract.py index d6fe6d5bb..a9110ae4c 100644 --- a/connexion/apps/abstract.py +++ b/connexion/apps/abstract.py @@ -193,7 +193,7 @@ def add_api(self, specification, base_path=None, arguments=None, def _resolver_error_handler(self, *args, **kwargs): from connexion.handlers import ResolverErrorHandler - return ResolverErrorHandler(self.api_cls, self.resolver_error, *args, **kwargs) + return ResolverErrorHandler(self.resolver_error, *args, **kwargs) def add_url_rule(self, rule, endpoint=None, view_func=None, **options): """ diff --git a/connexion/exceptions.py b/connexion/exceptions.py index e1311f3e1..a112dc1dd 100644 --- a/connexion/exceptions.py +++ b/connexion/exceptions.py @@ -5,7 +5,7 @@ import warnings from jsonschema.exceptions import ValidationError -from werkzeug.exceptions import Forbidden, Unauthorized +from starlette.exceptions import HTTPException from .problem import problem @@ -61,6 +61,10 @@ class InvalidSpecification(ConnexionException, ValidationError): pass +class MissingMiddleware(ConnexionException): + pass + + class NonConformingResponse(ProblemException): def __init__(self, reason='Unknown Reason', message=None): """ @@ -123,6 +127,19 @@ def __init__(self, message, reason="Response headers do not conform to specifica super().__init__(reason=reason, message=message) +class Unauthorized(HTTPException): + + description = ( + "The server could not verify that you are authorized to access" + " the URL requested. You either supplied the wrong credentials" + " (e.g. a bad password), or your browser doesn't understand" + " how to supply the credentials required." + ) + + def __init__(self, detail: str = description, **kwargs): + super().__init__(401, detail=detail, **kwargs) + + class OAuthProblem(Unauthorized): pass @@ -133,6 +150,18 @@ def __init__(self, token_response, **kwargs): super().__init__(**kwargs) +class Forbidden(HTTPException): + + description = ( + "You don't have the permission to access the requested" + " resource. It is either read-protected or not readable by the" + " server." + ) + + def __init__(self, detail: str = description, **kwargs): + super().__init__(403, detail=detail, **kwargs) + + class OAuthScopeProblem(Forbidden): def __init__(self, token_scopes, required_scopes, **kwargs): self.required_scopes = required_scopes diff --git a/connexion/handlers.py b/connexion/handlers.py index f8d8d9966..c5198d2a4 100644 --- a/connexion/handlers.py +++ b/connexion/handlers.py @@ -4,67 +4,21 @@ import logging -from .exceptions import AuthenticationProblem, ResolverProblem -from .operations.secure import SecureOperation +from .exceptions import ResolverProblem logger = logging.getLogger('connexion.handlers') RESOLVER_ERROR_ENDPOINT_RANDOM_DIGITS = 6 -class AuthErrorHandler(SecureOperation): - """ - Wraps an error with authentication. - """ - - def __init__(self, api, exception, security, security_definitions): - """ - This class uses the exception instance to produce the proper response problem in case the - request is authenticated. - - :param exception: the exception to be wrapped with authentication - :type exception: werkzeug.exceptions.HTTPException - :param security: list of security rules the application uses by default - :type security: list - :param security_definitions: `Security Definitions Object - `_ - :type security_definitions: dict - """ - self.exception = exception - super().__init__(api, security, security_definitions) - - @property - def function(self): - """ - Configured error auth handler. - """ - security_decorator = self.security_decorator - logger.debug('... Adding security decorator (%r)', security_decorator, extra=vars(self)) - function = self.handle - function = security_decorator(function) - function = self._request_response_decorator(function) - return function - - def handle(self, *args, **kwargs): - """ - Actual handler for the execution after authentication. - """ - raise AuthenticationProblem( - title=self.exception.name, - detail=self.exception.description, - status=self.exception.code - ) - - -class ResolverErrorHandler(SecureOperation): +class ResolverErrorHandler: """ Handler for responding to ResolverError. """ - def __init__(self, api, status_code, exception, security, security_definitions): + def __init__(self, status_code, exception): self.status_code = status_code self.exception = exception - super().__init__(api, security, security_definitions) @property def function(self): diff --git a/connexion/lifecycle.py b/connexion/lifecycle.py index 231d9d650..8cff5aa64 100644 --- a/connexion/lifecycle.py +++ b/connexion/lifecycle.py @@ -59,6 +59,18 @@ def __init__(self, class MiddlewareRequest(StarletteRequest): """Wraps starlette Request so it can easily be extended.""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._context = None + + @property + def context(self): + if self._context is None: + extensions = self.scope.setdefault('extensions', {}) + self._context = extensions.setdefault('connexion_context', {}) + + return self._context + class MiddlewareResponse(StarletteStreamingResponse): """Wraps starlette StreamingResponse so it can easily be extended.""" diff --git a/connexion/middleware/exceptions.py b/connexion/middleware/exceptions.py index 3e075781f..35122837a 100644 --- a/connexion/middleware/exceptions.py +++ b/connexion/middleware/exceptions.py @@ -6,13 +6,38 @@ from starlette.requests import Request from starlette.responses import Response -from connexion.exceptions import problem +from connexion.exceptions import ProblemException, problem class ExceptionMiddleware(StarletteExceptionMiddleware): """Subclass of starlette ExceptionMiddleware to change handling of HTTP exceptions to existing connexion behavior.""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.add_exception_handler(ProblemException, self.problem_handler) + + def problem_handler(self, _, exception: ProblemException): + """ + :type exception: Exception + """ + connexion_response = problem( + status=exception.status, + title=exception.title, + detail=exception.detail, + type=exception.type, + instance=exception.instance, + headers=exception.headers, + ext=exception.ext + ) + + return Response( + content=json.dumps(connexion_response.body), + status_code=connexion_response.status_code, + media_type=connexion_response.mimetype, + headers=connexion_response.headers + ) + def http_exception(self, request: Request, exc: HTTPException) -> Response: try: headers = exc.headers diff --git a/connexion/middleware/main.py b/connexion/middleware/main.py index 863abf4bb..817ecc7cd 100644 --- a/connexion/middleware/main.py +++ b/connexion/middleware/main.py @@ -6,6 +6,7 @@ from connexion.middleware.abstract import AppMiddleware from connexion.middleware.exceptions import ExceptionMiddleware from connexion.middleware.routing import RoutingMiddleware +from connexion.middleware.security import SecurityMiddleware from connexion.middleware.swagger_ui import SwaggerUIMiddleware @@ -15,6 +16,7 @@ class ConnexionMiddleware: ExceptionMiddleware, SwaggerUIMiddleware, RoutingMiddleware, + SecurityMiddleware, ] def __init__( diff --git a/connexion/middleware/routing.py b/connexion/middleware/routing.py index 08e528c18..9f5444dcd 100644 --- a/connexion/middleware/routing.py +++ b/connexion/middleware/routing.py @@ -13,7 +13,7 @@ from connexion.operations import AbstractOperation, make_operation from connexion.resolver import Resolver -CONNEXION_CONTEXT = 'connexion.context' +ROUTING_CONTEXT = 'connexion_routing' _scope_receive_send: ContextVar[tuple] = ContextVar('SCOPE_RECEIVE_SEND') @@ -84,8 +84,8 @@ async def default_fn(self, scope: Scope, receive: Receive, send: Send) -> None: api_base_path = scope.get('root_path', '')[len(original_scope.get('root_path', '')):] extensions = original_scope.setdefault('extensions', {}) - connexion_context = extensions.setdefault(CONNEXION_CONTEXT, {}) - connexion_context.update({ + connexion_routing = extensions.setdefault(ROUTING_CONTEXT, {}) + connexion_routing.update({ 'api_base_path': api_base_path }) await self.app(original_scope, receive, send) @@ -102,8 +102,8 @@ async def call_next( api_base_path = request.scope.get('root_path', '')[len(scope.get('root_path', '')):] extensions = scope.setdefault('extensions', {}) - connexion_context = extensions.setdefault(CONNEXION_CONTEXT, {}) - connexion_context.update({ + connexion_routing = extensions.setdefault(ROUTING_CONTEXT, {}) + connexion_routing.update({ 'api_base_path': api_base_path, 'operation_id': operation.operation_id }) @@ -163,8 +163,3 @@ def patch_operation_function(): def _add_operation_internal(self, method: str, path: str, operation: AbstractOperation) -> None: self.router.add_route(path, operation.function, methods=[method]) - - @staticmethod - def make_security_handler_factory(pass_context_arg_name): - """ Create default SecurityHandlerFactory to create all security check handlers """ - pass diff --git a/connexion/middleware/security.py b/connexion/middleware/security.py new file mode 100644 index 000000000..23d8300df --- /dev/null +++ b/connexion/middleware/security.py @@ -0,0 +1,238 @@ +import logging +import pathlib +import typing as t +from collections import defaultdict + +from starlette.types import ASGIApp, Receive, Scope, Send + +from connexion.apis.abstract import AbstractSpecAPI +from connexion.exceptions import MissingMiddleware +from connexion.http_facts import METHODS +from connexion.lifecycle import MiddlewareRequest +from connexion.middleware import AppMiddleware +from connexion.middleware.routing import ROUTING_CONTEXT +from connexion.security import SecurityHandlerFactory + +logger = logging.getLogger("connexion.middleware.security") + + +class SecurityMiddleware(AppMiddleware): + """Middleware to check if operation is accessible on scope.""" + + def __init__(self, app: ASGIApp) -> None: + self.app = app + self.apis: t.Dict[str, SecurityAPI] = {} + + def add_api(self, specification: t.Union[pathlib.Path, str, dict], **kwargs) -> None: + api = SecurityAPI(specification, **kwargs) + self.apis[api.base_path] = api + + async def __call__(self, scope: Scope, receive: Receive, send: Send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + try: + connexion_context = scope['extensions'][ROUTING_CONTEXT] + except KeyError: + raise MissingMiddleware('Could not find routing information in scope. Please make sure ' + 'you have a routing middleware registered upstream. ') + + api_base_path = connexion_context.get('api_base_path') + if api_base_path: + api = self.apis[api_base_path] + operation_id = connexion_context.get('operation_id') + try: + operation = api.operations[operation_id] + except KeyError as e: + if operation_id is None: + logger.debug('Skipping security check for operation without id. Enable ' + '`auth_all_paths` to check security for unknown operations.') + else: + raise MissingSecurityOperation('Encountered unknown operation_id.') from e + + else: + request = MiddlewareRequest(scope) + await operation(request) + + await self.app(scope, receive, send) + + +class SecurityAPI(AbstractSpecAPI): + + def __init__( + self, + specification: t.Union[pathlib.Path, str, dict], + auth_all_paths: bool = False, + *args, + **kwargs + ): + super().__init__(specification, *args, **kwargs) + self.security_handler_factory = SecurityHandlerFactory('context') + self.app_security = self.specification.security + self.security_schemes = self.specification.security_definitions + + if auth_all_paths: + self.add_auth_on_not_found() + else: + self.operations: t.Dict[str, SecurityOperation] = {} + + self.add_paths() + + def add_auth_on_not_found(self): + """Register a default SecurityOperation for routes that are not found.""" + default_operation = self.make_operation() + self.operations = defaultdict(lambda: default_operation) + + def add_paths(self): + paths = self.specification.get('paths', {}) + for path, methods in paths.items(): + for method, operation in methods.items(): + if method not in METHODS: + continue + operation_id = operation.get('operationId') + if operation_id: + self.operations[operation_id] = self.make_operation(operation) + + def make_operation(self, operation_spec: dict = None): + security = self.app_security + if operation_spec: + security = operation_spec.get('security', self.app_security) + + return SecurityOperation( + self.security_handler_factory, + security=security, + security_schemes=self.specification.security_definitions + ) + + +class SecurityOperation: + + def __init__( + self, + security_handler_factory: SecurityHandlerFactory, + security: list, + security_schemes: dict + ): + self.security_handler_factory = security_handler_factory + self.security = security + self.security_schemes = security_schemes + self.verification_fn = self._get_verification_fn() + + def _get_verification_fn(self): + logger.debug('... Security: %s', self.security, extra=vars(self)) + if not self.security: + return self.security_handler_factory.security_passthrough + + auth_funcs = [] + for security_req in self.security: + if not security_req: + auth_funcs.append(self.security_handler_factory.verify_none()) + continue + + sec_req_funcs = {} + oauth = False + for scheme_name, required_scopes in security_req.items(): + security_scheme = self.security_schemes[scheme_name] + + if security_scheme['type'] == 'oauth2': + if oauth: + logger.warning( + "... multiple OAuth2 security schemes in AND fashion not supported", + extra=vars(self)) + break + oauth = True + token_info_func = self.security_handler_factory.get_tokeninfo_func( + security_scheme) + scope_validate_func = self.security_handler_factory.get_scope_validate_func( + security_scheme) + if not token_info_func: + logger.warning("... x-tokenInfoFunc missing", extra=vars(self)) + break + + sec_req_funcs[scheme_name] = self.security_handler_factory.verify_oauth( + token_info_func, scope_validate_func, required_scopes) + + # Swagger 2.0 + elif security_scheme['type'] == 'basic': + basic_info_func = self.security_handler_factory.get_basicinfo_func( + security_scheme) + if not basic_info_func: + logger.warning("... x-basicInfoFunc missing", extra=vars(self)) + break + + sec_req_funcs[scheme_name] = self.security_handler_factory.verify_basic( + basic_info_func) + + # OpenAPI 3.0.0 + elif security_scheme['type'] == 'http': + scheme = security_scheme['scheme'].lower() + if scheme == 'basic': + basic_info_func = self.security_handler_factory.get_basicinfo_func( + security_scheme) + if not basic_info_func: + logger.warning("... x-basicInfoFunc missing", extra=vars(self)) + break + + sec_req_funcs[ + scheme_name] = self.security_handler_factory.verify_basic( + basic_info_func) + elif scheme == 'bearer': + bearer_info_func = self.security_handler_factory.get_bearerinfo_func( + security_scheme) + if not bearer_info_func: + logger.warning("... x-bearerInfoFunc missing", extra=vars(self)) + break + sec_req_funcs[ + scheme_name] = self.security_handler_factory.verify_bearer( + bearer_info_func) + else: + logger.warning("... Unsupported http authorization scheme %s" % scheme, + extra=vars(self)) + break + + elif security_scheme['type'] == 'apiKey': + scheme = security_scheme.get('x-authentication-scheme', '').lower() + if scheme == 'bearer': + bearer_info_func = self.security_handler_factory.get_bearerinfo_func( + security_scheme) + if not bearer_info_func: + logger.warning("... x-bearerInfoFunc missing", extra=vars(self)) + break + sec_req_funcs[ + scheme_name] = self.security_handler_factory.verify_bearer( + bearer_info_func) + else: + apikey_info_func = self.security_handler_factory.get_apikeyinfo_func( + security_scheme) + if not apikey_info_func: + logger.warning("... x-apikeyInfoFunc missing", extra=vars(self)) + break + + sec_req_funcs[ + scheme_name] = self.security_handler_factory.verify_api_key( + apikey_info_func, security_scheme['in'], security_scheme['name'] + ) + + else: + logger.warning( + "... Unsupported security scheme type %s" % security_scheme['type'], + extra=vars(self)) + break + else: + # No break encountered: no missing funcs + if len(sec_req_funcs) == 1: + (func,) = sec_req_funcs.values() + auth_funcs.append(func) + else: + auth_funcs.append( + self.security_handler_factory.verify_multiple_schemes(sec_req_funcs)) + + return self.security_handler_factory.verify_security(auth_funcs) + + async def __call__(self, request: MiddlewareRequest): + await self.verification_fn(request) + + +class MissingSecurityOperation(Exception): + pass diff --git a/connexion/operations/__init__.py b/connexion/operations/__init__.py index fd380debd..233fd2046 100644 --- a/connexion/operations/__init__.py +++ b/connexion/operations/__init__.py @@ -8,7 +8,6 @@ from .abstract import AbstractOperation # noqa from .openapi import OpenAPIOperation # noqa -from .secure import SecureOperation # noqa from .swagger2 import Swagger2Operation # noqa diff --git a/connexion/operations/abstract.py b/connexion/operations/abstract.py index bf76529cc..1a5abc9a4 100644 --- a/connexion/operations/abstract.py +++ b/connexion/operations/abstract.py @@ -6,8 +6,7 @@ import abc import logging -from connexion.operations.secure import SecureOperation - +from ..decorators.decorator import RequestResponseDecorator from ..decorators.metrics import UWSGIMetricsCollector from ..decorators.parameter import parameter_to_arg from ..decorators.produces import BaseSerializer, Produces @@ -26,7 +25,7 @@ } -class AbstractOperation(SecureOperation, metaclass=abc.ABCMeta): +class AbstractOperation(metaclass=abc.ABCMeta): """ An API routes requests to an Operation by a (path, method) pair. @@ -45,7 +44,6 @@ def user_provided_handler_function(important, stuff): serious_business(stuff) """ def __init__(self, api, method, path, operation, resolver, - app_security=None, security_schemes=None, validate_responses=False, strict_validation=False, randomize_endpoint=None, validator_map=None, pythonic_params=False, uri_parser_class=None, @@ -88,8 +86,6 @@ def __init__(self, api, method, path, operation, resolver, self._path = path self._operation = operation self._resolver = resolver - self._security = app_security - self._security_schemes = security_schemes self._validate_responses = validate_responses self._strict_validation = strict_validation self._pythonic_params = pythonic_params @@ -106,6 +102,10 @@ def __init__(self, api, method, path, operation, resolver, self._validator_map = dict(VALIDATOR_MAP) self._validator_map.update(validator_map or {}) + @property + def api(self): + return self._api + @property def method(self): """ @@ -377,11 +377,6 @@ def function(self): uri_parsing_decorator = self._uri_parsing_decorator function = uri_parsing_decorator(function) - # NOTE: the security decorator should be applied last to check auth before anything else :-) - security_decorator = self.security_decorator - logger.debug('... Adding security decorator (%r)', security_decorator) - function = security_decorator(function) - function = self._request_response_decorator(function) if UWSGIMetricsCollector.is_available(): # pragma: no cover @@ -390,6 +385,17 @@ def function(self): return function + @property + def _request_response_decorator(self): + """ + Guarantees that instead of the internal representation of the + operation handler response + (connexion.lifecycle.ConnexionRequest) a framework specific + object is returned. + :rtype: types.FunctionType + """ + return RequestResponseDecorator(self.api, self.get_mimetype()) + @property def __content_type_decorator(self): """ diff --git a/connexion/operations/compat.py b/connexion/operations/compat.py deleted file mode 100644 index 8596b66da..000000000 --- a/connexion/operations/compat.py +++ /dev/null @@ -1,5 +0,0 @@ -""" -This is a dummy module for backwards compatibility with < v2.0. -""" -from .secure import * # noqa -from .swagger2 import * # noqa diff --git a/connexion/operations/openapi.py b/connexion/operations/openapi.py index 7f5d0de3a..69c4404c5 100644 --- a/connexion/operations/openapi.py +++ b/connexion/operations/openapi.py @@ -70,9 +70,6 @@ def __init__(self, api, method, path, operation, resolver, path_parameters=None, def component_get(oas3_name): return self.components.get(oas3_name, {}) - # operation overrides globals - security_schemes = component_get('securitySchemes') - app_security = operation.get('security', app_security) uri_parser_class = uri_parser_class or OpenAPIURIParser self._router_controller = operation.get('x-openapi-router-controller') @@ -83,8 +80,6 @@ def component_get(oas3_name): path=path, operation=operation, resolver=resolver, - app_security=app_security, - security_schemes=security_schemes, validate_responses=validate_responses, strict_validation=strict_validation, randomize_endpoint=randomize_endpoint, diff --git a/connexion/operations/secure.py b/connexion/operations/secure.py deleted file mode 100644 index 5dbd90d91..000000000 --- a/connexion/operations/secure.py +++ /dev/null @@ -1,174 +0,0 @@ -""" -This module defines a SecureOperation class, which implements the security handler for an operation. -""" - -import functools -import logging - -from ..decorators.decorator import RequestResponseDecorator - -logger = logging.getLogger("connexion.operations.secure") - -DEFAULT_MIMETYPE = 'application/json' - - -class SecureOperation: - - def __init__(self, api, security, security_schemes): - """ - :param security: list of security rules the application uses by default - :type security: list - :param security_definitions: `Security Definitions Object - `_ - :type security_definitions: dict - """ - self._api = api - self._security = security - self._security_schemes = security_schemes - - @property - def api(self): - return self._api - - @property - def security(self): - return self._security - - @property - def security_schemes(self): - return self._security_schemes - - @property - def security_decorator(self): - """ - Gets the security decorator for operation - - From Swagger Specification: - - **Security Definitions Object** - - A declaration of the security schemes available to be used in the specification. - - This does not enforce the security schemes on the operations and only serves to provide the relevant details - for each scheme. - - - **Operation Object -> security** - - A declaration of which security schemes are applied for this operation. The list of values describes alternative - security schemes that can be used (that is, there is a logical OR between the security requirements). - This definition overrides any declared top-level security. To remove a top-level security declaration, - an empty array can be used. - - - **Security Requirement Object** - - Lists the required security schemes to execute this operation. The object can have multiple security schemes - declared in it which are all required (that is, there is a logical AND between the schemes). - - The name used for each property **MUST** correspond to a security scheme declared in the Security Definitions. - - :rtype: types.FunctionType - """ - logger.debug('... Security: %s', self.security, extra=vars(self)) - if not self.security: - return self._api.security_handler_factory.security_passthrough - - auth_funcs = [] - for security_req in self.security: - if not security_req: - auth_funcs.append(self._api.security_handler_factory.verify_none()) - continue - - sec_req_funcs = {} - oauth = False - for scheme_name, required_scopes in security_req.items(): - security_scheme = self.security_schemes[scheme_name] - - if security_scheme['type'] == 'oauth2': - if oauth: - logger.warning("... multiple OAuth2 security schemes in AND fashion not supported", extra=vars(self)) - break - oauth = True - token_info_func = self._api.security_handler_factory.get_tokeninfo_func(security_scheme) - scope_validate_func = self._api.security_handler_factory.get_scope_validate_func(security_scheme) - if not token_info_func: - logger.warning("... x-tokenInfoFunc missing", extra=vars(self)) - break - - sec_req_funcs[scheme_name] = self._api.security_handler_factory.verify_oauth( - token_info_func, scope_validate_func, required_scopes) - - # Swagger 2.0 - elif security_scheme['type'] == 'basic': - basic_info_func = self._api.security_handler_factory.get_basicinfo_func(security_scheme) - if not basic_info_func: - logger.warning("... x-basicInfoFunc missing", extra=vars(self)) - break - - sec_req_funcs[scheme_name] = self._api.security_handler_factory.verify_basic(basic_info_func) - - # OpenAPI 3.0.0 - elif security_scheme['type'] == 'http': - scheme = security_scheme['scheme'].lower() - if scheme == 'basic': - basic_info_func = self._api.security_handler_factory.get_basicinfo_func(security_scheme) - if not basic_info_func: - logger.warning("... x-basicInfoFunc missing", extra=vars(self)) - break - - sec_req_funcs[scheme_name] = self._api.security_handler_factory.verify_basic(basic_info_func) - elif scheme == 'bearer': - bearer_info_func = self._api.security_handler_factory.get_bearerinfo_func(security_scheme) - if not bearer_info_func: - logger.warning("... x-bearerInfoFunc missing", extra=vars(self)) - break - sec_req_funcs[scheme_name] = self._api.security_handler_factory.verify_bearer(bearer_info_func) - else: - logger.warning("... Unsupported http authorization scheme %s" % scheme, extra=vars(self)) - break - - elif security_scheme['type'] == 'apiKey': - scheme = security_scheme.get('x-authentication-scheme', '').lower() - if scheme == 'bearer': - bearer_info_func = self._api.security_handler_factory.get_bearerinfo_func(security_scheme) - if not bearer_info_func: - logger.warning("... x-bearerInfoFunc missing", extra=vars(self)) - break - sec_req_funcs[scheme_name] = self._api.security_handler_factory.verify_bearer(bearer_info_func) - else: - apikey_info_func = self._api.security_handler_factory.get_apikeyinfo_func(security_scheme) - if not apikey_info_func: - logger.warning("... x-apikeyInfoFunc missing", extra=vars(self)) - break - - sec_req_funcs[scheme_name] = self._api.security_handler_factory.verify_api_key( - apikey_info_func, security_scheme['in'], security_scheme['name'] - ) - - else: - logger.warning("... Unsupported security scheme type %s" % security_scheme['type'], extra=vars(self)) - break - else: - # No break encountered: no missing funcs - if len(sec_req_funcs) == 1: - (func,) = sec_req_funcs.values() - auth_funcs.append(func) - else: - auth_funcs.append(self._api.security_handler_factory.verify_multiple_schemes(sec_req_funcs)) - - return functools.partial(self._api.security_handler_factory.verify_security, auth_funcs) - - def get_mimetype(self): - return DEFAULT_MIMETYPE - - @property - def _request_response_decorator(self): - """ - Guarantees that instead of the internal representation of the - operation handler response - (connexion.lifecycle.ConnexionRequest) a framework specific - object is returned. - :rtype: types.FunctionType - """ - return RequestResponseDecorator(self.api, self.get_mimetype()) diff --git a/connexion/operations/swagger2.py b/connexion/operations/swagger2.py index e89ce1cbd..88767bc2e 100644 --- a/connexion/operations/swagger2.py +++ b/connexion/operations/swagger2.py @@ -27,8 +27,7 @@ class Swagger2Operation(AbstractOperation): """ def __init__(self, api, method, path, operation, resolver, app_produces, app_consumes, - path_parameters=None, app_security=None, security_definitions=None, - definitions=None, parameter_definitions=None, + path_parameters=None, definitions=None, parameter_definitions=None, response_definitions=None, validate_responses=False, strict_validation=False, randomize_endpoint=None, validator_map=None, pythonic_params=False, uri_parser_class=None, pass_context_arg_name=None): @@ -49,11 +48,6 @@ def __init__(self, api, method, path, operation, resolver, app_produces, app_con :type app_consumes: list :param path_parameters: Parameters defined in the path level :type path_parameters: list - :param app_security: list of security rules the application uses by default - :type app_security: list - :param security_definitions: `Security Definitions Object - `_ - :type security_definitions: dict :param definitions: `Definitions Object `_ :type definitions: dict @@ -78,7 +72,6 @@ def __init__(self, api, method, path, operation, resolver, app_produces, app_con name. :type pass_context_arg_name: str|None """ - app_security = operation.get('security', app_security) uri_parser_class = uri_parser_class or Swagger2URIParser self._router_controller = operation.get('x-swagger-router-controller') @@ -89,8 +82,6 @@ def __init__(self, api, method, path, operation, resolver, app_produces, app_con path=path, operation=operation, resolver=resolver, - app_security=app_security, - security_schemes=security_definitions, validate_responses=validate_responses, strict_validation=strict_validation, randomize_endpoint=randomize_endpoint, @@ -130,10 +121,8 @@ def from_spec(cls, spec, api, path, method, resolver, *args, **kwargs): spec.get_operation(path, method), resolver=resolver, path_parameters=spec.get_path_params(path), - app_security=spec.security, app_produces=spec.produces, app_consumes=spec.consumes, - security_definitions=spec.security_definitions, definitions=spec.definitions, parameter_definitions=spec.parameter_definitions, response_definitions=spec.response_definitions, diff --git a/connexion/security/__init__.py b/connexion/security/__init__.py index 7d3325937..136011c5e 100644 --- a/connexion/security/__init__.py +++ b/connexion/security/__init__.py @@ -5,14 +5,4 @@ isort:skip_file """ -# abstract -from .async_security_handler_factory import AbstractAsyncSecurityHandlerFactory # NOQA -from .security_handler_factory import AbstractSecurityHandlerFactory # NOQA - -from ..utils import not_installed_error - -# concrete -try: - from .flask_security_handler_factory import FlaskSecurityHandlerFactory -except ImportError as err: # pragma: no cover - FlaskSecurityHandlerFactory = not_installed_error(err) +from .security_handler_factory import SecurityHandlerFactory # NOQA diff --git a/connexion/security/async_security_handler_factory.py b/connexion/security/async_security_handler_factory.py deleted file mode 100644 index 4eeb927e1..000000000 --- a/connexion/security/async_security_handler_factory.py +++ /dev/null @@ -1,105 +0,0 @@ -""" -This module defines an abstract asynchronous SecurityHandlerFactory which supports the creation of -asynchronous security handlers for coroutine operations. -""" - -import abc -import asyncio -import functools -import logging - -from ..exceptions import OAuthProblem, OAuthResponseProblem, OAuthScopeProblem -from .security_handler_factory import AbstractSecurityHandlerFactory - -logger = logging.getLogger('connexion.api.security') - - -class AbstractAsyncSecurityHandlerFactory(AbstractSecurityHandlerFactory): - def _generic_check(self, func, exception_msg): - need_to_add_context, need_to_add_required_scopes = self._need_to_add_context_or_scopes(func) - - async def wrapper(request, *args, required_scopes=None): - kwargs = {} - if need_to_add_context: - kwargs[self.pass_context_arg_name] = request.context - if need_to_add_required_scopes: - kwargs[self.required_scopes_kw] = required_scopes - token_info = func(*args, **kwargs) - while asyncio.iscoroutine(token_info): - token_info = await token_info - if token_info is self.no_value: - return self.no_value - if token_info is None: - raise OAuthResponseProblem(description=exception_msg, token_response=None) - return token_info - - return wrapper - - def check_oauth_func(self, token_info_func, scope_validate_func): - get_token_info = self._generic_check(token_info_func, 'Provided token is not valid') - need_to_add_context, _ = self._need_to_add_context_or_scopes(scope_validate_func) - - async def wrapper(request, token, required_scopes): - token_info = await get_token_info(request, token, required_scopes=required_scopes) - - # Fallback to 'scopes' for backward compatibility - token_scopes = token_info.get('scope', token_info.get('scopes', '')) - - kwargs = {} - if need_to_add_context: - kwargs[self.pass_context_arg_name] = request.context - validation = scope_validate_func(required_scopes, token_scopes, **kwargs) - while asyncio.iscoroutine(validation): - validation = await validation - if not validation: - raise OAuthScopeProblem( - description='Provided token doesn\'t have the required scope', - required_scopes=required_scopes, - token_scopes=token_scopes - ) - - return token_info - return wrapper - - @classmethod - def verify_security(cls, auth_funcs, function): - @functools.wraps(function) - async def wrapper(request): - token_info = cls.no_value - errors = [] - for func in auth_funcs: - try: - token_info = func(request) - while asyncio.iscoroutine(token_info): - token_info = await token_info - if token_info is not cls.no_value: - break - except Exception as err: - errors.append(err) - - if token_info is cls.no_value: - if errors != []: - cls._raise_most_specific(errors) - else: - logger.info("... No auth provided. Aborting with 401.") - raise OAuthProblem(description='No authorization token provided') - - # Fallback to 'uid' for backward compatibility - request.context['user'] = token_info.get('sub', token_info.get('uid')) - request.context['token_info'] = token_info - return function(request) - - return wrapper - - @abc.abstractmethod - def get_token_info_remote(self, token_info_url): - """ - Return a function which will call `token_info_url` to retrieve token info. - - Returned function must accept oauth token in parameter. - It must return a token_info dict in case of success, None otherwise. - - :param token_info_url: Url to get information about the token - :type token_info_url: str - :rtype: types.FunctionType - """ diff --git a/connexion/security/flask_security_handler_factory.py b/connexion/security/flask_security_handler_factory.py deleted file mode 100644 index 610aa1899..000000000 --- a/connexion/security/flask_security_handler_factory.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -This module defines a Flask-specific SecurityHandlerFactory. -""" - -import requests - -from .security_handler_factory import AbstractSecurityHandlerFactory - -# use connection pool for OAuth tokeninfo -adapter = requests.adapters.HTTPAdapter(pool_connections=100, pool_maxsize=100) -session = requests.Session() -session.mount('http://', adapter) -session.mount('https://', adapter) - - -class FlaskSecurityHandlerFactory(AbstractSecurityHandlerFactory): - def get_token_info_remote(self, token_info_url): - """ - Return a function which will call `token_info_url` to retrieve token info. - - Returned function must accept oauth token in parameter. - It must return a token_info dict in case of success, None otherwise. - - :param token_info_url: Url to get information about the token - :type token_info_url: str - :rtype: types.FunctionType - """ - def wrapper(token): - """ - Retrieve oauth token_info remotely using HTTP - :param token: oauth token from authorization header - :type token: str - :rtype: dict - """ - headers = {'Authorization': f'Bearer {token}'} - token_request = session.get(token_info_url, headers=headers, timeout=5) - if not token_request.ok: - return None - return token_request.json() - return wrapper diff --git a/connexion/security/security_handler_factory.py b/connexion/security/security_handler_factory.py index 5a09888c0..ee2827522 100644 --- a/connexion/security/security_handler_factory.py +++ b/connexion/security/security_handler_factory.py @@ -3,15 +3,16 @@ handlers for operations. """ -import abc +import asyncio import base64 -import functools import http.cookies import logging import os import textwrap import typing as t +import httpx + from ..decorators.parameter import inspect_function_arguments from ..exceptions import (ConnexionException, OAuthProblem, OAuthResponseProblem, OAuthScopeProblem) @@ -20,7 +21,7 @@ logger = logging.getLogger('connexion.api.security') -class AbstractSecurityHandlerFactory(abc.ABC): +class SecurityHandlerFactory: """ get_*_func -> _get_function -> get_function_from_name (name=security function defined in spec) (if url defined instead of a function -> get_token_info_remote) @@ -36,6 +37,7 @@ class AbstractSecurityHandlerFactory(abc.ABC): """ no_value = object() required_scopes_kw = 'required_scopes' + client = None def __init__(self, pass_context_arg_name): self.pass_context_arg_name = pass_context_arg_name @@ -113,12 +115,8 @@ def get_bearerinfo_func(cls, security_definition): return cls._get_function(security_definition, "x-bearerInfoFunc", 'BEARERINFO_FUNC') @staticmethod - def security_passthrough(function): - """ - :type function: types.FunctionType - :rtype: types.FunctionType - """ - return function + async def security_passthrough(request): + return request @staticmethod def security_deny(function): @@ -170,7 +168,7 @@ def get_auth_header_value(request): try: auth_type, value = authorization.split(None, 1) except ValueError: - raise OAuthProblem(description='Invalid authorization header') + raise OAuthProblem(detail='Invalid authorization header') return auth_type.lower(), value def verify_oauth(self, token_info_func, scope_validate_func, required_scopes): @@ -196,7 +194,7 @@ def wrapper(request): try: username, password = base64.b64decode(user_pass).decode('latin1').split(':', 1) except Exception: - raise OAuthProblem(description='Invalid authorization header') + raise OAuthProblem(detail='Invalid authorization header') return check_basic_info_func(request, username, password) @@ -313,17 +311,19 @@ def _need_to_add_context_or_scopes(self, func): def _generic_check(self, func, exception_msg): need_to_add_context, need_to_add_required_scopes = self._need_to_add_context_or_scopes(func) - def wrapper(request, *args, required_scopes=None): + async def wrapper(request, *args, required_scopes=None): kwargs = {} if need_to_add_context: kwargs[self.pass_context_arg_name] = request.context if need_to_add_required_scopes: kwargs[self.required_scopes_kw] = required_scopes token_info = func(*args, **kwargs) + while asyncio.iscoroutine(token_info): + token_info = await token_info if token_info is self.no_value: return self.no_value if token_info is None: - raise OAuthResponseProblem(description=exception_msg, token_response=None) + raise OAuthResponseProblem(detail=exception_msg, token_response=None) return token_info return wrapper @@ -341,8 +341,8 @@ def check_oauth_func(self, token_info_func, scope_validate_func): get_token_info = self._generic_check(token_info_func, 'Provided token is not valid') need_to_add_context, _ = self._need_to_add_context_or_scopes(scope_validate_func) - def wrapper(request, token, required_scopes): - token_info = get_token_info(request, token, required_scopes=required_scopes) + async def wrapper(request, token, required_scopes): + token_info = await get_token_info(request, token, required_scopes=required_scopes) # Fallback to 'scopes' for backward compatibility token_scopes = token_info.get('scope', token_info.get('scopes', '')) @@ -351,43 +351,48 @@ def wrapper(request, token, required_scopes): if need_to_add_context: kwargs[self.pass_context_arg_name] = request.context validation = scope_validate_func(required_scopes, token_scopes, **kwargs) + while asyncio.iscoroutine(validation): + validation = await validation if not validation: raise OAuthScopeProblem( - description='Provided token doesn\'t have the required scope', + detail='Provided token doesn\'t have the required scope', required_scopes=required_scopes, token_scopes=token_scopes - ) + ) return token_info return wrapper @classmethod - def verify_security(cls, auth_funcs, function): - @functools.wraps(function) - def wrapper(request): + def verify_security(cls, auth_funcs): + + async def verify_fn(request): token_info = cls.no_value errors = [] for func in auth_funcs: try: token_info = func(request) + while asyncio.iscoroutine(token_info): + token_info = await token_info if token_info is not cls.no_value: break except Exception as err: errors.append(err) - if token_info is cls.no_value: + else: if errors != []: cls._raise_most_specific(errors) else: logger.info("... No auth provided. Aborting with 401.") - raise OAuthProblem(description='No authorization token provided') + raise OAuthProblem(detail='No authorization token provided') - # Fallback to 'uid' for backward compatibility - request.context['user'] = token_info.get('sub', token_info.get('uid')) - request.context['token_info'] = token_info - return function(request) + request.context.update({ + # Fallback to 'uid' for backward compatibility + 'user': token_info.get('sub', token_info.get('uid')), + 'token_info': token_info + }) - return wrapper + return verify_fn @staticmethod def _raise_most_specific(exceptions: t.List[Exception]) -> None: @@ -409,7 +414,7 @@ def _raise_most_specific(exceptions: t.List[Exception]) -> None: # We only use status code attributes from exceptions # We use 600 as default because 599 is highest valid status code status_to_exc = { - getattr(exc, 'code', getattr(exc, 'status', 600)): exc + getattr(exc, 'status_code', getattr(exc, 'status', 600)): exc for exc in exceptions } if 403 in status_to_exc: @@ -420,7 +425,6 @@ def _raise_most_specific(exceptions: t.List[Exception]) -> None: lowest_status_code = min(status_to_exc) raise status_to_exc[lowest_status_code] - @abc.abstractmethod def get_token_info_remote(self, token_info_url): """ Return a function which will call `token_info_url` to retrieve token info. @@ -432,3 +436,12 @@ def get_token_info_remote(self, token_info_url): :type token_info_url: str :rtype: types.FunctionType """ + async def wrapper(token): + if self.client is None: + self.client = httpx.AsyncClient() + headers = {'Authorization': f'Bearer {token}'} + token_request = await self.client.get(token_info_url, headers=headers, timeout=5) + if token_request.status_code != 200: + return + return token_request.json() + return wrapper diff --git a/setup.py b/setup.py index cc14c0c02..2b5b067ba 100755 --- a/setup.py +++ b/setup.py @@ -29,13 +29,14 @@ def read_version(package): 'importlib-metadata>=1 ; python_version<"3.8"', 'packaging>=20', 'starlette>=0.15,<1', + 'httpx>=0.15,<1', ] swagger_ui_require = 'swagger-ui-bundle>=0.0.2,<0.1' flask_require = [ 'flask>=2,<3', - 'a2wsgi>=1.1,<2', + 'a2wsgi>=1.4,<2', ] tests_require = [ diff --git a/tests/api/test_secure_api.py b/tests/api/test_secure_api.py index 195de9851..1e53cbd66 100644 --- a/tests/api/test_secure_api.py +++ b/tests/api/test_secure_api.py @@ -36,7 +36,6 @@ def test_security(oauth_requests, secure_endpoint_app): assert get_bye_no_auth.status_code == 401 assert get_bye_no_auth.content_type == 'application/problem+json' get_bye_no_auth_reponse = json.loads(get_bye_no_auth.data.decode('utf-8', 'replace')) # type: dict - assert get_bye_no_auth_reponse['title'] == 'Unauthorized' assert get_bye_no_auth_reponse['detail'] == "No authorization token provided" headers = {"Authorization": "Bearer 100"} @@ -49,7 +48,6 @@ def test_security(oauth_requests, secure_endpoint_app): assert get_bye_wrong_scope.status_code == 403 assert get_bye_wrong_scope.content_type == 'application/problem+json' get_bye_wrong_scope_reponse = json.loads(get_bye_wrong_scope.data.decode('utf-8', 'replace')) # type: dict - assert get_bye_wrong_scope_reponse['title'] == 'Forbidden' assert get_bye_wrong_scope_reponse['detail'] == "Provided token doesn't have the required scope" headers = {"Authorization": "Bearer 300"} @@ -57,7 +55,6 @@ def test_security(oauth_requests, secure_endpoint_app): assert get_bye_bad_token.status_code == 401 assert get_bye_bad_token.content_type == 'application/problem+json' get_bye_bad_token_reponse = json.loads(get_bye_bad_token.data.decode('utf-8', 'replace')) # type: dict - assert get_bye_bad_token_reponse['title'] == 'Unauthorized' assert get_bye_bad_token_reponse['detail'] == "Provided token is not valid" response = app_client.get('/v1.0/more-than-one-security-definition') # type: flask.Response diff --git a/tests/conftest.py b/tests/conftest.py index b87672a97..1da100a9d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,7 @@ import pytest from connexion import App -from connexion.security import FlaskSecurityHandlerFactory +from connexion.security import SecurityHandlerFactory from werkzeug.test import Client, EnvironBuilder logging.basicConfig(level=logging.DEBUG) @@ -69,32 +69,36 @@ def f(*args, **kwargs): @pytest.fixture def oauth_requests(monkeypatch): - def fake_get(url, params=None, headers=None, timeout=None): - """ - :type url: str - :type params: dict| None - """ - headers = headers or {} - if url == "https://oauth.example/token_info": - token = headers.get('Authorization', 'invalid').split()[-1] - if token in ["100", "has_myscope"]: - return FakeResponse(200, '{"uid": "test-user", "scope": ["myscope"]}') - if token in ["200", "has_wrongscope"]: - return FakeResponse(200, '{"uid": "test-user", "scope": ["wrongscope"]}') - if token == "has_myscope_otherscope": - return FakeResponse(200, '{"uid": "test-user", "scope": ["myscope", "otherscope"]}') - if token in ["300", "is_not_invalid"]: - return FakeResponse(404, '') - if token == "has_scopes_in_scopes_with_s": - return FakeResponse(200, '{"uid": "test-user", "scopes": ["myscope", "otherscope"]}') - return url - - monkeypatch.setattr('connexion.security.flask_security_handler_factory.session.get', fake_get) + + class FakeClient: + + @staticmethod + async def get(url, params=None, headers=None, timeout=None): + """ + :type url: str + :type params: dict| None + """ + headers = headers or {} + if url == "https://oauth.example/token_info": + token = headers.get('Authorization', 'invalid').split()[-1] + if token in ["100", "has_myscope"]: + return FakeResponse(200, '{"uid": "test-user", "scope": ["myscope"]}') + if token in ["200", "has_wrongscope"]: + return FakeResponse(200, '{"uid": "test-user", "scope": ["wrongscope"]}') + if token == "has_myscope_otherscope": + return FakeResponse(200, '{"uid": "test-user", "scope": ["myscope", "otherscope"]}') + if token in ["300", "is_not_invalid"]: + return FakeResponse(404, '') + if token == "has_scopes_in_scopes_with_s": + return FakeResponse(200, '{"uid": "test-user", "scopes": ["myscope", "otherscope"]}') + return url + + monkeypatch.setattr(SecurityHandlerFactory, 'client', FakeClient()) @pytest.fixture def security_handler_factory(): - security_handler_factory = FlaskSecurityHandlerFactory(None) + security_handler_factory = SecurityHandlerFactory(None) yield security_handler_factory diff --git a/tests/decorators/test_security.py b/tests/decorators/test_security.py index 60ab26260..14a624cb4 100644 --- a/tests/decorators/test_security.py +++ b/tests/decorators/test_security.py @@ -6,6 +6,7 @@ from connexion.exceptions import (BadRequestProblem, ConnexionException, OAuthProblem, OAuthResponseProblem, OAuthScopeProblem) +from connexion.security import SecurityHandlerFactory def test_get_tokeninfo_url(monkeypatch, security_handler_factory): @@ -43,10 +44,10 @@ def somefunc(token): assert wrapped_func(request) is security_handler_factory.no_value -def test_verify_oauth_scopes_remote(monkeypatch, security_handler_factory): +async def test_verify_oauth_scopes_remote(monkeypatch, security_handler_factory): tokeninfo = dict(uid="foo", scope="scope1 scope2") - def get_tokeninfo_response(*args, **kwargs): + async def get_tokeninfo_response(*args, **kwargs): tokeninfo_response = requests.Response() tokeninfo_response.status_code = requests.codes.ok tokeninfo_response._content = json.dumps(tokeninfo).encode() @@ -58,25 +59,25 @@ def get_tokeninfo_response(*args, **kwargs): request = MagicMock() request.headers = {"Authorization": "Bearer 123"} - session = MagicMock() - session.get = get_tokeninfo_response - monkeypatch.setattr('connexion.security.flask_security_handler_factory.session', session) + client = MagicMock() + client.get = get_tokeninfo_response + monkeypatch.setattr(SecurityHandlerFactory, 'client', client) with pytest.raises(OAuthScopeProblem, match="Provided token doesn't have the required scope"): - wrapped_func(request) + await wrapped_func(request) tokeninfo["scope"] += " admin" - assert wrapped_func(request) is not None + assert await wrapped_func(request) is not None tokeninfo["scope"] = ["foo", "bar"] with pytest.raises(OAuthScopeProblem, match="Provided token doesn't have the required scope"): - wrapped_func(request) + await wrapped_func(request) tokeninfo["scope"].append("admin") - assert wrapped_func(request) is not None + assert await wrapped_func(request) is not None -def test_verify_oauth_invalid_local_token_response_none(security_handler_factory): +async def test_verify_oauth_invalid_local_token_response_none(security_handler_factory): def somefunc(token): return None @@ -86,10 +87,10 @@ def somefunc(token): request.headers = {"Authorization": "Bearer 123"} with pytest.raises(OAuthResponseProblem): - wrapped_func(request) + await wrapped_func(request) -def test_verify_oauth_scopes_local(security_handler_factory): +async def test_verify_oauth_scopes_local(security_handler_factory): tokeninfo = dict(uid="foo", scope="scope1 scope2") def token_info(token): @@ -101,17 +102,17 @@ def token_info(token): request.headers = {"Authorization": "Bearer 123"} with pytest.raises(OAuthScopeProblem, match="Provided token doesn't have the required scope"): - wrapped_func(request) + await wrapped_func(request) tokeninfo["scope"] += " admin" - assert wrapped_func(request) is not None + assert await wrapped_func(request) is not None tokeninfo["scope"] = ["foo", "bar"] with pytest.raises(OAuthScopeProblem, match="Provided token doesn't have the required scope"): - wrapped_func(request) + await wrapped_func(request) tokeninfo["scope"].append("admin") - assert wrapped_func(request) is not None + assert await wrapped_func(request) is not None def test_verify_basic_missing_auth_header(security_handler_factory): @@ -168,7 +169,7 @@ def apikey_info(apikey, required_scopes=None): assert wrapped_func(request) is not None -def test_multiple_schemes(security_handler_factory): +async def test_multiple_schemes(security_handler_factory): def apikey1_info(apikey, required_scopes=None): if apikey == 'foobar': return {'sub': 'foo'} @@ -195,7 +196,7 @@ def apikey2_info(apikey, required_scopes=None): request = MagicMock() request.headers = {"X-Auth-2": 'bar'} - assert wrapped_func(request) is security_handler_factory.no_value + assert await wrapped_func(request) is security_handler_factory.no_value # Supplying both keys does succeed request = MagicMock() @@ -208,17 +209,16 @@ def apikey2_info(apikey, required_scopes=None): 'key1': {'sub': 'foo'}, 'key2': {'sub': 'bar'}, } - assert wrapped_func(request) == expected_token_info + assert await wrapped_func(request) == expected_token_info -def test_verify_security_oauthproblem(security_handler_factory): +async def test_verify_security_oauthproblem(security_handler_factory): """Tests whether verify_security raises an OAuthProblem if there are no auth_funcs.""" - func_to_secure = MagicMock(return_value='func') - secured_func = security_handler_factory.verify_security([], func_to_secure) + security_func = security_handler_factory.verify_security([], []) request = MagicMock() with pytest.raises(OAuthProblem) as exc_info: - secured_func(request) + await security_func(request) assert str(exc_info.value) == '401 Unauthorized: No authorization token provided' diff --git a/tests/fixtures/secure_api/swagger.yaml b/tests/fixtures/secure_api/swagger.yaml index 326d02aad..acad2a6fb 100644 --- a/tests/fixtures/secure_api/swagger.yaml +++ b/tests/fixtures/secure_api/swagger.yaml @@ -36,3 +36,4 @@ paths: description: Name of the person to greet. required: true type: string + format: path diff --git a/tests/fixtures/secure_endpoint/swagger.yaml b/tests/fixtures/secure_endpoint/swagger.yaml index 06e9d8315..6dfafc362 100644 --- a/tests/fixtures/secure_endpoint/swagger.yaml +++ b/tests/fixtures/secure_endpoint/swagger.yaml @@ -106,7 +106,7 @@ paths: required: true type: string - /byesecure-jwt/: + /byesecure-jwt/{name}: get: summary: Generate goodbye description: "" diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 28a0a01a9..5db57fa92 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -1,6 +1,5 @@ import pytest from connexion.middleware import ConnexionMiddleware -from connexion.middleware.routing import CONNEXION_CONTEXT from starlette.datastructures import MutableHeaders from conftest import SPECS, build_app_from_fixture @@ -13,7 +12,7 @@ def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): - operation_id = scope['extensions'][CONNEXION_CONTEXT]['operation_id'] + operation_id = scope['extensions']['connexion_routing']['operation_id'] async def patched_send(message): if message["type"] != "http.response.start": diff --git a/tests/test_mock.py b/tests/test_mock.py index f23db4afc..0b2bbace7 100644 --- a/tests/test_mock.py +++ b/tests/test_mock.py @@ -24,8 +24,6 @@ def test_mock_resolver_default(): }, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, parameter_definitions={}, resolver=resolver) @@ -57,8 +55,6 @@ def test_mock_resolver_numeric(): }, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, parameter_definitions={}, resolver=resolver) @@ -96,8 +92,6 @@ def test_mock_resolver_example(): }, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, parameter_definitions={}, resolver=resolver) @@ -133,8 +127,6 @@ def test_mock_resolver_example_nested_in_object(): }, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, parameter_definitions={}, resolver=resolver) @@ -168,8 +160,6 @@ def test_mock_resolver_example_nested_in_list(): }, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, parameter_definitions={}, resolver=resolver) @@ -274,8 +264,6 @@ def test_mock_resolver_no_example_nested_in_object(): }, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, parameter_definitions={}, resolver=resolver) @@ -334,8 +322,6 @@ def test_mock_resolver_no_examples(): }, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, parameter_definitions={}, resolver=resolver) @@ -363,8 +349,6 @@ def test_mock_resolver_notimplemented(): }, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, parameter_definitions={}, resolver=resolver) @@ -381,8 +365,6 @@ def test_mock_resolver_notimplemented(): }, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, parameter_definitions={}, resolver=resolver) diff --git a/tests/test_operation2.py b/tests/test_operation2.py index 692f5fbb4..ae0efc33e 100644 --- a/tests/test_operation2.py +++ b/tests/test_operation2.py @@ -9,6 +9,7 @@ from connexion.apis.flask_api import Jsonifier from connexion.exceptions import InvalidSpecification from connexion.json_schema import resolve_refs +from connexion.middleware.security import SecurityOperation from connexion.operations import Swagger2Operation from connexion.resolver import Resolver @@ -280,10 +281,6 @@ def make_operation(op, definitions=True, parameters=True): def test_operation(api, security_handler_factory): - verify_oauth = mock.MagicMock(return_value='verify_oauth_result') - security_handler_factory.verify_oauth = verify_oauth - security_handler_factory.get_token_info_remote = mock.MagicMock(return_value='get_token_info_remote_result') - op_spec = make_operation(OPERATION1) operation = Swagger2Operation(api=api, method='GET', @@ -292,29 +289,34 @@ def test_operation(api, security_handler_factory): operation=op_spec, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions=SECURITY_DEFINITIONS_REMOTE, definitions=DEFINITIONS, parameter_definitions=PARAMETER_DEFINITIONS, resolver=Resolver()) - assert isinstance(operation.function, types.FunctionType) - - security_decorator = operation.security_decorator - assert len(security_decorator.args[0]) == 1 - assert security_decorator.args[0][0] == 'verify_oauth_result' - verify_oauth.assert_called_with('get_token_info_remote_result', security_handler_factory.validate_scope, ['uid']) - security_handler_factory.get_token_info_remote.assert_called_with('https://oauth.example/token_info') assert operation.method == 'GET' assert operation.produces == ['application/json'] assert operation.consumes == ['application/json'] - assert operation.security == [{'oauth': ['uid']}] expected_body_schema = op_spec["parameters"][0]["schema"] expected_body_schema.update({'definitions': DEFINITIONS}) assert operation.body_schema == expected_body_schema +def test_operation_remote_token_info(security_handler_factory): + verify_oauth = mock.MagicMock(return_value='verify_oauth_result') + security_handler_factory.verify_oauth = verify_oauth + security_handler_factory.get_token_info_remote = mock.MagicMock(return_value='get_token_info_remote_result') + + SecurityOperation(security_handler_factory=security_handler_factory, + security=[{'oauth': ['uid']}], + security_schemes=SECURITY_DEFINITIONS_REMOTE) + + verify_oauth.assert_called_with('get_token_info_remote_result', + security_handler_factory.validate_scope, + ['uid']) + security_handler_factory.get_token_info_remote.assert_called_with('https://oauth.example/token_info') + + def test_operation_array(api): op_spec = make_operation(OPERATION7) operation = Swagger2Operation(api=api, @@ -324,8 +326,6 @@ def test_operation_array(api): operation=op_spec, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions=SECURITY_DEFINITIONS_REMOTE, definitions=DEFINITIONS, parameter_definitions=PARAMETER_DEFINITIONS, resolver=Resolver()) @@ -334,7 +334,7 @@ def test_operation_array(api): assert operation.method == 'GET' assert operation.produces == ['application/json'] assert operation.consumes == ['application/json'] - assert operation.security == [{'oauth': ['uid']}] + expected_body_schema = { 'type': 'array', 'items': DEFINITIONS["new_stack"], @@ -352,8 +352,6 @@ def test_operation_composed_definition(api): operation=op_spec, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions=SECURITY_DEFINITIONS_REMOTE, definitions=DEFINITIONS, parameter_definitions=PARAMETER_DEFINITIONS, resolver=Resolver()) @@ -362,75 +360,32 @@ def test_operation_composed_definition(api): assert operation.method == 'GET' assert operation.produces == ['application/json'] assert operation.consumes == ['application/json'] - assert operation.security == [{'oauth': ['uid']}] + expected_body_schema = op_spec["parameters"][0]["schema"] expected_body_schema.update({'definitions': DEFINITIONS}) assert operation.body_schema == expected_body_schema -def test_operation_local_security_oauth2(api): +def test_operation_local_security_oauth2(security_handler_factory): verify_oauth = mock.MagicMock(return_value='verify_oauth_result') - api.security_handler_factory.verify_oauth = verify_oauth + security_handler_factory.verify_oauth = verify_oauth - op_spec = make_operation(OPERATION8) - operation = Swagger2Operation(api=api, - method='GET', - path='endpoint', - path_parameters=[], - operation=op_spec, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions=SECURITY_DEFINITIONS_LOCAL, - definitions=DEFINITIONS, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=Resolver()) - assert isinstance(operation.function, types.FunctionType) - security_decorator = operation.security_decorator - assert len(security_decorator.args[0]) == 1 - assert security_decorator.args[0][0] == 'verify_oauth_result' - verify_oauth.assert_called_with(math.ceil, api.security_handler_factory.validate_scope, ['uid']) + SecurityOperation(security_handler_factory=security_handler_factory, + security=[{'oauth': ['uid']}], + security_schemes=SECURITY_DEFINITIONS_LOCAL) - assert operation.method == 'GET' - assert operation.produces == ['application/json'] - assert operation.consumes == ['application/json'] - assert operation.security == [{'oauth': ['uid']}] - expected_body_schema = op_spec["parameters"][0]["schema"] - expected_body_schema.update({'definitions': DEFINITIONS}) - assert operation.body_schema == expected_body_schema + verify_oauth.assert_called_with(math.ceil, security_handler_factory.validate_scope, ['uid']) -def test_operation_local_security_duplicate_token_info(api): +def test_operation_local_security_duplicate_token_info(security_handler_factory): verify_oauth = mock.MagicMock(return_value='verify_oauth_result') - api.security_handler_factory.verify_oauth = verify_oauth - - op_spec = make_operation(OPERATION8) - operation = Swagger2Operation(api=api, - method='GET', - path='endpoint', - path_parameters=[], - operation=op_spec, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions=SECURITY_DEFINITIONS_BOTH, - definitions=DEFINITIONS, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=Resolver()) - assert isinstance(operation.function, types.FunctionType) + security_handler_factory.verify_oauth = verify_oauth - security_decorator = operation.security_decorator - assert len(security_decorator.args[0]) == 1 - assert security_decorator.args[0][0] == 'verify_oauth_result' - verify_oauth.call_args.assert_called_with(math.ceil, api.security_handler_factory.validate_scope, ['uid']) + SecurityOperation(security_handler_factory, + security=[{'oauth': ['uid']}], + security_schemes=SECURITY_DEFINITIONS_BOTH) - assert operation.method == 'GET' - assert operation.produces == ['application/json'] - assert operation.consumes == ['application/json'] - assert operation.security == [{'oauth': ['uid']}] - expected_body_schema = op_spec["parameters"][0]["schema"] - expected_body_schema.update({'definitions': DEFINITIONS}) - assert operation.body_schema == expected_body_schema + verify_oauth.call_args.assert_called_with(math.ceil, security_handler_factory.validate_scope) def test_multi_body(api): @@ -443,8 +398,6 @@ def test_multi_body(api): operation=op_spec, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions=DEFINITIONS, parameter_definitions=PARAMETER_DEFINITIONS, resolver=Resolver()) @@ -455,58 +408,27 @@ def test_multi_body(api): assert repr(exception) == """""" -def test_no_token_info(api): - op_spec = make_operation(OPERATION1) - operation = Swagger2Operation(api=api, - method='GET', - path='endpoint', - path_parameters=[], - operation=op_spec, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=SECURITY_DEFINITIONS_WO_INFO, - security_definitions=SECURITY_DEFINITIONS_WO_INFO, - definitions=DEFINITIONS, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=Resolver()) - assert isinstance(operation.function, types.FunctionType) - - security_decorator = operation.security_decorator - assert len(security_decorator.args[0]) == 0 - - assert operation.method == 'GET' - assert operation.produces == ['application/json'] - assert operation.consumes == ['application/json'] - assert operation.security == [{'oauth': ['uid']}] - - expected_body_schema = {'definitions': DEFINITIONS} - expected_body_schema.update(DEFINITIONS["new_stack"]) - assert operation.body_schema == expected_body_schema +def test_no_token_info(security_handler_factory): + SecurityOperation(security_handler_factory=security_handler_factory, + security=[{'oauth': ['uid']}], + security_schemes=SECURITY_DEFINITIONS_WO_INFO) -def test_multiple_security_schemes_and(api): +def test_multiple_security_schemes_and(security_handler_factory): """Tests an operation with multiple security schemes in AND fashion.""" def return_api_key_name(func, in_, name): return name verify_api_key = mock.MagicMock(side_effect=return_api_key_name) - api.security_handler_factory.verify_api_key = verify_api_key + security_handler_factory.verify_api_key = verify_api_key verify_multiple = mock.MagicMock(return_value='verify_multiple_result') - api.security_handler_factory.verify_multiple_schemes = verify_multiple + security_handler_factory.verify_multiple_schemes = verify_multiple + + security = [{'key1': [], 'key2': []}] + + SecurityOperation(security_handler_factory=security_handler_factory, + security=security, + security_schemes=SECURITY_DEFINITIONS_2_KEYS) - op_spec = make_operation(OPERATION9) - operation = Swagger2Operation(api=api, - method='GET', - path='endpoint', - path_parameters=[], - operation=op_spec, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=SECURITY_DEFINITIONS_2_KEYS, - security_definitions=SECURITY_DEFINITIONS_2_KEYS, - definitions=DEFINITIONS, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=Resolver()) - assert isinstance(operation.function, types.FunctionType) assert verify_api_key.call_count == 2 verify_api_key.assert_any_call(math.ceil, 'header', 'X-Auth-1') verify_api_key.assert_any_call(math.ceil, 'header', 'X-Auth-2') @@ -514,50 +436,23 @@ def return_api_key_name(func, in_, name): # to result of security_handler_factory.verify_api_key() verify_multiple.assert_called_with({'key1': 'X-Auth-1', 'key2': 'X-Auth-2'}) - security_decorator = operation.security_decorator - assert len(security_decorator.args[0]) == 1 - assert security_decorator.args[0][0] == 'verify_multiple_result' - - assert operation.method == 'GET' - assert operation.produces == ['application/json'] - assert operation.consumes == ['application/json'] - assert operation.security == [{'key1': [], 'key2': []}] - -def test_multiple_oauth_in_and(api, caplog): +def test_multiple_oauth_in_and(security_handler_factory, caplog): """Tests an operation with multiple oauth security schemes in AND fashion. These should be ignored and raise a warning. """ caplog.set_level(logging.WARNING, logger="connexion.operations.secure") verify_oauth = mock.MagicMock(return_value='verify_oauth_result') - api.security_handler_factory.verify_oauth = verify_oauth + security_handler_factory.verify_oauth = verify_oauth - op_spec = make_operation(OPERATION10) - operation = Swagger2Operation(api=api, - method='GET', - path='endpoint', - path_parameters=[], - operation=op_spec, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=SECURITY_DEFINITIONS_2_OAUTH, - security_definitions=SECURITY_DEFINITIONS_2_OAUTH, - definitions=DEFINITIONS, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=Resolver()) - assert isinstance(operation.function, types.FunctionType) + security = [{'oauth_1': ['uid'], 'oauth_2': ['uid']}] - security_decorator = operation.security_decorator - assert len(security_decorator.args[0]) == 0 - assert security_decorator.args[0] == [] + SecurityOperation(security_handler_factory=security_handler_factory, + security=security, + security_schemes=SECURITY_DEFINITIONS_2_OAUTH) assert '... multiple OAuth2 security schemes in AND fashion not supported' in caplog.text - assert operation.method == 'GET' - assert operation.produces == ['application/json'] - assert operation.consumes == ['application/json'] - assert operation.security == [{'oauth_1': ['uid'], 'oauth_2': ['uid']}] - def test_parameter_reference(api): op_spec = make_operation(OPERATION3, definitions=False) @@ -568,8 +463,6 @@ def test_parameter_reference(api): operation=op_spec, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, parameter_definitions=PARAMETER_DEFINITIONS, resolver=Resolver()) @@ -582,8 +475,7 @@ def test_default(api): Swagger2Operation( api=api, method='GET', path='endpoint', path_parameters=[], operation=op_spec, app_produces=['application/json'], - app_consumes=['application/json'], app_security=[], - security_definitions={}, definitions=DEFINITIONS, + app_consumes=['application/json'], definitions=DEFINITIONS, parameter_definitions=PARAMETER_DEFINITIONS, resolver=Resolver() ) op_spec = make_operation(OPERATION6, parameters=False) @@ -596,8 +488,7 @@ def test_default(api): Swagger2Operation( api=api, method='POST', path='endpoint', path_parameters=[], operation=op_spec, app_produces=['application/json'], - app_consumes=['application/json'], app_security=[], - security_definitions={}, definitions=DEFINITIONS, + app_consumes=['application/json'], definitions=DEFINITIONS, parameter_definitions={}, resolver=Resolver() ) @@ -620,35 +511,18 @@ def test_get_path_parameter_types(api): assert {'int_path': 'int', 'string_path': 'string', 'path_path': 'path'} == operation.get_path_parameter_types() -def test_oauth_scopes_in_or(api): +def test_oauth_scopes_in_or(security_handler_factory): """Tests whether an OAuth security scheme with 2 different possible scopes is correctly handled.""" verify_oauth = mock.MagicMock(return_value='verify_oauth_result') - api.security_handler_factory.verify_oauth = verify_oauth + security_handler_factory.verify_oauth = verify_oauth + + security = [{'oauth': ['myscope']}, {'oauth': ['myscope2']}] + + SecurityOperation(security_handler_factory=security_handler_factory, + security=security, + security_schemes=SECURITY_DEFINITIONS_LOCAL) - op_spec = make_operation(OPERATION11) - operation = Swagger2Operation(api=api, - method='GET', - path='endpoint', - path_parameters=[], - operation=op_spec, - app_produces=['application/json'], - app_consumes=['application/json'], - app_security=[], - security_definitions=SECURITY_DEFINITIONS_LOCAL, - definitions=DEFINITIONS, - parameter_definitions=PARAMETER_DEFINITIONS, - resolver=Resolver()) - assert isinstance(operation.function, types.FunctionType) - security_decorator = operation.security_decorator - assert len(security_decorator.args[0]) == 2 - assert security_decorator.args[0][0] == 'verify_oauth_result' - assert security_decorator.args[0][1] == 'verify_oauth_result' verify_oauth.assert_has_calls([ - mock.call(math.ceil, api.security_handler_factory.validate_scope, ['myscope']), - mock.call(math.ceil, api.security_handler_factory.validate_scope, ['myscope2']), + mock.call(math.ceil, security_handler_factory.validate_scope, ['myscope']), + mock.call(math.ceil, security_handler_factory.validate_scope, ['myscope2']), ]) - - assert operation.method == 'GET' - assert operation.produces == ['application/json'] - assert operation.consumes == ['application/json'] - assert operation.security == [{'oauth': ['myscope']}, {'oauth': ['myscope2']}] diff --git a/tests/test_resolver.py b/tests/test_resolver.py index ece8a53b7..e92e7f9f2 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -55,8 +55,6 @@ def test_standard_resolve_x_router_controller(): }, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, parameter_definitions=PARAMETER_DEFINITIONS, resolver=Resolver()) @@ -74,8 +72,6 @@ def test_relative_resolve_x_router_controller(): }, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, parameter_definitions=PARAMETER_DEFINITIONS, resolver=RelativeResolver('root_path')) @@ -92,8 +88,6 @@ def test_relative_resolve_operation_id(): }, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, parameter_definitions=PARAMETER_DEFINITIONS, resolver=RelativeResolver('fakeapi')) @@ -111,8 +105,6 @@ def test_relative_resolve_operation_id_with_module(): }, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, parameter_definitions=PARAMETER_DEFINITIONS, resolver=RelativeResolver(fakeapi)) @@ -129,8 +121,6 @@ def test_resty_resolve_operation_id(): }, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, parameter_definitions=PARAMETER_DEFINITIONS, resolver=RestyResolver('fakeapi')) @@ -148,8 +138,6 @@ def test_resty_resolve_x_router_controller_with_operation_id(): }, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, parameter_definitions=PARAMETER_DEFINITIONS, resolver=RestyResolver('fakeapi')) @@ -164,8 +152,6 @@ def test_resty_resolve_x_router_controller_without_operation_id(): operation={'x-swagger-router-controller': 'fakeapi.hello'}, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, parameter_definitions=PARAMETER_DEFINITIONS, resolver=RestyResolver('fakeapi')) @@ -180,8 +166,6 @@ def test_resty_resolve_with_default_module_name(): operation={}, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, parameter_definitions=PARAMETER_DEFINITIONS, resolver=RestyResolver('fakeapi')) @@ -196,8 +180,6 @@ def test_resty_resolve_with_default_module_name_nested(): operation={}, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, parameter_definitions=PARAMETER_DEFINITIONS, resolver=RestyResolver('fakeapi')) @@ -212,8 +194,6 @@ def test_resty_resolve_with_default_module_name_lowercase_verb(): operation={}, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, parameter_definitions=PARAMETER_DEFINITIONS, resolver=RestyResolver('fakeapi')) @@ -227,8 +207,6 @@ def test_resty_resolve_with_default_module_name_lowercase_verb_nested(): operation={}, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, parameter_definitions=PARAMETER_DEFINITIONS, resolver=RestyResolver('fakeapi')) @@ -243,8 +221,6 @@ def test_resty_resolve_with_default_module_name_will_translate_dashes_in_resourc operation={}, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, parameter_definitions=PARAMETER_DEFINITIONS, resolver=RestyResolver('fakeapi')) @@ -259,8 +235,6 @@ def test_resty_resolve_with_default_module_name_can_resolve_api_root(): operation={}, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, parameter_definitions=PARAMETER_DEFINITIONS, resolver=RestyResolver('fakeapi')) @@ -275,8 +249,6 @@ def test_resty_resolve_with_default_module_name_will_resolve_resource_root_get_a operation={}, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, parameter_definitions=PARAMETER_DEFINITIONS, resolver=RestyResolver('fakeapi')) @@ -293,8 +265,6 @@ def test_resty_resolve_with_default_module_name_and_x_router_controller_will_res }, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, parameter_definitions=PARAMETER_DEFINITIONS, resolver=RestyResolver('fakeapi')) @@ -309,8 +279,6 @@ def test_resty_resolve_with_default_module_name_will_resolve_resource_root_as_co operation={}, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, parameter_definitions=PARAMETER_DEFINITIONS, resolver=RestyResolver('fakeapi', 'api_list')) @@ -325,8 +293,6 @@ def test_resty_resolve_with_default_module_name_will_resolve_resource_root_post_ operation={}, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, parameter_definitions=PARAMETER_DEFINITIONS, resolver=RestyResolver('fakeapi')) From 972d1a9f9c113a2deedec7b2e345a4983293a63c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Car=C3=ADcio?= Date: Wed, 27 Apr 2022 22:27:10 +0200 Subject: [PATCH 08/20] Remove myself as maintainer (#1517) --- MAINTAINERS | 1 - 1 file changed, 1 deletion(-) diff --git a/MAINTAINERS b/MAINTAINERS index 37d6398e7..8e5a26b3a 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -1,6 +1,5 @@ João Santos Henning Jacobs -Rafael Caricio Daniel Grossmann-Kavanagh Ruwan Lambrichts Robbe Sneyders From 156bf79ee68a82feaf02840f5ba6d1f3ee92291c Mon Sep 17 00:00:00 2001 From: Robbe Sneyders Date: Wed, 4 May 2022 17:31:04 +0200 Subject: [PATCH 09/20] Merge V2 to main (#1518) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix uri parsing for query parameter with empty brackets (#1501) * Update tests for changed werkzeug behavior in 2.1 (#1506) https://github.com/pallets/werkzeug/issues/2352 * Bugfix/async security check (#1512) * Add failing tests * Use for else construct * openapi: remove JSON body second validation and type casting (#1170) * openapi: remove body preprocessing Body is already validated using jsonschema. There was also some type casting but it was wrong: e.g. not recurring deeply into dicts and lists, relying on existence of "type" in schema (which is not there e.g. if oneOf is used). Anyway, the only reason why types should be casted is converting integer values to float if the type is number. But this is in most cases irrelevant. Added an example, which did not work before this commit (echoed `{}`) e.g. for ``` curl localhost:8080/api/foo -H 'content-type: application/json' -d '{"foo": 1}' ``` but now the example works (echoes `{"foo": 1}`). * test with oneOf in the requestBody * remove oneof examples: superseded by tests Co-authored-by: Pavol Vargovcik Co-authored-by: Ruwann Co-authored-by: Pavol Vargovčík Co-authored-by: Pavol Vargovcik --- connexion/decorators/parameter.py | 18 ++++++---- connexion/operations/openapi.py | 38 +++++++++++---------- tests/api/test_responses.py | 31 +++++++++++++++++ tests/api/test_secure_api.py | 4 +++ tests/decorators/test_parameter.py | 7 +++- tests/fakeapi/auth.py | 7 +++- tests/fakeapi/hello/__init__.py | 8 +++++ tests/fixtures/secure_endpoint/openapi.yaml | 16 +++++++++ tests/fixtures/secure_endpoint/swagger.yaml | 17 +++++++++ tests/fixtures/simple/openapi.yaml | 17 +++++++++ 10 files changed, 136 insertions(+), 27 deletions(-) diff --git a/connexion/decorators/parameter.py b/connexion/decorators/parameter.py index c4a7a77e0..bcd61adf7 100644 --- a/connexion/decorators/parameter.py +++ b/connexion/decorators/parameter.py @@ -48,6 +48,17 @@ def snake_and_shadow(name): return snake +def sanitized(name): + return name and re.sub('^[^a-zA-Z_]+', '', + re.sub('[^0-9a-zA-Z_]', '', + re.sub(r'\[(?!])', '_', name))) + + +def pythonic(name): + name = name and snake_and_shadow(name) + return sanitized(name) + + def parameter_to_arg(operation, function, pythonic_params=False, pass_context_arg_name=None): """ @@ -65,13 +76,6 @@ def parameter_to_arg(operation, function, pythonic_params=False, """ consumes = operation.consumes - def sanitized(name): - return name and re.sub('^[^a-zA-Z_]+', '', re.sub('[^0-9a-zA-Z[_]', '', re.sub(r'[\[]', '_', name))) - - def pythonic(name): - name = name and snake_and_shadow(name) - return sanitized(name) - sanitize = pythonic if pythonic_params else sanitized arguments, has_kwargs = inspect_function_arguments(function) diff --git a/connexion/operations/openapi.py b/connexion/operations/openapi.py index 69c4404c5..0f9a55839 100644 --- a/connexion/operations/openapi.py +++ b/connexion/operations/openapi.py @@ -9,6 +9,7 @@ from connexion.operations.abstract import AbstractOperation from ..decorators.uri_parsing import OpenAPIURIParser +from ..http_facts import FORM_CONTENT_TYPES from ..utils import deep_get, deep_merge, is_null, is_nullable, make_type logger = logging.getLogger("connexion.operations.openapi3") @@ -281,13 +282,28 @@ def _get_body_argument(self, body, arguments, has_kwargs, sanitize): 'the requestBody instead.', DeprecationWarning) x_body_name = sanitize(self.body_schema.get('x-body-name', 'body')) + if self.consumes[0] in FORM_CONTENT_TYPES: + result = self._get_body_argument_form(body) + else: + result = self._get_body_argument_json(body) + + if x_body_name in arguments or has_kwargs: + return {x_body_name: result} + return {} + + def _get_body_argument_json(self, body): # if the body came in null, and the schema says it can be null, we decide # to include no value for the body argument, rather than the default body if is_nullable(self.body_schema) and is_null(body): - if x_body_name in arguments or has_kwargs: - return {x_body_name: None} - return {} + return None + + if body is None: + default_body = self.body_schema.get('default', {}) + return deepcopy(default_body) + return body + + def _get_body_argument_form(self, body): # now determine the actual value for the body (whether it came in or is default) default_body = self.body_schema.get('default', {}) body_props = {k: {"schema": v} for k, v @@ -297,25 +313,11 @@ def _get_body_argument(self, body, arguments, has_kwargs, sanitize): # see: https://github.com/OAI/OpenAPI-Specification/blame/3.0.2/versions/3.0.2.md#L2305 additional_props = self.body_schema.get("additionalProperties", True) - if body is None: - body = deepcopy(default_body) - - # if the body isn't even an object, then none of the concerns below matter - if self.body_schema.get("type") != "object": - if x_body_name in arguments or has_kwargs: - return {x_body_name: body} - return {} - - # supply the initial defaults and convert all values to the proper types by schema body_arg = deepcopy(default_body) body_arg.update(body or {}) - res = {} if body_props or additional_props: - res = self._get_typed_body_values(body_arg, body_props, additional_props) - - if x_body_name in arguments or has_kwargs: - return {x_body_name: res} + return self._get_typed_body_values(body_arg, body_props, additional_props) return {} def _get_typed_body_values(self, body_arg, body_props, additional_props): diff --git a/tests/api/test_responses.py b/tests/api/test_responses.py index 6781f4445..406f0d1cd 100644 --- a/tests/api/test_responses.py +++ b/tests/api/test_responses.py @@ -388,3 +388,34 @@ def test_streaming_response(simple_app): app_client = simple_app.app.test_client() resp = app_client.get('/v1.0/get_streaming_response') assert resp.status_code == 200 + + +def test_oneof(simple_openapi_app): + app_client = simple_openapi_app.app.test_client() + + post_greeting = app_client.post( # type: flask.Response + '/v1.0/oneof_greeting', + data=json.dumps({"name": 3}), + content_type="application/json" + ) + assert post_greeting.status_code == 200 + assert post_greeting.content_type == 'application/json' + greeting_reponse = json.loads(post_greeting.data.decode('utf-8', 'replace')) + assert greeting_reponse['greeting'] == 'Hello 3' + + post_greeting = app_client.post( # type: flask.Response + '/v1.0/oneof_greeting', + data=json.dumps({"name": True}), + content_type="application/json" + ) + assert post_greeting.status_code == 200 + assert post_greeting.content_type == 'application/json' + greeting_reponse = json.loads(post_greeting.data.decode('utf-8', 'replace')) + assert greeting_reponse['greeting'] == 'Hello True' + + post_greeting = app_client.post( # type: flask.Response + '/v1.0/oneof_greeting', + data=json.dumps({"name": "jsantos"}), + content_type="application/json" + ) + assert post_greeting.status_code == 400 diff --git a/tests/api/test_secure_api.py b/tests/api/test_secure_api.py index 1e53cbd66..6f14336cd 100644 --- a/tests/api/test_secure_api.py +++ b/tests/api/test_secure_api.py @@ -96,6 +96,10 @@ def test_security(oauth_requests, secure_endpoint_app): assert response.data == b'"Unauthenticated"\n' assert response.status_code == 200 + # security function throws exception + response = app_client.get('/v1.0/auth-exception', headers={'X-Api-Key': 'foo'}) + assert response.status_code == 401 + def test_checking_that_client_token_has_all_necessary_scopes( oauth_requests, secure_endpoint_app): diff --git a/tests/decorators/test_parameter.py b/tests/decorators/test_parameter.py index 990065dc5..269693bb7 100644 --- a/tests/decorators/test_parameter.py +++ b/tests/decorators/test_parameter.py @@ -1,6 +1,6 @@ from unittest.mock import MagicMock -from connexion.decorators.parameter import parameter_to_arg +from connexion.decorators.parameter import parameter_to_arg, pythonic def test_injection(): @@ -25,3 +25,8 @@ def get_arguments(self, *args, **kwargs): parameter_to_arg(Op(), handler, pass_context_arg_name='framework_request_ctx')(request) func.assert_called_with(p1='123', framework_request_ctx=request.context) + + +def test_pythonic_params(): + assert pythonic('orderBy[eq]') == 'order_by_eq' + assert pythonic('ids[]') == 'ids' diff --git a/tests/fakeapi/auth.py b/tests/fakeapi/auth.py index 2aa7bff4e..ca6402505 100644 --- a/tests/fakeapi/auth.py +++ b/tests/fakeapi/auth.py @@ -1,6 +1,7 @@ -import asyncio import json +from connexion.exceptions import OAuthProblem + def fake_basic_auth(username, password, required_scopes=None): if username == password: @@ -13,3 +14,7 @@ def fake_json_auth(token, required_scopes=None): return json.loads(token) except ValueError: return None + + +async def async_auth_exception(token, required_scopes=None, request=None): + raise OAuthProblem diff --git a/tests/fakeapi/hello/__init__.py b/tests/fakeapi/hello/__init__.py index fdf76969c..b2b265fda 100644 --- a/tests/fakeapi/hello/__init__.py +++ b/tests/fakeapi/hello/__init__.py @@ -3,6 +3,7 @@ import uuid from connexion import NoContent, ProblemException, context, request +from connexion.exceptions import OAuthProblem from flask import jsonify, redirect, send_file @@ -463,6 +464,9 @@ def optional_auth(**kwargs): return "Authenticated" +def auth_exception(): + return 'foo' + def test_args_kwargs(*args, **kwargs): return kwargs @@ -569,6 +573,10 @@ def jwt_info(token): return None +def apikey_exception(token): + raise OAuthProblem() + + def get_add_operation_on_http_methods_only(): return "" diff --git a/tests/fixtures/secure_endpoint/openapi.yaml b/tests/fixtures/secure_endpoint/openapi.yaml index d4dcf3a94..d3d16b83b 100644 --- a/tests/fixtures/secure_endpoint/openapi.yaml +++ b/tests/fixtures/secure_endpoint/openapi.yaml @@ -138,6 +138,17 @@ paths: responses: '200': description: some response + /auth-exception: + get: + summary: Test security handler function that raises an exception + description: Throw error from security function + operationId: fakeapi.hello.auth_exception + security: + - auth_exception: [] + responses: + '200': + description: some response + servers: - url: /v1.0 components: @@ -161,3 +172,8 @@ components: scheme: bearer bearerFormat: JWT x-bearerInfoFunc: fakeapi.hello.jwt_info + auth_exception: + type: apiKey + name: X-Api-Key + in: header + x-apikeyInfoFunc: fakeapi.hello.apikey_exception diff --git a/tests/fixtures/secure_endpoint/swagger.yaml b/tests/fixtures/secure_endpoint/swagger.yaml index 6dfafc362..53f9c2b60 100644 --- a/tests/fixtures/secure_endpoint/swagger.yaml +++ b/tests/fixtures/secure_endpoint/swagger.yaml @@ -29,6 +29,12 @@ securityDefinitions: x-authentication-scheme: Bearer x-bearerInfoFunc: fakeapi.hello.jwt_info + auth_exception: + type: apiKey + name: X-Api-Key + in: header + x-apikeyInfoFunc: fakeapi.hello.apikey_exception + paths: /byesecure/{name}: get: @@ -171,3 +177,14 @@ paths: responses: '200': description: some response + + /auth-exception: + get: + summary: Test security handler function that raises an exception + description: Throw error from security function + operationId: fakeapi.hello.auth_exception + security: + - auth_exception: [] + responses: + '200': + description: some response diff --git a/tests/fixtures/simple/openapi.yaml b/tests/fixtures/simple/openapi.yaml index 749abcb3f..9ab6f6915 100644 --- a/tests/fixtures/simple/openapi.yaml +++ b/tests/fixtures/simple/openapi.yaml @@ -1222,6 +1222,23 @@ paths: schema: type: string format: binary + /oneof_greeting: + post: + operationId: fakeapi.hello.post_greeting3 + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + oneOf: + - {type: boolean} + - {type: number} + additionalProperties: false + responses: + '200': + description: Echo the validated request. servers: - url: http://localhost:{port}/{basePath} From d2391a289806f8cba780da2493480be18b51d704 Mon Sep 17 00:00:00 2001 From: Jonas Boecquaert Date: Thu, 12 May 2022 18:23:42 +0200 Subject: [PATCH 10/20] Fix deprecation warning for Validator.iter_errors (#1536) * Fix deprecation warning for iter_errors Bump jsonschema version to at least v4 * Bump jsonschema to v4.0.1 * Provide schema as keyword arg to evolve * Fix evolve statement --- connexion/spec.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/connexion/spec.py b/connexion/spec.py index 5b0a1a24f..02acfd59f 100644 --- a/connexion/spec.py +++ b/connexion/spec.py @@ -47,7 +47,7 @@ def validate_defaults(validator, properties, instance, schema): if not valid: return if isinstance(instance, dict) and 'default' in instance: - for error in instance_validator.iter_errors(instance['default'], instance): + for error in instance_validator.evolve(schema=instance).iter_errors(instance['default']): yield error SpecValidator = extend_validator(Draft4Validator, {"properties": validate_defaults}) diff --git a/setup.py b/setup.py index 2b5b067ba..0ebf999cc 100755 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ def read_version(package): install_requires = [ 'clickclick>=1.2,<21', - 'jsonschema>=2.5.1,<5', + 'jsonschema>=4.0.1,<5', 'PyYAML>=5.1,<7', 'requests>=2.27,<3', 'inflection>=0.3.1,<0.6', From 7d23a1e1ed51d6aa3f36ea38b0bfd368e2a48548 Mon Sep 17 00:00:00 2001 From: Robbe Sneyders Date: Fri, 13 May 2022 15:04:36 +0200 Subject: [PATCH 11/20] Refactor routing into middleware-api-operation model (#1533) * Rename MiddlewareAPI to RoutingAPI * Refactor routing into middleware-api-operation model --- connexion/apis/__init__.py | 2 +- connexion/apis/abstract.py | 4 +- connexion/handlers.py | 7 ++ connexion/middleware/routing.py | 120 ++++++++++---------------------- 4 files changed, 47 insertions(+), 86 deletions(-) diff --git a/connexion/apis/__init__.py b/connexion/apis/__init__.py index da5a4bfdc..4bb486f47 100644 --- a/connexion/apis/__init__.py +++ b/connexion/apis/__init__.py @@ -13,5 +13,5 @@ """ -from .abstract import (AbstractAPI, AbstractMinimalAPI, # NOQA +from .abstract import (AbstractAPI, AbstractRoutingAPI, # NOQA AbstractSwaggerUIAPI) diff --git a/connexion/apis/abstract.py b/connexion/apis/abstract.py index 28cc9aaca..45ec68f5b 100644 --- a/connexion/apis/abstract.py +++ b/connexion/apis/abstract.py @@ -116,7 +116,7 @@ def add_swagger_ui(self): """ -class AbstractMinimalAPI(AbstractSpecAPI): +class AbstractRoutingAPI(AbstractSpecAPI): def __init__( self, @@ -203,7 +203,7 @@ def _handle_add_operation_error(self, path: str, method: str, exc_info: tuple): raise value.with_traceback(traceback) -class AbstractAPI(AbstractMinimalAPI, metaclass=AbstractAPIMeta): +class AbstractAPI(AbstractRoutingAPI, metaclass=AbstractAPIMeta): """ Defines an abstract interface for a Swagger API """ diff --git a/connexion/handlers.py b/connexion/handlers.py index c5198d2a4..2ee11d029 100644 --- a/connexion/handlers.py +++ b/connexion/handlers.py @@ -41,3 +41,10 @@ def randomize_endpoint(self): def get_path_parameter_types(self): return {} + + async def __call__(self, *args, **kwargs): + raise ResolverProblem( + title='Not Implemented', + detail=self.exception.reason, + status=self.status_code + ) diff --git a/connexion/middleware/routing.py b/connexion/middleware/routing.py index 9f5444dcd..f264f0125 100644 --- a/connexion/middleware/routing.py +++ b/connexion/middleware/routing.py @@ -1,33 +1,19 @@ import pathlib import typing as t -from contextlib import contextmanager from contextvars import ContextVar -from starlette.requests import Request as StarletteRequest from starlette.routing import Router from starlette.types import ASGIApp, Receive, Scope, Send -from connexion.apis import AbstractMinimalAPI +from connexion.apis import AbstractRoutingAPI from connexion.exceptions import NotFoundProblem from connexion.middleware import AppMiddleware -from connexion.operations import AbstractOperation, make_operation from connexion.resolver import Resolver ROUTING_CONTEXT = 'connexion_routing' -_scope_receive_send: ContextVar[tuple] = ContextVar('SCOPE_RECEIVE_SEND') - - -class MiddlewareResolver(Resolver): - - def __init__(self, call_next: t.Callable) -> None: - """Resolver that resolves each operation to the provided call_next function.""" - super().__init__() - self.call_next = call_next - - def resolve_function_from_operation_id(self, operation_id: str) -> t.Callable: - return self.call_next +_scope: ContextVar[dict] = ContextVar('SCOPE') class RoutingMiddleware(AppMiddleware): @@ -40,7 +26,7 @@ def __init__(self, app: ASGIApp) -> None: """ self.app = app # Pass unknown routes to next app - self.router = Router(default=self.default_fn) + self.router = Router(default=RoutingOperation(None, self.app)) def add_api( self, @@ -55,10 +41,8 @@ def add_api( :param base_path: Base path where to add this API. :param arguments: Jinja arguments to replace in the spec. """ - kwargs.pop("resolver", None) - resolver = MiddlewareResolver(self.create_call_next()) - api = MiddlewareAPI(specification, base_path=base_path, arguments=arguments, - resolver=resolver, default=self.default_fn, **kwargs) + api = RoutingAPI(specification, base_path=base_path, arguments=arguments, + next_app=self.app, **kwargs) self.router.mount(api.base_path, app=api.router) async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: @@ -68,7 +52,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await self.app(scope, receive, send) return - _scope_receive_send.set((scope.copy(), receive, send)) + _scope.set(scope.copy()) # Needs to be set so starlette router throws exceptions instead of returning error responses scope['app'] = self @@ -77,42 +61,8 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: except ValueError: raise NotFoundProblem - async def default_fn(self, scope: Scope, receive: Receive, send: Send) -> None: - """Callback to call next app as default when no matching route is found.""" - original_scope, *_ = _scope_receive_send.get() - - api_base_path = scope.get('root_path', '')[len(original_scope.get('root_path', '')):] - - extensions = original_scope.setdefault('extensions', {}) - connexion_routing = extensions.setdefault(ROUTING_CONTEXT, {}) - connexion_routing.update({ - 'api_base_path': api_base_path - }) - await self.app(original_scope, receive, send) - - def create_call_next(self): - - async def call_next( - operation: AbstractOperation, - request: StarletteRequest = None - ) -> None: - """Attach operation to scope and pass it to the next app""" - scope, receive, send = _scope_receive_send.get() - - api_base_path = request.scope.get('root_path', '')[len(scope.get('root_path', '')):] - - extensions = scope.setdefault('extensions', {}) - connexion_routing = extensions.setdefault(ROUTING_CONTEXT, {}) - connexion_routing.update({ - 'api_base_path': api_base_path, - 'operation_id': operation.operation_id - }) - return await self.app(scope, receive, send) - - return call_next - -class MiddlewareAPI(AbstractMinimalAPI): +class RoutingAPI(AbstractRoutingAPI): def __init__( self, @@ -120,13 +70,14 @@ def __init__( base_path: t.Optional[str] = None, arguments: t.Optional[dict] = None, resolver: t.Optional[Resolver] = None, - default: ASGIApp = None, + next_app: ASGIApp = None, resolver_error_handler: t.Optional[t.Callable] = None, debug: bool = False, **kwargs ) -> None: """API implementation on top of Starlette Router for Connexion middleware.""" - self.router = Router(default=default) + self.next_app = next_app + self.router = Router(default=RoutingOperation(None, next_app)) super().__init__( specification, @@ -138,28 +89,31 @@ def __init__( ) def add_operation(self, path: str, method: str) -> None: - operation = make_operation( - self.specification, - self, - path, - method, - self.resolver - ) + operation_cls = self.specification.operation_cls + operation = operation_cls.from_spec(self.specification, self, path, method, self.resolver) + routing_operation = RoutingOperation(operation.operation_id, next_app=self.next_app) + self._add_operation_internal(method, path, routing_operation) + + def _add_operation_internal(self, method: str, path: str, operation: 'RoutingOperation') -> None: + self.router.add_route(path, operation, methods=[method]) - @contextmanager - def patch_operation_function(): - """Patch the operation function so no decorators are set in the middleware. This - should be cleaned up by separating the APIs and Operations between the App and - middleware""" - original_operation_function = AbstractOperation.function - AbstractOperation.function = operation._resolution.function - try: - yield - finally: - AbstractOperation.function = original_operation_function - - with patch_operation_function(): - self._add_operation_internal(method, path, operation) - - def _add_operation_internal(self, method: str, path: str, operation: AbstractOperation) -> None: - self.router.add_route(path, operation.function, methods=[method]) + +class RoutingOperation: + + def __init__(self, operation_id: t.Optional[str], next_app: ASGIApp) -> None: + self.operation_id = operation_id + self.next_app = next_app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + """Attach operation to scope and pass it to the next app""" + original_scope = _scope.get() + + api_base_path = scope.get('root_path', '')[len(original_scope.get('root_path', '')):] + + extensions = original_scope.setdefault('extensions', {}) + connexion_routing = extensions.setdefault(ROUTING_CONTEXT, {}) + connexion_routing.update({ + 'api_base_path': api_base_path, + 'operation_id': self.operation_id + }) + await self.next_app(original_scope, receive, send) From e0183d34858e0539b954cdeafd85b3e79840ebb9 Mon Sep 17 00:00:00 2001 From: Robbe Sneyders Date: Fri, 13 May 2022 15:04:51 +0200 Subject: [PATCH 12/20] Clean up operation classes (#1535) --- connexion/operations/openapi.py | 22 ++-------------------- connexion/operations/swagger2.py | 19 +++---------------- tests/test_mock.py | 12 ------------ tests/test_mock3.py | 6 ------ tests/test_operation2.py | 9 ++------- tests/test_resolver.py | 19 ------------------- tests/test_resolver3.py | 17 ----------------- tests/test_resolver_methodview.py | 12 ------------ 8 files changed, 7 insertions(+), 109 deletions(-) diff --git a/connexion/operations/openapi.py b/connexion/operations/openapi.py index 0f9a55839..a68879d93 100644 --- a/connexion/operations/openapi.py +++ b/connexion/operations/openapi.py @@ -22,8 +22,8 @@ class OpenAPIOperation(AbstractOperation): """ def __init__(self, api, method, path, operation, resolver, path_parameters=None, - app_security=None, components=None, validate_responses=False, - strict_validation=False, randomize_endpoint=None, validator_map=None, + components=None, validate_responses=False, strict_validation=False, + randomize_endpoint=None, validator_map=None, pythonic_params=False, uri_parser_class=None, pass_context_arg_name=None): """ This class uses the OperationID identify the module and function that will handle the operation @@ -44,8 +44,6 @@ def __init__(self, api, method, path, operation, resolver, path_parameters=None, :param resolver: Callable that maps operationID to a function :param path_parameters: Parameters defined in the path level :type path_parameters: list - :param app_security: list of security rules the application uses by default - :type app_security: list :param components: `Components Object `_ :type components: dict @@ -68,9 +66,6 @@ def __init__(self, api, method, path, operation, resolver, path_parameters=None, """ self.components = components or {} - def component_get(oas3_name): - return self.components.get(oas3_name, {}) - uri_parser_class = uri_parser_class or OpenAPIURIParser self._router_controller = operation.get('x-openapi-router-controller') @@ -90,18 +85,6 @@ def component_get(oas3_name): pass_context_arg_name=pass_context_arg_name ) - self._definitions_map = { - 'components': { - 'schemas': component_get('schemas'), - 'examples': component_get('examples'), - 'requestBodies': component_get('requestBodies'), - 'parameters': component_get('parameters'), - 'securitySchemes': component_get('securitySchemes'), - 'responses': component_get('responses'), - 'headers': component_get('headers'), - } - } - self._request_body = operation.get('requestBody', {}) self._parameters = operation.get('parameters', []) @@ -133,7 +116,6 @@ def from_spec(cls, spec, api, path, method, resolver, *args, **kwargs): spec.get_operation(path, method), resolver=resolver, path_parameters=spec.get_path_params(path), - app_security=spec.security, components=spec.components, *args, **kwargs diff --git a/connexion/operations/swagger2.py b/connexion/operations/swagger2.py index 88767bc2e..9eeed5522 100644 --- a/connexion/operations/swagger2.py +++ b/connexion/operations/swagger2.py @@ -27,10 +27,9 @@ class Swagger2Operation(AbstractOperation): """ def __init__(self, api, method, path, operation, resolver, app_produces, app_consumes, - path_parameters=None, definitions=None, parameter_definitions=None, - response_definitions=None, validate_responses=False, strict_validation=False, - randomize_endpoint=None, validator_map=None, pythonic_params=False, - uri_parser_class=None, pass_context_arg_name=None): + path_parameters=None, definitions=None, validate_responses=False, + strict_validation=False, randomize_endpoint=None, validator_map=None, + pythonic_params=False, uri_parser_class=None, pass_context_arg_name=None): """ :param api: api that this operation is attached to :type api: apis.AbstractAPI @@ -51,10 +50,6 @@ def __init__(self, api, method, path, operation, resolver, app_produces, app_con :param definitions: `Definitions Object `_ :type definitions: dict - :param parameter_definitions: Global parameter definitions - :type parameter_definitions: dict - :param response_definitions: Global response definitions - :type response_definitions: dict :param validate_responses: True enables validation. Validation errors generate HTTP 500 responses. :type validate_responses: bool :param strict_validation: True enables validation on invalid request parameters @@ -96,12 +91,6 @@ def __init__(self, api, method, path, operation, resolver, app_produces, app_con self.definitions = definitions or {} - self.definitions_map = { - 'definitions': self.definitions, - 'parameters': parameter_definitions, - 'responses': response_definitions - } - self._parameters = operation.get('parameters', []) if path_parameters: self._parameters += path_parameters @@ -124,8 +113,6 @@ def from_spec(cls, spec, api, path, method, resolver, *args, **kwargs): app_produces=spec.produces, app_consumes=spec.consumes, definitions=spec.definitions, - parameter_definitions=spec.parameter_definitions, - response_definitions=spec.response_definitions, *args, **kwargs ) diff --git a/tests/test_mock.py b/tests/test_mock.py index 0b2bbace7..72f433a8d 100644 --- a/tests/test_mock.py +++ b/tests/test_mock.py @@ -25,7 +25,6 @@ def test_mock_resolver_default(): app_produces=['application/json'], app_consumes=['application/json'], definitions={}, - parameter_definitions={}, resolver=resolver) assert operation.operation_id == 'mock-1' @@ -56,7 +55,6 @@ def test_mock_resolver_numeric(): app_produces=['application/json'], app_consumes=['application/json'], definitions={}, - parameter_definitions={}, resolver=resolver) assert operation.operation_id == 'mock-1' @@ -93,7 +91,6 @@ def test_mock_resolver_example(): app_produces=['application/json'], app_consumes=['application/json'], definitions={}, - parameter_definitions={}, resolver=resolver) assert operation.operation_id == 'mock-1' @@ -128,7 +125,6 @@ def test_mock_resolver_example_nested_in_object(): app_produces=['application/json'], app_consumes=['application/json'], definitions={}, - parameter_definitions={}, resolver=resolver) assert operation.operation_id == 'mock-1' @@ -161,7 +157,6 @@ def test_mock_resolver_example_nested_in_list(): app_produces=['application/json'], app_consumes=['application/json'], definitions={}, - parameter_definitions={}, resolver=resolver) assert operation.operation_id == 'mock-1' @@ -197,7 +192,6 @@ def test_mock_resolver_example_nested_in_object_openapi(): operation={ 'responses': responses }, - app_security=[], resolver=resolver) assert operation.operation_id == 'mock-1' @@ -231,7 +225,6 @@ def test_mock_resolver_example_nested_in_list_openapi(): operation={ 'responses': responses }, - app_security=[], resolver=resolver) assert operation.operation_id == 'mock-1' @@ -265,7 +258,6 @@ def test_mock_resolver_no_example_nested_in_object(): app_produces=['application/json'], app_consumes=['application/json'], definitions={}, - parameter_definitions={}, resolver=resolver) assert operation.operation_id == 'mock-1' @@ -298,7 +290,6 @@ def test_mock_resolver_no_example_nested_in_list_openapi(): operation={ 'responses': responses }, - app_security=[], resolver=resolver) assert operation.operation_id == 'mock-1' @@ -323,7 +314,6 @@ def test_mock_resolver_no_examples(): app_produces=['application/json'], app_consumes=['application/json'], definitions={}, - parameter_definitions={}, resolver=resolver) assert operation.operation_id == 'mock-1' @@ -350,7 +340,6 @@ def test_mock_resolver_notimplemented(): app_produces=['application/json'], app_consumes=['application/json'], definitions={}, - parameter_definitions={}, resolver=resolver) assert operation.operation_id == 'fakeapi.hello.get' @@ -366,7 +355,6 @@ def test_mock_resolver_notimplemented(): app_produces=['application/json'], app_consumes=['application/json'], definitions={}, - parameter_definitions={}, resolver=resolver) # check if it is using the mock function diff --git a/tests/test_mock3.py b/tests/test_mock3.py index fe834fe83..8c6743907 100644 --- a/tests/test_mock3.py +++ b/tests/test_mock3.py @@ -29,7 +29,6 @@ def test_mock_resolver_default(): operation={ 'responses': responses }, - app_security=[], resolver=resolver ) assert operation.operation_id == 'mock-1' @@ -66,7 +65,6 @@ def test_mock_resolver_numeric(): operation={ 'responses': responses }, - app_security=[], resolver=resolver ) assert operation.operation_id == 'mock-1' @@ -109,7 +107,6 @@ def test_mock_resolver_inline_schema_example(): operation={ 'responses': responses }, - app_security=[], resolver=resolver ) assert operation.operation_id == 'mock-1' @@ -133,7 +130,6 @@ def test_mock_resolver_no_examples(): operation={ 'responses': responses }, - app_security=[], resolver=resolver ) assert operation.operation_id == 'mock-1' @@ -158,7 +154,6 @@ def test_mock_resolver_notimplemented(): operation={ 'operationId': 'fakeapi.hello.get' }, - app_security=[], resolver=resolver ) assert operation.operation_id == 'fakeapi.hello.get' @@ -173,7 +168,6 @@ def test_mock_resolver_notimplemented(): 'operationId': 'fakeapi.hello.nonexistent_function', 'responses': responses }, - app_security=[], resolver=resolver ) # check if it is using the mock function diff --git a/tests/test_operation2.py b/tests/test_operation2.py index ae0efc33e..6b099f32c 100644 --- a/tests/test_operation2.py +++ b/tests/test_operation2.py @@ -290,7 +290,6 @@ def test_operation(api, security_handler_factory): app_produces=['application/json'], app_consumes=['application/json'], definitions=DEFINITIONS, - parameter_definitions=PARAMETER_DEFINITIONS, resolver=Resolver()) assert operation.method == 'GET' @@ -327,7 +326,6 @@ def test_operation_array(api): app_produces=['application/json'], app_consumes=['application/json'], definitions=DEFINITIONS, - parameter_definitions=PARAMETER_DEFINITIONS, resolver=Resolver()) assert isinstance(operation.function, types.FunctionType) @@ -353,7 +351,6 @@ def test_operation_composed_definition(api): app_produces=['application/json'], app_consumes=['application/json'], definitions=DEFINITIONS, - parameter_definitions=PARAMETER_DEFINITIONS, resolver=Resolver()) assert isinstance(operation.function, types.FunctionType) @@ -399,7 +396,6 @@ def test_multi_body(api): app_produces=['application/json'], app_consumes=['application/json'], definitions=DEFINITIONS, - parameter_definitions=PARAMETER_DEFINITIONS, resolver=Resolver()) operation.body_schema @@ -464,7 +460,6 @@ def test_parameter_reference(api): app_produces=['application/json'], app_consumes=['application/json'], definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, resolver=Resolver()) assert operation.parameters == [{'in': 'path', 'type': 'integer'}] @@ -476,7 +471,7 @@ def test_default(api): api=api, method='GET', path='endpoint', path_parameters=[], operation=op_spec, app_produces=['application/json'], app_consumes=['application/json'], definitions=DEFINITIONS, - parameter_definitions=PARAMETER_DEFINITIONS, resolver=Resolver() + resolver=Resolver() ) op_spec = make_operation(OPERATION6, parameters=False) op_spec['parameters'][0]['default'] = { @@ -489,7 +484,7 @@ def test_default(api): api=api, method='POST', path='endpoint', path_parameters=[], operation=op_spec, app_produces=['application/json'], app_consumes=['application/json'], definitions=DEFINITIONS, - parameter_definitions={}, resolver=Resolver() + resolver=Resolver() ) diff --git a/tests/test_resolver.py b/tests/test_resolver.py index e92e7f9f2..fbb66ea89 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -4,8 +4,6 @@ from connexion.operations import Swagger2Operation from connexion.resolver import RelativeResolver, Resolver, RestyResolver -PARAMETER_DEFINITIONS = {'myparam': {'in': 'path', 'type': 'integer'}} - def test_standard_get_function(): function = Resolver().resolve_function_from_operation_id('connexion.FlaskApp.common_error_handler') @@ -56,7 +54,6 @@ def test_standard_resolve_x_router_controller(): app_produces=['application/json'], app_consumes=['application/json'], definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, resolver=Resolver()) assert operation.operation_id == 'fakeapi.hello.post_greeting' @@ -73,7 +70,6 @@ def test_relative_resolve_x_router_controller(): app_produces=['application/json'], app_consumes=['application/json'], definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, resolver=RelativeResolver('root_path')) assert operation.operation_id == 'fakeapi.hello.post_greeting' @@ -89,7 +85,6 @@ def test_relative_resolve_operation_id(): app_produces=['application/json'], app_consumes=['application/json'], definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, resolver=RelativeResolver('fakeapi')) assert operation.operation_id == 'fakeapi.hello.post_greeting' @@ -106,7 +101,6 @@ def test_relative_resolve_operation_id_with_module(): app_produces=['application/json'], app_consumes=['application/json'], definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, resolver=RelativeResolver(fakeapi)) assert operation.operation_id == 'fakeapi.hello.post_greeting' @@ -122,7 +116,6 @@ def test_resty_resolve_operation_id(): app_produces=['application/json'], app_consumes=['application/json'], definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, resolver=RestyResolver('fakeapi')) assert operation.operation_id == 'fakeapi.hello.post_greeting' @@ -139,7 +132,6 @@ def test_resty_resolve_x_router_controller_with_operation_id(): app_produces=['application/json'], app_consumes=['application/json'], definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, resolver=RestyResolver('fakeapi')) assert operation.operation_id == 'fakeapi.hello.post_greeting' @@ -153,7 +145,6 @@ def test_resty_resolve_x_router_controller_without_operation_id(): app_produces=['application/json'], app_consumes=['application/json'], definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, resolver=RestyResolver('fakeapi')) assert operation.operation_id == 'fakeapi.hello.get' @@ -167,7 +158,6 @@ def test_resty_resolve_with_default_module_name(): app_produces=['application/json'], app_consumes=['application/json'], definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, resolver=RestyResolver('fakeapi')) assert operation.operation_id == 'fakeapi.hello.get' @@ -181,7 +171,6 @@ def test_resty_resolve_with_default_module_name_nested(): app_produces=['application/json'], app_consumes=['application/json'], definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, resolver=RestyResolver('fakeapi')) assert operation.operation_id == 'fakeapi.hello.world.search' @@ -195,7 +184,6 @@ def test_resty_resolve_with_default_module_name_lowercase_verb(): app_produces=['application/json'], app_consumes=['application/json'], definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, resolver=RestyResolver('fakeapi')) assert operation.operation_id == 'fakeapi.hello.get' @@ -208,7 +196,6 @@ def test_resty_resolve_with_default_module_name_lowercase_verb_nested(): app_produces=['application/json'], app_consumes=['application/json'], definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, resolver=RestyResolver('fakeapi')) assert operation.operation_id == 'fakeapi.hello.world.get' @@ -222,7 +209,6 @@ def test_resty_resolve_with_default_module_name_will_translate_dashes_in_resourc app_produces=['application/json'], app_consumes=['application/json'], definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, resolver=RestyResolver('fakeapi')) assert operation.operation_id == 'fakeapi.foo_bar.search' @@ -236,7 +222,6 @@ def test_resty_resolve_with_default_module_name_can_resolve_api_root(): app_produces=['application/json'], app_consumes=['application/json'], definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, resolver=RestyResolver('fakeapi')) assert operation.operation_id == 'fakeapi.get' @@ -250,7 +235,6 @@ def test_resty_resolve_with_default_module_name_will_resolve_resource_root_get_a app_produces=['application/json'], app_consumes=['application/json'], definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, resolver=RestyResolver('fakeapi')) assert operation.operation_id == 'fakeapi.hello.search' @@ -266,7 +250,6 @@ def test_resty_resolve_with_default_module_name_and_x_router_controller_will_res app_produces=['application/json'], app_consumes=['application/json'], definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, resolver=RestyResolver('fakeapi')) assert operation.operation_id == 'fakeapi.hello.search' @@ -280,7 +263,6 @@ def test_resty_resolve_with_default_module_name_will_resolve_resource_root_as_co app_produces=['application/json'], app_consumes=['application/json'], definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, resolver=RestyResolver('fakeapi', 'api_list')) assert operation.operation_id == 'fakeapi.hello.api_list' @@ -294,6 +276,5 @@ def test_resty_resolve_with_default_module_name_will_resolve_resource_root_post_ app_produces=['application/json'], app_consumes=['application/json'], definitions={}, - parameter_definitions=PARAMETER_DEFINITIONS, resolver=RestyResolver('fakeapi')) assert operation.operation_id == 'fakeapi.hello.post' diff --git a/tests/test_resolver3.py b/tests/test_resolver3.py index 1a3ceb1ce..143f6e84a 100644 --- a/tests/test_resolver3.py +++ b/tests/test_resolver3.py @@ -14,7 +14,6 @@ def test_standard_resolve_x_router_controller(): 'x-openapi-router-controller': 'fakeapi.hello', 'operationId': 'post_greeting', }, - app_security=[], components=COMPONENTS, resolver=Resolver() ) @@ -31,7 +30,6 @@ def test_relative_resolve_x_router_controller(): 'x-openapi-router-controller': 'fakeapi.hello', 'operationId': 'post_greeting', }, - app_security=[], components=COMPONENTS, resolver=RelativeResolver('root_path') ) @@ -47,7 +45,6 @@ def test_relative_resolve_operation_id(): operation={ 'operationId': 'hello.post_greeting', }, - app_security=[], components=COMPONENTS, resolver=RelativeResolver('fakeapi') ) @@ -64,7 +61,6 @@ def test_relative_resolve_operation_id_with_module(): operation={ 'operationId': 'hello.post_greeting', }, - app_security=[], components=COMPONENTS, resolver=RelativeResolver(fakeapi) ) @@ -80,7 +76,6 @@ def test_resty_resolve_operation_id(): operation={ 'operationId': 'fakeapi.hello.post_greeting', }, - app_security=[], components=COMPONENTS, resolver=RestyResolver('fakeapi') ) @@ -97,7 +92,6 @@ def test_resty_resolve_x_router_controller_with_operation_id(): 'x-openapi-router-controller': 'fakeapi.hello', 'operationId': 'post_greeting', }, - app_security=[], components=COMPONENTS, resolver=RestyResolver('fakeapi') ) @@ -111,7 +105,6 @@ def test_resty_resolve_x_router_controller_without_operation_id(): path='/hello/{id}', path_parameters=[], operation={'x-openapi-router-controller': 'fakeapi.hello'}, - app_security=[], components=COMPONENTS, resolver=RestyResolver('fakeapi')) assert operation.operation_id == 'fakeapi.hello.get' @@ -124,7 +117,6 @@ def test_resty_resolve_with_default_module_name(): path='/hello/{id}', path_parameters=[], operation={}, - app_security=[], components=COMPONENTS, resolver=RestyResolver('fakeapi') ) @@ -138,7 +130,6 @@ def test_resty_resolve_with_default_module_name(): path='/hello/{id}/world', path_parameters=[], operation={}, - app_security=[], components=COMPONENTS, resolver=RestyResolver('fakeapi') ) @@ -152,7 +143,6 @@ def test_resty_resolve_with_default_module_name_lowercase_verb(): path='/hello/{id}', path_parameters=[], operation={}, - app_security=[], components=COMPONENTS, resolver=RestyResolver('fakeapi') ) @@ -166,7 +156,6 @@ def test_resty_resolve_with_default_module_name_lowercase_verb_nested(): path='/hello/world/{id}', path_parameters=[], operation={}, - app_security=[], components=COMPONENTS, resolver=RestyResolver('fakeapi') ) @@ -180,7 +169,6 @@ def test_resty_resolve_with_default_module_name_will_translate_dashes_in_resourc path='/foo-bar', path_parameters=[], operation={}, - app_security=[], components=COMPONENTS, resolver=RestyResolver('fakeapi') ) @@ -194,7 +182,6 @@ def test_resty_resolve_with_default_module_name_can_resolve_api_root(): path='/', path_parameters=[], operation={}, - app_security=[], components=COMPONENTS, resolver=RestyResolver('fakeapi') ) @@ -208,7 +195,6 @@ def test_resty_resolve_with_default_module_name_will_resolve_resource_root_get_a path='/hello', path_parameters=[], operation={}, - app_security=[], components=COMPONENTS, resolver=RestyResolver('fakeapi') ) @@ -224,7 +210,6 @@ def test_resty_resolve_with_default_module_name_and_x_router_controller_will_res operation={ 'x-openapi-router-controller': 'fakeapi.hello', }, - app_security=[], components=COMPONENTS, resolver=RestyResolver('fakeapi') ) @@ -238,7 +223,6 @@ def test_resty_resolve_with_default_module_name_will_resolve_resource_root_as_co path='/hello', path_parameters=[], operation={}, - app_security=[], components=COMPONENTS, resolver=RestyResolver('fakeapi', 'api_list') ) @@ -252,7 +236,6 @@ def test_resty_resolve_with_default_module_name_will_resolve_resource_root_post_ path='/hello', path_parameters=[], operation={}, - app_security=[], components=COMPONENTS, resolver=RestyResolver('fakeapi') ) diff --git a/tests/test_resolver_methodview.py b/tests/test_resolver_methodview.py index 336baf244..589c13872 100644 --- a/tests/test_resolver_methodview.py +++ b/tests/test_resolver_methodview.py @@ -14,7 +14,6 @@ def test_standard_resolve_x_router_controller(): 'x-openapi-router-controller': 'fakeapi.hello', 'operationId': 'post_greeting', }, - app_security=[], components=COMPONENTS, resolver=Resolver() ) @@ -29,7 +28,6 @@ def test_methodview_resolve_operation_id(): operation={ 'operationId': 'fakeapi.hello.post_greeting', }, - app_security=[], components=COMPONENTS, resolver=MethodViewResolver('fakeapi') ) @@ -46,7 +44,6 @@ def test_methodview_resolve_x_router_controller_with_operation_id(): 'x-openapi-router-controller': 'fakeapi.ExampleMethodView', 'operationId': 'post_greeting', }, - app_security=[], components=COMPONENTS, resolver=MethodViewResolver('fakeapi') ) @@ -59,7 +56,6 @@ def test_methodview_resolve_x_router_controller_without_operation_id(): path='/hello/{id}', path_parameters=[], operation={'x-openapi-router-controller': 'fakeapi.example_method'}, - app_security=[], components=COMPONENTS, resolver=MethodViewResolver('fakeapi')) assert operation.operation_id == 'fakeapi.ExampleMethodView.get' @@ -72,7 +68,6 @@ def test_methodview_resolve_with_default_module_name(): path='/example_method/{id}', path_parameters=[], operation={}, - app_security=[], components=COMPONENTS, resolver=MethodViewResolver('fakeapi') ) @@ -86,7 +81,6 @@ def test_methodview_resolve_with_default_module_name_lowercase_verb(): path='/example_method/{id}', path_parameters=[], operation={}, - app_security=[], components=COMPONENTS, resolver=MethodViewResolver('fakeapi') ) @@ -100,7 +94,6 @@ def test_methodview_resolve_with_default_module_name_will_translate_dashes_in_re path='/example-method', path_parameters=[], operation={}, - app_security=[], components=COMPONENTS, resolver=MethodViewResolver('fakeapi') ) @@ -114,7 +107,6 @@ def test_methodview_resolve_with_default_module_name_can_resolve_api_root(): path='/', path_parameters=[], operation={}, - app_security=[], components=COMPONENTS, resolver=MethodViewResolver('fakeapi.example_method',) ) @@ -128,7 +120,6 @@ def test_methodview_resolve_with_default_module_name_will_resolve_resource_root_ path='/example_method', path_parameters=[], operation={}, - app_security=[], components=COMPONENTS, resolver=MethodViewResolver('fakeapi') ) @@ -144,7 +135,6 @@ def test_methodview_resolve_with_default_module_name_and_x_router_controller_wil operation={ 'x-openapi-router-controller': 'fakeapi.example_method', }, - app_security=[], components=COMPONENTS, resolver=MethodViewResolver('fakeapi') ) @@ -158,7 +148,6 @@ def test_methodview_resolve_with_default_module_name_will_resolve_resource_root_ path='/example_method', path_parameters=[], operation={}, - app_security=[], components=COMPONENTS, resolver=MethodViewResolver('fakeapi', 'api_list') ) @@ -172,7 +161,6 @@ def test_methodview_resolve_with_default_module_name_will_resolve_resource_root_ path='/example_method', path_parameters=[], operation={}, - app_security=[], components=COMPONENTS, resolver=MethodViewResolver('fakeapi') ) From 68564826035281c03d0601fc68d9beaeedb9bedb Mon Sep 17 00:00:00 2001 From: Jonas Boecquaert Date: Tue, 17 May 2022 23:00:33 +0200 Subject: [PATCH 13/20] Removed jsonschema version check (#1540) * Removed jsonschema version check * Remove dependency on importlib-metadata * Remove packaging dependency --- connexion/decorators/validation.py | 28 +++++++--------------------- setup.py | 2 -- 2 files changed, 7 insertions(+), 23 deletions(-) diff --git a/connexion/decorators/validation.py b/connexion/decorators/validation.py index 30e9ea72c..01c5b16b0 100644 --- a/connexion/decorators/validation.py +++ b/connexion/decorators/validation.py @@ -8,14 +8,8 @@ import logging from typing import AnyStr, Union -try: - from importlib.metadata import version -except ImportError: - from importlib_metadata import version - from jsonschema import Draft4Validator, ValidationError, draft4_format_checker from jsonschema.validators import extend -from packaging.version import Version from werkzeug.datastructures import FileStorage from ..exceptions import (BadRequestProblem, ExtraParameterProblem, @@ -25,8 +19,6 @@ from ..lifecycle import ConnexionResponse from ..utils import all_json, boolean, is_json_mimetype, is_null, is_nullable -_jsonschema_3_or_newer = Version(version("jsonschema")) >= Version("3.0.0") - logger = logging.getLogger('connexion.decorators.validation') TYPE_MAP = { @@ -280,19 +272,13 @@ def validate_parameter(parameter_type, value, param, param_name=None): del param['required'] try: if parameter_type == 'formdata' and param.get('type') == 'file': - if _jsonschema_3_or_newer: - extend( - Draft4Validator, - type_checker=Draft4Validator.TYPE_CHECKER.redefine( - "file", - lambda checker, instance: isinstance(instance, FileStorage) - ) - )(param, format_checker=draft4_format_checker).validate(converted_value) - else: - Draft4Validator( - param, - format_checker=draft4_format_checker, - types={'file': FileStorage}).validate(converted_value) + extend( + Draft4Validator, + type_checker=Draft4Validator.TYPE_CHECKER.redefine( + "file", + lambda checker, instance: isinstance(instance, FileStorage) + ) + )(param, format_checker=draft4_format_checker).validate(converted_value) else: Draft4Validator( param, format_checker=draft4_format_checker).validate(converted_value) diff --git a/setup.py b/setup.py index 0ebf999cc..7591cc1ef 100755 --- a/setup.py +++ b/setup.py @@ -26,8 +26,6 @@ def read_version(package): 'requests>=2.27,<3', 'inflection>=0.3.1,<0.6', 'werkzeug>=2,<3', - 'importlib-metadata>=1 ; python_version<"3.8"', - 'packaging>=20', 'starlette>=0.15,<1', 'httpx>=0.15,<1', ] From be492f99f2eb09a909961a88ecaabff44cb88dfb Mon Sep 17 00:00:00 2001 From: jacobstanly89 <105638648+jacobstanly89@users.noreply.github.com> Date: Tue, 24 May 2022 18:40:57 +0200 Subject: [PATCH 14/20] Remove built-in support for uWSGI (#1544) * Remove check for uwsgi metrics * Removing uwsgi metrics and corresponding test files --- connexion/decorators/metrics.py | 61 -------------------------------- connexion/operations/abstract.py | 5 --- tests/test_metrics.py | 23 ------------ 3 files changed, 89 deletions(-) delete mode 100644 connexion/decorators/metrics.py delete mode 100644 tests/test_metrics.py diff --git a/connexion/decorators/metrics.py b/connexion/decorators/metrics.py deleted file mode 100644 index 77214171a..000000000 --- a/connexion/decorators/metrics.py +++ /dev/null @@ -1,61 +0,0 @@ -""" -This module defines view function decorator to collect UWSGI metrics and expose them via an -endpoint. -""" - -import functools -import os -import time - -from werkzeug.exceptions import HTTPException - -from connexion.exceptions import ProblemException - -try: - import uwsgi_metrics - HAS_UWSGI_METRICS = True # pragma: no cover -except ImportError: - uwsgi_metrics = None - HAS_UWSGI_METRICS = False - - -class UWSGIMetricsCollector: - def __init__(self, path, method): - self.path = path - self.method = method - swagger_path = path.strip('/').replace('/', '.').replace('<', '{').replace('>', '}') - self.key_suffix = f'{method.upper()}.{swagger_path}' - self.prefix = os.getenv('HTTP_METRICS_PREFIX', 'connexion.response') - - @staticmethod - def is_available(): - return HAS_UWSGI_METRICS - - def __call__(self, function): - """ - :type function: types.FunctionType - :rtype: types.FunctionType - """ - - @functools.wraps(function) - def wrapper(*args, **kwargs): - status = 500 - start_time_s = time.time() - try: - response = function(*args, **kwargs) - status = response.status_code - except HTTPException as http_e: - status = http_e.code - raise http_e - except ProblemException as prob_e: - status = prob_e.status - raise prob_e - finally: - end_time_s = time.time() - delta_s = end_time_s - start_time_s - delta_ms = delta_s * 1000 - key = f'{status}.{self.key_suffix}' - uwsgi_metrics.timer(self.prefix, key, delta_ms) - return response - - return wrapper diff --git a/connexion/operations/abstract.py b/connexion/operations/abstract.py index 1a5abc9a4..7b90e4366 100644 --- a/connexion/operations/abstract.py +++ b/connexion/operations/abstract.py @@ -7,7 +7,6 @@ import logging from ..decorators.decorator import RequestResponseDecorator -from ..decorators.metrics import UWSGIMetricsCollector from ..decorators.parameter import parameter_to_arg from ..decorators.produces import BaseSerializer, Produces from ..decorators.response import ResponseValidator @@ -379,10 +378,6 @@ def function(self): function = self._request_response_decorator(function) - if UWSGIMetricsCollector.is_available(): # pragma: no cover - decorator = UWSGIMetricsCollector(self.path, self.method) - function = decorator(function) - return function @property diff --git a/tests/test_metrics.py b/tests/test_metrics.py deleted file mode 100644 index 20cfe0839..000000000 --- a/tests/test_metrics.py +++ /dev/null @@ -1,23 +0,0 @@ -from unittest.mock import MagicMock - -import flask -import pytest -from connexion.decorators.metrics import UWSGIMetricsCollector -from connexion.exceptions import ProblemException - - -def test_timer(monkeypatch): - wrapper = UWSGIMetricsCollector('/foo/bar/', 'get') - - def operation(req): - raise ProblemException(418, '', '') - - op = wrapper(operation) - metrics = MagicMock() - monkeypatch.setattr('flask.request', MagicMock()) - monkeypatch.setattr('flask.current_app', MagicMock(response_class=flask.Response)) - monkeypatch.setattr('connexion.decorators.metrics.uwsgi_metrics', metrics) - with pytest.raises(ProblemException) as exc: - op(MagicMock()) - assert metrics.timer.call_args[0][:2] == ('connexion.response', - '418.GET.foo.bar.{param}') From b561ecfdaacd7606d1398512762b946e2d778c74 Mon Sep 17 00:00:00 2001 From: jacobstanly89 <105638648+jacobstanly89@users.noreply.github.com> Date: Tue, 31 May 2022 19:01:27 +0200 Subject: [PATCH 15/20] Fix for bug of the function is_json_mimetype() (#1541) * Fix for bug of the function is_json_mimetype() (#1114) * Splitting the mimetype correctly * Added a test for the json mimetype usecases * Update connexion/utils.py Co-authored-by: Ruwann --- connexion/utils.py | 3 +++ tests/test_utils.py | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/connexion/utils.py b/connexion/utils.py index 5a52ca801..176d847bb 100644 --- a/connexion/utils.py +++ b/connexion/utils.py @@ -136,7 +136,10 @@ def is_json_mimetype(mimetype): :type mimetype: str :rtype: bool """ + maintype, subtype = mimetype.split('/') # type: str, str + if ';' in subtype: + subtype, parameter = subtype.split(';', maxsplit=1) return maintype == 'application' and (subtype == 'json' or subtype.endswith('+json')) diff --git a/tests/test_utils.py b/tests/test_utils.py index 72c9e2774..9d6b47deb 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -60,3 +60,11 @@ def test_deep_get_dict(): def test_deep_get_list(): obj = [{'type': 'object', 'properties': {'id': {'type': 'string'}}}] assert utils.deep_get(obj, ['0', 'properties', 'id']) == {'type': 'string'} + + +def test_is_json_mimetype(): + assert utils.is_json_mimetype('application/json') + assert utils.is_json_mimetype('application/vnd.com.myEntreprise.v6+json') + assert utils.is_json_mimetype('application/vnd.scanner.adapter.vuln.report.harbor+json; version=1.0') + assert utils.is_json_mimetype('application/vnd.com.myEntreprise.v6+json; charset=UTF-8') + assert not utils.is_json_mimetype('text/html') From 2e0facea3cf1602ad4de4f7ecedeef7180bdd94d Mon Sep 17 00:00:00 2001 From: Motti Lanzkron Date: Thu, 9 Jun 2022 11:26:13 +0300 Subject: [PATCH 16/20] Avoid false positive deprecation warning Only warn about the schema's `x-body-name` being deprecated if it's used. --- connexion/operations/openapi.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/connexion/operations/openapi.py b/connexion/operations/openapi.py index a68879d93..1a970f803 100644 --- a/connexion/operations/openapi.py +++ b/connexion/operations/openapi.py @@ -254,15 +254,15 @@ def _get_body_argument(self, body, arguments, has_kwargs, sanitize): if len(arguments) <= 0 and not has_kwargs: return {} - # prefer the x-body-name as an extension of requestBody - x_body_name = sanitize(self.request_body.get('x-body-name', None)) - - if not x_body_name: - # x-body-name also accepted in the schema field for legacy connexion compat + # get the deprecated name from the body-schema for legacy connexion co + x_body_name = sanitize(self.body_schema.get('x-body-name')) + if x_body_name: warnings.warn('x-body-name within the requestBody schema will be deprecated in the ' 'next major version. It should be provided directly under ' 'the requestBody instead.', DeprecationWarning) - x_body_name = sanitize(self.body_schema.get('x-body-name', 'body')) + + # prefer the x-body-name as an extension of requestBody, fallback to deprecated schema name, default 'body' + x_body_name = sanitize(self.request_body.get('x-body-name', x_body_name or 'body')) if self.consumes[0] in FORM_CONTENT_TYPES: result = self._get_body_argument_form(body) From e8734223919e7884f57f6596f2a2ac422e4dfa8d Mon Sep 17 00:00:00 2001 From: Motti Lanzkron Date: Thu, 9 Jun 2022 11:30:12 +0300 Subject: [PATCH 17/20] fix truncated comment --- connexion/operations/openapi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/connexion/operations/openapi.py b/connexion/operations/openapi.py index 1a970f803..410f0aca5 100644 --- a/connexion/operations/openapi.py +++ b/connexion/operations/openapi.py @@ -254,7 +254,7 @@ def _get_body_argument(self, body, arguments, has_kwargs, sanitize): if len(arguments) <= 0 and not has_kwargs: return {} - # get the deprecated name from the body-schema for legacy connexion co + # get the deprecated name from the body-schema for legacy connexion compat x_body_name = sanitize(self.body_schema.get('x-body-name')) if x_body_name: warnings.warn('x-body-name within the requestBody schema will be deprecated in the ' From 79d370257109650eea888add37cd9a2046405b2f Mon Sep 17 00:00:00 2001 From: Motti Lanzkron Date: Sun, 12 Jun 2022 10:59:19 +0300 Subject: [PATCH 18/20] Revert "fix truncated comment" This reverts commit e8734223919e7884f57f6596f2a2ac422e4dfa8d. --- connexion/operations/openapi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/connexion/operations/openapi.py b/connexion/operations/openapi.py index 410f0aca5..1a970f803 100644 --- a/connexion/operations/openapi.py +++ b/connexion/operations/openapi.py @@ -254,7 +254,7 @@ def _get_body_argument(self, body, arguments, has_kwargs, sanitize): if len(arguments) <= 0 and not has_kwargs: return {} - # get the deprecated name from the body-schema for legacy connexion compat + # get the deprecated name from the body-schema for legacy connexion co x_body_name = sanitize(self.body_schema.get('x-body-name')) if x_body_name: warnings.warn('x-body-name within the requestBody schema will be deprecated in the ' From 945eb36c7abd9501ab1aff5a57b45961d2bb8c19 Mon Sep 17 00:00:00 2001 From: Motti Lanzkron Date: Sun, 12 Jun 2022 10:59:27 +0300 Subject: [PATCH 19/20] Revert "Avoid false positive deprecation warning" This reverts commit 2e0facea3cf1602ad4de4f7ecedeef7180bdd94d. --- connexion/operations/openapi.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/connexion/operations/openapi.py b/connexion/operations/openapi.py index 1a970f803..a68879d93 100644 --- a/connexion/operations/openapi.py +++ b/connexion/operations/openapi.py @@ -254,15 +254,15 @@ def _get_body_argument(self, body, arguments, has_kwargs, sanitize): if len(arguments) <= 0 and not has_kwargs: return {} - # get the deprecated name from the body-schema for legacy connexion co - x_body_name = sanitize(self.body_schema.get('x-body-name')) - if x_body_name: + # prefer the x-body-name as an extension of requestBody + x_body_name = sanitize(self.request_body.get('x-body-name', None)) + + if not x_body_name: + # x-body-name also accepted in the schema field for legacy connexion compat warnings.warn('x-body-name within the requestBody schema will be deprecated in the ' 'next major version. It should be provided directly under ' 'the requestBody instead.', DeprecationWarning) - - # prefer the x-body-name as an extension of requestBody, fallback to deprecated schema name, default 'body' - x_body_name = sanitize(self.request_body.get('x-body-name', x_body_name or 'body')) + x_body_name = sanitize(self.body_schema.get('x-body-name', 'body')) if self.consumes[0] in FORM_CONTENT_TYPES: result = self._get_body_argument_form(body) From 861a535a61c75d3f4b2370d78017b87acb2a1ffd Mon Sep 17 00:00:00 2001 From: Motti Lanzkron Date: Sun, 12 Jun 2022 14:05:52 +0300 Subject: [PATCH 20/20] Only warn about the schema's `x-body-name` being deprecated if it's used. --- connexion/operations/openapi.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/connexion/operations/openapi.py b/connexion/operations/openapi.py index a68879d93..9e044c17d 100644 --- a/connexion/operations/openapi.py +++ b/connexion/operations/openapi.py @@ -254,15 +254,16 @@ def _get_body_argument(self, body, arguments, has_kwargs, sanitize): if len(arguments) <= 0 and not has_kwargs: return {} - # prefer the x-body-name as an extension of requestBody - x_body_name = sanitize(self.request_body.get('x-body-name', None)) + # get the deprecated name from the body-schema for legacy connexion compat + x_body_name = sanitize(self.body_schema.get('x-body-name')) - if not x_body_name: - # x-body-name also accepted in the schema field for legacy connexion compat + if x_body_name: warnings.warn('x-body-name within the requestBody schema will be deprecated in the ' 'next major version. It should be provided directly under ' 'the requestBody instead.', DeprecationWarning) - x_body_name = sanitize(self.body_schema.get('x-body-name', 'body')) + + # prefer the x-body-name as an extension of requestBody, fallback to deprecated schema name, default 'body' + x_body_name = sanitize(self.request_body.get('x-body-name', x_body_name or 'body')) if self.consumes[0] in FORM_CONTENT_TYPES: result = self._get_body_argument_form(body)