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/.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/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/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 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/connexion/__init__.py b/connexion/__init__.py index 789eff529..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 @@ -38,13 +30,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/__init__.py b/connexion/apis/__init__.py index b1a7553d5..4bb486f47 100644 --- a/connexion/apis/__init__.py +++ b/connexion/apis/__init__.py @@ -13,4 +13,5 @@ """ -from .abstract import AbstractAPI # NOQA +from .abstract import (AbstractAPI, AbstractRoutingAPI, # NOQA + AbstractSwaggerUIAPI) diff --git a/connexion/apis/abstract.py b/connexion/apis/abstract.py index 8027d7524..45ec68f5b 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 @@ -15,11 +14,10 @@ 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 -from ..utils import is_json_mimetype MODULE_PATH = pathlib.Path(__file__).absolute().parent.parent SWAGGER_UI_URL = 'ui' @@ -34,48 +32,29 @@ def __init__(cls, name, bases, attrs): cls._set_jsonifier() -class AbstractAPI(metaclass=AbstractAPIMeta): - """ - Defines an abstract interface for a Swagger API - """ +class AbstractSpecAPI(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, - ): - """ - :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 + 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. + + :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. - :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}) + 'arguments': arguments}) # Avoid validator having ability to modify specification self.specification = Specification.load(specification, arguments=arguments) @@ -91,23 +70,23 @@ def __init__(self, specification, base_path=None, arguments=None, 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 + 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 - logger.debug('Strict Request Validation: %s', str(strict_validation)) - self.strict_validation = strict_validation + @classmethod + def _set_jsonifier(cls): + cls.jsonifier = Jsonifier() - 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 +class AbstractSwaggerUIAPI(AbstractSpecAPI): - self.security_handler_factory = self.make_security_handler_factory(pass_context_arg_name) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) if self.options.openapi_spec_available: self.add_openapi_json() @@ -116,22 +95,6 @@ def __init__(self, specification, base_path=None, arguments=None, if self.options.openapi_console_ui_available: self.add_swagger_ui() - self.add_paths() - - if auth_all_paths: - self.add_auth_on_not_found( - self.specification.security, - 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): """ @@ -140,76 +103,53 @@ def add_openapi_json(self): """ @abc.abstractmethod - def add_swagger_ui(self): + def add_openapi_yaml(self): """ - Adds swagger ui to {base_path}/ui/ + Adds openapi spec to {base_path}/openapi.yaml + (or {base_path}/swagger.yaml for swagger2) """ @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_swagger_ui(self): """ - - @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 swagger ui to {base_path}/ui/ """ - Adds one operation to the api. - This method uses the OperationID identify the module and function that will handle the operation - - From Swagger Specification: - **OperationID** - - A friendly name for the operation. The id MUST be unique among all operations described in the API. - Tools and libraries MAY use the operation id to uniquely identify an operation. +class AbstractRoutingAPI(AbstractSpecAPI): - :type method: str - :type path: str - """ - operation = make_operation( - self.specification, + def __init__( self, - path, - method, - self.resolver, - validate_responses=self.validate_responses, - validator_map=self.validator_map, - strict_validation=self.strict_validation, - pythonic_params=self.pythonic_params, - uri_parser_class=self.options.uri_parser_class, - pass_context_arg_name=self.pass_context_arg_name - ) - self._add_operation_internal(method, path, operation) + *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. - @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. + :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 - 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) + 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.add_paths() - def add_paths(self, paths=None): + def add_paths(self, paths: t.Optional[dict] = None) -> 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(): @@ -231,7 +171,26 @@ def add_paths(self, paths=None): # 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): + 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, + ) + 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(), @@ -243,6 +202,72 @@ def _handle_add_operation_error(self, path, method, exc_info): _type, value, traceback = exc_info raise value.with_traceback(traceback) + +class AbstractAPI(AbstractRoutingAPI, metaclass=AbstractAPIMeta): + """ + Defines an abstract interface for a Swagger API + """ + + def __init__(self, specification, base_path=None, arguments=None, + validate_responses=False, strict_validation=False, resolver=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 + :param validator_map: Custom validators for the types "parameter", "body" and "response". + :type validator_map: dict + :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 + """ + self.validator_map = validator_map + + logger.debug('Validate Responses: %s', str(validate_responses)) + self.validate_responses = validate_responses + + logger.debug('Strict Request Validation: %s', str(strict_validation)) + self.strict_validation = strict_validation + + logger.debug('Pythonic params: %s', str(pythonic_params)) + self.pythonic_params = pythonic_params + + super().__init__(specification, base_path=base_path, arguments=arguments, + resolver=resolver, resolver_error_handler=resolver_error_handler, + debug=debug, pass_context_arg_name=pass_context_arg_name, options=options) + + def add_operation(self, path, method): + """ + Adds one operation to the api. + + This method uses the OperationID identify the module and function that will handle the operation + + From Swagger Specification: + + **OperationID** + + A friendly name for the operation. The id MUST be unique among all operations described in the API. + Tools and libraries MAY use the operation id to uniquely identify an operation. + + :type method: str + :type path: str + """ + operation = make_operation( + self.specification, + self, + path, + method, + self.resolver, + validate_responses=self.validate_responses, + validator_map=self.validator_map, + strict_validation=self.strict_validation, + pythonic_params=self.pythonic_params, + uri_parser_class=self.options.uri_parser_class, + pass_context_arg_name=self.pass_context_arg_name + ) + self._add_operation_internal(method, path, operation) + @classmethod @abc.abstractmethod def get_request(self, *args, **kwargs): @@ -256,7 +281,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 +372,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,31 +443,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) - - @classmethod - def _set_jsonifier(cls): - cls.jsonifier = Jsonifier() 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..da15bbbcd 100644 --- a/connexion/apis/flask_api.py +++ b/connexion/apis/flask_api.py @@ -4,32 +4,23 @@ """ import logging -import pathlib import warnings 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, yamldumper +from connexion.utils import is_json_mimetype logger = logging.getLogger('connexion.apis.flask_api') 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() @@ -40,82 +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_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. - """ - 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, @@ -127,13 +42,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 @@ -199,8 +107,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)): @@ -232,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, @@ -269,65 +176,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 13e9bd8cf..a9110ae4c 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,13 @@ 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() + 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() logger.debug('Root Path: %s', self.root_path) @@ -78,6 +86,12 @@ def create_app(self): Creates the user framework application """ + @abc.abstractmethod + def _apply_middleware(self, middlewares): + """ + Apply middleware to application + """ + @abc.abstractmethod def get_root_path(self): """ @@ -146,6 +160,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, @@ -163,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): """ @@ -243,12 +273,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/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/apps/flask_app.py b/connexion/apps/flask_app.py index 737689a2c..c8da4d2c9 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 @@ -21,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 @@ -28,9 +31,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 [] + super().__init__(import_name, FlaskApi, server=server, **kwargs) + def create_app(self): app = flask.Flask(self.import_name, **self.server_args) app.json_encoder = FlaskJSONEncoder @@ -38,6 +42,16 @@ def create_app(self): app.url_map.converters['int'] = IntegerConverter return app + def _apply_middleware(self, middlewares): + middlewares = [*middlewares, + a2wsgi.WSGIMiddleware] + 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(middleware) + + return middleware + def get_root_path(self): return pathlib.Path(self.app.root_path) @@ -147,6 +161,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/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/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/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/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/connexion/exceptions.py b/connexion/exceptions.py index 0436b0c59..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): """ @@ -96,6 +100,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): @@ -112,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 @@ -122,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..2ee11d029 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): @@ -87,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/lifecycle.py b/connexion/lifecycle.py index bfe741ac8..8cff5aa64 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,23 @@ 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.""" + + 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/__init__.py b/connexion/middleware/__init__.py new file mode 100644 index 000000000..302bc67c7 --- /dev/null +++ b/connexion/middleware/__init__.py @@ -0,0 +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/abstract.py b/connexion/middleware/abstract.py new file mode 100644 index 000000000..4afbc24bb --- /dev/null +++ b/connexion/middleware/abstract.py @@ -0,0 +1,12 @@ +import abc +import pathlib +import typing as t + + +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: + pass diff --git a/connexion/middleware/exceptions.py b/connexion/middleware/exceptions.py new file mode 100644 index 000000000..35122837a --- /dev/null +++ b/connexion/middleware/exceptions.py @@ -0,0 +1,58 @@ +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 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 + 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 new file mode 100644 index 000000000..817ecc7cd --- /dev/null +++ b/connexion/middleware/main.py @@ -0,0 +1,73 @@ +import pathlib +import typing as t + +from starlette.types import ASGIApp, Receive, Scope, Send + +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 + + +class ConnexionMiddleware: + + default_middlewares = [ + ExceptionMiddleware, + SwaggerUIMiddleware, + RoutingMiddleware, + SecurityMiddleware, + ] + + 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) + + @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, + **kwargs + ) -> 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. + """ + 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/routing.py b/connexion/middleware/routing.py new file mode 100644 index 000000000..f264f0125 --- /dev/null +++ b/connexion/middleware/routing.py @@ -0,0 +1,119 @@ +import pathlib +import typing as t +from contextvars import ContextVar + +from starlette.routing import Router +from starlette.types import ASGIApp, Receive, Scope, Send + +from connexion.apis import AbstractRoutingAPI +from connexion.exceptions import NotFoundProblem +from connexion.middleware import AppMiddleware +from connexion.resolver import Resolver + +ROUTING_CONTEXT = 'connexion_routing' + + +_scope: ContextVar[dict] = ContextVar('SCOPE') + + +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=RoutingOperation(None, self.app)) + + 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 = 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: + """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.set(scope.copy()) + + # 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 + + +class RoutingAPI(AbstractRoutingAPI): + + 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, + 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.next_app = next_app + self.router = Router(default=RoutingOperation(None, next_app)) + + 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_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]) + + +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) 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/middleware/swagger_ui.py b/connexion/middleware/swagger_ui.py new file mode 100644 index 000000000..b7543de3b --- /dev/null +++ b/connexion/middleware/swagger_ui.py @@ -0,0 +1,210 @@ +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.middleware import AppMiddleware +from connexion.utils import yamldumper + +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/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..7b90e4366 100644 --- a/connexion/operations/abstract.py +++ b/connexion/operations/abstract.py @@ -6,9 +6,7 @@ import abc import logging -from connexion.operations.secure import SecureOperation - -from ..decorators.metrics import UWSGIMetricsCollector +from ..decorators.decorator import RequestResponseDecorator from ..decorators.parameter import parameter_to_arg from ..decorators.produces import BaseSerializer, Produces from ..decorators.response import ResponseValidator @@ -26,7 +24,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 +43,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 +85,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 +101,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,19 +376,21 @@ 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 - decorator = UWSGIMetricsCollector(self.path, self.method) - function = decorator(function) - 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..9e044c17d 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") @@ -21,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 @@ -43,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 @@ -67,12 +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, {}) - - # 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 +76,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, @@ -94,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', []) @@ -137,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 @@ -276,23 +254,39 @@ 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) + 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 @@ -302,25 +296,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/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..9eeed5522 100644 --- a/connexion/operations/swagger2.py +++ b/connexion/operations/swagger2.py @@ -27,11 +27,9 @@ 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, - 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 @@ -49,18 +47,9 @@ 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 - :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 @@ -78,7 +67,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 +77,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, @@ -105,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 @@ -130,13 +110,9 @@ 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, *args, **kwargs ) diff --git a/connexion/security/__init__.py b/connexion/security/__init__.py index 2404ea47c..136011c5e 100644 --- a/connexion/security/__init__.py +++ b/connexion/security/__init__.py @@ -5,19 +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) - -try: - from .aiohttp_security_handler_factory import AioHttpSecurityHandlerFactory -except ImportError as err: # pragma: no cover - AioHttpSecurityHandlerFactory = not_installed_error(err) +from .security_handler_factory import SecurityHandlerFactory # NOQA 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/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/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/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/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..7591cc1ef 100755 --- a/setup.py +++ b/setup.py @@ -21,40 +21,30 @@ 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.9.1,<3', + 'requests>=2.27,<3', 'inflection>=0.3.1,<0.6', - 'werkzeug>=1.0,<3', - 'importlib-metadata>=1 ; python_version<"3.8"', - 'packaging>=20', + 'werkzeug>=2,<3', + 'starlette>=0.15,<1', + 'httpx>=0.15,<1', ] swagger_ui_require = 'swagger-ui-bundle>=0.0.2,<0.1' flask_require = [ - 'flask>=1.0.4,<3', - 'itsdangerous>=0.24', -] -aiohttp_require = [ - 'aiohttp>=2.3.10,<4', - 'aiohttp-jinja2>=0.14.0,<2', - 'MarkupSafe>=0.23', + 'flask>=2,<3', + 'a2wsgi>=1.4,<2', ] tests_require = [ - 'decorator>=5,<6', 'pytest>=6,<7', + 'pre-commit>=2,<3', 'pytest-cov>=2,<3', - 'testfixtures>=6,<7', *flask_require, 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' ] @@ -100,7 +90,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, @@ -108,17 +97,16 @@ def readme(): 'tests': tests_require, 'flask': flask_require, 'swagger-ui': swagger_ui_require, - 'aiohttp': aiohttp_require, 'docs': docs_require }, cmdclass={'test': PyTest}, 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/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/api/test_errors.py b/tests/api/test_errors.py index 1ad2a8f82..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 @@ -44,23 +43,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/api/test_responses.py b/tests/api/test_responses.py index e5a6f38be..406f0d1cd 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': @@ -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 195de9851..6f14336cd 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 @@ -99,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/conftest.py b/tests/conftest.py index fe3ebb4ec..1da100a9d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,11 @@ import json import logging import pathlib -import sys 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) @@ -31,38 +31,74 @@ 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 # ========================= @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 @@ -78,11 +114,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' @@ -108,7 +139,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'] @@ -117,6 +148,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) @@ -226,9 +258,3 @@ def unordered_definition_app(request): 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/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/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/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/fakeapi/auth.py b/tests/fakeapi/auth.py index 396ee5112..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: @@ -15,13 +16,5 @@ def fake_json_auth(token, required_scopes=None): 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 +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 ba1824215..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 @@ -96,7 +97,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 +105,7 @@ def with_problem(): def with_problem_txt(): raise ProblemException(title='Some Error', detail='Something went wrong somewhere', - status=418, + status=402, instance='instance1') @@ -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/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/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/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 06e9d8315..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: @@ -106,7 +112,7 @@ paths: required: true type: string - /byesecure-jwt/: + /byesecure-jwt/{name}: get: summary: Generate goodbye description: "" @@ -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} 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/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}') diff --git a/tests/test_middleware.py b/tests/test_middleware.py new file mode 100644 index 000000000..5db57fa92 --- /dev/null +++ b/tests/test_middleware.py @@ -0,0 +1,43 @@ +import pytest +from connexion.middleware import ConnexionMiddleware +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_routing']['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 diff --git a/tests/test_mock.py b/tests/test_mock.py index f23db4afc..72f433a8d 100644 --- a/tests/test_mock.py +++ b/tests/test_mock.py @@ -24,10 +24,7 @@ def test_mock_resolver_default(): }, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, - parameter_definitions={}, resolver=resolver) assert operation.operation_id == 'mock-1' @@ -57,10 +54,7 @@ def test_mock_resolver_numeric(): }, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, - parameter_definitions={}, resolver=resolver) assert operation.operation_id == 'mock-1' @@ -96,10 +90,7 @@ def test_mock_resolver_example(): }, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, - parameter_definitions={}, resolver=resolver) assert operation.operation_id == 'mock-1' @@ -133,10 +124,7 @@ 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) assert operation.operation_id == 'mock-1' @@ -168,10 +156,7 @@ 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) assert operation.operation_id == 'mock-1' @@ -207,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' @@ -241,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' @@ -274,10 +257,7 @@ 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) assert operation.operation_id == 'mock-1' @@ -310,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' @@ -334,10 +313,7 @@ def test_mock_resolver_no_examples(): }, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, - parameter_definitions={}, resolver=resolver) assert operation.operation_id == 'mock-1' @@ -363,10 +339,7 @@ def test_mock_resolver_notimplemented(): }, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, definitions={}, - parameter_definitions={}, resolver=resolver) assert operation.operation_id == 'fakeapi.hello.get' @@ -381,10 +354,7 @@ def test_mock_resolver_notimplemented(): }, app_produces=['application/json'], app_consumes=['application/json'], - app_security=[], - security_definitions={}, 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 692f5fbb4..6b099f32c 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,33 @@ 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,17 +325,14 @@ 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()) assert isinstance(operation.function, types.FunctionType) 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,85 +350,39 @@ 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()) assert isinstance(operation.function, types.FunctionType) 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,10 +395,7 @@ 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()) operation.body_schema @@ -455,58 +404,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 +432,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,10 +459,7 @@ 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()) assert operation.parameters == [{'in': 'path', 'type': 'integer'}] @@ -582,9 +470,8 @@ 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, - parameter_definitions=PARAMETER_DEFINITIONS, resolver=Resolver() + app_consumes=['application/json'], definitions=DEFINITIONS, + resolver=Resolver() ) op_spec = make_operation(OPERATION6, parameters=False) op_spec['parameters'][0]['default'] = { @@ -596,9 +483,8 @@ 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, - parameter_definitions={}, resolver=Resolver() + app_consumes=['application/json'], definitions=DEFINITIONS, + resolver=Resolver() ) @@ -620,35 +506,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..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') @@ -55,10 +53,7 @@ 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()) assert operation.operation_id == 'fakeapi.hello.post_greeting' @@ -74,10 +69,7 @@ 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')) assert operation.operation_id == 'fakeapi.hello.post_greeting' @@ -92,10 +84,7 @@ 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')) assert operation.operation_id == 'fakeapi.hello.post_greeting' @@ -111,10 +100,7 @@ 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)) assert operation.operation_id == 'fakeapi.hello.post_greeting' @@ -129,10 +115,7 @@ 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')) assert operation.operation_id == 'fakeapi.hello.post_greeting' @@ -148,10 +131,7 @@ 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')) assert operation.operation_id == 'fakeapi.hello.post_greeting' @@ -164,10 +144,7 @@ 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')) assert operation.operation_id == 'fakeapi.hello.get' @@ -180,10 +157,7 @@ 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')) assert operation.operation_id == 'fakeapi.hello.get' @@ -196,10 +170,7 @@ 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')) assert operation.operation_id == 'fakeapi.hello.world.search' @@ -212,10 +183,7 @@ 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')) assert operation.operation_id == 'fakeapi.hello.get' @@ -227,10 +195,7 @@ 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')) assert operation.operation_id == 'fakeapi.hello.world.get' @@ -243,10 +208,7 @@ 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')) assert operation.operation_id == 'fakeapi.foo_bar.search' @@ -259,10 +221,7 @@ 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')) assert operation.operation_id == 'fakeapi.get' @@ -275,10 +234,7 @@ 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')) assert operation.operation_id == 'fakeapi.hello.search' @@ -293,10 +249,7 @@ 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')) assert operation.operation_id == 'fakeapi.hello.search' @@ -309,10 +262,7 @@ 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')) assert operation.operation_id == 'fakeapi.hello.api_list' @@ -325,9 +275,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')) 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') ) 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') diff --git a/tox.ini b/tox.ini index 8b8085af5..ad76253cf 100644 --- a/tox.ini +++ b/tox.ini @@ -5,59 +5,36 @@ rst-roles=class [tox] envlist = - {py36}-{min,pypi,dev} {py37}-{min,pypi,dev} {py38}-{min,pypi,dev} {py39}-{min,pypi,dev} - isort-check - isort-check-examples - isort-check-tests - flake8 + {py310}-{min,pypi,dev} + pre-commit mypy [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,pre-commit,mypy [testenv] 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 -[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 aiohttp,connexion --check-only --diff . +[testenv:pre-commit] +deps=pre-commit +commands=pre-commit run --all-files --show-diff-on-failure [testenv:mypy] deps=