Skip to content

Commit

Permalink
Add async app (#1613)
Browse files Browse the repository at this point in the history
Fixes #1534

Changes proposed in this pull request:
- Adds a "native" async app based on lower level Starlette tools. It
leverages the routing already done in the middleware.
- Extracts code related to the parameter decorator out of the Operation
objects

There's some more work to be done, but this PR is already quite huge, so
I wanted to stop at a point where I got all the tests and a simple
example working.
- Still some general refactoring. I left some TODOs, and the Operation
classes are still relied on in too many places.
- Sync routes on an Async app should receive async decorators, so they
can access the body using `await`. The decorators should then run the
sync route in a threadpool to prevent blocking.
- We should be able to reuse the parameter decorator for all ASGI apps
since we're working with a Starlette Request which we can get directly
from the scope and receive channel, which we can make available as
context. This means we'll pass Starlette Datastructures to the view
functions (eg. FormData, UploadFiles, ...), but if this is opt-in, I
don't see any issue with this.
- We should be able to reuse the response parsing for Starlette apps as
well since it returns a StarletteResponse.
- We should test the AsyncApp as well. I'm hoping we can do this quite
quickly by generating a test client for it in the fixtures as well, but
in the long term some more work will be needed.
  • Loading branch information
RobbeSneyders committed Dec 28, 2022
2 parents 1af4733 + 0add539 commit e5784c5
Show file tree
Hide file tree
Showing 47 changed files with 1,287 additions and 883 deletions.
9 changes: 6 additions & 3 deletions connexion/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@

from .apis import AbstractAPI # NOQA
from .apps import AbstractApp # NOQA
from .decorators.produces import NoContent # NOQA
from .apps.async_app import AsyncApp
from .datastructures import NoContent # NOQA
from .exceptions import ProblemException # NOQA
from .problem import problem # NOQA
from .resolver import Resolution, Resolver, RestyResolver # NOQA
Expand All @@ -20,13 +21,15 @@
try:
from flask import request # NOQA

from .apis.flask_api import FlaskApi, context # NOQA
from .apps.flask_app import FlaskApp
from connexion.apis.flask_api import FlaskApi # NOQA
from connexion.apps.flask_app import FlaskApp
except ImportError as e: # pragma: no cover
_flask_not_installed_error = not_installed_error(e)
FlaskApi = _flask_not_installed_error # type: ignore
FlaskApp = _flask_not_installed_error # type: ignore

from connexion.apps.async_app import AsyncApi, AsyncApp, ConnexionMiddleware

App = FlaskApp
Api = FlaskApi

Expand Down
89 changes: 37 additions & 52 deletions connexion/apis/abstract.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,26 @@
"""
This module defines an AbstractAPI, which defines a standardized interface for a Connexion API.
"""

import abc
import logging
import pathlib
import sys
import typing as t
from enum import Enum

from ..decorators.produces import NoContent
from ..exceptions import ResolverError
from ..http_facts import METHODS
from ..jsonifier import Jsonifier
from ..lifecycle import ConnexionResponse
from ..operations import make_operation
from ..options import ConnexionOptions
from ..resolver import Resolver
from ..spec import Specification
from starlette.requests import Request

from connexion.datastructures import NoContent
from connexion.decorators.parameter import parameter_to_arg
from connexion.exceptions import ResolverError
from connexion.http_facts import METHODS
from connexion.jsonifier import Jsonifier
from connexion.lifecycle import ConnexionResponse
from connexion.operations import make_operation
from connexion.options import ConnexionOptions
from connexion.resolver import Resolver
from connexion.spec import Specification
from connexion.utils import is_json_mimetype

MODULE_PATH = pathlib.Path(__file__).absolute().parent.parent
SWAGGER_UI_URL = "ui"
Expand Down Expand Up @@ -101,14 +104,19 @@ def __init__(
self,
*args,
resolver_error_handler: t.Optional[t.Callable] = None,
pythonic_params=False,
debug: bool = False,
**kwargs,
) -> None:
"""Minimal interface of an API, with only functionality related to routing.
:param pythonic_params: When True CamelCase parameters are converted to snake_case and an underscore is appended
to any shadowed built-ins
:param debug: Flag to run in debug mode
"""
super().__init__(*args, **kwargs)
logger.debug("Pythonic params: %s", str(pythonic_params))
self.pythonic_params = pythonic_params
self.debug = debug
self.resolver_error_handler = resolver_error_handler

Expand Down Expand Up @@ -184,18 +192,13 @@ def __init__(
resolver=None,
debug=False,
resolver_error_handler=None,
pythonic_params=False,
options=None,
**kwargs,
):
"""
: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
"""
logger.debug("Pythonic params: %s", str(pythonic_params))
self.pythonic_params = pythonic_params

super().__init__(
specification,
Expand All @@ -205,6 +208,7 @@ def __init__(
resolver_error_handler=resolver_error_handler,
debug=debug,
options=options,
**kwargs,
)

def add_operation(self, path, method):
Expand All @@ -231,62 +235,55 @@ def add_operation(self, path, method):
self.resolver,
pythonic_params=self.pythonic_params,
uri_parser_class=self.options.uri_parser_class,
parameter_to_arg=parameter_to_arg,
)
self._add_operation_internal(method, path, operation)

@classmethod
@abc.abstractmethod
def get_request(self, *args, **kwargs):
def get_request(cls, **kwargs) -> Request:
"""
This method converts the user framework request to a ConnexionRequest.
"""

@classmethod
@abc.abstractmethod
def get_response(self, response, mimetype=None, request=None):
def get_response(cls, response, mimetype=None):
"""
This method converts a handler response to a framework response.
This method should just retrieve response from handler then call `cls._get_response`.
:param response: A response to cast (tuple, framework response, etc).
:param mimetype: The response mimetype.
:type mimetype: Union[None, str]
:param request: The request associated with this response (the user framework request).
"""

@classmethod
def _get_response(cls, response, mimetype=None, extra_context=None):
def _get_response(cls, response, mimetype=None):
"""
This method converts a handler response to a framework response.
The response can be a ConnexionResponse, an operation handler, a framework response or a tuple.
Other type than ConnexionResponse are handled by `cls._response_from_handler`
:param response: A response to cast (tuple, framework response, etc).
:param mimetype: The response mimetype.
:type mimetype: Union[None, str]
:param extra_context: dict of extra details, like url, to include in logs
:type extra_context: Union[None, dict]
"""
if extra_context is None:
extra_context = {}
logger.debug(
"Getting data and status code",
extra={"data": response, "data_type": type(response), **extra_context},
extra={"data": response, "data_type": type(response)},
)

if isinstance(response, ConnexionResponse):
framework_response = cls._connexion_to_framework_response(
response, mimetype, extra_context
response, mimetype
)
else:
framework_response = cls._response_from_handler(
response, mimetype, extra_context
)
framework_response = cls._response_from_handler(response, mimetype)

logger.debug(
"Got framework response",
extra={
"response": framework_response,
"response_type": type(framework_response),
**extra_context,
},
)
return framework_response
Expand All @@ -298,7 +295,6 @@ def _response_from_handler(
t.Any, str, t.Tuple[str], t.Tuple[str, int], t.Tuple[str, int, dict]
],
mimetype: str,
extra_context: t.Optional[dict] = None,
) -> t.Any:
"""
Create a framework response from the operation handler data.
Expand All @@ -311,7 +307,6 @@ def _response_from_handler(
:param response: A response from an operation handler.
:param mimetype: The response mimetype.
:param extra_context: dict of extra details, like url, to include in logs
"""
if cls._is_framework_response(response):
return response
Expand All @@ -320,25 +315,21 @@ def _response_from_handler(
len_response = len(response)
if len_response == 1:
(data,) = response
return cls._build_response(
mimetype=mimetype, data=data, extra_context=extra_context
)
return cls._build_response(mimetype=mimetype, data=data)
if len_response == 2:
if isinstance(response[1], (int, Enum)):
data, status_code = response
return cls._build_response(
mimetype=mimetype,
data=data,
status_code=status_code,
extra_context=extra_context,
)
else:
data, headers = response
return cls._build_response(
mimetype=mimetype,
data=data,
headers=headers,
extra_context=extra_context,
)
elif len_response == 3:
data, status_code, headers = response
Expand All @@ -347,7 +338,6 @@ def _response_from_handler(
data=data,
status_code=status_code,
headers=headers,
extra_context=extra_context,
)
else:
raise TypeError(
Expand All @@ -356,9 +346,7 @@ def _response_from_handler(
" (body, status), or (body, headers)."
)
else:
return cls._build_response(
mimetype=mimetype, data=response, extra_context=extra_context
)
return cls._build_response(mimetype=mimetype, data=response)

@classmethod
def get_connexion_response(cls, response, mimetype=None):
Expand All @@ -384,7 +372,7 @@ def _framework_to_connexion_response(cls, response, mimetype):

@classmethod
@abc.abstractmethod
def _connexion_to_framework_response(cls, response, mimetype, extra_context=None):
def _connexion_to_framework_response(cls, response, mimetype):
"""Cast ConnexionResponse to framework response class"""

@classmethod
Expand All @@ -396,7 +384,6 @@ def _build_response(
content_type=None,
status_code=None,
headers=None,
extra_context=None,
):
"""
Create a framework response from the provided arguments.
Expand All @@ -407,16 +394,12 @@ def _build_response(
:type status_code: int
:param headers: The response status code.
:type headers: Union[Iterable[Tuple[str, str]], Dict[str, str]]
:param extra_context: dict of extra details, like url, to include in logs
:type extra_context: Union[None, dict]
:return A framework response.
:rtype Response
"""

@classmethod
def _prepare_body_and_status_code(
cls, data, mimetype, status_code=None, extra_context=None
):
def _prepare_body_and_status_code(cls, data, mimetype, status_code=None):
if data is NoContent:
data = None

Expand All @@ -435,20 +418,22 @@ def _prepare_body_and_status_code(
else:
body = data

if extra_context is None:
extra_context = {}
logger.debug(
"Prepared body and status code (%d)",
status_code,
extra={"body": body, **extra_context},
extra={"body": body},
)

return body, status_code, mimetype

@classmethod
@abc.abstractmethod
def _serialize_data(cls, data, mimetype):
pass
if isinstance(mimetype, str) and is_json_mimetype(mimetype):
body = cls.jsonifier.dumps(data)
else:
body = data

return body, mimetype

def json_loads(self, data):
return self.jsonifier.loads(data)

0 comments on commit e5784c5

Please sign in to comment.