diff --git a/requirements-http.txt b/requirements-http.txt --- a/requirements-http.txt +++ b/requirements-http.txt @@ -1,5 +1,6 @@ # requirements for swh.core.api aiohttp +aiohttp_utils >= 3.1 arrow decorator Flask diff --git a/swh/core/api/asynchronous.py b/swh/core/api/asynchronous.py --- a/swh/core/api/asynchronous.py +++ b/swh/core/api/asynchronous.py @@ -3,24 +3,43 @@ import pickle import sys import traceback +from collections import OrderedDict import aiohttp.web from deprecated import deprecated -import multidict -from .serializers import msgpack_dumps, msgpack_loads, SWHJSONDecoder +from .serializers import msgpack_dumps, msgpack_loads +from .serializers import SWHJSONDecoder, SWHJSONEncoder +try: + from aiohttp_utils import negotiation, Response -def encode_data_server(data, **kwargs): - return aiohttp.web.Response( - body=msgpack_dumps(data), - headers=multidict.MultiDict({'Content-Type': 'application/x-msgpack'}), - **kwargs - ) + def encode_data_server(data, **kwargs): + return Response(data, **kwargs) + +except ImportError: + negotiation = None + import multidict + + def encode_data_server(data, **kwargs): + return aiohttp.web.Response( + body=msgpack_dumps(data), + headers=multidict.MultiDict( + {'Content-Type': 'application/x-msgpack'}), + **kwargs + ) + + +def render_msgpack(request, data): + return msgpack_dumps(data) + + +def render_json(request, data): + return json.dumps(data, cls=SWHJSONEncoder) async def decode_request(request): - content_type = request.headers.get('Content-Type') + content_type = request.headers.get('Content-Type').split(';')[0].strip() data = await request.read() if not data: return {} @@ -37,7 +56,10 @@ async def error_middleware(app, handler): async def middleware_handler(request): try: - return (await handler(request)) + ret = await handler(request) + if not isinstance(ret, aiohttp.web.Response): + ret = encode_data_server(ret) + return ret except Exception as e: if isinstance(e, aiohttp.web.HTTPException): raise @@ -52,6 +74,18 @@ class RPCServerApp(aiohttp.web.Application): def __init__(self, *args, middlewares=(), **kwargs): middlewares = (error_middleware,) + middlewares + if negotiation: + # renderers are sorted in order of increasing desirability (!) + # see mimeparse.best_match() docstring. + renderers = OrderedDict([ + ('application/json', render_json), + ('application/x-msgpack', render_msgpack), + ]) + nego_middleware = negotiation.negotiation_middleware( + renderers=renderers, + force_rendering=True) + middlewares = (nego_middleware,) + middlewares + super().__init__(*args, middlewares=middlewares, **kwargs) diff --git a/swh/core/api/tests/test_async.py b/swh/core/api/tests/test_async.py --- a/swh/core/api/tests/test_async.py +++ b/swh/core/api/tests/test_async.py @@ -43,12 +43,25 @@ return encode_data_server(data) +async def raw_echo(request): + # let the content negotiation handle the serialization for us... + data = await decode_request(request) + return data + + +def check_mimetype(src, dst): + src = src.split(';')[0].strip() + dst = dst.split(';')[0].strip() + assert src == dst + + @pytest.fixture def app(): app = RPCServerApp() app.router.add_route('GET', '/', root) app.router.add_route('GET', '/struct', struct) app.router.add_route('POST', '/echo', echo) + app.router.add_route('POST', '/echo-nego', raw_echo) return app @@ -58,21 +71,41 @@ cli = await aiohttp_client(app) resp = await cli.get('/') assert resp.status == 200 - assert resp.headers['Content-Type'] == 'application/x-msgpack' + check_mimetype(resp.headers['Content-Type'], 'application/x-msgpack') data = await resp.read() value = msgpack.unpackb(data, raw=False) assert value == 'toor' +async def test_get_simple_nego(app, aiohttp_client) -> None: + cli = await aiohttp_client(app) + for ctype in ('x-msgpack', 'json'): + resp = await cli.get('/', headers={'Accept': 'application/%s' % ctype}) + assert resp.status == 200 + check_mimetype(resp.headers['Content-Type'], 'application/%s' % ctype) + assert (await decode_request(resp)) == 'toor' + + async def test_get_struct(app, aiohttp_client) -> None: """Test returned structured from a simple GET data is OK""" cli = await aiohttp_client(app) resp = await cli.get('/struct') assert resp.status == 200 - assert resp.headers['Content-Type'] == 'application/x-msgpack' + check_mimetype(resp.headers['Content-Type'], 'application/x-msgpack') assert (await decode_request(resp)) == STRUCT +async def test_get_struct_nego(app, aiohttp_client) -> None: + """Test returned structured from a simple GET data is OK""" + cli = await aiohttp_client(app) + for ctype in ('x-msgpack', 'json'): + resp = await cli.get('/struct', + headers={'Accept': 'application/%s' % ctype}) + assert resp.status == 200 + check_mimetype(resp.headers['Content-Type'], 'application/%s' % ctype) + assert (await decode_request(resp)) == STRUCT + + async def test_post_struct_msgpack(app, aiohttp_client) -> None: """Test that msgpack encoded posted struct data is returned as is""" cli = await aiohttp_client(app) @@ -82,7 +115,7 @@ headers={'Content-Type': 'application/x-msgpack'}, data=msgpack_dumps({'toto': 42})) assert resp.status == 200 - assert resp.headers['Content-Type'] == 'application/x-msgpack' + check_mimetype(resp.headers['Content-Type'], 'application/x-msgpack') assert (await decode_request(resp)) == {'toto': 42} # complex struct resp = await cli.post( @@ -90,7 +123,7 @@ headers={'Content-Type': 'application/x-msgpack'}, data=msgpack_dumps(STRUCT)) assert resp.status == 200 - assert resp.headers['Content-Type'] == 'application/x-msgpack' + check_mimetype(resp.headers['Content-Type'], 'application/x-msgpack') assert (await decode_request(resp)) == STRUCT @@ -103,7 +136,7 @@ headers={'Content-Type': 'application/json'}, data=json.dumps({'toto': 42}, cls=SWHJSONEncoder)) assert resp.status == 200 - assert resp.headers['Content-Type'] == 'application/x-msgpack' + check_mimetype(resp.headers['Content-Type'], 'application/x-msgpack') assert (await decode_request(resp)) == {'toto': 42} resp = await cli.post( @@ -111,5 +144,24 @@ headers={'Content-Type': 'application/json'}, data=json.dumps(STRUCT, cls=SWHJSONEncoder)) assert resp.status == 200 - assert resp.headers['Content-Type'] == 'application/x-msgpack' + check_mimetype(resp.headers['Content-Type'], 'application/x-msgpack') + # assert resp.headers['Content-Type'] == 'application/x-msgpack' assert (await decode_request(resp)) == STRUCT + + +async def test_post_struct_nego(app, aiohttp_client) -> None: + """Test that json encoded posted struct data is returned as is + + using content negotiation (accept json or msgpack). + """ + cli = await aiohttp_client(app) + + for ctype in ('x-msgpack', 'json'): + resp = await cli.post( + '/echo-nego', + headers={'Content-Type': 'application/json', + 'Accept': 'application/%s' % ctype}, + data=json.dumps(STRUCT, cls=SWHJSONEncoder)) + assert resp.status == 200 + check_mimetype(resp.headers['Content-Type'], 'application/%s' % ctype) + assert (await decode_request(resp)) == STRUCT