diff --git a/aiobravado/client.py b/aiobravado/client.py index 7fab8e0..762fed6 100644 --- a/aiobravado/client.py +++ b/aiobravado/client.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """ The :class:`SwaggerClient` provides an interface for making API calls based on a swagger spec, and returns responses of python objects which build from the @@ -52,34 +53,16 @@ from six import iteritems from six import itervalues +from aiobravado.aiohttp_client import AiohttpClient +from aiobravado.config_defaults import CONFIG_DEFAULTS +from aiobravado.config_defaults import REQUEST_OPTIONS_DEFAULTS from aiobravado.docstring_property import docstring_property from aiobravado.swagger_model import Loader from aiobravado.warning import warn_for_deprecated_op -from aiobravado.aiohttp_client import AiohttpClient - log = logging.getLogger(__name__) -CONFIG_DEFAULTS = { - # See the constructor of :class:`aiobravado.http_future.HttpFuture` for an - # in depth explanation of what this means. - 'also_return_response': False, -} - -REQUEST_OPTIONS_DEFAULTS = { - # List of callbacks that are executed after the incoming response has been - # validated and the swagger_result has been unmarshalled. - # - # The callback should expect two arguments: - # param : incoming_response - # type : subclass of class:`bravado_core.response.IncomingResponse` - # param : operation - # type : class:`bravado_core.operation.Operation` - 'response_callbacks': [], -} - - class SwaggerClient(object): """A client for accessing a Swagger-documented RESTful service. @@ -91,8 +74,7 @@ def __init__(self, swagger_spec, also_return_response=False): self.swagger_spec = swagger_spec @classmethod - async def from_url(cls, spec_url, http_client=None, request_headers=None, - config=None): + async def from_url(cls, spec_url, http_client=None, request_headers=None, config=None): """Build a :class:`SwaggerClient` from a url to the Swagger specification for a RESTful API. @@ -293,6 +275,9 @@ def construct_request(operation, request_options, **op_kwargs): 'params': {}, # filled in downstream 'headers': request_options.get('headers', {}), } + # Adds Accept header to request for msgpack response if specified + if request_options.get('use_msgpack', False): + request['headers']['Accept'] = 'application/msgpack' # Copy over optional request options for request_option in ('connect_timeout', 'timeout'): diff --git a/aiobravado/config_defaults.py b/aiobravado/config_defaults.py new file mode 100644 index 0000000..97ec3aa --- /dev/null +++ b/aiobravado/config_defaults.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +CONFIG_DEFAULTS = { + # See the constructor of :class:`bravado.http_future.HttpFuture` for an + # in depth explanation of what this means. + 'also_return_response': False, +} + +REQUEST_OPTIONS_DEFAULTS = { + # List of callbacks that are executed after the incoming response has been + # validated and the swagger_result has been unmarshalled. + # + # The callback should expect two arguments: + # param : incoming_response + # type : subclass of class:`bravado_core.response.IncomingResponse` + # param : operation + # type : class:`bravado_core.operation.Operation` + 'response_callbacks': [], +} diff --git a/aiobravado/http_future.py b/aiobravado/http_future.py index 7dc2773..8a9bf7e 100644 --- a/aiobravado/http_future.py +++ b/aiobravado/http_future.py @@ -3,13 +3,15 @@ from functools import wraps import six -import umsgpack -from bravado_core.content_type import APP_JSON, APP_MSGPACK +from bravado_core.content_type import APP_JSON +from bravado_core.content_type import APP_MSGPACK from bravado_core.exception import MatchingResponseNotFound from bravado_core.response import get_response_spec from bravado_core.unmarshal import unmarshal_schema_object from bravado_core.validate import validate_schema_object +from msgpack import unpackb +from aiobravado.config_defaults import REQUEST_OPTIONS_DEFAULTS from aiobravado.exception import BravadoTimeoutError from aiobravado.exception import make_http_exception @@ -96,7 +98,7 @@ def __init__(self, future, response_adapter, operation=None, self.future = future self.response_adapter = response_adapter self.operation = operation - self.response_callbacks = response_callbacks or [] + self.response_callbacks = response_callbacks or REQUEST_OPTIONS_DEFAULTS['response_callbacks'] self.also_return_response = also_return_response @reraise_errors @@ -134,12 +136,10 @@ async def unmarshal_response(incoming_response, operation, response_callbacks=No This hands the response over to bravado_core for validation and unmarshalling and then runs any response callbacks. On success, the swagger_result is available as ``incoming_response.swagger_result``. - :type incoming_response: :class:`bravado_core.response.IncomingResponse` :type operation: :class:`bravado_core.operation.Operation` :type response_callbacks: list of callable. See bravado_core.client.REQUEST_OPTIONS_DEFAULTS. - :raises: HTTPError - On 5XX status code, the HTTPError has minimal information. - On non-2XX status code with no matching response, the HTTPError @@ -152,8 +152,8 @@ async def unmarshal_response(incoming_response, operation, response_callbacks=No try: raise_on_unexpected(incoming_response) incoming_response.swagger_result = await unmarshal_response_inner( - incoming_response, - operation, + response=incoming_response, + op=operation, ) except MatchingResponseNotFound as e: exception = make_http_exception( @@ -173,21 +173,18 @@ async def unmarshal_response(incoming_response, operation, response_callbacks=No async def unmarshal_response_inner(response, op): - """Unmarshal incoming http response into a value based on the + """ + Unmarshal incoming http response into a value based on the response specification. - :type response: :class:`bravado_core.response.IncomingResponse` :type op: :class:`bravado_core.operation.Operation` :returns: value where type(value) matches response_spec['schema']['type'] if it exists, None otherwise. """ deref = op.swagger_spec.deref - response_spec = get_response_spec(response.status_code, op) - - def has_content(response_spec): - return 'schema' in response_spec + response_spec = get_response_spec(status_code=response.status_code, op=op) - if not has_content(response_spec): + if 'schema' not in response_spec: return None content_type = response.headers.get('content-type', '').lower() @@ -197,12 +194,16 @@ def has_content(response_spec): if content_type.startswith(APP_JSON): content_value = await response.json() else: - content_value = umsgpack.loads(await response.raw_bytes) - if op.swagger_spec.config['validate_responses']: + content_value = unpackb(await response.raw_bytes, encoding='utf-8') + + if op.swagger_spec.config.get('validate_responses', False): validate_schema_object(op.swagger_spec, content_spec, content_value) return unmarshal_schema_object( - op.swagger_spec, content_spec, content_value) + swagger_spec=op.swagger_spec, + schema_object_spec=content_spec, + value=content_value, + ) # TODO: Non-json response contents return response.text diff --git a/changelog.rst b/changelog.rst index 88f2d06..df597a4 100644 --- a/changelog.rst +++ b/changelog.rst @@ -1,6 +1,10 @@ Changelog ========= +0.9.1 (2018-XX-XX) +------------------ +- bravado version: 9.2.2 + 0.9.0 (2017-11-17) ------------------ - Initial release, based on the source code of bravado 9.2.0. diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index a3668c4..86c3176 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -82,4 +82,6 @@ Config key Type Default Description | - ``operation`` of type ``bravado_core.operation.Operation`` *timeout* float N/A | TCP idle timeout in seconds. This is passed along to the | http_client when making a service call. +*use_msgpack* boolean False | If a msgpack serialization is desired for the response. This + | will add a Accept: application/msgpack header to the request. ========================= =============== ========= =============================================================== diff --git a/setup.py b/setup.py index dc9e42a..9db4806 100755 --- a/setup.py +++ b/setup.py @@ -30,7 +30,8 @@ ], install_requires=[ 'aiohttp', - 'bravado-core >= 4.2.2', + 'bravado-core >= 4.11.0', + 'msgpack-python', 'python-dateutil', 'pyyaml', ], diff --git a/tests/client/construct_request_test.py b/tests/client/construct_request_test.py index 814e4ff..8843d96 100644 --- a/tests/client/construct_request_test.py +++ b/tests/client/construct_request_test.py @@ -83,3 +83,25 @@ def test_with_not_string_headers( assert request['headers'][header_name] == str(header_value) unmarshalled_request = unmarshal_request(request_object, operation) assert unmarshalled_request[header_name] == header_value + + +def test_use_msgpack( + minimal_swagger_spec, + getPetById_spec, +): + op = CallableOperation( + Operation.from_spec( + minimal_swagger_spec, + '/pet/{petId}', + 'get', + getPetById_spec + ) + ) + request = construct_request( + op, + request_options={ + 'use_msgpack': True, + }, + petId=1, + ) + assert request['headers']['Accept'] == 'application/msgpack' diff --git a/tests/http_future/unmarshall_response_inner_test.py b/tests/http_future/unmarshall_response_inner_test.py new file mode 100644 index 0000000..0e03700 --- /dev/null +++ b/tests/http_future/unmarshall_response_inner_test.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +from asyncio import coroutine + +import mock +import msgpack +import pytest +from bravado_core.content_type import APP_JSON +from bravado_core.content_type import APP_MSGPACK +from bravado_core.response import IncomingResponse +from bravado_core.spec import Spec + +from aiobravado.http_future import unmarshal_response_inner + + +@pytest.fixture +def empty_swagger_spec(): + return Spec(spec_dict={}) + + +@pytest.fixture +def response_spec(): + return { + 'description': "Day of the week", + 'schema': { + 'type': 'string', + } + } + + +@pytest.fixture +def mock_get_response_spec(): + with mock.patch('aiobravado.http_future.get_response_spec') as m: + yield m + + +@pytest.fixture +def mock_validate_schema_object(): + with mock.patch('aiobravado.http_future.validate_schema_object') as m: + yield m + + +def test_no_content(mock_get_response_spec, empty_swagger_spec, event_loop): + response_spec = { + 'description': "I don't have a 'schema' key so I return nothing", + } + response = mock.Mock(spec=IncomingResponse, status_code=200) + + mock_get_response_spec.return_value = response_spec + op = mock.Mock(swagger_spec=empty_swagger_spec) + result = event_loop.run_until_complete(unmarshal_response_inner(response, op)) + assert result is None + + +def test_json_content(mock_get_response_spec, empty_swagger_spec, response_spec, event_loop): + response = mock.Mock( + spec=IncomingResponse, + status_code=200, + headers={'content-type': APP_JSON}, + json=coroutine(mock.Mock(return_value='Monday')), + ) + + mock_get_response_spec.return_value = response_spec + op = mock.Mock(swagger_spec=empty_swagger_spec) + assert 'Monday' == event_loop.run_until_complete(unmarshal_response_inner(response, op)) + + +def test_msgpack_content(mock_get_response_spec, empty_swagger_spec, response_spec, event_loop): + message = 'Monday' + response = mock.Mock( + spec=IncomingResponse, + status_code=200, + headers={'content-type': APP_MSGPACK}, + raw_bytes=coroutine(lambda: msgpack.dumps(message))(), + ) + + mock_get_response_spec.return_value = response_spec + op = mock.Mock(swagger_spec=empty_swagger_spec) + assert message == event_loop.run_until_complete(unmarshal_response_inner(response, op)) + + +def test_text_content(mock_get_response_spec, empty_swagger_spec, response_spec, event_loop): + response = mock.Mock( + spec=IncomingResponse, + status_code=200, + headers={'content-type': 'text/plain'}, + text='Monday', + ) + + mock_get_response_spec.return_value = response_spec + op = mock.Mock(swagger_spec=empty_swagger_spec) + assert 'Monday' == event_loop.run_until_complete(unmarshal_response_inner(response, op)) + + +def test_skips_validation( + mock_validate_schema_object, + mock_get_response_spec, + empty_swagger_spec, + response_spec, + event_loop, +): + empty_swagger_spec.config['validate_responses'] = False + response = mock.Mock( + spec=IncomingResponse, + status_code=200, + headers={'content-type': APP_JSON}, + json=coroutine(mock.Mock(return_value='Monday')), + ) + + mock_get_response_spec.return_value = response_spec + op = mock.Mock(swagger_spec=empty_swagger_spec) + event_loop.run_until_complete(unmarshal_response_inner(response, op)) + assert mock_validate_schema_object.call_count == 0 + + +def test_performs_validation( + mock_validate_schema_object, + mock_get_response_spec, + empty_swagger_spec, + response_spec, + event_loop, +): + empty_swagger_spec.config['validate_responses'] = True + response = mock.Mock( + spec=IncomingResponse, + status_code=200, + headers={'content-type': APP_JSON}, + json=coroutine(mock.Mock(return_value='Monday')), + ) + + mock_get_response_spec.return_value = response_spec + op = mock.Mock(swagger_spec=empty_swagger_spec) + event_loop.run_until_complete(unmarshal_response_inner(response, op)) + assert mock_validate_schema_object.call_count == 1 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 2310640..79f5ccb 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,11 +1,109 @@ +# -*- coding: utf-8 -*- import os import os.path import subprocess import time import urllib +import bottle import ephemeral_port_reserve import pytest +from bravado_core.content_type import APP_JSON +from bravado_core.content_type import APP_MSGPACK +from msgpack import packb + + +ROUTE_1_RESPONSE = b'HEY BUDDY' +ROUTE_2_RESPONSE = b'BYE BUDDY' +API_RESPONSE = {'answer': 42} +SWAGGER_SPEC_DICT = { + 'swagger': '2.0', + 'info': {'version': '1.0.0', 'title': 'Integration tests'}, + 'definitions': { + 'api_response': { + 'properties': { + 'answer': { + 'type': 'integer' + }, + }, + 'required': ['answer'], + 'type': 'object', + 'x-model': 'api_response', + 'title': 'api_response', + } + }, + 'basePath': '/', + 'paths': { + '/json': { + 'get': { + 'produces': ['application/json'], + 'responses': { + '200': { + 'description': 'HTTP/200', + 'schema': {'$ref': '#/definitions/api_response'}, + }, + }, + }, + }, + '/json_or_msgpack': { + 'get': { + 'produces': [ + 'application/msgpack', + 'application/json' + ], + 'responses': { + '200': { + 'description': 'HTTP/200', + 'schema': {'$ref': '#/definitions/api_response'}, + } + } + } + } + } +} + + +@bottle.get('/swagger.json') +def swagger_spec(): + return SWAGGER_SPEC_DICT + + +@bottle.get('/json') +def api_json(): + bottle.response.content_type = APP_JSON + return API_RESPONSE + + +@bottle.route('/json_or_msgpack') +def api_json_or_msgpack(): + if bottle.request.headers.get('accept') == APP_MSGPACK: + bottle.response.content_type = APP_MSGPACK + return packb(API_RESPONSE) + else: + return API_RESPONSE + + +@bottle.route('/1') +def one(): + return ROUTE_1_RESPONSE + + +@bottle.route('/2') +def two(): + return ROUTE_2_RESPONSE + + +@bottle.post('/double') +def double(): + x = bottle.request.params['number'] + return str(int(x) * 2) + + +@bottle.get('/sleep') +def sleep_api(): + sec_to_sleep = float(bottle.request.GET.get('sec', '1')) + time.sleep(sec_to_sleep) + return sec_to_sleep def wait_unit_service_starts(url, timeout=10):