Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Run framework agnostic tests #828

Closed
wants to merge 15 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 53 additions & 2 deletions connexion/apis/abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from ..options import ConnexionOptions
from ..resolver import Resolver
from ..spec import Specification
from ..utils import Jsonifier
from ..utils import Jsonifier, decode, is_json_mimetype, is_string

MODULE_PATH = pathlib.Path(__file__).absolute().parent.parent
SWAGGER_UI_URL = 'ui'
Expand Down Expand Up @@ -236,7 +236,11 @@ def get_request(self, *args, **kwargs):
@abc.abstractmethod
def get_response(self, response, mimetype=None, request=None):
"""
This method converts the ConnexionResponse to a user framework response.
This method converts a response to a user framework response.

The response can be a ConnexionResponse or an operation handler
result. This type of result is handled by `cls._response_from_handler`

:param response: A response to cast.
:param mimetype: The response mimetype.
:param request: The request associated with this response (the user framework request).
Expand All @@ -245,11 +249,58 @@ def get_response(self, response, mimetype=None, request=None):
:type mimetype: str
"""

@classmethod
@abc.abstractmethod
def _response_from_handler(cls, response, mimetype):
# type: Union[Response, str, Tuple[str, int], Tuple[str, int, dict]] -> Response
"""
Create a framework response from the operation handler data.

An operation handler can return:
- a framework response
- a body (str / binary / dict / list), a response will be created
with a status code 200 by default and empty headers.
- a tuple of (body: str, status_code: int)
- a tuple of (body: str, status_code: int, headers: dict)

:param response: A response from an operation handler.
:param mimetype: The response mimetype.

:return A framwork response.
"""

@classmethod
def encode_body(cls, body, mimetype=None):
"""Helper to appropriatly encode the body.

If JSON mimetype is given, serialize the body.
If an object other than a string is given, serialize it.
It is needed when user is expecting to play with a JSON
API without specifying the mimetype.
In other case return the body.

For strings the return value is bytes.
"""
json_encode = mimetype and is_json_mimetype(mimetype)
if json_encode or not is_string(body):
if isinstance(body, six.binary_type):
body = decode(body)
"""if body is empty transform it to object."""
if body == "":
body = {}
body = cls.jsonifier.dumps(body)
if isinstance(body, six.text_type):
body = body.encode("UTF-8")
return body

@classmethod
@abc.abstractmethod
def get_connexion_response(cls, response, mimetype=None):
"""
This method converts the user framework response to a ConnexionResponse.

It is used after the user returned a response,
to give it to response validators.
:param response: A response to cast.
"""

Expand Down
57 changes: 34 additions & 23 deletions connexion/apis/aiohttp_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
from connexion.exceptions import OAuthProblem
from connexion.handlers import AuthErrorHandler
from connexion.lifecycle import ConnexionRequest, ConnexionResponse
from connexion.utils import Jsonifier, is_json_mimetype
from connexion.operations.validation import validate_operation_output
from connexion.utils import Jsonifier

try:
import ujson as json
Expand Down Expand Up @@ -205,8 +206,7 @@ def get_response(cls, response, mimetype=None, request=None):
'url': url
})

if isinstance(response, ConnexionResponse):
response = cls._get_aiohttp_response_from_connexion(response, mimetype)
response = cls._get_response(response, mimetype)

