From a96fbf30061ea4fbd56054c10ac49422504d503e Mon Sep 17 00:00:00 2001 From: dsuresh-ap Date: Thu, 12 Feb 2026 16:01:31 -0500 Subject: [PATCH] fix: extract jsonBodySection from body_structure [PC-3936] Pass jsonBodySection from agent config body_structure through to ActivityMetadata so the SDK uses the correct multipart part name. --- pyproject.toml | 2 +- .../connections/_connections_service.py | 5 +- .../platform/connections/connections.py | 1 + .../sdk/services/test_connections_service.py | 122 ++++++++++++++++++ uv.lock | 2 +- 5 files changed, 128 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7cb356dab..021741059 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.8.21" +version = "2.8.22" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath/platform/connections/_connections_service.py b/src/uipath/platform/connections/_connections_service.py index 688871814..cb78508a2 100644 --- a/src/uipath/platform/connections/_connections_service.py +++ b/src/uipath/platform/connections/_connections_service.py @@ -771,13 +771,14 @@ def _build_activity_request_spec( files: Dict[str, Any] | None = None # multipart/form-data for file uploads + json_section = activity_metadata.json_body_section or "body" if "multipart" in activity_metadata.content_type.lower(): files = {} for key, val in multipart_params.items(): # json body itself appears as a multipart param as well # instead of making assumptions on whether or not it's present, we'll handle it defensively - if key == "body": + if key == json_section: continue # files not supported yet supported so this will likely not work files[key] = ( @@ -786,7 +787,7 @@ def _build_activity_request_spec( None, ) # probably needs to extract content type from val since IS metadata doesn't provide it - files["body"] = ( + files[json_section] = ( "", json.dumps(body_fields), "application/json", diff --git a/src/uipath/platform/connections/connections.py b/src/uipath/platform/connections/connections.py index a9d31ae1d..394861708 100644 --- a/src/uipath/platform/connections/connections.py +++ b/src/uipath/platform/connections/connections.py @@ -105,3 +105,4 @@ class ActivityMetadata(BaseModel): method_name: str content_type: str parameter_location_info: ActivityParameterLocationInfo + json_body_section: Optional[str] = None diff --git a/tests/sdk/services/test_connections_service.py b/tests/sdk/services/test_connections_service.py index 28c964056..d75f0643f 100644 --- a/tests/sdk/services/test_connections_service.py +++ b/tests/sdk/services/test_connections_service.py @@ -1285,6 +1285,25 @@ def multipart_activity_metadata() -> ActivityMetadata: multipart_params=["file_param"], body_fields=["description"], ), + json_body_section="body", + ) + + +@pytest.fixture +def multipart_custom_section_metadata() -> ActivityMetadata: + """Sample multipart activity metadata with custom json_body_section.""" + return ActivityMetadata( + object_path="/elements/test-connector/rag", + method_name="POST", + content_type="multipart/form-data", + parameter_location_info=ActivityParameterLocationInfo( + query_params=[], + header_params=[], + path_params=[], + multipart_params=["file_param"], + body_fields=["prompt", "model"], + ), + json_body_section="RagRequest", ) @@ -1651,3 +1670,106 @@ def test_invoke_activity_empty_input( ) assert result == expected_response + + def test_invoke_activity_multipart_custom_json_body_section( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + multipart_custom_section_metadata: ActivityMetadata, + ) -> None: + """Test multipart request uses custom json_body_section name instead of 'body'.""" + connection_id = "test-connection-123" + activity_input = { + "file_param": b"test file content", + "prompt": "Summarize this document", + "model": "gpt-4", + } + expected_response = {"result": "summary text"} + + httpx_mock.add_response( + method="POST", + status_code=200, + json=expected_response, + ) + + _ = service.invoke_activity( + activity_metadata=multipart_custom_section_metadata, + connection_id=connection_id, + activity_input=activity_input, + ) + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert "multipart/form-data" in sent_request.headers.get("content-type", "") + + # Parse the multipart body to verify part names + content_type = sent_request.headers["content-type"] + boundary = content_type.split("boundary=")[1] + body = sent_request.content.decode("utf-8", errors="replace") + parts = body.split(f"--{boundary}") + + # Find the part names in the multipart body + part_names = [] + for part in parts: + if 'name="' in part: + name = part.split('name="')[1].split('"')[0] + part_names.append(name) + + # The JSON body should be in "RagRequest" part, not "body" + assert "RagRequest" in part_names + assert "body" not in part_names + assert "file_param" in part_names + + def test_invoke_activity_multipart_default_json_body_section( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + ) -> None: + """Test multipart request defaults to 'body' when json_body_section is None.""" + metadata = ActivityMetadata( + object_path="/elements/test-connector/upload", + method_name="POST", + content_type="multipart/form-data", + parameter_location_info=ActivityParameterLocationInfo( + query_params=[], + header_params=[], + path_params=[], + multipart_params=["file_param"], + body_fields=["description"], + ), + # json_body_section is None (default) + ) + connection_id = "test-connection-123" + activity_input = { + "file_param": b"file data", + "description": "A file", + } + + httpx_mock.add_response(method="POST", status_code=200, json={"ok": True}) + + _ = service.invoke_activity( + activity_metadata=metadata, + connection_id=connection_id, + activity_input=activity_input, + ) + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + content_type = sent_request.headers["content-type"] + boundary = content_type.split("boundary=")[1] + body = sent_request.content.decode("utf-8", errors="replace") + parts = body.split(f"--{boundary}") + + part_names = [] + for part in parts: + if 'name="' in part: + name = part.split('name="')[1].split('"')[0] + part_names.append(name) + + # Should default to "body" when json_body_section is None + assert "body" in part_names + assert "file_param" in part_names diff --git a/uv.lock b/uv.lock index abab01297..8cecbce23 100644 --- a/uv.lock +++ b/uv.lock @@ -2531,7 +2531,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.8.21" +version = "2.8.22" source = { editable = "." } dependencies = [ { name = "applicationinsights" },