diff --git a/splunklib/binding.py b/splunklib/binding.py index 958be96e..89e4bd45 100644 --- a/splunklib/binding.py +++ b/splunklib/binding.py @@ -809,6 +809,171 @@ def post(self, path_segment, owner=None, app=None, sharing=None, headers=None, * response = self.http.post(path, all_headers, **query) return response + + @_authentication + @_log_duration + def put(self, path_segment, object, owner=None, app=None, sharing=None, headers=None, **query): + """Performs a PUT operation from the REST path segment with the given object, + namespace and query. + + This method is named to match the HTTP method. ``put`` makes at least + one round trip to the server, one additional round trip for each 303 + status returned, and at most two additional round trips if + the ``autologin`` field of :func:`connect` is set to ``True``. + + If *owner*, *app*, and *sharing* are omitted, this method uses the + default :class:`Context` namespace. All other keyword arguments are + included in the URL as query parameters. + + Some of Splunk's endpoints, such as ``receivers/simple`` and + ``receivers/stream``, require unstructured data in the PUT body + and all metadata passed as GET-style arguments. If you provide + a ``body`` argument to ``put``, it will be used as the PUT + body, and all other keyword arguments will be passed as + GET-style arguments in the URL. + + :raises AuthenticationError: Raised when the ``Context`` object is not + logged in. + :raises HTTPError: Raised when an error occurred in a GET operation from + *path_segment*. + :param path_segment: A REST path segment. + :type path_segment: ``string`` + :param object: The object to be PUT. + :type object: ``string`` + :param owner: The owner context of the namespace (optional). + :type owner: ``string`` + :param app: The app context of the namespace (optional). + :type app: ``string`` + :param sharing: The sharing mode of the namespace (optional). + :type sharing: ``string`` + :param headers: List of extra HTTP headers to send (optional). + :type headers: ``list`` of 2-tuples. + :param query: All other keyword arguments, which are used as query + parameters. + :param body: Parameters to be used in the post body. If specified, + any parameters in the query will be applied to the URL instead of + the body. If a dict is supplied, the key-value pairs will be form + encoded. If a string is supplied, the body will be passed through + in the request unchanged. + :type body: ``dict`` or ``str`` + :return: The response from the server. + :rtype: ``dict`` with keys ``body``, ``headers``, ``reason``, + and ``status`` + + **Example**:: + + c = binding.connect(...) + c.post('saved/searches', name='boris', + search='search * earliest=-1m | head 1') == \\ + {'body': ...a response reader object..., + 'headers': [('content-length', '10455'), + ('expires', 'Fri, 30 Oct 1998 00:00:00 GMT'), + ('server', 'Splunkd'), + ('connection', 'close'), + ('cache-control', 'no-store, max-age=0, must-revalidate, no-cache'), + ('date', 'Fri, 11 May 2012 16:46:06 GMT'), + ('content-type', 'text/xml; charset=utf-8')], + 'reason': 'Created', + 'status': 201} + c.post('nonexistant/path') # raises HTTPError + c.logout() + # raises AuthenticationError: + c.put('saved/searches/boris', + search='search * earliest=-1m | head 1') + """ + if headers is None: + headers = [] + + path = self.authority + self._abspath(path_segment, owner=owner, app=app, sharing=sharing) + f"/{object}" + + logger.debug("PUT request to %s (body: %s)", path, mask_sensitive_data(query)) + all_headers = headers + self.additional_headers + self._auth_headers + response = self.http.put(path, all_headers, **query) + return response + + + @_authentication + @_log_duration + def patch(self, path_segment, object, owner=None, app=None, sharing=None, headers=None, **query): + """Performs a PATCH operation from the REST path segment with the given object, + namespace and query. + + This method is named to match the HTTP method. ``patch`` makes at least + one round trip to the server, one additional round trip for each 303 + status returned, and at most two additional round trips if + the ``autologin`` field of :func:`connect` is set to ``True``. + + If *owner*, *app*, and *sharing* are omitted, this method uses the + default :class:`Context` namespace. All other keyword arguments are + included in the URL as query parameters. + + Some of Splunk's endpoints, such as ``receivers/simple`` and + ``receivers/stream``, require unstructured data in the PATCH body + and all metadata passed as GET-style arguments. If you provide + a ``body`` argument to ``patch``, it will be used as the PATCH + body, and all other keyword arguments will be passed as + GET-style arguments in the URL. + + :raises AuthenticationError: Raised when the ``Context`` object is not + logged in. + :raises HTTPError: Raised when an error occurred in a GET operation from + *path_segment*. + :param path_segment: A REST path segment. + :type path_segment: ``string`` + :param object: The object to be PUT. + :type object: ``string`` + :param owner: The owner context of the namespace (optional). + :type owner: ``string`` + :param app: The app context of the namespace (optional). + :type app: ``string`` + :param sharing: The sharing mode of the namespace (optional). + :type sharing: ``string`` + :param headers: List of extra HTTP headers to send (optional). + :type headers: ``list`` of 2-tuples. + :param query: All other keyword arguments, which are used as query + parameters. + :param body: Parameters to be used in the post body. If specified, + any parameters in the query will be applied to the URL instead of + the body. If a dict is supplied, the key-value pairs will be form + encoded. If a string is supplied, the body will be passed through + in the request unchanged. + :type body: ``dict`` or ``str`` + :return: The response from the server. + :rtype: ``dict`` with keys ``body``, ``headers``, ``reason``, + and ``status`` + + **Example**:: + + c = binding.connect(...) + c.post('saved/searches', name='boris', + search='search * earliest=-1m | head 1') == \\ + {'body': ...a response reader object..., + 'headers': [('content-length', '10455'), + ('expires', 'Fri, 30 Oct 1998 00:00:00 GMT'), + ('server', 'Splunkd'), + ('connection', 'close'), + ('cache-control', 'no-store, max-age=0, must-revalidate, no-cache'), + ('date', 'Fri, 11 May 2012 16:46:06 GMT'), + ('content-type', 'text/xml; charset=utf-8')], + 'reason': 'Created', + 'status': 201} + c.post('nonexistant/path') # raises HTTPError + c.logout() + # raises AuthenticationError: + c.patch('saved/searches/boris', + search='search * earliest=-1m | head 1') + """ + if headers is None: + headers = [] + + path = self.authority + self._abspath(path_segment, owner=owner, app=app, sharing=sharing) + f"/{object}" + + logger.debug("PATCH request to %s (body: %s)", path, mask_sensitive_data(query)) + all_headers = headers + self.additional_headers + self._auth_headers + response = self.http.patch(path, all_headers, **query) + return response + + @_authentication @_log_duration def request(self, path_segment, method="GET", headers=None, body={}, @@ -1210,6 +1375,40 @@ def __init__(self, custom_handler=None, verify=False, key_file=None, cert_file=N self.retries = retries self.retryDelay = retryDelay + def _prepare_request_body_and_url(self, url, headers, **kwargs): + """Helper function to prepare the request body and URL. + + :param url: The URL. + :type url: ``string`` + :param headers: A list of pairs specifying the headers for the HTTP request. + :type headers: ``list`` + :param kwargs: Additional keyword arguments (optional). + :type kwargs: ``dict`` + :returns: A tuple containing the updated URL, headers, and body. + :rtype: ``tuple`` + """ + if headers is None: + headers = [] + + # We handle GET-style arguments and an unstructured body. This is here + # to support the receivers/stream endpoint. + if 'body' in kwargs: + # We only use application/x-www-form-urlencoded if there is no other + # Content-Type header present. This can happen in cases where we + # send requests as application/json, e.g. for KV Store. + if len([x for x in headers if x[0].lower() == "content-type"]) == 0: + headers.append(("Content-Type", "application/x-www-form-urlencoded")) + + body = kwargs.pop('body') + if isinstance(body, dict): + body = _encode(**body).encode('utf-8') + if len(kwargs) > 0: + url = url + UrlEncoded('?' + _encode(**kwargs), skip_encode=True) + else: + body = _encode(**kwargs).encode('utf-8') + + return url, headers, body + def delete(self, url, headers=None, **kwargs): """Sends a DELETE request to a URL. @@ -1282,31 +1481,66 @@ def post(self, url, headers=None, **kwargs): its structure). :rtype: ``dict`` """ - if headers is None: headers = [] + url, headers, body = self._prepare_request_body_and_url(url, headers, **kwargs) + message = { + 'method': "POST", + 'headers': headers, + 'body': body + } + return self.request(url, message) - # We handle GET-style arguments and an unstructured body. This is here - # to support the receivers/stream endpoint. - if 'body' in kwargs: - # We only use application/x-www-form-urlencoded if there is no other - # Content-Type header present. This can happen in cases where we - # send requests as application/json, e.g. for KV Store. - if len([x for x in headers if x[0].lower() == "content-type"]) == 0: - headers.append(("Content-Type", "application/x-www-form-urlencoded")) + def put(self, url, headers=None, **kwargs): + """Sends a PUT request to a URL. - body = kwargs.pop('body') - if isinstance(body, dict): - body = _encode(**body).encode('utf-8') - if len(kwargs) > 0: - url = url + UrlEncoded('?' + _encode(**kwargs), skip_encode=True) - else: - body = _encode(**kwargs).encode('utf-8') + :param url: The URL. + :type url: ``string`` + :param headers: A list of pairs specifying the headers for the HTTP + response (for example, ``[('Content-Type': 'text/cthulhu'), ('Token': 'boris')]``). + :type headers: ``list`` + :param kwargs: Additional keyword arguments (optional). If the argument + is ``body``, the value is used as the body for the request, and the + keywords and their arguments will be URL encoded. If there is no + ``body`` keyword argument, all the keyword arguments are encoded + into the body of the request in the format ``x-www-form-urlencoded``. + :type kwargs: ``dict`` + :returns: A dictionary describing the response (see :class:`HttpLib` for + its structure). + :rtype: ``dict`` + """ + url, headers, body = self._prepare_request_body_and_url(url, headers, **kwargs) message = { - 'method': "POST", + 'method': "PUT", 'headers': headers, 'body': body } return self.request(url, message) + + def patch(self, url, headers=None, **kwargs): + """Sends a PATCH request to a URL. + :param url: The URL. + :type url: ``string`` + :param headers: A list of pairs specifying the headers for the HTTP + response (for example, ``[('Content-Type': 'text/cthulhu'), ('Token': 'boris')]``). + :type headers: ``list`` + :param kwargs: Additional keyword arguments (optional). If the argument + is ``body``, the value is used as the body for the request, and the + keywords and their arguments will be URL encoded. If there is no + ``body`` keyword argument, all the keyword arguments are encoded + into the body of the request in the format ``x-www-form-urlencoded``. + :type kwargs: ``dict`` + :returns: A dictionary describing the response (see :class:`HttpLib` for + its structure). + :rtype: ``dict`` + """ + url, headers, body = self._prepare_request_body_and_url(url, headers, **kwargs) + message = { + 'method': "PATCH", + 'headers': headers, + 'body': body + } + return self.request(url, message) + def request(self, url, message, **kwargs): """Issues an HTTP request to a URL. diff --git a/tests/test_binding.py b/tests/test_binding.py index fe14c259..c528250f 100755 --- a/tests/test_binding.py +++ b/tests/test_binding.py @@ -879,6 +879,18 @@ def handler(url, message, **kwargs): ctx = binding.Context(handler=handler) ctx.post("foo/bar", extrakey="extraval", owner="testowner", app="testapp", body={"testkey": "testvalue"}) + def test_post_with_params_and_body_json(self): + def handler(url, message, **kwargs): + assert url == "https://localhost:8089/servicesNS/testowner/testapp/foo/bar?extrakey=extraval" + assert message["body"] == '{"testkey": "testvalue"}' + return splunklib.data.Record({ + "status": 200, + "headers": [], + }) + + ctx = binding.Context(handler=handler) + ctx.post("foo/bar", extrakey="extraval", owner="testowner", app="testapp", body=json.dumps({"testkey": "testvalue"}), headers=[('Content-Type', 'application/json')]) + def test_post_with_params_and_no_body(self): def handler(url, message, **kwargs): assert url == "https://localhost:8089/servicesNS/testowner/testapp/foo/bar" @@ -891,6 +903,108 @@ def handler(url, message, **kwargs): ctx = binding.Context(handler=handler) ctx.post("foo/bar", extrakey="extraval", owner="testowner", app="testapp") +class TestPutWithBodyParam(unittest.TestCase): + + def test_put(self): + def handler(url, message, **kwargs): + assert url == "https://localhost:8089/servicesNS/testowner/testapp/foo/bar/gugus" + assert message["body"] == b"testkey=testvalue" + return splunklib.data.Record({ + "status": 200, + "headers": [], + }) + + ctx = binding.Context(handler=handler) + ctx.put("foo/bar", object="gugus", owner="testowner", app="testapp", body={"testkey": "testvalue"}) + + def test_put_with_params_and_body_form(self): + def handler(url, message, **kwargs): + assert url == "https://localhost:8089/servicesNS/testowner/testapp/foo/bar/gugus?extrakey=extraval" + assert message["body"] == b"testkey=testvalue" + return splunklib.data.Record({ + "status": 200, + "headers": [], + }) + + ctx = binding.Context(handler=handler) + ctx.put("foo/bar", object="gugus", extrakey="extraval", owner="testowner", app="testapp", body={"testkey": "testvalue"}) + + def test_put_with_params_and_body_json(self): + def handler(url, message, **kwargs): + assert url == "https://localhost:8089/servicesNS/testowner/testapp/foo/bar/gugus?extrakey=extraval" + assert message["body"] == '{"testkey": "testvalue"}' + return splunklib.data.Record({ + "status": 200, + "headers": [], + }) + + ctx = binding.Context(handler=handler) + ctx.put("foo/bar", object="gugus", extrakey="extraval", owner="testowner", app="testapp", body=json.dumps({"testkey": "testvalue"}), headers=[('Content-Type', 'application/json')]) + + + def test_put_with_params_and_no_body(self): + def handler(url, message, **kwargs): + assert url == "https://localhost:8089/servicesNS/testowner/testapp/foo/bar/gugus" + assert message["body"] == b"extrakey=extraval" + return splunklib.data.Record({ + "status": 200, + "headers": [], + }) + + ctx = binding.Context(handler=handler) + ctx.put("foo/bar", object="gugus", extrakey="extraval", owner="testowner", app="testapp") + +class TestPatchWithBodyParam(unittest.TestCase): + + def test_patch(self): + def handler(url, message, **kwargs): + assert url == "https://localhost:8089/servicesNS/testowner/testapp/foo/bar/gugus" + assert message["body"] == b"testkey=testvalue" + return splunklib.data.Record({ + "status": 200, + "headers": [], + }) + + ctx = binding.Context(handler=handler) + ctx.patch("foo/bar", object="gugus", owner="testowner", app="testapp", body={"testkey": "testvalue"}) + + def test_patch_with_params_and_body_form(self): + def handler(url, message, **kwargs): + assert url == "https://localhost:8089/servicesNS/testowner/testapp/foo/bar/gugus?extrakey=extraval" + assert message["body"] == b"testkey=testvalue" + return splunklib.data.Record({ + "status": 200, + "headers": [], + }) + + ctx = binding.Context(handler=handler) + ctx.patch("foo/bar", object="gugus", extrakey="extraval", owner="testowner", app="testapp", body={"testkey": "testvalue"}) + + def test_patch_with_params_and_body_json(self): + def handler(url, message, **kwargs): + assert url == "https://localhost:8089/servicesNS/testowner/testapp/foo/bar/gugus?extrakey=extraval" + assert message["body"] == '{"testkey": "testvalue"}' + return splunklib.data.Record({ + "status": 200, + "headers": [], + }) + + ctx = binding.Context(handler=handler) + ctx.patch("foo/bar", object="gugus", extrakey="extraval", owner="testowner", app="testapp", body=json.dumps({"testkey": "testvalue"}), headers=[('Content-Type', 'application/json')]) + + + def test_patch_with_params_and_no_body(self): + def handler(url, message, **kwargs): + assert url == "https://localhost:8089/servicesNS/testowner/testapp/foo/bar/gugus" + assert message["body"] == b"extrakey=extraval" + return splunklib.data.Record({ + "status": 200, + "headers": [], + }) + + ctx = binding.Context(handler=handler) + ctx.patch("foo/bar", object="gugus", extrakey="extraval", owner="testowner", app="testapp") + def _wrap_handler(func, response_code=200, body=""): def wrapped(handler_self): @@ -971,5 +1085,76 @@ def check_response(handler): ctx.post("/", foo="bar", body={"baz": "baf", "hep": "cat"}) +class TestFullPut(unittest.TestCase): + + def test_put_with_body_urlencoded(self): + def check_response(handler): + length = int(handler.headers.get('content-length', 0)) + body = handler.rfile.read(length) + assert body.decode('utf-8') == "foo=bar" + + with MockServer(PUT=check_response): + ctx = binding.connect(port=9093, scheme='http', token="waffle") + ctx.put("/", object="gugus", foo="bar") + + def test_put_with_body_string(self): + def check_response(handler): + length = int(handler.headers.get('content-length', 0)) + body = handler.rfile.read(length) + assert handler.headers['content-type'] == 'application/json' + assert json.loads(body)["baz"] == "baf" + + with MockServer(PUT=check_response): + ctx = binding.connect(port=9093, scheme='http', token="waffle", + headers=[("Content-Type", "application/json")]) + ctx.put("/", object="gugus", foo="bar", body='{"baz": "baf"}') + + def test_put_with_body_dict(self): + def check_response(handler): + length = int(handler.headers.get('content-length', 0)) + body = handler.rfile.read(length) + assert handler.headers['content-type'] == 'application/x-www-form-urlencoded' + assert ensure_str(body) in ['baz=baf&hep=cat', 'hep=cat&baz=baf'] + + with MockServer(PUT=check_response): + ctx = binding.connect(port=9093, scheme='http', token="waffle") + ctx.put("/", object="gugus", foo="bar", body={"baz": "baf", "hep": "cat"}) + + +class TestFullPatch(unittest.TestCase): + + def test_patch_with_body_urlencoded(self): + def check_response(handler): + length = int(handler.headers.get('content-length', 0)) + body = handler.rfile.read(length) + assert body.decode('utf-8') == "foo=bar" + + with MockServer(PATCH=check_response): + ctx = binding.connect(port=9093, scheme='http', token="waffle") + ctx.patch("/", object="gugus", foo="bar") + + def test_patch_with_body_string(self): + def check_response(handler): + length = int(handler.headers.get('content-length', 0)) + body = handler.rfile.read(length) + assert handler.headers['content-type'] == 'application/json' + assert json.loads(body)["baz"] == "baf" + + with MockServer(PATCH=check_response): + ctx = binding.connect(port=9093, scheme='http', token="waffle", + headers=[("Content-Type", "application/json")]) + ctx.patch("/", object="gugus", foo="bar", body='{"baz": "baf"}') + + def test_patch_with_body_dict(self): + def check_response(handler): + length = int(handler.headers.get('content-length', 0)) + body = handler.rfile.read(length) + assert handler.headers['content-type'] == 'application/x-www-form-urlencoded' + assert ensure_str(body) in ['baz=baf&hep=cat', 'hep=cat&baz=baf'] + + with MockServer(PATCH=check_response): + ctx = binding.connect(port=9093, scheme='http', token="waffle") + ctx.patch("/", object="gugus", foo="bar", body={"baz": "baf", "hep": "cat"}) + if __name__ == "__main__": unittest.main()