Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
5 changes: 3 additions & 2 deletions src/uipath/platform/connections/_connections_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = (
Expand All @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/uipath/platform/connections/connections.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,4 @@ class ActivityMetadata(BaseModel):
method_name: str
content_type: str
parameter_location_info: ActivityParameterLocationInfo
json_body_section: Optional[str] = None
122 changes: 122 additions & 0 deletions tests/sdk/services/test_connections_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)


Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.