feat: Enhance API request component (#8070)

* update the api request component

* [autofix.ci] apply automated fixes

* update the component

* Update test_api_request_component.py

* [autofix.ci] apply automated fixes

* remove MODE_CONFIG unused variable

* [autofix.ci] apply automated fixes

* use normalize function

* Update template

* Update test_api_request_component.py

* UI test fix

* selector fix

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Yuqi Tang <yuqi.tang@datastax.com>
Co-authored-by: Mike Fortman <michael.fortman@datastax.com>
This commit is contained in:
Edwin Jose 2025-05-22 11:41:19 -04:00 • committed by GitHub
commit ba92fc1e78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 256 additions and 351 deletions

View file

@ -1,4 +1,3 @@
import asyncio
import json import json
import re import re
import tempfile import tempfile
@ -14,65 +13,74 @@ import validators
from langflow.base.curl.parse import parse_context from langflow.base.curl.parse import parse_context
from langflow.custom import Component from langflow.custom import Component
from langflow.inputs.inputs import TabInput
from langflow.io import ( from langflow.io import (
BoolInput, BoolInput,
DataInput, DataInput,
DropdownInput, DropdownInput,
FloatInput,
IntInput, IntInput,
MessageTextInput, MessageTextInput,
MultilineInput, MultilineInput,
Output, Output,
StrInput,
TableInput, TableInput,
) )
from langflow.schema import Data from langflow.schema import Data
from langflow.schema.dataframe import DataFrame
from langflow.schema.dotdict import dotdict from langflow.schema.dotdict import dotdict
from langflow.services.deps import get_settings_service from langflow.services.deps import get_settings_service
from langflow.utils.component_utils import set_current_fields, set_field_advanced, set_field_display
# Get settings using the service # Define fields for each mode
MODE_FIELDS = {
"URL": [
"url_input",
"method",
],
"cURL": ["curl_input"],
}
# Fields that should always be visible
DEFAULT_FIELDS = ["mode"]
class APIRequestComponent(Component): class APIRequestComponent(Component):
display_name = "API Request" display_name = "API Request"
description = "Make HTTP requests using URLs or cURL commands." description = "Make HTTP requests using URL or cURL commands."
icon = "Globe" icon = "Globe"
name = "APIRequest" name = "APIRequest"
default_keys = ["urls", "method", "query_params"]
inputs = [ inputs = [
MessageTextInput( MessageTextInput(
name="urls", name="url_input",
display_name="URLs", display_name="URL",
list=True, info="Enter the URL for the request.",
info="Enter one or more URLs, separated by commas.",
advanced=False, advanced=False,
tool_mode=True, tool_mode=True,
), ),
MultilineInput( MultilineInput(
name="curl", name="curl_input",
display_name="cURL", display_name="cURL",
info=( info=(
"Paste a curl command to populate the fields. " "Paste a curl command to populate the fields. "
"This will fill in the dictionary fields for headers and body." "This will fill in the dictionary fields for headers and body."
), ),
advanced=True,
real_time_refresh=True, real_time_refresh=True,
tool_mode=True, tool_mode=True,
advanced=True,
show=False,
), ),
DropdownInput( DropdownInput(
name="method", name="method",
display_name="Method", display_name="Method",
options=["GET", "POST", "PATCH", "PUT", "DELETE"], options=["GET", "POST", "PATCH", "PUT", "DELETE"],
value="GET",
info="The HTTP method to use.", info="The HTTP method to use.",
real_time_refresh=True, real_time_refresh=True,
), ),
BoolInput( TabInput(
name="use_curl", name="mode",
display_name="Use cURL", display_name="Mode",
value=False, options=["URL", "cURL"],
value="URL",
info="Enable cURL mode to populate fields from a cURL command.", info="Enable cURL mode to populate fields from a cURL command.",
real_time_refresh=True, real_time_refresh=True,
), ),
@ -161,8 +169,7 @@ class APIRequestComponent(Component):
] ]
outputs = [ outputs = [
Output(display_name="Data", name="data", method="make_requests"), Output(display_name="API Response", name="data", method="make_api_requests"),
Output(display_name="DataFrame", name="dataframe", method="as_dataframe"),
] ]
def _parse_json_value(self, value: Any) -> Any: def _parse_json_value(self, value: Any) -> Any:
@ -178,13 +185,7 @@ class APIRequestComponent(Component):
return parsed return parsed
def _process_body(self, body: Any) -> dict: def _process_body(self, body: Any) -> dict:
"""Process the body input into a valid dictionary. """Process the body input into a valid dictionary."""
Args:
body: The body to process, can be dict, str, or list
Returns:
Processed dictionary
"""
if body is None: if body is None:
return {} return {}
if isinstance(body, dict): if isinstance(body, dict):
@ -193,7 +194,6 @@ class APIRequestComponent(Component):
return self._process_string_body(body) return self._process_string_body(body)
if isinstance(body, list): if isinstance(body, list):
return self._process_list_body(body) return self._process_list_body(body)
return {} return {}
def _process_dict_body(self, body: dict) -> dict: def _process_dict_body(self, body: dict) -> dict:
@ -210,20 +210,16 @@ class APIRequestComponent(Component):
def _process_list_body(self, body: list) -> dict: def _process_list_body(self, body: list) -> dict:
"""Process list body by converting to key-value dictionary.""" """Process list body by converting to key-value dictionary."""
processed_dict = {} processed_dict = {}
try: try:
for item in body: for item in body:
if not self._is_valid_key_value_item(item): if not self._is_valid_key_value_item(item):
continue continue
key = item["key"] key = item["key"]
value = self._parse_json_value(item["value"]) value = self._parse_json_value(item["value"])
processed_dict[key] = value processed_dict[key] = value
except (KeyError, TypeError, ValueError) as e: except (KeyError, TypeError, ValueError) as e:
self.log(f"Failed to process body list: {e}") self.log(f"Failed to process body list: {e}")
return {} # Return empty dictionary instead of None return {}
return processed_dict return processed_dict
def _is_valid_key_value_item(self, item: Any) -> bool: def _is_valid_key_value_item(self, item: Any) -> bool:
@ -231,30 +227,22 @@ class APIRequestComponent(Component):
return isinstance(item, dict) and "key" in item and "value" in item return isinstance(item, dict) and "key" in item and "value" in item
def parse_curl(self, curl: str, build_config: dotdict) -> dotdict: def parse_curl(self, curl: str, build_config: dotdict) -> dotdict:
"""Parse a cURL command and update build configuration. """Parse a cURL command and update build configuration."""
Args:
curl: The cURL command to parse
build_config: The build configuration to update
Returns:
Updated build configuration
"""
try: try:
parsed = parse_context(curl) parsed = parse_context(curl)
# Update basic configuration # Update basic configuration
build_config["urls"]["value"] = [parsed.url] url = parsed.url
# Normalize URL before setting it
url = self._normalize_url(url)
build_config["url_input"]["value"] = url
build_config["method"]["value"] = parsed.method.upper() build_config["method"]["value"] = parsed.method.upper()
build_config["headers"]["advanced"] = True
build_config["body"]["advanced"] = True
# Process headers # Process headers
headers_list = [{"key": k, "value": v} for k, v in parsed.headers.items()] headers_list = [{"key": k, "value": v} for k, v in parsed.headers.items()]
build_config["headers"]["value"] = headers_list build_config["headers"]["value"] = headers_list
if headers_list:
build_config["headers"]["advanced"] = False
# Process body data # Process body data
if not parsed.data: if not parsed.data:
build_config["body"]["value"] = [] build_config["body"]["value"] = []
@ -267,13 +255,10 @@ class APIRequestComponent(Component):
for k, v in json_data.items() for k, v in json_data.items()
] ]
build_config["body"]["value"] = body_list build_config["body"]["value"] = body_list
build_config["body"]["advanced"] = False
else: else:
build_config["body"]["value"] = [{"key": "data", "value": json.dumps(json_data)}] build_config["body"]["value"] = [{"key": "data", "value": json.dumps(json_data)}]
build_config["body"]["advanced"] = False
except json.JSONDecodeError: except json.JSONDecodeError:
build_config["body"]["value"] = [{"key": "data", "value": parsed.data}] build_config["body"]["value"] = [{"key": "data", "value": parsed.data}]
build_config["body"]["advanced"] = False
except Exception as exc: except Exception as exc:
msg = f"Error parsing curl: {exc}" msg = f"Error parsing curl: {exc}"
@ -282,106 +267,16 @@ class APIRequestComponent(Component):
return build_config return build_config
def update_build_config(self, build_config: dotdict, field_value: Any, field_name: str | None = None) -> dotdict: def _normalize_url(self, url: str) -> str:
if field_name == "use_curl" and field_value: """Normalize URL by adding https:// if no protocol is specified."""
# if we remove field value from validation, this gets validated every time if not url or not isinstance(url, str):
build_config = self._update_curl_mode(build_config, use_curl=field_value) msg = "URL cannot be empty"
raise ValueError(msg)
# If curl is not used, we don't need to reset the fields url = url.strip()
if not self.use_curl: if url.startswith(("http://", "https://")):
return build_config return url
return f"https://{url}"
# Fields that should not be reset
preserve_fields = {"timeout", "follow_redirects", "save_to_file", "include_httpx_metadata", "use_curl"}
# Mapping between input types and their reset values
type_reset_mapping = {
TableInput: [],
BoolInput: False,
IntInput: 0,
FloatInput: 0.0,
MessageTextInput: "",
StrInput: "",
MultilineInput: "",
DropdownInput: "GET",
DataInput: {},
}
for input_field in self.inputs:
# Only reset if field is not in preserve list
if input_field.name not in preserve_fields:
reset_value = type_reset_mapping.get(type(input_field), None)
build_config[input_field.name]["value"] = reset_value
self.log(f"Reset field {input_field.name} to {reset_value}")
# Don't try to parse the boolean value as a curl command
return build_config
if field_name == "method" and not self.use_curl:
build_config = self._update_method_fields(build_config, field_value)
elif field_name == "curl" and self.use_curl and field_value:
# Not reachable, because we don't have a way to update
# the curl field, self.use_curl is set after the build_config is created
build_config = self.parse_curl(field_value, build_config)
return build_config
def _update_curl_mode(self, build_config: dotdict, *, use_curl: bool) -> dotdict:
always_visible = ["method", "use_curl"]
for field in self.inputs:
field_name = field.name
field_config = build_config.get(field_name)
if isinstance(field_config, dict):
if field_name in always_visible:
field_config["advanced"] = False
elif field_name == "urls":
field_config["advanced"] = use_curl
elif field_name == "curl":
field_config["advanced"] = not use_curl
field_config["real_time_refresh"] = use_curl
elif field_name in {"body", "headers"}:
field_config["advanced"] = True # Always keep body and headers in advanced when use_curl is False
else:
field_config["advanced"] = use_curl or field_config.get("advanced")
else:
self.log(f"Expected dict for build_config[{field_name}], got {type(field_config).__name__}")
if not use_curl:
current_method = build_config.get("method", {}).get("value", "GET")
build_config = self._update_method_fields(build_config, current_method)
return build_config
def _update_method_fields(self, build_config: dotdict, method: str) -> dotdict:
common_fields = [
"urls",
"method",
"use_curl",
]
always_advanced_fields = [
"body",
"headers",
"timeout",
"follow_redirects",
"save_to_file",
"include_httpx_metadata",
]
body_fields = ["body"]
for field in self.inputs:
field_name = field.name
field_config = build_config.get(field_name)
if isinstance(field_config, dict):
if field_name in common_fields:
field_config["advanced"] = False
elif field_name in body_fields:
field_config["advanced"] = method not in {"POST", "PUT", "PATCH"}
elif field_name in always_advanced_fields:
field_config["advanced"] = True
else:
self.log(f"Expected dict for build_config[{field_name}], got {type(field_config).__name__}")
return build_config
async def make_request( async def make_request(
self, self,
@ -401,19 +296,20 @@ class APIRequestComponent(Component):
msg = f"Unsupported method: {method}" msg = f"Unsupported method: {method}"
raise ValueError(msg) raise ValueError(msg)
# Process body using the new helper method
processed_body = self._process_body(body) processed_body = self._process_body(body)
redirection_history = [] redirection_history = []
try: try:
response = await client.request( # Prepare request parameters
method, request_params = {
url, "method": method,
headers=headers, "url": url,
json=processed_body, "headers": headers,
timeout=timeout, "json": processed_body,
follow_redirects=follow_redirects, "timeout": timeout,
) "follow_redirects": follow_redirects,
}
response = await client.request(**request_params)
redirection_history = [ redirection_history = [
{ {
@ -426,15 +322,20 @@ class APIRequestComponent(Component):
is_binary, file_path = await self._response_info(response, with_file_path=save_to_file) is_binary, file_path = await self._response_info(response, with_file_path=save_to_file)
response_headers = self._headers_to_dict(response.headers) response_headers = self._headers_to_dict(response.headers)
metadata: dict[str, Any] = { # Base metadata
metadata = {
"source": url, "source": url,
"status_code": response.status_code,
"response_headers": response_headers,
} }
if redirection_history:
metadata["redirection_history"] = redirection_history
if save_to_file: if save_to_file:
mode = "wb" if is_binary else "w" mode = "wb" if is_binary else "w"
encoding = response.encoding if mode == "w" else None encoding = response.encoding if mode == "w" else None
if file_path: if file_path:
# Ensure parent directory exists
await aiofiles_os.makedirs(file_path.parent, exist_ok=True) await aiofiles_os.makedirs(file_path.parent, exist_ok=True)
if is_binary: if is_binary:
async with aiofiles.open(file_path, "wb") as f: async with aiofiles.open(file_path, "wb") as f:
@ -447,16 +348,10 @@ class APIRequestComponent(Component):
metadata["file_path"] = str(file_path) metadata["file_path"] = str(file_path)
if include_httpx_metadata: if include_httpx_metadata:
metadata.update( metadata.update({"headers": headers})
{
"headers": headers,
"status_code": response.status_code,
"response_headers": response_headers,
**({"redirection_history": redirection_history} if redirection_history else {}),
}
)
return Data(data=metadata) return Data(data=metadata)
# Handle response content
if is_binary: if is_binary:
result = response.content result = response.content
else: else:
@ -466,28 +361,13 @@ class APIRequestComponent(Component):
self.log("Failed to decode JSON response") self.log("Failed to decode JSON response")
result = response.text.encode("utf-8") result = response.text.encode("utf-8")
metadata.update({"result": result}) metadata["result"] = result
if include_httpx_metadata: if include_httpx_metadata:
metadata.update( metadata.update({"headers": headers})
{
"headers": headers,
"status_code": response.status_code,
"response_headers": response_headers,
**({"redirection_history": redirection_history} if redirection_history else {}),
}
)
return Data(data=metadata) return Data(data=metadata)
except httpx.TimeoutException: except (httpx.HTTPError, httpx.RequestError, httpx.TimeoutException) as exc:
return Data(
data={
"source": url,
"headers": headers,
"status_code": 408,
"error": "Request timed out",
},
)
except Exception as exc: # noqa: BLE001
self.log(f"Error making request to {url}") self.log(f"Error making request to {url}")
return Data( return Data(
data={ data={
@ -500,15 +380,33 @@ class APIRequestComponent(Component):
) )
def add_query_params(self, url: str, params: dict) -> str: def add_query_params(self, url: str, params: dict) -> str:
"""Add query parameters to URL efficiently."""
if not params:
return url
url_parts = list(urlparse(url)) url_parts = list(urlparse(url))
query = dict(parse_qsl(url_parts[4])) query = dict(parse_qsl(url_parts[4]))
query.update(params) query.update(params)
url_parts[4] = urlencode(query) url_parts[4] = urlencode(query)
return urlunparse(url_parts) return urlunparse(url_parts)
async def make_requests(self) -> list[Data]: def _headers_to_dict(self, headers: httpx.Headers) -> dict[str, str]:
"""Convert HTTP headers to a dictionary with lowercased keys."""
return {k.lower(): v for k, v in headers.items()}
def _process_headers(self, headers: Any) -> dict:
"""Process the headers input into a valid dictionary."""
if headers is None:
return {}
if isinstance(headers, dict):
return headers
if isinstance(headers, list):
return {item["key"]: item["value"] for item in headers if self._is_valid_key_value_item(item)}
return {}
async def make_api_requests(self) -> Data:
"""Make HTTP request with optimized parameter handling."""
method = self.method method = self.method
urls = [url.strip() for url in self.urls if url.strip()] url = self.url_input.strip() if isinstance(self.url_input, str) else ""
headers = self.headers or {} headers = self.headers or {}
body = self.body or {} body = self.body or {}
timeout = self.timeout timeout = self.timeout
@ -516,48 +414,65 @@ class APIRequestComponent(Component):
save_to_file = self.save_to_file save_to_file = self.save_to_file
include_httpx_metadata = self.include_httpx_metadata include_httpx_metadata = self.include_httpx_metadata
if self.use_curl and self.curl: # if self.mode == "cURL" and self.curl_input:
self._build_config = self.parse_curl(self.curl, dotdict()) # self._build_config = self.parse_curl(self.curl_input, dotdict())
# # After parsing curl, get the normalized URL
# url = self._build_config["url_input"]["value"]
invalid_urls = [url for url in urls if not validators.url(url)] # Normalize URL before validation
if invalid_urls: url = self._normalize_url(url)
msg = f"Invalid URLs provided: {invalid_urls}"
# Validate URL
if not validators.url(url):
msg = f"Invalid URL provided: {url}"
raise ValueError(msg) raise ValueError(msg)
# Process query parameters
if isinstance(self.query_params, str): if isinstance(self.query_params, str):
query_params = dict(parse_qsl(self.query_params)) query_params = dict(parse_qsl(self.query_params))
else: else:
query_params = self.query_params.data if self.query_params else {} query_params = self.query_params.data if self.query_params else {}
# Process headers here # Process headers and body
headers = self._process_headers(headers) headers = self._process_headers(headers)
# Process body
body = self._process_body(body) body = self._process_body(body)
url = self.add_query_params(url, query_params)
bodies = [body] * len(urls)
urls = [self.add_query_params(url, query_params) for url in urls]
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
results = await asyncio.gather( result = await self.make_request(
*[
self.make_request(
client, client,
method, method,
u, url,
headers, headers,
rec, body,
timeout, timeout,
follow_redirects=follow_redirects, follow_redirects=follow_redirects,
save_to_file=save_to_file, save_to_file=save_to_file,
include_httpx_metadata=include_httpx_metadata, include_httpx_metadata=include_httpx_metadata,
) )
for u, rec in zip(urls, bodies, strict=False) self.status = result
] return result
def update_build_config(self, build_config: dotdict, field_value: Any, field_name: str | None = None) -> dotdict:
"""Update the build config based on the selected mode."""
if field_name != "mode":
return build_config
# print(f"Current mode: {field_value}")
if field_value == "cURL":
set_field_display(build_config, "curl_input", value=True)
build_config = self.parse_curl(self.curl_input, build_config)
else:
set_field_display(build_config, "curl_input", value=False)
return set_current_fields(
build_config=build_config,
action_fields=MODE_FIELDS,
selected_action=field_value,
default_fields=DEFAULT_FIELDS,
func=set_field_advanced,
default_value=True,
) )
self.status = results
return results
async def _response_info( async def _response_info(
self, response: httpx.Response, *, with_file_path: bool = False self, response: httpx.Response, *, with_file_path: bool = False
@ -624,43 +539,3 @@ class APIRequestComponent(Component):
file_path = component_temp_dir / f"{timestamp}-{filename}" file_path = component_temp_dir / f"{timestamp}-{filename}"
return is_binary, file_path return is_binary, file_path
def _headers_to_dict(self, headers: httpx.Headers) -> dict[str, str]:
"""Convert HTTP headers to a dictionary with lowercased keys."""
return {k.lower(): v for k, v in headers.items()}
def _process_headers(self, headers: Any) -> dict:
"""Process the headers input into a valid dictionary.
Args:
headers: The headers to process, can be dict, str, or list
Returns:
Processed dictionary
"""
if headers is None:
return {}
if isinstance(headers, dict):
return headers
if isinstance(headers, list):
processed_headers = {}
try:
for item in headers:
if not self._is_valid_key_value_item(item):
continue
key = item["key"]
value = item["value"]
processed_headers[key] = value
except (KeyError, TypeError, ValueError) as e:
self.log(f"Failed to process headers list: {e}")
return {} # Return empty dictionary instead of None
return processed_headers
return {}
async def as_dataframe(self) -> DataFrame:
"""Convert the API response data into a DataFrame.
Returns:
DataFrame: A DataFrame containing the API response data.
"""
data = await self.make_requests()
return DataFrame(data)

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,4 @@
from pathlib import Path from pathlib import Path
from unittest.mock import patch
import aiofiles import aiofiles
import aiofiles.os import aiofiles.os
@ -8,7 +7,8 @@ import pytest
import respx import respx
from httpx import Response from httpx import Response
from langflow.components.data import APIRequestComponent from langflow.components.data import APIRequestComponent
from langflow.schema import Data, DataFrame from langflow.schema import Data
from langflow.schema.dotdict import dotdict
from tests.base import ComponentTestBaseWithoutClient from tests.base import ComponentTestBaseWithoutClient
@ -23,16 +23,17 @@ class TestAPIRequestComponent(ComponentTestBaseWithoutClient):
def default_kwargs(self): def default_kwargs(self):
"""Return the default kwargs for the component.""" """Return the default kwargs for the component."""
return { return {
"urls": ["https://example.com/api/test"], "url_input": "https://example.com/api/test",
"method": "GET", "method": "GET",
"headers": [], "headers": [{"key": "User-Agent", "value": "test-agent"}],
"body": [], "body": [],
"timeout": 5, "timeout": 30,
"follow_redirects": True, "follow_redirects": True,
"save_to_file": False, "save_to_file": False,
"include_httpx_metadata": False, "include_httpx_metadata": False,
"use_curl": False, "mode": "URL",
"curl": "", "curl_input": "",
"query_params": {},
} }
@pytest.fixture @pytest.fixture
@ -50,16 +51,18 @@ class TestAPIRequestComponent(ComponentTestBaseWithoutClient):
curl_cmd = ( curl_cmd = (
"curl -X GET https://example.com/api/test -H 'Content-Type: application/json' -d '{\"key\": \"value\"}'" "curl -X GET https://example.com/api/test -H 'Content-Type: application/json' -d '{\"key\": \"value\"}'"
) )
build_config = { build_config = dotdict(
{
"method": {"value": ""}, "method": {"value": ""},
"urls": {"value": []}, "url_input": {"value": ""},
"headers": {}, "headers": {"value": []},
"body": {}, "body": {"value": []},
} }
)
new_build_config = component.parse_curl(curl_cmd, build_config.copy()) new_build_config = component.parse_curl(curl_cmd, build_config.copy())
assert new_build_config["method"]["value"] == "GET" assert new_build_config["method"]["value"] == "GET"
assert new_build_config["urls"]["value"] == ["https://example.com/api/test"] assert new_build_config["url_input"]["value"] == "https://example.com/api/test"
assert new_build_config["headers"]["value"] == [{"key": "Content-Type", "value": "application/json"}] assert new_build_config["headers"]["value"] == [{"key": "Content-Type", "value": "application/json"}]
assert new_build_config["body"]["value"] == [{"key": "key", "value": "value"}] assert new_build_config["body"]["value"] == [{"key": "key", "value": "value"}]
@ -78,7 +81,6 @@ class TestAPIRequestComponent(ComponentTestBaseWithoutClient):
assert isinstance(result, Data) assert isinstance(result, Data)
assert result.data["source"] == url assert result.data["source"] == url
# The JSON response is nested in the 'result' key
assert "result" in result.data assert "result" in result.data
assert result.data["result"]["key"] == "value" assert result.data["result"]["key"] == "value"
@ -145,6 +147,7 @@ class TestAPIRequestComponent(ComponentTestBaseWithoutClient):
assert isinstance(result, Data) assert isinstance(result, Data)
assert result.data["source"] == url assert result.data["source"] == url
assert result.data["result"] == binary_content
@respx.mock @respx.mock
async def test_make_request_timeout(self, component): async def test_make_request_timeout(self, component):
@ -160,8 +163,8 @@ class TestAPIRequestComponent(ComponentTestBaseWithoutClient):
) )
assert isinstance(result, Data) assert isinstance(result, Data)
assert result.data["status_code"] == 408 assert result.data["status_code"] == 500
assert result.data["error"] == "Request timed out" assert "Request timed out" in result.data["error"]
@respx.mock @respx.mock
async def test_make_request_with_redirects(self, component): async def test_make_request_with_redirects(self, component):
@ -235,55 +238,55 @@ class TestAPIRequestComponent(ComponentTestBaseWithoutClient):
assert "param1=value1" in result assert "param1=value1" in result
assert "param2=value2" in result assert "param2=value2" in result
async def test_output_formats(self, component): async def test_make_api_requests(self, component):
# Test different output formats # Test making API requests
with patch.object(component, "make_requests") as mock_make_requests: url = "https://example.com/api/test"
mock_make_requests.return_value = [Data(data={"key": "value"})] response_data = {"key": "value"}
# Test DataFrame output with respx.mock:
df_result = await component.as_dataframe() respx.get(url).mock(return_value=Response(200, json=response_data))
assert isinstance(df_result, DataFrame)
# Test Data output - to_data returns a list of Data objects result = await component.make_api_requests()
test_data = {"test": "value"}
data_result = component.to_data(test_data) assert isinstance(result, Data)
assert isinstance(data_result, list) assert result.data["source"] == url
assert all(isinstance(item, Data) for item in data_result) assert result.data["result"]["key"] == "value"
async def test_invalid_urls(self, component): async def test_invalid_urls(self, component):
# Test invalid URL handling # Test invalid URL handling
component.urls = ["not_a_valid_url"] component.url_input = "not_a_valid_url"
with pytest.raises(ValueError, match="Invalid URLs provided"): with pytest.raises(ValueError, match="Invalid URL provided"):
await component.make_requests() await component.make_api_requests()
async def test_update_build_config(self, component): async def test_update_build_config(self, component):
# Test build config updates # Test build config updates
build_config = { build_config = dotdict(
{
"method": {"value": "GET", "advanced": False}, "method": {"value": "GET", "advanced": False},
"urls": {"value": [], "advanced": False}, "url_input": {"value": "", "advanced": False},
"headers": {"value": [], "advanced": True}, "headers": {"value": [], "advanced": True},
"body": {"value": [], "advanced": True}, "body": {"value": [], "advanced": True},
"use_curl": {"value": False, "advanced": False}, "mode": {"value": "URL", "advanced": False},
"curl": {"value": "", "advanced": True}, "curl_input": {"value": "curl -X GET https://example.com/api/test", "advanced": True},
"timeout": {"value": 5, "advanced": True}, "timeout": {"value": 30, "advanced": True},
"follow_redirects": {"value": True, "advanced": True}, "follow_redirects": {"value": True, "advanced": True},
"save_to_file": {"value": False, "advanced": True}, "save_to_file": {"value": False, "advanced": True},
"include_httpx_metadata": {"value": False, "advanced": True}, "include_httpx_metadata": {"value": False, "advanced": True},
"query_params": {"value": {}, "advanced": True}, "query_params": {"value": {}, "advanced": True},
} }
# Test curl mode update
updated = component.update_build_config(
build_config=build_config.copy(), field_value=True, field_name="use_curl"
) )
assert updated["curl"]["advanced"] is False
assert updated["urls"]["advanced"] is True
# Test method update # Test URL mode
updated = component.update_build_config( updated = component.update_build_config(build_config=build_config.copy(), field_value="URL", field_name="mode")
build_config=build_config.copy(), field_value="POST", field_name="method" assert updated["curl_input"]["advanced"] is True
) assert updated["url_input"]["advanced"] is False
assert updated["body"]["advanced"] is False
# Set the component's curl_input attribute to match the build_config before switching to cURL mode
component.curl_input = build_config["curl_input"]["value"]
# Test cURL mode
updated = component.update_build_config(build_config=build_config.copy(), field_value="cURL", field_name="mode")
assert updated["curl_input"]["advanced"] is False
assert updated["url_input"]["advanced"] is True
@respx.mock @respx.mock
async def test_error_handling(self, component): async def test_error_handling(self, component):
@ -307,3 +310,24 @@ class TestAPIRequestComponent(ComponentTestBaseWithoutClient):
method="INVALID", method="INVALID",
url=url, url=url,
) )
async def test_response_info(self, component):
# Test response info handling
url = "https://example.com/api/test"
request = httpx.Request("GET", url)
response = Response(200, text="test content", request=request)
is_binary, file_path = await component._response_info(response, with_file_path=True)
assert not is_binary
assert file_path is not None
assert file_path.suffix == ".txt"
# Test binary response
binary_response = Response(
200, content=b"binary content", headers={"Content-Type": "application/octet-stream"}, request=request
)
is_binary, file_path = await component._response_info(binary_response, with_file_path=True)
assert is_binary
assert file_path is not None
assert file_path.suffix == ".bin"

View file

@ -32,12 +32,12 @@ test(
await adjustScreenView(page); await adjustScreenView(page);
await page.waitForSelector( await page.waitForSelector(
'[data-testid="handle-apirequest-shownode-urls-left"]', '[data-testid="handle-apirequest-shownode-url-left"]',
{ {
timeout: 3000, timeout: 3000,
}, },
); );
await page.getByTestId("handle-apirequest-shownode-urls-left").click(); await page.getByTestId("handle-apirequest-shownode-url-left").click();
await page.waitForTimeout(500); await page.waitForTimeout(500);

View file

@ -9,17 +9,17 @@ test(
await page.getByTestId("blank-flow").click(); await page.getByTestId("blank-flow").click();
await page.getByTestId("sidebar-search-input").click(); await page.getByTestId("sidebar-search-input").click();
await page.getByTestId("sidebar-search-input").fill("api request"); await page.getByTestId("sidebar-search-input").fill("url");
await page.waitForSelector('[data-testid="dataAPI Request"]', { await page.waitForSelector('[data-testid="dataURL"]', {
timeout: 3000, timeout: 3000,
}); });
await page await page
.getByTestId("dataAPI Request") .getByTestId("dataURL")
.hover() .hover()
.then(async () => { .then(async () => {
await page.getByTestId("add-component-button-api-request").click(); await page.getByTestId("add-component-button-url").click();
}); });
await page.waitForSelector( await page.waitForSelector(
@ -44,7 +44,7 @@ test(
await page.getByTestId("button_open_actions").click(); await page.getByTestId("button_open_actions").click();
await page.waitForSelector("text=API Request", { timeout: 30000 }); await page.waitForSelector("text=URL", { timeout: 30000 });
const rowsCount = await page.getByRole("gridcell").count(); const rowsCount = await page.getByRole("gridcell").count();
@ -200,7 +200,11 @@ test(
await page.getByText("Close").last().click(); await page.getByText("Close").last().click();
expect( expect(
await page.locator('[data-testid="tool_make_requests"]').isVisible(), await page.locator('[data-testid="tool_fetch_content"]').isVisible(),
).toBe(true);
expect(
await page.locator('[data-testid="tool_as_dataframe"]').isVisible(),
).toBe(true); ).toBe(true);
}, },
); );