Skip to content
Merged
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
34 changes: 33 additions & 1 deletion runner/arazzo_runner/executor/parameter_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,28 @@ def _process_multipart_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
# Handle blob references
if isinstance(value, dict) and "blob_ref" in value:
processed_payload[key] = self._rehydrate_blob_reference(value, key)
# Handle already-formatted file dicts (has "content" and "file_name"/"filename")
# This must come before the generic dict check to avoid JSON serialization
elif isinstance(value, dict) and "content" in value:
# Check if this is already a file dict (has file_name or filename)
has_file_name = "file_name" in value or "filename" in value
if has_file_name:
logger.debug(f"File dict already formatted for field '{key}', using as-is.")
# Ensure file_name exists (even if None, it will be set to default in HTTP executor)
if "file_name" not in value:
value["file_name"] = value.get("filename")
processed_payload[key] = value
else:
# Dict with "content" but no file_name - might be a regular dict, serialize it
try:
processed_payload[key] = json.dumps(value, separators=(",", ":"))
except (TypeError, ValueError):
processed_payload[key] = str(value)
elif isinstance(value, bytes | bytearray):
logger.debug(f"Wrapping binary data in field '{key}' for multipart upload.")
processed_payload[key] = {
"content": value,
"filename": "attachment", # Using a generic filename
"file_name": "attachment",
"contentType": "application/octet-stream",
}
else:
Expand Down Expand Up @@ -579,6 +596,21 @@ def prepare_parameters(self, step: dict, state: ExecutionState) -> dict[str, Any
value = ExpressionEvaluator.evaluate_expression(
value, state, self.source_descriptions
)
elif re.search(r"\$inputs\.\w+|\$steps\.\w+", value):
# Substitute $inputs.x and $steps.stepId.outputs.x inside strings (e.g. q param)
def replace_embedded(match):
expr = match.group(0)
eval_val = ExpressionEvaluator.evaluate_expression(
expr, state, self.source_descriptions
)
if eval_val is None:
logger.warning(
f"Embedded expression {expr} evaluated to None - keeping original substring"
)
return expr
return str(eval_val)

value = re.sub(r"\$inputs\.[\w.]+|\$steps\.[\w.]+", replace_embedded, value)
elif "{" in value and "}" in value:
# Template with expressions
def replace_expr(match):
Expand Down
32 changes: 28 additions & 4 deletions runner/arazzo_runner/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,14 +187,38 @@ def execute_request(
data = {}
for key, value in payload.items():
# A field is treated as a file upload if its value is an object
# containing 'content' and 'filename' keys.
if isinstance(value, dict) and "content" in value and "filename" in value:
# containing 'content' and 'file_name' (canonical key; parameter_processor
# normalizes filename -> file_name before payload reaches here).
has_file_name = (
isinstance(value, dict) and "content" in value and "file_name" in value
)
if has_file_name:
# requests expects a tuple: (filename, file_data, content_type)
file_content = value["content"]
file_name = value["filename"] if value.get("filename") else "attachment"
file_name = value.get("file_name") or "attachment"
file_type = value.get("contentType", "application/octet-stream")

# Validate that file_content is bytes/bytearray
if not isinstance(file_content, bytes | bytearray):
if file_content is None:
logger.error(
f"File content for field '{key}' is None. Cannot upload file."
)
raise ValueError(
f"File content for field '{key}' is None. Ensure the file content expression evaluates to bytes."
)
else:
logger.error(
f"File content for field '{key}' is {type(file_content).__name__}, expected bytes. Value: {str(file_content)[:100]}"
)
raise ValueError(
f"File content for field '{key}' must be bytes or bytearray, got {type(file_content).__name__}"
)

files[key] = (file_name, file_content, file_type)
logger.debug(f"Preparing file '{file_name}' for upload.")
logger.debug(
f"Preparing file '{file_name}' for upload ({len(file_content)} bytes)."
)
elif isinstance(value, bytes | bytearray):
# Fallback: treat raw bytes as a file with a generic name
files[key] = ("attachment", value, "application/octet-stream")
Expand Down
9 changes: 8 additions & 1 deletion runner/arazzo_runner/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -793,10 +793,17 @@ def generate_env_mappings(
- 'auth': Environment variable mappings for authentication
- 'servers': Environment variable mappings for server URLs (only included if server variables exist)
"""
# Normalize: accept single doc or list; auth_processor expects list of doc dicts
if arazzo_docs is None:
arazzo_specs: list[dict[str, Any]] = []
elif isinstance(arazzo_docs, dict) and "workflows" in arazzo_docs:
arazzo_specs = [arazzo_docs]
else:
arazzo_specs = list(arazzo_docs) if arazzo_docs else []
auth_processor = AuthProcessor()
auth_config = auth_processor.process_api_auth(
openapi_specs=source_descriptions or {},
arazzo_specs=arazzo_docs or [],
arazzo_specs=arazzo_specs,
)
auth_env_mappings = auth_config.get("env_mappings", {})

Expand Down
34 changes: 30 additions & 4 deletions runner/tests/executor/test_parameter_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@ def test_prepare_multipart_form_body(self):
"payload": {
"file": {
"content": b"file content",
"filename": "attachment",
"file_name": "attachment",
"contentType": "application/octet-stream",
},
"description": "this is a file",
Expand All @@ -408,7 +408,7 @@ def test_prepare_multipart_form_body_with_bytearray(self):
"payload": {
"file": {
"content": bytearray(b"file content"),
"filename": "attachment",
"file_name": "attachment",
"contentType": "application/octet-stream",
},
"description": "a bytearray file",
Expand Down Expand Up @@ -453,12 +453,12 @@ def test_prepare_multipart_form_body_with_mixed_types(self):
expected_payload = {
"file_bytes": {
"content": b"this is bytes",
"filename": "attachment",
"file_name": "attachment",
"contentType": "application/octet-stream",
},
"file_bytearray": {
"content": bytearray(b"this is bytearray"),
"filename": "attachment",
"file_name": "attachment",
"contentType": "application/octet-stream",
},
"description": "mixed payload",
Expand All @@ -467,6 +467,32 @@ def test_prepare_multipart_form_body_with_mixed_types(self):
self.assertEqual(result["body"]["contentType"], "multipart/form-data")
self.assertEqual(result["body"]["payload"], expected_payload)

def test_process_multipart_payload_preserves_file_dict_not_json_serialized(self):
"""Dict with content + file_name (or filename) is treated as file object and preserved, not json.dumps'd."""
binary_content = b"PDF binary \x00\x01\x02"
payload = {
"file": {
"content": binary_content,
"file_name": "document.pdf",
"contentType": "application/pdf",
},
"purpose": "ocr",
}
result = self.processor._process_multipart_payload(payload)
self.assertIn("file", result)
out = result["file"]
# Preserved as file dict with canonical file_name
self.assertIsInstance(out, dict)
self.assertIn("content", out)
self.assertIn("file_name", out)
# Content must still be bytes (not JSON-serialized string)
self.assertIsInstance(out["content"], bytes)
self.assertEqual(out["content"], binary_content)
self.assertEqual(out["file_name"], "document.pdf")
self.assertEqual(out["contentType"], "application/pdf")
# Scalar field unchanged
self.assertEqual(result["purpose"], "ocr")


if __name__ == "__main__":
unittest.main()
1 change: 0 additions & 1 deletion runner/tests/fixtures/bnpl/test_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ workflows:
"customer": "https://api.bnpl-example.com/bnpl/v1/customers/CUST1001"
"redirectAuthToken": "eda8c851-f36e-4d78-b832-2d1411e1414b"
"loanTransactionResourceUrl": "https://api.bnpl-example.com/bnpl/v1/loan-transactions/LOAN1001"
# The workflow makes 4 API calls because the auth step is skipped
expected_api_calls: 4
# Despite the issues, all steps that run actually succeed
expect_success: true
Expand Down
100 changes: 98 additions & 2 deletions runner/tests/test_http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ def test_apply_auth_multiple_apis(http_client: HTTPExecutor):


def test_execute_request_multipart(http_client: HTTPExecutor):
"""Test executing a multipart/form-data request."""
"""Test executing a multipart/form-data request (canonical file_name key)."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/json"}
Expand All @@ -331,7 +331,7 @@ def test_execute_request_multipart(http_client: HTTPExecutor):
"payload": {
"file": {
"content": b"file content",
"filename": "test.txt",
"file_name": "test.txt",
"contentType": "text/plain",
},
"description": "A test file",
Expand Down Expand Up @@ -717,6 +717,102 @@ def test_execute_request_raw_with_unserializable_payload(http_client: HTTPExecut
assert kwargs["headers"]["Content-Type"] == "text/plain"


def test_execute_request_multipart_file_dict_with_file_name(http_client: HTTPExecutor):
"""Test multipart upload with file dict using canonical file_name key."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/json"}
mock_response.json.return_value = {"status": "ok"}

request_body = {
"contentType": "multipart/form-data",
"payload": {
"file": {
"content": b"binary file content",
"file_name": "document.pdf",
},
"description": "A PDF document",
},
}

with patch("requests.Session.request", return_value=mock_response) as mock_request:
http_client.execute_request(
method="POST",
url="http://test.com/upload",
parameters={},
request_body=request_body,
security_options=None,
source_name=None,
)

mock_request.assert_called_once()
args, kwargs = mock_request.call_args
assert kwargs["method"] == "POST"
assert kwargs["url"] == "http://test.com/upload"
assert "files" in kwargs
assert kwargs["files"]["file"] == (
"document.pdf",
b"binary file content",
"application/octet-stream",
)
assert "data" in kwargs
assert kwargs["data"]["description"] == "A PDF document"
assert "Content-Type" not in kwargs["headers"]


def test_execute_request_multipart_file_dict_content_none_raises(http_client: HTTPExecutor):
"""Test that a file dict with content=None raises ValueError."""
request_body = {
"contentType": "multipart/form-data",
"payload": {
"file": {
"content": None,
"file_name": "test.txt",
},
},
}

with pytest.raises(ValueError) as exc_info:
http_client.execute_request(
method="POST",
url="http://test.com/upload",
parameters={},
request_body=request_body,
security_options=None,
source_name=None,
)

assert "File content for field 'file' is None" in str(exc_info.value)
assert "bytes" in str(exc_info.value).lower()


def test_execute_request_multipart_file_dict_content_str_raises(http_client: HTTPExecutor):
"""Test that a file dict with content as str (non-bytes) raises ValueError."""
request_body = {
"contentType": "multipart/form-data",
"payload": {
"file": {
"content": "not bytes",
"file_name": "test.txt",
},
},
}

with pytest.raises(ValueError) as exc_info:
http_client.execute_request(
method="POST",
url="http://test.com/upload",
parameters={},
request_body=request_body,
security_options=None,
source_name=None,
)

assert "File content for field 'file'" in str(exc_info.value)
assert "must be bytes or bytearray" in str(exc_info.value)
assert "str" in str(exc_info.value).lower()


def test_execute_request_multipart_missing_content_key(http_client: HTTPExecutor):
"""Test multipart processing with a file dict missing 'content': should treat it as regular field."""
mock_response = MagicMock()
Expand Down
Loading