diff --git a/docker-compose.yml b/docker-compose.yml index f2c52522d..3160978a6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,7 @@ services: - "./tests/system/test_apps/reporting_app:/opt/splunk/etc/apps/reporting_app" - "./tests/system/test_apps/streaming_app:/opt/splunk/etc/apps/streaming_app" - "./tests/system/test_apps/modularinput_app:/opt/splunk/etc/apps/modularinput_app" + - "./tests/system/test_apps/cre_app:/opt/splunk/etc/apps/cre_app" - "./splunklib:/opt/splunk/etc/apps/eventing_app/bin/splunklib" - "./splunklib:/opt/splunk/etc/apps/generating_app/bin/splunklib" - "./splunklib:/opt/splunk/etc/apps/reporting_app/bin/splunklib" diff --git a/splunklib/binding.py b/splunklib/binding.py index 4785309d1..84044fa62 100644 --- a/splunklib/binding.py +++ b/splunklib/binding.py @@ -511,7 +511,7 @@ class Context: :param headers: List of extra HTTP headers to send (optional). :type headers: ``list`` of 2-tuples. :param retries: Number of retries for each HTTP connection (optional, the default is 0). - NOTE: THIS MAY INCREASE THE NUMBER OF ROUNDTRIP CONNECTIONS + NOTE: THIS MAY INCREASE THE NUMBER OF ROUNDTRIP CONNECTIONS TO THE SPLUNK SERVER AND BLOCK THE CURRENT THREAD WHILE RETRYING. :type retries: ``int`` :param retryDelay: How long to wait between connection attempts if `retries` > 0 (optional, defaults to 10s). @@ -938,7 +938,12 @@ def request( str(mask_sensitive_data(dict(all_headers))), mask_sensitive_data(body), ) - if body: + + if isinstance(body, str): + if method.upper() == "GET": + raise Exception("unable to set string body on GET method request") + message = {"method": method, "headers": all_headers, "body": body} + elif body: body = _encode(**body) if method == "GET": diff --git a/tests/system/test_apps/cre_app/bin/execute.py b/tests/system/test_apps/cre_app/bin/execute.py new file mode 100644 index 000000000..6dcf2122c --- /dev/null +++ b/tests/system/test_apps/cre_app/bin/execute.py @@ -0,0 +1,50 @@ +import splunk.rest +import json + + +class Handler(splunk.rest.BaseRestHandler): + def handle_GET(self): + self.response.setHeader("Content-Type", "application/json") + self.response.setHeader("x-foo", "bar") + self.response.status = 200 + self.response.write( + json.dumps( + { + "headers": self.headers(), + "method": "GET", + } + ) + ) + + def handle_DELETE(self): + self.handle_with_payload("DELETE") + + def handle_POST(self): + self.handle_with_payload("POST") + + def handle_PUT(self): + self.handle_with_payload("PUT") + + def handle_PATCH(self): + self.handle_with_payload("PATCH") + + def handle_with_payload(self, method): + self.response.setHeader("Content-Type", "application/json") + self.response.setHeader("x-foo", "bar") + self.response.status = 200 + self.response.write( + json.dumps( + { + "payload": self.request.get("payload"), + "headers": self.headers(), + "method": method, + } + ) + ) + + def headers(self): + return { + k: v + for k, v in self.request.get("headers", {}).items() + if k.lower().startswith("x") + } diff --git a/tests/system/test_apps/cre_app/default/app.conf b/tests/system/test_apps/cre_app/default/app.conf new file mode 100644 index 000000000..3bed3cf95 --- /dev/null +++ b/tests/system/test_apps/cre_app/default/app.conf @@ -0,0 +1,20 @@ +[id] +name = cre_app +version = 0.1.0 + +[package] +id = cre_app +check_for_updates = False + +[install] +is_configured = 0 +state = enabled + +[ui] +is_visible = 1 +label = [EXAMPLE] CRE app + +[launcher] +description = Example app that exposes custom rest endpoints +version = 0.0.1 +author = Splunk diff --git a/tests/system/test_apps/cre_app/default/restmap.conf b/tests/system/test_apps/cre_app/default/restmap.conf new file mode 100644 index 000000000..2d9213910 --- /dev/null +++ b/tests/system/test_apps/cre_app/default/restmap.conf @@ -0,0 +1,5 @@ +[script:execute] +match = /execute +scripttype = python +handler = execute.Handler + diff --git a/tests/system/test_cre_apps.py b/tests/system/test_cre_apps.py new file mode 100644 index 000000000..8a1a28f99 --- /dev/null +++ b/tests/system/test_cre_apps.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python +# +# Copyright © 2011-2025 Splunk, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"): you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import json +import pytest + +from tests import testlib +from splunklib import results + + +class TestJSONCustomRestEndpointsSpecialMethodHelpers(testlib.SDKTestCase): + app_name = "cre_app" + + def test_GET(self): + resp = self.service.get( + app=self.app_name, + path_segment="execute", + headers=[("x-bar", "baz")], + ) + self.assertIn(("x-foo", "bar"), resp.headers) + self.assertEqual(resp.status, 200) + self.assertEqual( + json.loads(str(resp.body)), + { + "headers": {"x-bar": "baz"}, + "method": "GET", + }, + ) + + def test_POST(self): + body = json.dumps({"foo": "bar"}) + resp = self.service.post( + app=self.app_name, + path_segment="execute", + body=body, + headers=[("x-bar", "baz")], + ) + self.assertIn(("x-foo", "bar"), resp.headers) + self.assertEqual(resp.status, 200) + self.assertEqual( + json.loads(str(resp.body)), + { + "payload": '{"foo": "bar"}', + "headers": {"x-bar": "baz"}, + "method": "POST", + }, + ) + + def test_DELETE(self): + # delete does allow specifying body and custom headers. + resp = self.service.delete( + app=self.app_name, + path_segment="execute", + ) + self.assertIn(("x-foo", "bar"), resp.headers) + self.assertEqual(resp.status, 200) + self.assertEqual( + json.loads(str(resp.body)), + { + "payload": "", + "headers": {}, + "method": "DELETE", + }, + ) + + +class TestJSONCustomRestEndpointGenericRequest(testlib.SDKTestCase): + app_name = "cre_app" + + def test_no_str_body_GET(self): + def with_body(): + self.service.request( + app=self.app_name, method="GET", path_segment="execute", body="str" + ) + + self.assertRaisesRegex( + Exception, "unable to set string body on GET method request", with_body + ) + + def test_GET(self): + resp = self.service.request( + app=self.app_name, + method="GET", + path_segment="execute", + headers=[("x-bar", "baz")], + ) + self.assertIn(("x-foo", "bar"), resp.headers) + self.assertEqual(resp.status, 200) + self.assertEqual( + json.loads(str(resp.body)), + { + "headers": {"x-bar": "baz"}, + "method": "GET", + }, + ) + + def test_POST(self): + self.method("POST") + + def test_PUT(self): + self.method("PUT") + + def test_PATCH(self): + if self.service.splunk_version[0] < 10: + self.skipTest("PATCH custom REST endpoints not supported on splunk < 10") + self.method("PATCH") + + def test_DELETE(self): + self.method("DELETE") + + def method(self, method: str): + body = json.dumps({"foo": "bar"}) + resp = self.service.request( + app=self.app_name, + method=method, + path_segment="execute", + body=body, + headers=[("x-bar", "baz")], + ) + self.assertIn(("x-foo", "bar"), resp.headers) + self.assertEqual(resp.status, 200) + self.assertEqual( + json.loads(str(resp.body)), + { + "payload": '{"foo": "bar"}', + "headers": {"x-bar": "baz"}, + "method": method, + }, + )