Skip to content

Commit 1e6452c

Browse files
committed
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.
1 parent af3602e commit 1e6452c

2 files changed

Lines changed: 186 additions & 6 deletions

File tree

packages/uipath-platform/src/uipath/platform/connections/_connections_service.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -788,12 +788,22 @@ def _build_activity_request_spec(
788788
# instead of making assumptions on whether or not it's present, we'll handle it defensively
789789
if key == json_section:
790790
continue
791-
# files not supported yet supported so this will likely not work
792-
files[key] = (
793-
key,
794-
val,
795-
None,
796-
) # probably needs to extract content type from val since IS metadata doesn't provide it
791+
if isinstance(val, tuple):
792+
# Caller supplied httpx's (filename, content[, content_type])
793+
# shape — pass through verbatim. This is the recommended path
794+
# for file uploads so the multipart Content-Disposition gets
795+
# the real filename instead of the form-field name.
796+
files[key] = val
797+
elif isinstance(val, (bytes, bytearray)) or hasattr(val, "read"):
798+
# Raw file content with no filename — fall back to the
799+
# form-field name (legacy behaviour). Backwards compatible
800+
# with callers that still pass bytes directly.
801+
files[key] = (key, val, "application/octet-stream")
802+
else:
803+
# Scalar (string/number/etc.) — send as a plain multipart
804+
# form field, not a file part. The (None, value) shape tells
805+
# httpx to omit `filename=...` from the Content-Disposition.
806+
files[key] = (None, str(val))
797807

798808
files[json_section] = (
799809
"",

packages/uipath-platform/tests/services/test_connections_service.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2116,3 +2116,173 @@ async def test_invoke_activity_async_uses_connection_id_from_retrieve_response(
21162116
assert f"/element/instances/{original_connection_id}/" not in str(
21172117
activity_request.url
21182118
)
2119+
2120+
2121+
def _multipart_part(body: bytes, boundary: str, name: str) -> str:
2122+
"""Return the raw text of the multipart part with the given form-field name."""
2123+
text = body.decode("utf-8", errors="replace")
2124+
for part in text.split(f"--{boundary}"):
2125+
if f'name="{name}"' in part:
2126+
return part
2127+
raise AssertionError(f"part {name!r} not found in multipart body")
2128+
2129+
2130+
class TestMultipartFileUpload:
2131+
"""Regression tests for the multipart serializer that handles file uploads.
2132+
2133+
Before this fix, ``_build_activity_request_spec`` always built
2134+
``files[key] = (key, val, None)``, using the form-field name as the
2135+
multipart filename and dropping the content type. Downstream services
2136+
(e.g. Coupa's ``add_attachment`` endpoint) ended up storing every
2137+
attachment with the literal name ``attachment[file]`` and no extension.
2138+
2139+
The serializer now branches on the value type:
2140+
2141+
* tuple → passed through (caller controls filename + content type)
2142+
* bytes → legacy fallback, key as filename, octet-stream content type
2143+
* scalar → plain multipart form field (no filename in Content-Disposition)
2144+
"""
2145+
2146+
def test_invoke_activity_multipart_tuple_3_preserves_filename(
2147+
self,
2148+
httpx_mock: HTTPXMock,
2149+
service: ConnectionsService,
2150+
multipart_activity_metadata: ActivityMetadata,
2151+
) -> None:
2152+
"""3-tuple input is forwarded verbatim, so the real filename + content type land on the wire."""
2153+
connection_id = "test-connection-123"
2154+
activity_input = {
2155+
"file_param": ("invoice.pdf", b"%PDF-1.4 fake", "application/pdf"),
2156+
"description": "Test file upload",
2157+
}
2158+
2159+
httpx_mock.add_response(
2160+
method="GET",
2161+
status_code=200,
2162+
json={"id": connection_id, "name": "Test", "elementInstanceId": 1},
2163+
)
2164+
httpx_mock.add_response(method="POST", status_code=200, json={"ok": True})
2165+
2166+
_ = service.invoke_activity(
2167+
activity_metadata=multipart_activity_metadata,
2168+
connection_id=connection_id,
2169+
activity_input=activity_input,
2170+
)
2171+
2172+
sent_request = httpx_mock.get_requests()[1]
2173+
boundary = sent_request.headers["content-type"].split("boundary=")[1]
2174+
part = _multipart_part(sent_request.content, boundary, "file_param")
2175+
2176+
assert 'filename="invoice.pdf"' in part
2177+
assert "Content-Type: application/pdf" in part
2178+
assert b"%PDF-1.4 fake" in sent_request.content
2179+
2180+
def test_invoke_activity_multipart_tuple_2_preserves_filename(
2181+
self,
2182+
httpx_mock: HTTPXMock,
2183+
service: ConnectionsService,
2184+
multipart_activity_metadata: ActivityMetadata,
2185+
) -> None:
2186+
"""2-tuple (filename, content) shorthand: filename preserved, httpx infers the content type."""
2187+
connection_id = "test-connection-123"
2188+
activity_input = {
2189+
"file_param": ("invoice.pdf", b"%PDF-1.4 fake"),
2190+
"description": "Test file upload",
2191+
}
2192+
2193+
httpx_mock.add_response(
2194+
method="GET",
2195+
status_code=200,
2196+
json={"id": connection_id, "name": "Test", "elementInstanceId": 1},
2197+
)
2198+
httpx_mock.add_response(method="POST", status_code=200, json={"ok": True})
2199+
2200+
_ = service.invoke_activity(
2201+
activity_metadata=multipart_activity_metadata,
2202+
connection_id=connection_id,
2203+
activity_input=activity_input,
2204+
)
2205+
2206+
sent_request = httpx_mock.get_requests()[1]
2207+
boundary = sent_request.headers["content-type"].split("boundary=")[1]
2208+
part = _multipart_part(sent_request.content, boundary, "file_param")
2209+
2210+
assert 'filename="invoice.pdf"' in part
2211+
assert b"%PDF-1.4 fake" in sent_request.content
2212+
2213+
def test_invoke_activity_multipart_bytes_backwards_compatible(
2214+
self,
2215+
httpx_mock: HTTPXMock,
2216+
service: ConnectionsService,
2217+
multipart_activity_metadata: ActivityMetadata,
2218+
) -> None:
2219+
"""Existing callers passing raw bytes keep working — filename = form-field name (legacy)."""
2220+
connection_id = "test-connection-123"
2221+
activity_input = {
2222+
"file_param": b"raw bytes",
2223+
"description": "Test",
2224+
}
2225+
2226+
httpx_mock.add_response(
2227+
method="GET",
2228+
status_code=200,
2229+
json={"id": connection_id, "name": "Test", "elementInstanceId": 1},
2230+
)
2231+
httpx_mock.add_response(method="POST", status_code=200, json={"ok": True})
2232+
2233+
_ = service.invoke_activity(
2234+
activity_metadata=multipart_activity_metadata,
2235+
connection_id=connection_id,
2236+
activity_input=activity_input,
2237+
)
2238+
2239+
sent_request = httpx_mock.get_requests()[1]
2240+
boundary = sent_request.headers["content-type"].split("boundary=")[1]
2241+
part = _multipart_part(sent_request.content, boundary, "file_param")
2242+
2243+
# Legacy fallback: form-field name used as filename, octet-stream content type.
2244+
assert 'filename="file_param"' in part
2245+
assert "Content-Type: application/octet-stream" in part
2246+
assert b"raw bytes" in sent_request.content
2247+
2248+
def test_invoke_activity_multipart_scalar_is_plain_form_field(
2249+
self,
2250+
httpx_mock: HTTPXMock,
2251+
service: ConnectionsService,
2252+
) -> None:
2253+
"""Scalar multipart_params get sent as plain form fields (no bogus filename)."""
2254+
metadata = ActivityMetadata(
2255+
object_path="/elements/test-connector/upload",
2256+
method_name="POST",
2257+
content_type="multipart/form-data",
2258+
parameter_location_info=ActivityParameterLocationInfo(
2259+
multipart_params=["file_param", "payload"],
2260+
body_fields=[],
2261+
),
2262+
)
2263+
connection_id = "test-connection-123"
2264+
activity_input = {
2265+
"file_param": ("doc.pdf", b"data", "application/pdf"),
2266+
"payload": "{}",
2267+
}
2268+
2269+
httpx_mock.add_response(
2270+
method="GET",
2271+
status_code=200,
2272+
json={"id": connection_id, "name": "Test", "elementInstanceId": 1},
2273+
)
2274+
httpx_mock.add_response(method="POST", status_code=200, json={"ok": True})
2275+
2276+
_ = service.invoke_activity(
2277+
activity_metadata=metadata,
2278+
connection_id=connection_id,
2279+
activity_input=activity_input,
2280+
)
2281+
2282+
sent_request = httpx_mock.get_requests()[1]
2283+
boundary = sent_request.headers["content-type"].split("boundary=")[1]
2284+
payload_part = _multipart_part(sent_request.content, boundary, "payload")
2285+
2286+
# Scalar payload must NOT carry a filename in Content-Disposition.
2287+
assert "filename=" not in payload_part
2288+
assert "{}" in payload_part

0 commit comments

Comments
 (0)