if isinstance(response, web.StreamResponse):
logger.debug('Got stream response with status code (%d)',
Expand All @@ -218,12 +218,39 @@ def get_response(cls, response, mimetype=None, request=None):
return response

@classmethod
def get_connexion_response(cls, response, mimetype=None):
response.body = cls._cast_body(response.body, mimetype)
def _get_response(cls, response, mimetype):
"""Synchronous part of get_response method."""
if isinstance(response, ConnexionResponse):
response = cls._get_aiohttp_response_from_connexion(response, mimetype)
else:
response = cls._response_from_handler(response, mimetype)
return response

@classmethod
def _response_from_handler(cls, response, mimetype=None):
"""Return a web.Response from operation handler."""
if isinstance(response, web.StreamResponse):
"""this check handles web.StreamResponse and web.Response."""
return response
elif (
isinstance(response, tuple) and
isinstance(response[0], web.StreamResponse)
):
return response[0]

body, status, headers = validate_operation_output(response)
body = cls.encode_body(body, mimetype)
return web.Response(
body=body, status=status, headers=headers
)

@classmethod
def get_connexion_response(cls, response, mimetype=None):
if isinstance(response, ConnexionResponse):
return response

response = cls._get_response(response, mimetype)

return ConnexionResponse(
status_code=response.status,
mimetype=response.content_type,
Expand All @@ -234,10 +261,8 @@ def get_connexion_response(cls, response, mimetype=None):

@classmethod
def _get_aiohttp_response_from_connexion(cls, response, mimetype):
content_type = response.content_type if response.content_type else \
response.mimetype if response.mimetype else mimetype

body = cls._cast_body(response.body, content_type)
content_type = response.content_type or response.mimetype or mimetype
body = cls.encode_body(response.body, content_type)

return web.Response(
status=response.status_code,
Expand All @@ -246,20 +271,6 @@ def _get_aiohttp_response_from_connexion(cls, response, mimetype):
body=body
)

@classmethod
def _cast_body(cls, body, content_type=None):
if not isinstance(body, bytes):
if content_type and is_json_mimetype(content_type):
return json.dumps(body).encode()

elif isinstance(body, str):
return body.encode()

else:
return str(body).encode()
else:
return body

@classmethod
def _set_jsonifier(cls):
cls.jsonifier = Jsonifier(json)
Expand Down
49 changes: 16 additions & 33 deletions connexion/apis/flask_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@

from connexion.apis import flask_utils
from connexion.apis.abstract import AbstractAPI
from connexion.decorators.produces import NoContent
from connexion.handlers import AuthErrorHandler
from connexion.lifecycle import ConnexionRequest, ConnexionResponse
from connexion.utils import Jsonifier, is_json_mimetype
from connexion.operations.validation import validate_operation_output
from connexion.utils import Jsonifier

logger = logging.getLogger('connexion.apis.flask_api')

Expand Down Expand Up @@ -115,7 +115,7 @@ def get_response(cls, response, mimetype=None, request=None):
if isinstance(response, ConnexionResponse):
flask_response = cls._get_flask_response_from_connexion(response, mimetype)
else:
flask_response = cls._get_flask_response(response, mimetype)
flask_response = cls._response_from_handler(response, mimetype)

logger.debug('Got data and status code (%d)',
flask_response.status_code,
Expand Down Expand Up @@ -152,49 +152,32 @@ def _build_flask_response(cls, mimetype=None, content_type=None,
flask_response = flask.current_app.response_class(**kwargs) # type: flask.Response

if status_code is not None:
# If we got an enum instead of an int, extract the value.
if hasattr(status_code, "value"):
status_code = status_code.value

flask_response.status_code = status_code

if data is not None and data is not NoContent:
data = cls._jsonify_data(data, mimetype)
flask_response.set_data(data)

elif data is NoContent:
flask_response.set_data('')
data = cls.encode_body(data, mimetype)
flask_response.set_data(data)

return flask_response

@classmethod
def _jsonify_data(cls, data, mimetype):
if (isinstance(mimetype, six.string_types) and is_json_mimetype(mimetype)) \
or not (isinstance(data, six.binary_type) or isinstance(data, six.text_type)):
return cls.jsonifier.dumps(data)

return data
def _response_from_handler(cls, response, mimetype):
"""Create a framework response from the operation handler data.

@classmethod
def _get_flask_response(cls, response, mimetype):
Handle all cases describe in `AbstractApi.get_response`.
"""
if flask_utils.is_flask_response(response):
return response

elif isinstance(response, tuple) and flask_utils.is_flask_response(response[0]):
return flask.current_app.make_response(response)

elif isinstance(response, tuple) and len(response) == 3:
data, status_code, headers = response
return cls._build_flask_response(mimetype, None,
headers, status_code, data)

elif isinstance(response, tuple) and len(response) == 2:
data, status_code = response
return cls._build_flask_response(mimetype, None, None,
status_code, data)
body, status_code, headers = validate_operation_output(response)

else:
return cls._build_flask_response(mimetype=mimetype, data=response)
return cls._build_flask_response(
mimetype=mimetype,
headers=headers,
status_code=status_code,
data=body
)

@classmethod
def get_connexion_response(cls, response, mimetype=None):
Expand Down
7 changes: 7 additions & 0 deletions connexion/apps/abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,3 +246,10 @@ def __call__(self, environ, start_response): # pragma: no cover
class and protect it from unwanted modification.
"""
return self.app(environ, start_response)

@abc.abstractmethod
def test_client(self):
"""Return a synchronous client, compatible wit flask test_client.

An abstract is implemented in connexion.tests.AbstractClient.
"""
84 changes: 81 additions & 3 deletions connexion/apps/aiohttp_app.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,60 @@
import asyncio
import logging
import os.path
import pkgutil
import sys

from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer

from ..apis.aiohttp_api import AioHttpApi
from ..exceptions import ConnexionException
from ..exceptions import ConnexionException, ProblemException
from ..http_facts import HTTP_ERRORS
from ..lifecycle import ConnexionResponse
from ..problem import problem
from ..tests import AbstractClient
from .abstract import AbstractApp

logger = logging.getLogger('connexion.aiohttp_app')


@web.middleware
@asyncio.coroutine
def error_middleware(request, handler):
error_response = None
try:
return (yield from handler(request))
except web.HTTPException as ex:
if ex.status_code >= 400:
error_response = problem(
title=HTTP_ERRORS[ex.status_code]["title"],
detail=HTTP_ERRORS[ex.status_code]["detail"],
status=ex.status_code
)
except ProblemException as exception:
error_response = exception.to_problem()
except Exception:
logger.exception("aiohttp error")
error_response = problem(
title=HTTP_ERRORS[500]["title"],
detail=HTTP_ERRORS[500]["detail"],
status=500
)
if error_response:
return (yield from AioHttpApi.get_response(error_response))


class AioHttpApp(AbstractApp):

api_cls = AioHttpApi

def __init__(self, import_name, only_one_api=False, **kwargs):
super(AioHttpApp, self).__init__(import_name, AioHttpApi, server='aiohttp', **kwargs)
super(AioHttpApp, self).__init__(import_name, self.api_cls, server='aiohttp', **kwargs)
self._only_one_api = only_one_api
self._api_added = False

def create_app(self):
return web.Application(debug=self.debug)
return web.Application(debug=self.debug, middlewares=[error_middleware])

def get_root_path(self):
mod = sys.modules.get(self.import_name)
Expand All @@ -39,6 +73,7 @@ def get_root_path(self):
return os.path.dirname(os.path.abspath(filepath))

def set_errors_handlers(self):
"""error handlers are set in `create_app`."""
pass

def add_api(self, specification, **kwargs):
Expand Down Expand Up @@ -96,3 +131,46 @@ def run(self, port=None, server=None, debug=None, host=None, **options):
web.run_app(self.app, port=self.port, host=self.host, access_log=access_log)
else:
raise Exception('Server {} not recognized'.format(self.server))

def test_client(self):
"""Return a flask's test_client compatible."""
return AioHttpClient.from_app(self)


class AioHttpClient(AbstractClient):
""" A specific test client for aiohttp framework."""

def _request(
self,
method,
url,
**kwargs
):
# code inspired from https://github.com/aio-libs/aiohttp/blob/v3.4.4/aiohttp/pytest_plugin.py#L286
# set the loop in the app,
# and use only this one to avoid loop conflicts
self.app.app._set_loop(None)
loop = self.app.app.loop
client = TestClient(TestServer(self.app.app, loop=loop), loop=loop)
loop.run_until_complete(client.start_server())

@asyncio.coroutine
def _async_request():
nonlocal client
content_type = kwargs.pop("content_type", None)
if content_type:
headers = kwargs.setdefault("headers", {})
if "Content-Type" not in headers:
headers["Content-Type"] = content_type
kwargs["params"] = kwargs.pop("query_string", None)
res = yield from client.request(method.upper(), url, **kwargs)
body = yield from res.read()
return ConnexionResponse(
status_code=res.status,
headers=res.headers,
body=body
)

response = loop.run_until_complete(_async_request())
loop.run_until_complete(client.close())
return response
Loading