From d456c5f406942c071f8257c342e8f1b5aa291c78 Mon Sep 17 00:00:00 2001 From: Dennis-UiPath Date: Mon, 4 May 2026 10:27:57 +0200 Subject: [PATCH 1/2] fix(connections): preserve filename and content type in multipart uploads `_build_activity_request_spec` always constructed `files[key] = (key, val, None)` for multipart activities, which used the form-field name (e.g. `attachment[file]`) as the multipart filename and sent a `None` content type. Downstream services that store attachments by filename ended up with literal names like `attachment_file_.` and no extension. Branch on the value type so callers can supply httpx's standard tuple shape: * tuple -> passed through (recommended for files) * bytes / file-like -> legacy fallback, key as filename, octet-stream content type (backwards compatible) * scalar (str/int/...) -> plain multipart form field, no fake filename in Content-Disposition The two pre-existing TODO comments (`# files not supported yet supported so this will likely not work` and the content-type note) are removed by the change. Tests: 4 new cases under `TestMultipartFileUpload` in `test_connections_service.py` exercise each branch by inspecting the serialized multipart body. Verified that 3 of them fail against the unpatched serializer (the bytes-fallback case passes either way because httpx defaults a `None` content type to `application/octet-stream`). `uv run pytest tests/` reports `1105 passed, 7 skipped` (pre-existing LLM-integration skips that need real credentials), and `uv run ruff check` is clean on the changed file. --- .../connections/_connections_service.py | 22 ++- .../services/test_connections_service.py | 170 ++++++++++++++++++ 2 files changed, 186 insertions(+), 6 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/connections/_connections_service.py b/packages/uipath-platform/src/uipath/platform/connections/_connections_service.py index af3bb4f78..8b139c1a4 100644 --- a/packages/uipath-platform/src/uipath/platform/connections/_connections_service.py +++ b/packages/uipath-platform/src/uipath/platform/connections/_connections_service.py @@ -788,12 +788,22 @@ def _build_activity_request_spec( # instead of making assumptions on whether or not it's present, we'll handle it defensively if key == json_section: continue - # files not supported yet supported so this will likely not work - files[key] = ( - key, - val, - None, - ) # probably needs to extract content type from val since IS metadata doesn't provide it + if isinstance(val, tuple): + # Caller supplied httpx's (filename, content[, content_type]) + # shape — pass through verbatim. This is the recommended path + # for file uploads so the multipart Content-Disposition gets + # the real filename instead of the form-field name. + files[key] = val + elif isinstance(val, (bytes, bytearray)) or hasattr(val, "read"): + # Raw file content with no filename — fall back to the + # form-field name (legacy behaviour). Backwards compatible + # with callers that still pass bytes directly. + files[key] = (key, val, "application/octet-stream") + else: + # Scalar (string/number/etc.) — send as a plain multipart + # form field, not a file part. The (None, value) shape tells + # httpx to omit `filename=...` from the Content-Disposition. + files[key] = (None, str(val)) files[json_section] = ( "", diff --git a/packages/uipath-platform/tests/services/test_connections_service.py b/packages/uipath-platform/tests/services/test_connections_service.py index 75c89b517..f9889272f 100644 --- a/packages/uipath-platform/tests/services/test_connections_service.py +++ b/packages/uipath-platform/tests/services/test_connections_service.py @@ -2116,3 +2116,173 @@ async def test_invoke_activity_async_uses_connection_id_from_retrieve_response( assert f"/element/instances/{original_connection_id}/" not in str( activity_request.url ) + + +def _multipart_part(body: bytes, boundary: str, name: str) -> str: + """Return the raw text of the multipart part with the given form-field name.""" + text = body.decode("utf-8", errors="replace") + for part in text.split(f"--{boundary}"): + if f'name="{name}"' in part: + return part + raise AssertionError(f"part {name!r} not found in multipart body") + + +class TestMultipartFileUpload: + """Regression tests for the multipart serializer that handles file uploads. + + Before this fix, ``_build_activity_request_spec`` always built + ``files[key] = (key, val, None)``, using the form-field name as the + multipart filename and dropping the content type. Downstream services + (e.g. Coupa's ``add_attachment`` endpoint) ended up storing every + attachment with the literal name ``attachment[file]`` and no extension. + + The serializer now branches on the value type: + + * tuple → passed through (caller controls filename + content type) + * bytes → legacy fallback, key as filename, octet-stream content type + * scalar → plain multipart form field (no filename in Content-Disposition) + """ + + def test_invoke_activity_multipart_tuple_3_preserves_filename( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + multipart_activity_metadata: ActivityMetadata, + ) -> None: + """3-tuple input is forwarded verbatim, so the real filename + content type land on the wire.""" + connection_id = "test-connection-123" + activity_input = { + "file_param": ("invoice.pdf", b"%PDF-1.4 fake", "application/pdf"), + "description": "Test file upload", + } + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + httpx_mock.add_response(method="POST", status_code=200, json={"ok": True}) + + _ = service.invoke_activity( + activity_metadata=multipart_activity_metadata, + connection_id=connection_id, + activity_input=activity_input, + ) + + sent_request = httpx_mock.get_requests()[1] + boundary = sent_request.headers["content-type"].split("boundary=")[1] + part = _multipart_part(sent_request.content, boundary, "file_param") + + assert 'filename="invoice.pdf"' in part + assert "Content-Type: application/pdf" in part + assert b"%PDF-1.4 fake" in sent_request.content + + def test_invoke_activity_multipart_tuple_2_preserves_filename( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + multipart_activity_metadata: ActivityMetadata, + ) -> None: + """2-tuple (filename, content) shorthand: filename preserved, httpx infers the content type.""" + connection_id = "test-connection-123" + activity_input = { + "file_param": ("invoice.pdf", b"%PDF-1.4 fake"), + "description": "Test file upload", + } + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + httpx_mock.add_response(method="POST", status_code=200, json={"ok": True}) + + _ = service.invoke_activity( + activity_metadata=multipart_activity_metadata, + connection_id=connection_id, + activity_input=activity_input, + ) + + sent_request = httpx_mock.get_requests()[1] + boundary = sent_request.headers["content-type"].split("boundary=")[1] + part = _multipart_part(sent_request.content, boundary, "file_param") + + assert 'filename="invoice.pdf"' in part + assert b"%PDF-1.4 fake" in sent_request.content + + def test_invoke_activity_multipart_bytes_backwards_compatible( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + multipart_activity_metadata: ActivityMetadata, + ) -> None: + """Existing callers passing raw bytes keep working — filename = form-field name (legacy).""" + connection_id = "test-connection-123" + activity_input = { + "file_param": b"raw bytes", + "description": "Test", + } + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + httpx_mock.add_response(method="POST", status_code=200, json={"ok": True}) + + _ = service.invoke_activity( + activity_metadata=multipart_activity_metadata, + connection_id=connection_id, + activity_input=activity_input, + ) + + sent_request = httpx_mock.get_requests()[1] + boundary = sent_request.headers["content-type"].split("boundary=")[1] + part = _multipart_part(sent_request.content, boundary, "file_param") + + # Legacy fallback: form-field name used as filename, octet-stream content type. + assert 'filename="file_param"' in part + assert "Content-Type: application/octet-stream" in part + assert b"raw bytes" in sent_request.content + + def test_invoke_activity_multipart_scalar_is_plain_form_field( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + ) -> None: + """Scalar multipart_params get sent as plain form fields (no bogus filename).""" + metadata = ActivityMetadata( + object_path="/elements/test-connector/upload", + method_name="POST", + content_type="multipart/form-data", + parameter_location_info=ActivityParameterLocationInfo( + multipart_params=["file_param", "payload"], + body_fields=[], + ), + ) + connection_id = "test-connection-123" + activity_input = { + "file_param": ("doc.pdf", b"data", "application/pdf"), + "payload": "{}", + } + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + 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_requests()[1] + boundary = sent_request.headers["content-type"].split("boundary=")[1] + payload_part = _multipart_part(sent_request.content, boundary, "payload") + + # Scalar payload must NOT carry a filename in Content-Disposition. + assert "filename=" not in payload_part + assert "{}" in payload_part From 7b868657b689589b9d282e20c0ac0200b6e8d01f Mon Sep 17 00:00:00 2001 From: Dennis-UiPath Date: Mon, 4 May 2026 12:16:01 +0200 Subject: [PATCH 2/2] chore(connections): bump uipath-platform to 0.1.46 Required by the `check-versions` CI gate so the multipart filename fix can ship as a new release. Also bumps the `uipath-platform` floor in `packages/uipath/pyproject.toml` so `uipath` pulls the fixed version. --- packages/uipath-platform/pyproject.toml | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 2 +- packages/uipath/uv.lock | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index f9e9d14a0..99c6d16ab 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.45" +version = "0.1.46" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index ae35f75a9..dbea2b79a 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.45" +version = "0.1.46" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index f3012976e..80b6d6c63 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.8, <0.6.0", "uipath-runtime>=0.10.1, <0.11.0", - "uipath-platform>=0.1.45, <0.2.0", + "uipath-platform>=0.1.46, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 6cbe534e9..4339a51d2 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.45" +version = "0.1.46" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" },