Skip to content

Commit

Permalink
common handler to validate operation handler response
Browse files Browse the repository at this point in the history
Documented in AbstractApi._response_from_handler.
This method is implemented by subclasses.
Body and tuples are validated by connexion.operations.validation.validate_operation_output
fixes #578
  • Loading branch information
ainquel committed Dec 19, 2018
1 parent ef7c9ca commit 58e3fbb
Show file tree
Hide file tree
Showing 13 changed files with 294 additions and 58 deletions.
52 changes: 50 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,55 @@ 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)
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
61 changes: 38 additions & 23 deletions connexion/apis/aiohttp_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,20 @@
from urllib.parse import parse_qs

import jinja2
import six

import aiohttp_jinja2
from aiohttp import web
from aiohttp.payload import Payload
from aiohttp.web_exceptions import HTTPNotFound
from connexion.apis.abstract import AbstractAPI
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 +210,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 +222,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 +265,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 +275,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
39 changes: 15 additions & 24 deletions connexion/apis/flask_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
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 +116,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 @@ -159,7 +160,7 @@ def _build_flask_response(cls, mimetype=None, content_type=None,
flask_response.status_code = status_code

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

elif data is NoContent:
Expand All @@ -168,33 +169,23 @@ def _build_flask_response(cls, mimetype=None, content_type=None,
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)
def _response_from_handler(cls, response, mimetype):
"""Create a framework response from the operation handler data.
return 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)

else:
return cls._build_flask_response(mimetype=mimetype, data=response)
body, status_code, headers = validate_operation_output(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
4 changes: 0 additions & 4 deletions connexion/apps/aiohttp_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,6 @@ def _async_request():
kwargs["params"] = kwargs.get("query_string")
res = yield from client.request(method.upper(), url, **kwargs)
body = yield from res.read()
print(res.content_type)
if is_json_mimetype(res.content_type):
body = body + b"\n"
print("test_client", body)
return ConnexionResponse(
status_code=res.status,
headers=res.headers,
Expand Down
1 change: 0 additions & 1 deletion connexion/decorators/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,6 @@ def wrapper(request):
data[k] = coerce_type(param_defn, data[k], 'requestBody', k)
except TypeValidationError as e:
errs += [str(e)]
print(errs)
if errs:
return problem(400, 'Bad Request', errs)

Expand Down
26 changes: 26 additions & 0 deletions connexion/operations/validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import six

from connexion.utils import normalize_tuple

BODY_TYPES = (six.text_type, six.binary_type, dict, list)


def validate_operation_output(response):
"""Will validate the format returned by a handler."""
if isinstance(response, BODY_TYPES):
response = (response, )
body, status, headers = normalize_tuple(response, 3)
if not isinstance(body, BODY_TYPES):
raise ValueError(
"first returned value has to be {}, got {}".format(
BODY_TYPES, type(body)
)
)
status = status or 200
if headers is not None and not isinstance(headers, dict):
raise ValueError(
"Type of 3rd return value is dict, got {}".format(
type(headers)
)
)
return body, status, headers
18 changes: 18 additions & 0 deletions connexion/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ def is_null(value):
return False


def is_string(value):
return isinstance(value, (six.text_type, six.binary_type))


class Jsonifier(object):
def __init__(self, json_):
self.json = json_
Expand Down Expand Up @@ -235,3 +239,17 @@ def encode(_string):
except (UnicodeEncodeError, UnicodeDecodeError):
pass
return _string.encode(ENCODINGS[0], errors="ignore")


def normalize_tuple(obj, length):
"""return a tuple of length `length`."""
if not isinstance(obj, tuple):
raise ValueError("expected tuple, got {}".format(type(obj)))
elif len(obj) > length:
raise ValueError("length expected is smaller than obj size.")

new_obj = [o for o in obj]
while len(new_obj) < length:
new_obj.append(None)

return tuple(new_obj)
35 changes: 35 additions & 0 deletions tests/api/test_abstract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import json

import pytest

from connexion.apis import AbstractAPI
from connexion.utils import Jsonifier


@pytest.mark.parametrize("body,mimetype,expected", [
(None, None, b"null\n"),
# is returned as it is with mimetype text/plain
("test", "text/plain", b"test"),
(b"test", "text/plain", b"test"),
("test", "application/json", b'"test"\n'),
(b"test", "application/json", b'"test"\n'),
])
def test_encode_body(body, mimetype, expected):
"""Test the body encoding.
Jsonifier adds a `\n` after the serialized string.
"""
assert AbstractAPI.encode_body(body, mimetype) == expected


@pytest.mark.parametrize("body", [
None,
{"test": 1},
["test"],
])
def test_encode_body_objects(body):
encoded = AbstractAPI.encode_body(body, mimetype="application/json")
serde = Jsonifier(json)
assert encoded == serde.dumps(body).encode("UTF-8")
assert encoded.decode("UTF-8")[-1] == "\n"
assert serde.loads(encoded) == body
Loading

0 comments on commit 58e3fbb

Please sign in to comment.