diff --git a/tests/unit/legacy/api/xmlrpc/test_xmlrpc.py b/tests/unit/legacy/api/xmlrpc/test_xmlrpc.py index 5e94a3782202..6593a833dfae 100644 --- a/tests/unit/legacy/api/xmlrpc/test_xmlrpc.py +++ b/tests/unit/legacy/api/xmlrpc/test_xmlrpc.py @@ -792,3 +792,112 @@ def test_browse(db_request): "Programming Language :: Python", ], )) == {(expected_release.name, expected_release.version)} + + +class TestMulticall: + + def test_multicall(self, monkeypatch): + dumped = pretend.stub(encode=lambda: None) + dumps = pretend.call_recorder(lambda *a, **kw: dumped) + monkeypatch.setattr(xmlrpc.xmlrpc.client, 'dumps', dumps) + + loaded = pretend.stub() + loads = pretend.call_recorder(lambda *a, **kw: loaded) + monkeypatch.setattr(xmlrpc.xmlrpc.client, 'loads', loads) + + subreq = pretend.stub() + blank = pretend.call_recorder(lambda *a, **kw: subreq) + monkeypatch.setattr(xmlrpc.Request, 'blank', blank) + + body = pretend.stub() + response = pretend.stub(body=body) + + request = pretend.stub( + invoke_subrequest=pretend.call_recorder(lambda *a, **kw: response), + add_response_callback=pretend.call_recorder( + lambda *a, **kw: response), + ) + + callback = pretend.stub() + monkeypatch.setattr( + xmlrpc, + 'measure_response_content_length', + pretend.call_recorder(lambda metric_name: callback) + ) + + args = [ + {'methodName': 'search', 'params': [{'name': 'foo'}]}, + {'methodName': 'browse', 'params': [{'classifiers': ['bar']}]}, + ] + + responses = xmlrpc.multicall(request, args) + + assert responses == [loaded, loaded] + assert blank.calls == [ + pretend.call('/RPC2', headers={'Content-Type': 'text/xml'}), + pretend.call('/RPC2', headers={'Content-Type': 'text/xml'}), + ] + assert request.invoke_subrequest.calls == [ + pretend.call(subreq, use_tweens=True), + pretend.call(subreq, use_tweens=True), + ] + assert request.add_response_callback.calls == [ + pretend.call(callback), + ] + assert dumps.calls == [ + pretend.call(({'name': 'foo'},), methodname='search'), + pretend.call(({'classifiers': ['bar']},), methodname='browse'), + ] + assert loads.calls == [pretend.call(body), pretend.call(body)] + + def test_recursive_multicall(self): + request = pretend.stub() + args = [ + {'methodName': 'system.multicall', 'params': []}, + ] + with pytest.raises(xmlrpc.XMLRPCWrappedError) as exc: + xmlrpc.multicall(request, args) + + assert exc.value.faultString == ( + 'ValueError: Cannot use system.multicall inside a multicall' + ) + + def test_missing_multicall_method(self): + request = pretend.stub() + args = [{}] + with pytest.raises(xmlrpc.XMLRPCWrappedError) as exc: + xmlrpc.multicall(request, args) + + assert exc.value.faultString == ( + 'ValueError: Method name not provided' + ) + + def test_too_many_multicalls_method(self): + request = pretend.stub() + args = [{'methodName': 'nah'}] * 21 + + with pytest.raises(xmlrpc.XMLRPCWrappedError) as exc: + xmlrpc.multicall(request, args) + + assert exc.value.faultString == ( + 'ValueError: Multicall limit is 20 calls' + ) + + def test_measure_response_content_length(self): + metric_name = 'some_metric_name' + callback = xmlrpc.measure_response_content_length(metric_name) + + request = pretend.stub( + registry=pretend.stub( + datadog=pretend.stub( + histogram=pretend.call_recorder(lambda *a: None) + ) + ) + ) + response = pretend.stub(content_length=pretend.stub()) + + callback(request, response) + + assert request.registry.datadog.histogram.calls == [ + pretend.call(metric_name, response.content_length), + ] diff --git a/warehouse/legacy/api/xmlrpc/views.py b/warehouse/legacy/api/xmlrpc/views.py index 11151ff16bb5..99ac8e5c8a61 100644 --- a/warehouse/legacy/api/xmlrpc/views.py +++ b/warehouse/legacy/api/xmlrpc/views.py @@ -13,10 +13,12 @@ import collections.abc import datetime import functools +import xmlrpc.client import xmlrpc.server from elasticsearch_dsl import Q from packaging.utils import canonicalize_name +from pyramid.request import Request from pyramid.view import view_config from pyramid_rpc.xmlrpc import ( exception_view as _exception_view, xmlrpc_method as _xmlrpc_method @@ -31,6 +33,9 @@ ) +_MAX_MULTICALLS = 20 + + def xmlrpc_method(**kwargs): """ Support multiple endpoints serving the same views by chaining calls to @@ -441,3 +446,49 @@ def browse(request, classifiers): ) return [(r.name, r.version) for r in releases] + + +def measure_response_content_length(metric_name): + + def _callback(request, response): + request.registry.datadog.histogram( + metric_name, response.content_length + ) + + return _callback + + +@xmlrpc_method(method='system.multicall') +def multicall(request, args): + if any(arg.get('methodName') == 'system.multicall' for arg in args): + raise XMLRPCWrappedError( + ValueError('Cannot use system.multicall inside a multicall') + ) + + if not all(arg.get('methodName') for arg in args): + raise XMLRPCWrappedError(ValueError('Method name not provided')) + + if len(args) > _MAX_MULTICALLS: + raise XMLRPCWrappedError( + ValueError(f'Multicall limit is {_MAX_MULTICALLS} calls') + ) + + responses = [] + for arg in args: + name = arg.get('methodName') + subreq = Request.blank('/RPC2', headers={'Content-Type': 'text/xml'}) + subreq.method = 'POST' + subreq.body = xmlrpc.client.dumps( + tuple(arg.get('params')), + methodname=name, + ).encode() + response = request.invoke_subrequest(subreq, use_tweens=True) + responses.append(xmlrpc.client.loads(response.body)) + + request.add_response_callback( + measure_response_content_length( + 'warehouse.xmlrpc.system.multicall.content_length' + ) + ) + + return responses