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:
parent
c02c237567
commit
ba92fc1e78
5 changed files with 256 additions and 351 deletions
|
|
@ -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(
|
||||||
*[
|
client,
|
||||||
self.make_request(
|
method,
|
||||||
client,
|
url,
|
||||||
method,
|
headers,
|
||||||
u,
|
body,
|
||||||
headers,
|
timeout,
|
||||||
rec,
|
follow_redirects=follow_redirects,
|
||||||
timeout,
|
save_to_file=save_to_file,
|
||||||
follow_redirects=follow_redirects,
|
include_httpx_metadata=include_httpx_metadata,
|
||||||
save_to_file=save_to_file,
|
|
||||||
include_httpx_metadata=include_httpx_metadata,
|
|
||||||
)
|
|
||||||
for u, rec in zip(urls, bodies, strict=False)
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
self.status = results
|
self.status = result
|
||||||
return results
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
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
|
|
@ -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": ""},
|
{
|
||||||
"urls": {"value": []},
|
"method": {"value": ""},
|
||||||
"headers": {},
|
"url_input": {"value": ""},
|
||||||
"body": {},
|
"headers": {"value": []},
|
||||||
}
|
"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},
|
{
|
||||||
"urls": {"value": [], "advanced": False},
|
"method": {"value": "GET", "advanced": False},
|
||||||
"headers": {"value": [], "advanced": True},
|
"url_input": {"value": "", "advanced": False},
|
||||||
"body": {"value": [], "advanced": True},
|
"headers": {"value": [], "advanced": True},
|
||||||
"use_curl": {"value": False, "advanced": False},
|
"body": {"value": [], "advanced": True},
|
||||||
"curl": {"value": "", "advanced": True},
|
"mode": {"value": "URL", "advanced": False},
|
||||||
"timeout": {"value": 5, "advanced": True},
|
"curl_input": {"value": "curl -X GET https://example.com/api/test", "advanced": True},
|
||||||
"follow_redirects": {"value": True, "advanced": True},
|
"timeout": {"value": 30, "advanced": True},
|
||||||
"save_to_file": {"value": False, "advanced": True},
|
"follow_redirects": {"value": True, "advanced": True},
|
||||||
"include_httpx_metadata": {"value": False, "advanced": True},
|
"save_to_file": {"value": False, "advanced": True},
|
||||||
"query_params": {"value": {}, "advanced": True},
|
"include_httpx_metadata": {"value": False, "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"
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue