feat(blocks;frontend): Add file multipart upload support for SendWebRequestBlock & Improve key-value input UI rendering (#10058)

Now, SendWebRequestBlock can upload files. To make this work, we also
need to improve the UI rendering on the key-value pair input so that it
can also render media/file upload.

### Changes 🏗️

* Add file multipart upload support for SendWebRequestBlock 
* Improve key-value input UI rendering to allow rendering any types as a
normal input block (it was only string & number).

<img width="381" alt="image"
src="https://github.com/user-attachments/assets/b41d778d-8f9d-4aec-95b6-0b32bef50e89"
/>


### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  <!-- Put your test plan here: -->
  - [x] Test running http request block, othe key-value pair input block
This commit is contained in:
Zamil Majdy
2025-06-03 14:55:12 +07:00
committed by GitHub
parent 3f6585f763
commit 0f558876e2
3 changed files with 146 additions and 67 deletions

View File

@@ -1,12 +1,19 @@
import json
import logging
from enum import Enum
from typing import Any
from io import BufferedReader
from pathlib import Path
from requests.exceptions import HTTPError, RequestException
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
from backend.data.model import SchemaField
from backend.util.file import (
MediaFileType,
get_exec_file_path,
get_mime_type,
store_media_file,
)
from backend.util.request import requests
logger = logging.getLogger(name=__name__)
@@ -38,13 +45,21 @@ class SendWebRequestBlock(Block):
)
json_format: bool = SchemaField(
title="JSON format",
description="Whether to send and receive body as JSON",
description="If true, send the body as JSON (unless files are also present).",
default=True,
)
body: Any = SchemaField(
description="The body of the request",
body: dict | None = SchemaField(
description="Form/JSON body payload. If files are supplied, this must be a mapping of formfields.",
default=None,
)
files_name: str = SchemaField(
description="The name of the file field in the form data.",
default="file",
)
files: list[MediaFileType] = SchemaField(
description="Mapping of *form field name* → Image url / path / base64 url.",
default_factory=list,
)
class Output(BlockSchema):
response: object = SchemaField(description="The response from the server")
@@ -55,67 +70,112 @@ class SendWebRequestBlock(Block):
def __init__(self):
super().__init__(
id="6595ae1f-b924-42cb-9a41-551a0611c4b4",
description="This block makes an HTTP request to the given URL.",
description="Make an HTTP request (JSON / form / multipart).",
categories={BlockCategory.OUTPUT},
input_schema=SendWebRequestBlock.Input,
output_schema=SendWebRequestBlock.Output,
)
def run(self, input_data: Input, **kwargs) -> BlockOutput:
@staticmethod
def _prepare_files(
graph_exec_id: str,
files_name: str,
files: list[MediaFileType],
) -> tuple[list[tuple[str, tuple[str, BufferedReader, str]]], list[BufferedReader]]:
"""Convert the `files` mapping into the structure expected by `requests`.
Returns a tuple of (**files_payload**, **open_handles**) so we can close handles later.
"""
files_payload: list[tuple[str, tuple[str, BufferedReader, str]]] = []
open_handles: list[BufferedReader] = []
for media in files:
# Normalise to a list so we can repeat the same key
rel_path = store_media_file(graph_exec_id, media, return_content=False)
abs_path = get_exec_file_path(graph_exec_id, rel_path)
try:
handle = open(abs_path, "rb")
except Exception as e:
for h in open_handles:
try:
h.close()
except Exception:
pass
raise RuntimeError(f"Failed to open file '{abs_path}': {e}") from e
open_handles.append(handle)
mime = get_mime_type(abs_path)
files_payload.append((files_name, (Path(abs_path).name, handle, mime)))
return files_payload, open_handles
def run(self, input_data: Input, *, graph_exec_id: str, **kwargs) -> BlockOutput:
# ─── Parse/normalise body ────────────────────────────────────
body = input_data.body
if isinstance(body, str):
try:
body = json.loads(body)
except json.JSONDecodeError:
# plain text treat as formfield value instead
input_data.json_format = False
if input_data.json_format:
if isinstance(body, str):
try:
# Try to parse as JSON first
body = json.loads(body)
except json.JSONDecodeError:
# If it's not valid JSON and just plain text,
# we should send it as plain text instead
input_data.json_format = False
# ─── Prepare files (if any) ──────────────────────────────────
use_files = bool(input_data.files)
files_payload: list[tuple[str, tuple[str, BufferedReader, str]]] = []
open_handles: list[BufferedReader] = []
if use_files:
files_payload, open_handles = self._prepare_files(
graph_exec_id, input_data.files_name, input_data.files
)
# Enforce body format rules
if use_files and input_data.json_format:
raise ValueError(
"json_format=True cannot be combined with file uploads; set json_format=False and put form fields in `body`."
)
# ─── Execute request ─────────────────────────────────────────
try:
response = requests.request(
input_data.method.value,
input_data.url,
headers=input_data.headers,
json=body if input_data.json_format else None,
files=files_payload if use_files else None,
# * If files → multipart ⇒ pass formfields via data=
data=body if not input_data.json_format else None,
# * Else, choose JSON vs urlencoded based on flag
json=body if (input_data.json_format and not use_files) else None,
)
if input_data.json_format:
if response.status_code == 204 or not response.content.strip():
result = None
else:
result = response.json()
# Decide how to parse the response
if input_data.json_format or response.headers.get(
"content-type", ""
).startswith("application/json"):
result = (
None
if (response.status_code == 204 or not response.content.strip())
else response.json()
)
else:
result = response.text
yield "response", result
# Yield according to status code bucket
if 200 <= response.status_code < 300:
yield "response", result
elif 400 <= response.status_code < 500:
yield "client_error", result
else:
yield "server_error", result
except HTTPError as e:
# Handle error responses
try:
result = e.response.json() if input_data.json_format else str(e)
except json.JSONDecodeError:
result = str(e)
if 400 <= e.response.status_code < 500:
yield "client_error", result
elif 500 <= e.response.status_code < 600:
yield "server_error", result
else:
error_msg = (
"Unexpected status code "
f"{e.response.status_code} '{e.response.reason}'"
)
logger.warning(error_msg)
yield "error", error_msg
yield "error", f"HTTP error: {str(e)}"
except RequestException as e:
# Handle other request-related exceptions
yield "error", str(e)
yield "error", f"Request error: {str(e)}"
except Exception as e:
# Catch any other unexpected exceptions
yield "error", str(e)
finally:
for h in open_handles:
try:
h.close()
except Exception:
pass

View File

@@ -67,8 +67,7 @@ def store_media_file(
return ext if ext else ".bin"
def _file_to_data_uri(path: Path) -> str:
mime_type, _ = mimetypes.guess_type(path)
mime_type = mime_type or "application/octet-stream"
mime_type = get_mime_type(str(path))
b64 = base64.b64encode(path.read_bytes()).decode("utf-8")
return f"data:{mime_type};base64,{b64}"
@@ -130,3 +129,21 @@ def store_media_file(
return MediaFileType(_file_to_data_uri(target_path))
else:
return MediaFileType(_strip_base_prefix(target_path, base_path))
def get_mime_type(file: str) -> str:
"""
Get the MIME type of a file, whether it's a data URI, URL, or local path.
"""
if file.startswith("data:"):
match = re.match(r"^data:([^;]+);base64,", file)
return match.group(1) if match else "application/octet-stream"
elif file.startswith(("http://", "https://")):
parsed_url = urlparse(file)
mime_type, _ = mimetypes.guess_type(parsed_url.path)
return mime_type or "application/octet-stream"
else:
mime_type, _ = mimetypes.guess_type(file)
return mime_type or "application/octet-stream"

View File

@@ -493,10 +493,11 @@ export const NodeGenericInputField: FC<{
schema={propSchema as BlockIOKVSubSchema}
entries={currentValue}
errors={errors}
className={className}
displayName={displayName}
connections={connections}
handleInputChange={handleInputChange}
handleInputClick={handleInputClick}
className={className}
displayName={displayName}
/>
);
@@ -732,6 +733,7 @@ const NodeKeyValueInput: FC<{
errors: { [key: string]: string | undefined };
connections: NodeObjectInputTreeProps["connections"];
handleInputChange: NodeObjectInputTreeProps["handleInputChange"];
handleInputClick: NodeObjectInputTreeProps["handleInputClick"];
className?: string;
displayName?: string;
}> = ({
@@ -741,6 +743,7 @@ const NodeKeyValueInput: FC<{
schema,
connections,
handleInputChange,
handleInputClick,
errors,
className,
displayName,
@@ -761,7 +764,7 @@ const NodeKeyValueInput: FC<{
}, [entries, schema.default, connections, nodeId, selfKey]);
const [keyValuePairs, setKeyValuePairs] = useState<
{ key: string; value: string | number | null }[]
{ key: string; value: any }[]
>([]);
useEffect(
@@ -778,18 +781,6 @@ const NodeKeyValueInput: FC<{
);
}
const isNumberType =
schema.additionalProperties &&
["number", "integer"].includes(schema.additionalProperties.type);
function convertValueType(value: string): string | number | null {
if (isNumberType) {
const numValue = Number(value);
return !isNaN(numValue) ? numValue : null;
}
return value;
}
function getEntryKey(key: string): string {
return `${selfKey}_#_${key}`;
}
@@ -799,6 +790,11 @@ const NodeKeyValueInput: FC<{
);
}
const propSchema =
schema.additionalProperties && schema.additionalProperties.type
? schema.additionalProperties
: ({ type: "string" } as BlockIOSimpleTypeSubSchema);
return (
<div
className={cn(className, keyValuePairs.length > 0 ? "flex flex-col" : "")}
@@ -832,18 +828,24 @@ const NodeKeyValueInput: FC<{
)
}
/>
<LocalValuedInput
type={isNumberType ? "number" : "text"}
placeholder="Value"
value={value ?? ""}
onChange={(e) =>
<NodeGenericInputField
className="w-full"
nodeId={nodeId}
propKey={`${selfKey}_#_${key}`}
propSchema={propSchema}
currentValue={value}
errors={errors}
connections={connections}
displayName={displayName || beautifyString(key)}
handleInputChange={(_, newValue) =>
updateKeyValuePairs(
keyValuePairs.toSpliced(index, 1, {
key: key,
value: convertValueType(e.target.value),
value: newValue,
}),
)
}
handleInputClick={handleInputClick}
/>
<Button
variant="ghost"