diff --git a/src/backend/base/langflow/components/data/api_request.py b/src/backend/base/langflow/components/data/api_request.py index 1697a6972..a859a191b 100644 --- a/src/backend/base/langflow/components/data/api_request.py +++ b/src/backend/base/langflow/components/data/api_request.py @@ -1,4 +1,3 @@ -import asyncio import json import re import tempfile @@ -14,65 +13,74 @@ import validators from langflow.base.curl.parse import parse_context from langflow.custom import Component +from langflow.inputs.inputs import TabInput from langflow.io import ( BoolInput, DataInput, DropdownInput, - FloatInput, IntInput, MessageTextInput, MultilineInput, Output, - StrInput, TableInput, ) from langflow.schema import Data -from langflow.schema.dataframe import DataFrame from langflow.schema.dotdict import dotdict 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): display_name = "API Request" - description = "Make HTTP requests using URLs or cURL commands." + description = "Make HTTP requests using URL or cURL commands." icon = "Globe" name = "APIRequest" - default_keys = ["urls", "method", "query_params"] - inputs = [ MessageTextInput( - name="urls", - display_name="URLs", - list=True, - info="Enter one or more URLs, separated by commas.", + name="url_input", + display_name="URL", + info="Enter the URL for the request.", advanced=False, tool_mode=True, ), MultilineInput( - name="curl", + name="curl_input", display_name="cURL", info=( "Paste a curl command to populate the fields. " "This will fill in the dictionary fields for headers and body." ), - advanced=True, real_time_refresh=True, tool_mode=True, + advanced=True, + show=False, ), DropdownInput( name="method", display_name="Method", options=["GET", "POST", "PATCH", "PUT", "DELETE"], + value="GET", info="The HTTP method to use.", real_time_refresh=True, ), - BoolInput( - name="use_curl", - display_name="Use cURL", - value=False, + TabInput( + name="mode", + display_name="Mode", + options=["URL", "cURL"], + value="URL", info="Enable cURL mode to populate fields from a cURL command.", real_time_refresh=True, ), @@ -161,8 +169,7 @@ class APIRequestComponent(Component): ] outputs = [ - Output(display_name="Data", name="data", method="make_requests"), - Output(display_name="DataFrame", name="dataframe", method="as_dataframe"), + Output(display_name="API Response", name="data", method="make_api_requests"), ] def _parse_json_value(self, value: Any) -> Any: @@ -178,13 +185,7 @@ class APIRequestComponent(Component): return parsed def _process_body(self, body: Any) -> dict: - """Process the body input into a valid dictionary. - - Args: - body: The body to process, can be dict, str, or list - Returns: - Processed dictionary - """ + """Process the body input into a valid dictionary.""" if body is None: return {} if isinstance(body, dict): @@ -193,7 +194,6 @@ class APIRequestComponent(Component): return self._process_string_body(body) if isinstance(body, list): return self._process_list_body(body) - return {} def _process_dict_body(self, body: dict) -> dict: @@ -210,20 +210,16 @@ class APIRequestComponent(Component): def _process_list_body(self, body: list) -> dict: """Process list body by converting to key-value dictionary.""" processed_dict = {} - try: for item in body: if not self._is_valid_key_value_item(item): continue - key = item["key"] value = self._parse_json_value(item["value"]) processed_dict[key] = value - except (KeyError, TypeError, ValueError) as e: self.log(f"Failed to process body list: {e}") - return {} # Return empty dictionary instead of None - + return {} return processed_dict 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 def parse_curl(self, curl: str, build_config: dotdict) -> dotdict: - """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 - """ + """Parse a cURL command and update build configuration.""" try: parsed = parse_context(curl) # 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["headers"]["advanced"] = True - build_config["body"]["advanced"] = True # Process headers headers_list = [{"key": k, "value": v} for k, v in parsed.headers.items()] build_config["headers"]["value"] = headers_list - if headers_list: - build_config["headers"]["advanced"] = False - # Process body data if not parsed.data: build_config["body"]["value"] = [] @@ -267,13 +255,10 @@ class APIRequestComponent(Component): for k, v in json_data.items() ] build_config["body"]["value"] = body_list - build_config["body"]["advanced"] = False else: build_config["body"]["value"] = [{"key": "data", "value": json.dumps(json_data)}] - build_config["body"]["advanced"] = False except json.JSONDecodeError: build_config["body"]["value"] = [{"key": "data", "value": parsed.data}] - build_config["body"]["advanced"] = False except Exception as exc: msg = f"Error parsing curl: {exc}" @@ -282,106 +267,16 @@ class APIRequestComponent(Component): return build_config - def update_build_config(self, build_config: dotdict, field_value: Any, field_name: str | None = None) -> dotdict: - if field_name == "use_curl" and field_value: - # if we remove field value from validation, this gets validated every time - build_config = self._update_curl_mode(build_config, use_curl=field_value) + def _normalize_url(self, url: str) -> str: + """Normalize URL by adding https:// if no protocol is specified.""" + if not url or not isinstance(url, str): + msg = "URL cannot be empty" + raise ValueError(msg) - # If curl is not used, we don't need to reset the fields - if not self.use_curl: - return build_config - - # 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 + url = url.strip() + if url.startswith(("http://", "https://")): + return url + return f"https://{url}" async def make_request( self, @@ -401,19 +296,20 @@ class APIRequestComponent(Component): msg = f"Unsupported method: {method}" raise ValueError(msg) - # Process body using the new helper method processed_body = self._process_body(body) redirection_history = [] try: - response = await client.request( - method, - url, - headers=headers, - json=processed_body, - timeout=timeout, - follow_redirects=follow_redirects, - ) + # Prepare request parameters + request_params = { + "method": method, + "url": url, + "headers": headers, + "json": processed_body, + "timeout": timeout, + "follow_redirects": follow_redirects, + } + response = await client.request(**request_params) redirection_history = [ { @@ -426,15 +322,20 @@ class APIRequestComponent(Component): is_binary, file_path = await self._response_info(response, with_file_path=save_to_file) response_headers = self._headers_to_dict(response.headers) - metadata: dict[str, Any] = { + # Base metadata + metadata = { "source": url, + "status_code": response.status_code, + "response_headers": response_headers, } + if redirection_history: + metadata["redirection_history"] = redirection_history + if save_to_file: mode = "wb" if is_binary else "w" encoding = response.encoding if mode == "w" else None if file_path: - # Ensure parent directory exists await aiofiles_os.makedirs(file_path.parent, exist_ok=True) if is_binary: async with aiofiles.open(file_path, "wb") as f: @@ -447,16 +348,10 @@ class APIRequestComponent(Component): metadata["file_path"] = str(file_path) if include_httpx_metadata: - metadata.update( - { - "headers": headers, - "status_code": response.status_code, - "response_headers": response_headers, - **({"redirection_history": redirection_history} if redirection_history else {}), - } - ) + metadata.update({"headers": headers}) return Data(data=metadata) + # Handle response content if is_binary: result = response.content else: @@ -466,28 +361,13 @@ class APIRequestComponent(Component): self.log("Failed to decode JSON response") result = response.text.encode("utf-8") - metadata.update({"result": result}) + metadata["result"] = result if include_httpx_metadata: - metadata.update( - { - "headers": headers, - "status_code": response.status_code, - "response_headers": response_headers, - **({"redirection_history": redirection_history} if redirection_history else {}), - } - ) + metadata.update({"headers": headers}) + return Data(data=metadata) - except httpx.TimeoutException: - return Data( - data={ - "source": url, - "headers": headers, - "status_code": 408, - "error": "Request timed out", - }, - ) - except Exception as exc: # noqa: BLE001 + except (httpx.HTTPError, httpx.RequestError, httpx.TimeoutException) as exc: self.log(f"Error making request to {url}") return Data( data={ @@ -500,15 +380,33 @@ class APIRequestComponent(Component): ) 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)) query = dict(parse_qsl(url_parts[4])) query.update(params) url_parts[4] = urlencode(query) 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 - 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 {} body = self.body or {} timeout = self.timeout @@ -516,48 +414,65 @@ class APIRequestComponent(Component): save_to_file = self.save_to_file include_httpx_metadata = self.include_httpx_metadata - if self.use_curl and self.curl: - self._build_config = self.parse_curl(self.curl, dotdict()) + # if self.mode == "cURL" and self.curl_input: + # 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)] - if invalid_urls: - msg = f"Invalid URLs provided: {invalid_urls}" + # Normalize URL before validation + url = self._normalize_url(url) + + # Validate URL + if not validators.url(url): + msg = f"Invalid URL provided: {url}" raise ValueError(msg) + # Process query parameters if isinstance(self.query_params, str): query_params = dict(parse_qsl(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) - - # Process body body = self._process_body(body) - - bodies = [body] * len(urls) - - urls = [self.add_query_params(url, query_params) for url in urls] + url = self.add_query_params(url, query_params) async with httpx.AsyncClient() as client: - results = await asyncio.gather( - *[ - self.make_request( - client, - method, - u, - headers, - rec, - timeout, - follow_redirects=follow_redirects, - save_to_file=save_to_file, - include_httpx_metadata=include_httpx_metadata, - ) - for u, rec in zip(urls, bodies, strict=False) - ] + result = await self.make_request( + client, + method, + url, + headers, + body, + timeout, + follow_redirects=follow_redirects, + save_to_file=save_to_file, + include_httpx_metadata=include_httpx_metadata, ) - self.status = results - return results + 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, + ) async def _response_info( self, response: httpx.Response, *, with_file_path: bool = False @@ -624,43 +539,3 @@ class APIRequestComponent(Component): file_path = component_temp_dir / f"{timestamp}-{filename}" 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) diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Pokédex Agent.json b/src/backend/base/langflow/initial_setup/starter_projects/Pokédex Agent.json index 417743bc5..7d11f2478 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Pokédex Agent.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Pokédex Agent.json @@ -806,7 +806,7 @@ "category": "data", "conditional_paths": [], "custom_fields": {}, - "description": "Make HTTP requests using URLs or cURL commands.", + "description": "Make HTTP requests using URL or cURL commands.", "display_name": "API Request", "documentation": "", "edited": false, @@ -920,9 +920,9 @@ "show": true, "title_case": false, "type": "code", - "value": "import asyncio\nimport json\nimport re\nimport tempfile\nfrom datetime import datetime, timezone\nfrom pathlib import Path\nfrom typing import Any\nfrom urllib.parse import parse_qsl, urlencode, urlparse, urlunparse\n\nimport aiofiles\nimport aiofiles.os as aiofiles_os\nimport httpx\nimport validators\n\nfrom langflow.base.curl.parse import parse_context\nfrom langflow.custom import Component\nfrom langflow.io import (\n BoolInput,\n DataInput,\n DropdownInput,\n FloatInput,\n IntInput,\n MessageTextInput,\n MultilineInput,\n Output,\n StrInput,\n TableInput,\n)\nfrom langflow.schema import Data\nfrom langflow.schema.dataframe import DataFrame\nfrom langflow.schema.dotdict import dotdict\nfrom langflow.services.deps import get_settings_service\n\n# Get settings using the service\n\n\nclass APIRequestComponent(Component):\n display_name = \"API Request\"\n description = \"Make HTTP requests using URLs or cURL commands.\"\n icon = \"Globe\"\n name = \"APIRequest\"\n\n default_keys = [\"urls\", \"method\", \"query_params\"]\n\n inputs = [\n MessageTextInput(\n name=\"urls\",\n display_name=\"URLs\",\n list=True,\n info=\"Enter one or more URLs, separated by commas.\",\n advanced=False,\n tool_mode=True,\n ),\n MultilineInput(\n name=\"curl\",\n display_name=\"cURL\",\n info=(\n \"Paste a curl command to populate the fields. \"\n \"This will fill in the dictionary fields for headers and body.\"\n ),\n advanced=True,\n real_time_refresh=True,\n tool_mode=True,\n ),\n DropdownInput(\n name=\"method\",\n display_name=\"Method\",\n options=[\"GET\", \"POST\", \"PATCH\", \"PUT\", \"DELETE\"],\n info=\"The HTTP method to use.\",\n real_time_refresh=True,\n ),\n BoolInput(\n name=\"use_curl\",\n display_name=\"Use cURL\",\n value=False,\n info=\"Enable cURL mode to populate fields from a cURL command.\",\n real_time_refresh=True,\n ),\n DataInput(\n name=\"query_params\",\n display_name=\"Query Parameters\",\n info=\"The query parameters to append to the URL.\",\n advanced=True,\n ),\n TableInput(\n name=\"body\",\n display_name=\"Body\",\n info=\"The body to send with the request as a dictionary (for POST, PATCH, PUT).\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Key\",\n \"type\": \"str\",\n \"description\": \"Parameter name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"description\": \"Parameter value\",\n },\n ],\n value=[],\n input_types=[\"Data\"],\n advanced=True,\n real_time_refresh=True,\n ),\n TableInput(\n name=\"headers\",\n display_name=\"Headers\",\n info=\"The headers to send with the request\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Header\",\n \"type\": \"str\",\n \"description\": \"Header name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"type\": \"str\",\n \"description\": \"Header value\",\n },\n ],\n value=[{\"key\": \"User-Agent\", \"value\": get_settings_service().settings.user_agent}],\n advanced=True,\n input_types=[\"Data\"],\n real_time_refresh=True,\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n value=30,\n info=\"The timeout to use for the request.\",\n advanced=True,\n ),\n BoolInput(\n name=\"follow_redirects\",\n display_name=\"Follow Redirects\",\n value=True,\n info=\"Whether to follow http redirects.\",\n advanced=True,\n ),\n BoolInput(\n name=\"save_to_file\",\n display_name=\"Save to File\",\n value=False,\n info=\"Save the API response to a temporary file\",\n advanced=True,\n ),\n BoolInput(\n name=\"include_httpx_metadata\",\n display_name=\"Include HTTPx Metadata\",\n value=False,\n info=(\n \"Include properties such as headers, status_code, response_headers, \"\n \"and redirection_history in the output.\"\n ),\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Data\", name=\"data\", method=\"make_requests\"),\n Output(display_name=\"DataFrame\", name=\"dataframe\", method=\"as_dataframe\"),\n ]\n\n def _parse_json_value(self, value: Any) -> Any:\n \"\"\"Parse a value that might be a JSON string.\"\"\"\n if not isinstance(value, str):\n return value\n\n try:\n parsed = json.loads(value)\n except json.JSONDecodeError:\n return value\n else:\n return parsed\n\n def _process_body(self, body: Any) -> dict:\n \"\"\"Process the body input into a valid dictionary.\n\n Args:\n body: The body to process, can be dict, str, or list\n Returns:\n Processed dictionary\n \"\"\"\n if body is None:\n return {}\n if isinstance(body, dict):\n return self._process_dict_body(body)\n if isinstance(body, str):\n return self._process_string_body(body)\n if isinstance(body, list):\n return self._process_list_body(body)\n\n return {}\n\n def _process_dict_body(self, body: dict) -> dict:\n \"\"\"Process dictionary body by parsing JSON values.\"\"\"\n return {k: self._parse_json_value(v) for k, v in body.items()}\n\n def _process_string_body(self, body: str) -> dict:\n \"\"\"Process string body by attempting JSON parse.\"\"\"\n try:\n return self._process_body(json.loads(body))\n except json.JSONDecodeError:\n return {\"data\": body}\n\n def _process_list_body(self, body: list) -> dict:\n \"\"\"Process list body by converting to key-value dictionary.\"\"\"\n processed_dict = {}\n\n try:\n for item in body:\n if not self._is_valid_key_value_item(item):\n continue\n\n key = item[\"key\"]\n value = self._parse_json_value(item[\"value\"])\n processed_dict[key] = value\n\n except (KeyError, TypeError, ValueError) as e:\n self.log(f\"Failed to process body list: {e}\")\n return {} # Return empty dictionary instead of None\n\n return processed_dict\n\n def _is_valid_key_value_item(self, item: Any) -> bool:\n \"\"\"Check if an item is a valid key-value dictionary.\"\"\"\n return isinstance(item, dict) and \"key\" in item and \"value\" in item\n\n def parse_curl(self, curl: str, build_config: dotdict) -> dotdict:\n \"\"\"Parse a cURL command and update build configuration.\n\n Args:\n curl: The cURL command to parse\n build_config: The build configuration to update\n Returns:\n Updated build configuration\n \"\"\"\n try:\n parsed = parse_context(curl)\n\n # Update basic configuration\n build_config[\"urls\"][\"value\"] = [parsed.url]\n build_config[\"method\"][\"value\"] = parsed.method.upper()\n build_config[\"headers\"][\"advanced\"] = True\n build_config[\"body\"][\"advanced\"] = True\n\n # Process headers\n headers_list = [{\"key\": k, \"value\": v} for k, v in parsed.headers.items()]\n build_config[\"headers\"][\"value\"] = headers_list\n\n if headers_list:\n build_config[\"headers\"][\"advanced\"] = False\n\n # Process body data\n if not parsed.data:\n build_config[\"body\"][\"value\"] = []\n elif parsed.data:\n try:\n json_data = json.loads(parsed.data)\n if isinstance(json_data, dict):\n body_list = [\n {\"key\": k, \"value\": json.dumps(v) if isinstance(v, dict | list) else str(v)}\n for k, v in json_data.items()\n ]\n build_config[\"body\"][\"value\"] = body_list\n build_config[\"body\"][\"advanced\"] = False\n else:\n build_config[\"body\"][\"value\"] = [{\"key\": \"data\", \"value\": json.dumps(json_data)}]\n build_config[\"body\"][\"advanced\"] = False\n except json.JSONDecodeError:\n build_config[\"body\"][\"value\"] = [{\"key\": \"data\", \"value\": parsed.data}]\n build_config[\"body\"][\"advanced\"] = False\n\n except Exception as exc:\n msg = f\"Error parsing curl: {exc}\"\n self.log(msg)\n raise ValueError(msg) from exc\n\n return build_config\n\n def update_build_config(self, build_config: dotdict, field_value: Any, field_name: str | None = None) -> dotdict:\n if field_name == \"use_curl\" and field_value:\n # if we remove field value from validation, this gets validated every time\n build_config = self._update_curl_mode(build_config, use_curl=field_value)\n\n # If curl is not used, we don't need to reset the fields\n if not self.use_curl:\n return build_config\n\n # Fields that should not be reset\n preserve_fields = {\"timeout\", \"follow_redirects\", \"save_to_file\", \"include_httpx_metadata\", \"use_curl\"}\n\n # Mapping between input types and their reset values\n type_reset_mapping = {\n TableInput: [],\n BoolInput: False,\n IntInput: 0,\n FloatInput: 0.0,\n MessageTextInput: \"\",\n StrInput: \"\",\n MultilineInput: \"\",\n DropdownInput: \"GET\",\n DataInput: {},\n }\n\n for input_field in self.inputs:\n # Only reset if field is not in preserve list\n if input_field.name not in preserve_fields:\n reset_value = type_reset_mapping.get(type(input_field), None)\n build_config[input_field.name][\"value\"] = reset_value\n self.log(f\"Reset field {input_field.name} to {reset_value}\")\n # Don't try to parse the boolean value as a curl command\n return build_config\n if field_name == \"method\" and not self.use_curl:\n build_config = self._update_method_fields(build_config, field_value)\n elif field_name == \"curl\" and self.use_curl and field_value:\n # Not reachable, because we don't have a way to update\n # the curl field, self.use_curl is set after the build_config is created\n build_config = self.parse_curl(field_value, build_config)\n return build_config\n\n def _update_curl_mode(self, build_config: dotdict, *, use_curl: bool) -> dotdict:\n always_visible = [\"method\", \"use_curl\"]\n\n for field in self.inputs:\n field_name = field.name\n field_config = build_config.get(field_name)\n if isinstance(field_config, dict):\n if field_name in always_visible:\n field_config[\"advanced\"] = False\n elif field_name == \"urls\":\n field_config[\"advanced\"] = use_curl\n elif field_name == \"curl\":\n field_config[\"advanced\"] = not use_curl\n field_config[\"real_time_refresh\"] = use_curl\n elif field_name in {\"body\", \"headers\"}:\n field_config[\"advanced\"] = True # Always keep body and headers in advanced when use_curl is False\n else:\n field_config[\"advanced\"] = use_curl or field_config.get(\"advanced\")\n else:\n self.log(f\"Expected dict for build_config[{field_name}], got {type(field_config).__name__}\")\n\n if not use_curl:\n current_method = build_config.get(\"method\", {}).get(\"value\", \"GET\")\n build_config = self._update_method_fields(build_config, current_method)\n\n return build_config\n\n def _update_method_fields(self, build_config: dotdict, method: str) -> dotdict:\n common_fields = [\n \"urls\",\n \"method\",\n \"use_curl\",\n ]\n\n always_advanced_fields = [\n \"body\",\n \"headers\",\n \"timeout\",\n \"follow_redirects\",\n \"save_to_file\",\n \"include_httpx_metadata\",\n ]\n\n body_fields = [\"body\"]\n\n for field in self.inputs:\n field_name = field.name\n field_config = build_config.get(field_name)\n if isinstance(field_config, dict):\n if field_name in common_fields:\n field_config[\"advanced\"] = False\n elif field_name in body_fields:\n field_config[\"advanced\"] = method not in {\"POST\", \"PUT\", \"PATCH\"}\n elif field_name in always_advanced_fields:\n field_config[\"advanced\"] = True\n else:\n self.log(f\"Expected dict for build_config[{field_name}], got {type(field_config).__name__}\")\n\n return build_config\n\n async def make_request(\n self,\n client: httpx.AsyncClient,\n method: str,\n url: str,\n headers: dict | None = None,\n body: Any = None,\n timeout: int = 5,\n *,\n follow_redirects: bool = True,\n save_to_file: bool = False,\n include_httpx_metadata: bool = False,\n ) -> Data:\n method = method.upper()\n if method not in {\"GET\", \"POST\", \"PATCH\", \"PUT\", \"DELETE\"}:\n msg = f\"Unsupported method: {method}\"\n raise ValueError(msg)\n\n # Process body using the new helper method\n processed_body = self._process_body(body)\n redirection_history = []\n\n try:\n response = await client.request(\n method,\n url,\n headers=headers,\n json=processed_body,\n timeout=timeout,\n follow_redirects=follow_redirects,\n )\n\n redirection_history = [\n {\n \"url\": redirect.headers.get(\"Location\", str(redirect.url)),\n \"status_code\": redirect.status_code,\n }\n for redirect in response.history\n ]\n\n is_binary, file_path = await self._response_info(response, with_file_path=save_to_file)\n response_headers = self._headers_to_dict(response.headers)\n\n metadata: dict[str, Any] = {\n \"source\": url,\n }\n\n if save_to_file:\n mode = \"wb\" if is_binary else \"w\"\n encoding = response.encoding if mode == \"w\" else None\n if file_path:\n # Ensure parent directory exists\n await aiofiles_os.makedirs(file_path.parent, exist_ok=True)\n if is_binary:\n async with aiofiles.open(file_path, \"wb\") as f:\n await f.write(response.content)\n await f.flush()\n else:\n async with aiofiles.open(file_path, \"w\", encoding=encoding) as f:\n await f.write(response.text)\n await f.flush()\n metadata[\"file_path\"] = str(file_path)\n\n if include_httpx_metadata:\n metadata.update(\n {\n \"headers\": headers,\n \"status_code\": response.status_code,\n \"response_headers\": response_headers,\n **({\"redirection_history\": redirection_history} if redirection_history else {}),\n }\n )\n return Data(data=metadata)\n\n if is_binary:\n result = response.content\n else:\n try:\n result = response.json()\n except json.JSONDecodeError:\n self.log(\"Failed to decode JSON response\")\n result = response.text.encode(\"utf-8\")\n\n metadata.update({\"result\": result})\n\n if include_httpx_metadata:\n metadata.update(\n {\n \"headers\": headers,\n \"status_code\": response.status_code,\n \"response_headers\": response_headers,\n **({\"redirection_history\": redirection_history} if redirection_history else {}),\n }\n )\n return Data(data=metadata)\n except httpx.TimeoutException:\n return Data(\n data={\n \"source\": url,\n \"headers\": headers,\n \"status_code\": 408,\n \"error\": \"Request timed out\",\n },\n )\n except Exception as exc: # noqa: BLE001\n self.log(f\"Error making request to {url}\")\n return Data(\n data={\n \"source\": url,\n \"headers\": headers,\n \"status_code\": 500,\n \"error\": str(exc),\n **({\"redirection_history\": redirection_history} if redirection_history else {}),\n },\n )\n\n def add_query_params(self, url: str, params: dict) -> str:\n url_parts = list(urlparse(url))\n query = dict(parse_qsl(url_parts[4]))\n query.update(params)\n url_parts[4] = urlencode(query)\n return urlunparse(url_parts)\n\n async def make_requests(self) -> list[Data]:\n method = self.method\n urls = [url.strip() for url in self.urls if url.strip()]\n headers = self.headers or {}\n body = self.body or {}\n timeout = self.timeout\n follow_redirects = self.follow_redirects\n save_to_file = self.save_to_file\n include_httpx_metadata = self.include_httpx_metadata\n\n if self.use_curl and self.curl:\n self._build_config = self.parse_curl(self.curl, dotdict())\n\n invalid_urls = [url for url in urls if not validators.url(url)]\n if invalid_urls:\n msg = f\"Invalid URLs provided: {invalid_urls}\"\n raise ValueError(msg)\n\n if isinstance(self.query_params, str):\n query_params = dict(parse_qsl(self.query_params))\n else:\n query_params = self.query_params.data if self.query_params else {}\n\n # Process headers here\n headers = self._process_headers(headers)\n\n # Process body\n body = self._process_body(body)\n\n bodies = [body] * len(urls)\n\n urls = [self.add_query_params(url, query_params) for url in urls]\n\n async with httpx.AsyncClient() as client:\n results = await asyncio.gather(\n *[\n self.make_request(\n client,\n method,\n u,\n headers,\n rec,\n timeout,\n follow_redirects=follow_redirects,\n save_to_file=save_to_file,\n include_httpx_metadata=include_httpx_metadata,\n )\n for u, rec in zip(urls, bodies, strict=False)\n ]\n )\n self.status = results\n return results\n\n async def _response_info(\n self, response: httpx.Response, *, with_file_path: bool = False\n ) -> tuple[bool, Path | None]:\n \"\"\"Determine the file path and whether the response content is binary.\n\n Args:\n response (Response): The HTTP response object.\n with_file_path (bool): Whether to save the response content to a file.\n\n Returns:\n Tuple[bool, Path | None]:\n A tuple containing a boolean indicating if the content is binary and the full file path (if applicable).\n \"\"\"\n content_type = response.headers.get(\"Content-Type\", \"\")\n is_binary = \"application/octet-stream\" in content_type or \"application/binary\" in content_type\n\n if not with_file_path:\n return is_binary, None\n\n component_temp_dir = Path(tempfile.gettempdir()) / self.__class__.__name__\n\n # Create directory asynchronously\n await aiofiles_os.makedirs(component_temp_dir, exist_ok=True)\n\n filename = None\n if \"Content-Disposition\" in response.headers:\n content_disposition = response.headers[\"Content-Disposition\"]\n filename_match = re.search(r'filename=\"(.+?)\"', content_disposition)\n if filename_match:\n extracted_filename = filename_match.group(1)\n filename = extracted_filename\n\n # Step 3: Infer file extension or use part of the request URL if no filename\n if not filename:\n # Extract the last segment of the URL path\n url_path = urlparse(str(response.request.url) if response.request else \"\").path\n base_name = Path(url_path).name # Get the last segment of the path\n if not base_name: # If the path ends with a slash or is empty\n base_name = \"response\"\n\n # Infer file extension\n content_type_to_extension = {\n \"text/plain\": \".txt\",\n \"application/json\": \".json\",\n \"image/jpeg\": \".jpg\",\n \"image/png\": \".png\",\n \"application/octet-stream\": \".bin\",\n }\n extension = content_type_to_extension.get(content_type, \".bin\" if is_binary else \".txt\")\n filename = f\"{base_name}{extension}\"\n\n # Step 4: Define the full file path\n file_path = component_temp_dir / filename\n\n # Step 5: Check if file exists asynchronously and handle accordingly\n try:\n # Try to create the file exclusively (x mode) to check existence\n async with aiofiles.open(file_path, \"x\") as _:\n pass # File created successfully, we can use this path\n except FileExistsError:\n # If file exists, append a timestamp to the filename\n timestamp = datetime.now(timezone.utc).strftime(\"%Y%m%d%H%M%S%f\")\n file_path = component_temp_dir / f\"{timestamp}-{filename}\"\n\n return is_binary, file_path\n\n def _headers_to_dict(self, headers: httpx.Headers) -> dict[str, str]:\n \"\"\"Convert HTTP headers to a dictionary with lowercased keys.\"\"\"\n return {k.lower(): v for k, v in headers.items()}\n\n def _process_headers(self, headers: Any) -> dict:\n \"\"\"Process the headers input into a valid dictionary.\n\n Args:\n headers: The headers to process, can be dict, str, or list\n Returns:\n Processed dictionary\n \"\"\"\n if headers is None:\n return {}\n if isinstance(headers, dict):\n return headers\n if isinstance(headers, list):\n processed_headers = {}\n try:\n for item in headers:\n if not self._is_valid_key_value_item(item):\n continue\n key = item[\"key\"]\n value = item[\"value\"]\n processed_headers[key] = value\n except (KeyError, TypeError, ValueError) as e:\n self.log(f\"Failed to process headers list: {e}\")\n return {} # Return empty dictionary instead of None\n return processed_headers\n return {}\n\n async def as_dataframe(self) -> DataFrame:\n \"\"\"Convert the API response data into a DataFrame.\n\n Returns:\n DataFrame: A DataFrame containing the API response data.\n \"\"\"\n data = await self.make_requests()\n return DataFrame(data)\n" + "value": "import json\nimport re\nimport tempfile\nfrom datetime import datetime, timezone\nfrom pathlib import Path\nfrom typing import Any\nfrom urllib.parse import parse_qsl, urlencode, urlparse, urlunparse\n\nimport aiofiles\nimport aiofiles.os as aiofiles_os\nimport httpx\nimport validators\n\nfrom langflow.base.curl.parse import parse_context\nfrom langflow.custom import Component\nfrom langflow.inputs.inputs import TabInput\nfrom langflow.io import (\n BoolInput,\n DataInput,\n DropdownInput,\n IntInput,\n MessageTextInput,\n MultilineInput,\n Output,\n TableInput,\n)\nfrom langflow.schema import Data\nfrom langflow.schema.dotdict import dotdict\nfrom langflow.services.deps import get_settings_service\nfrom langflow.utils.component_utils import set_current_fields, set_field_advanced, set_field_display\n\n# Define fields for each mode\nMODE_FIELDS = {\n \"URL\": [\n \"url_input\",\n \"method\",\n ],\n \"cURL\": [\"curl_input\"],\n}\n\n# Fields that should always be visible\nDEFAULT_FIELDS = [\"mode\"]\n\n\nclass APIRequestComponent(Component):\n display_name = \"API Request\"\n description = \"Make HTTP requests using URL or cURL commands.\"\n icon = \"Globe\"\n name = \"APIRequest\"\n\n inputs = [\n MessageTextInput(\n name=\"url_input\",\n display_name=\"URL\",\n info=\"Enter the URL for the request.\",\n advanced=False,\n tool_mode=True,\n ),\n MultilineInput(\n name=\"curl_input\",\n display_name=\"cURL\",\n info=(\n \"Paste a curl command to populate the fields. \"\n \"This will fill in the dictionary fields for headers and body.\"\n ),\n real_time_refresh=True,\n tool_mode=True,\n advanced=True,\n show=False,\n ),\n DropdownInput(\n name=\"method\",\n display_name=\"Method\",\n options=[\"GET\", \"POST\", \"PATCH\", \"PUT\", \"DELETE\"],\n value=\"GET\",\n info=\"The HTTP method to use.\",\n real_time_refresh=True,\n ),\n TabInput(\n name=\"mode\",\n display_name=\"Mode\",\n options=[\"URL\", \"cURL\"],\n value=\"URL\",\n info=\"Enable cURL mode to populate fields from a cURL command.\",\n real_time_refresh=True,\n ),\n DataInput(\n name=\"query_params\",\n display_name=\"Query Parameters\",\n info=\"The query parameters to append to the URL.\",\n advanced=True,\n ),\n TableInput(\n name=\"body\",\n display_name=\"Body\",\n info=\"The body to send with the request as a dictionary (for POST, PATCH, PUT).\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Key\",\n \"type\": \"str\",\n \"description\": \"Parameter name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"description\": \"Parameter value\",\n },\n ],\n value=[],\n input_types=[\"Data\"],\n advanced=True,\n real_time_refresh=True,\n ),\n TableInput(\n name=\"headers\",\n display_name=\"Headers\",\n info=\"The headers to send with the request\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Header\",\n \"type\": \"str\",\n \"description\": \"Header name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"type\": \"str\",\n \"description\": \"Header value\",\n },\n ],\n value=[{\"key\": \"User-Agent\", \"value\": get_settings_service().settings.user_agent}],\n advanced=True,\n input_types=[\"Data\"],\n real_time_refresh=True,\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n value=30,\n info=\"The timeout to use for the request.\",\n advanced=True,\n ),\n BoolInput(\n name=\"follow_redirects\",\n display_name=\"Follow Redirects\",\n value=True,\n info=\"Whether to follow http redirects.\",\n advanced=True,\n ),\n BoolInput(\n name=\"save_to_file\",\n display_name=\"Save to File\",\n value=False,\n info=\"Save the API response to a temporary file\",\n advanced=True,\n ),\n BoolInput(\n name=\"include_httpx_metadata\",\n display_name=\"Include HTTPx Metadata\",\n value=False,\n info=(\n \"Include properties such as headers, status_code, response_headers, \"\n \"and redirection_history in the output.\"\n ),\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"API Response\", name=\"data\", method=\"make_api_requests\"),\n ]\n\n def _parse_json_value(self, value: Any) -> Any:\n \"\"\"Parse a value that might be a JSON string.\"\"\"\n if not isinstance(value, str):\n return value\n\n try:\n parsed = json.loads(value)\n except json.JSONDecodeError:\n return value\n else:\n return parsed\n\n def _process_body(self, body: Any) -> dict:\n \"\"\"Process the body input into a valid dictionary.\"\"\"\n if body is None:\n return {}\n if isinstance(body, dict):\n return self._process_dict_body(body)\n if isinstance(body, str):\n return self._process_string_body(body)\n if isinstance(body, list):\n return self._process_list_body(body)\n return {}\n\n def _process_dict_body(self, body: dict) -> dict:\n \"\"\"Process dictionary body by parsing JSON values.\"\"\"\n return {k: self._parse_json_value(v) for k, v in body.items()}\n\n def _process_string_body(self, body: str) -> dict:\n \"\"\"Process string body by attempting JSON parse.\"\"\"\n try:\n return self._process_body(json.loads(body))\n except json.JSONDecodeError:\n return {\"data\": body}\n\n def _process_list_body(self, body: list) -> dict:\n \"\"\"Process list body by converting to key-value dictionary.\"\"\"\n processed_dict = {}\n try:\n for item in body:\n if not self._is_valid_key_value_item(item):\n continue\n key = item[\"key\"]\n value = self._parse_json_value(item[\"value\"])\n processed_dict[key] = value\n except (KeyError, TypeError, ValueError) as e:\n self.log(f\"Failed to process body list: {e}\")\n return {}\n return processed_dict\n\n def _is_valid_key_value_item(self, item: Any) -> bool:\n \"\"\"Check if an item is a valid key-value dictionary.\"\"\"\n return isinstance(item, dict) and \"key\" in item and \"value\" in item\n\n def parse_curl(self, curl: str, build_config: dotdict) -> dotdict:\n \"\"\"Parse a cURL command and update build configuration.\"\"\"\n try:\n parsed = parse_context(curl)\n\n # Update basic configuration\n url = parsed.url\n # Normalize URL before setting it\n url = self._normalize_url(url)\n\n build_config[\"url_input\"][\"value\"] = url\n build_config[\"method\"][\"value\"] = parsed.method.upper()\n\n # Process headers\n headers_list = [{\"key\": k, \"value\": v} for k, v in parsed.headers.items()]\n build_config[\"headers\"][\"value\"] = headers_list\n\n # Process body data\n if not parsed.data:\n build_config[\"body\"][\"value\"] = []\n elif parsed.data:\n try:\n json_data = json.loads(parsed.data)\n if isinstance(json_data, dict):\n body_list = [\n {\"key\": k, \"value\": json.dumps(v) if isinstance(v, dict | list) else str(v)}\n for k, v in json_data.items()\n ]\n build_config[\"body\"][\"value\"] = body_list\n else:\n build_config[\"body\"][\"value\"] = [{\"key\": \"data\", \"value\": json.dumps(json_data)}]\n except json.JSONDecodeError:\n build_config[\"body\"][\"value\"] = [{\"key\": \"data\", \"value\": parsed.data}]\n\n except Exception as exc:\n msg = f\"Error parsing curl: {exc}\"\n self.log(msg)\n raise ValueError(msg) from exc\n\n return build_config\n\n def _normalize_url(self, url: str) -> str:\n \"\"\"Normalize URL by adding https:// if no protocol is specified.\"\"\"\n if not url or not isinstance(url, str):\n msg = \"URL cannot be empty\"\n raise ValueError(msg)\n\n url = url.strip()\n if url.startswith((\"http://\", \"https://\")):\n return url\n return f\"https://{url}\"\n\n async def make_request(\n self,\n client: httpx.AsyncClient,\n method: str,\n url: str,\n headers: dict | None = None,\n body: Any = None,\n timeout: int = 5,\n *,\n follow_redirects: bool = True,\n save_to_file: bool = False,\n include_httpx_metadata: bool = False,\n ) -> Data:\n method = method.upper()\n if method not in {\"GET\", \"POST\", \"PATCH\", \"PUT\", \"DELETE\"}:\n msg = f\"Unsupported method: {method}\"\n raise ValueError(msg)\n\n processed_body = self._process_body(body)\n redirection_history = []\n\n try:\n # Prepare request parameters\n request_params = {\n \"method\": method,\n \"url\": url,\n \"headers\": headers,\n \"json\": processed_body,\n \"timeout\": timeout,\n \"follow_redirects\": follow_redirects,\n }\n response = await client.request(**request_params)\n\n redirection_history = [\n {\n \"url\": redirect.headers.get(\"Location\", str(redirect.url)),\n \"status_code\": redirect.status_code,\n }\n for redirect in response.history\n ]\n\n is_binary, file_path = await self._response_info(response, with_file_path=save_to_file)\n response_headers = self._headers_to_dict(response.headers)\n\n # Base metadata\n metadata = {\n \"source\": url,\n \"status_code\": response.status_code,\n \"response_headers\": response_headers,\n }\n\n if redirection_history:\n metadata[\"redirection_history\"] = redirection_history\n\n if save_to_file:\n mode = \"wb\" if is_binary else \"w\"\n encoding = response.encoding if mode == \"w\" else None\n if file_path:\n await aiofiles_os.makedirs(file_path.parent, exist_ok=True)\n if is_binary:\n async with aiofiles.open(file_path, \"wb\") as f:\n await f.write(response.content)\n await f.flush()\n else:\n async with aiofiles.open(file_path, \"w\", encoding=encoding) as f:\n await f.write(response.text)\n await f.flush()\n metadata[\"file_path\"] = str(file_path)\n\n if include_httpx_metadata:\n metadata.update({\"headers\": headers})\n return Data(data=metadata)\n\n # Handle response content\n if is_binary:\n result = response.content\n else:\n try:\n result = response.json()\n except json.JSONDecodeError:\n self.log(\"Failed to decode JSON response\")\n result = response.text.encode(\"utf-8\")\n\n metadata[\"result\"] = result\n\n if include_httpx_metadata:\n metadata.update({\"headers\": headers})\n\n return Data(data=metadata)\n except (httpx.HTTPError, httpx.RequestError, httpx.TimeoutException) as exc:\n self.log(f\"Error making request to {url}\")\n return Data(\n data={\n \"source\": url,\n \"headers\": headers,\n \"status_code\": 500,\n \"error\": str(exc),\n **({\"redirection_history\": redirection_history} if redirection_history else {}),\n },\n )\n\n def add_query_params(self, url: str, params: dict) -> str:\n \"\"\"Add query parameters to URL efficiently.\"\"\"\n if not params:\n return url\n url_parts = list(urlparse(url))\n query = dict(parse_qsl(url_parts[4]))\n query.update(params)\n url_parts[4] = urlencode(query)\n return urlunparse(url_parts)\n\n def _headers_to_dict(self, headers: httpx.Headers) -> dict[str, str]:\n \"\"\"Convert HTTP headers to a dictionary with lowercased keys.\"\"\"\n return {k.lower(): v for k, v in headers.items()}\n\n def _process_headers(self, headers: Any) -> dict:\n \"\"\"Process the headers input into a valid dictionary.\"\"\"\n if headers is None:\n return {}\n if isinstance(headers, dict):\n return headers\n if isinstance(headers, list):\n return {item[\"key\"]: item[\"value\"] for item in headers if self._is_valid_key_value_item(item)}\n return {}\n\n async def make_api_requests(self) -> Data:\n \"\"\"Make HTTP request with optimized parameter handling.\"\"\"\n method = self.method\n url = self.url_input.strip() if isinstance(self.url_input, str) else \"\"\n headers = self.headers or {}\n body = self.body or {}\n timeout = self.timeout\n follow_redirects = self.follow_redirects\n save_to_file = self.save_to_file\n include_httpx_metadata = self.include_httpx_metadata\n\n # if self.mode == \"cURL\" and self.curl_input:\n # self._build_config = self.parse_curl(self.curl_input, dotdict())\n # # After parsing curl, get the normalized URL\n # url = self._build_config[\"url_input\"][\"value\"]\n\n # Normalize URL before validation\n url = self._normalize_url(url)\n\n # Validate URL\n if not validators.url(url):\n msg = f\"Invalid URL provided: {url}\"\n raise ValueError(msg)\n\n # Process query parameters\n if isinstance(self.query_params, str):\n query_params = dict(parse_qsl(self.query_params))\n else:\n query_params = self.query_params.data if self.query_params else {}\n\n # Process headers and body\n headers = self._process_headers(headers)\n body = self._process_body(body)\n url = self.add_query_params(url, query_params)\n\n async with httpx.AsyncClient() as client:\n result = await self.make_request(\n client,\n method,\n url,\n headers,\n body,\n timeout,\n follow_redirects=follow_redirects,\n save_to_file=save_to_file,\n include_httpx_metadata=include_httpx_metadata,\n )\n self.status = result\n return result\n\n def update_build_config(self, build_config: dotdict, field_value: Any, field_name: str | None = None) -> dotdict:\n \"\"\"Update the build config based on the selected mode.\"\"\"\n if field_name != \"mode\":\n return build_config\n\n # print(f\"Current mode: {field_value}\")\n if field_value == \"cURL\":\n set_field_display(build_config, \"curl_input\", value=True)\n build_config = self.parse_curl(self.curl_input, build_config)\n else:\n set_field_display(build_config, \"curl_input\", value=False)\n\n return set_current_fields(\n build_config=build_config,\n action_fields=MODE_FIELDS,\n selected_action=field_value,\n default_fields=DEFAULT_FIELDS,\n func=set_field_advanced,\n default_value=True,\n )\n\n async def _response_info(\n self, response: httpx.Response, *, with_file_path: bool = False\n ) -> tuple[bool, Path | None]:\n \"\"\"Determine the file path and whether the response content is binary.\n\n Args:\n response (Response): The HTTP response object.\n with_file_path (bool): Whether to save the response content to a file.\n\n Returns:\n Tuple[bool, Path | None]:\n A tuple containing a boolean indicating if the content is binary and the full file path (if applicable).\n \"\"\"\n content_type = response.headers.get(\"Content-Type\", \"\")\n is_binary = \"application/octet-stream\" in content_type or \"application/binary\" in content_type\n\n if not with_file_path:\n return is_binary, None\n\n component_temp_dir = Path(tempfile.gettempdir()) / self.__class__.__name__\n\n # Create directory asynchronously\n await aiofiles_os.makedirs(component_temp_dir, exist_ok=True)\n\n filename = None\n if \"Content-Disposition\" in response.headers:\n content_disposition = response.headers[\"Content-Disposition\"]\n filename_match = re.search(r'filename=\"(.+?)\"', content_disposition)\n if filename_match:\n extracted_filename = filename_match.group(1)\n filename = extracted_filename\n\n # Step 3: Infer file extension or use part of the request URL if no filename\n if not filename:\n # Extract the last segment of the URL path\n url_path = urlparse(str(response.request.url) if response.request else \"\").path\n base_name = Path(url_path).name # Get the last segment of the path\n if not base_name: # If the path ends with a slash or is empty\n base_name = \"response\"\n\n # Infer file extension\n content_type_to_extension = {\n \"text/plain\": \".txt\",\n \"application/json\": \".json\",\n \"image/jpeg\": \".jpg\",\n \"image/png\": \".png\",\n \"application/octet-stream\": \".bin\",\n }\n extension = content_type_to_extension.get(content_type, \".bin\" if is_binary else \".txt\")\n filename = f\"{base_name}{extension}\"\n\n # Step 4: Define the full file path\n file_path = component_temp_dir / filename\n\n # Step 5: Check if file exists asynchronously and handle accordingly\n try:\n # Try to create the file exclusively (x mode) to check existence\n async with aiofiles.open(file_path, \"x\") as _:\n pass # File created successfully, we can use this path\n except FileExistsError:\n # If file exists, append a timestamp to the filename\n timestamp = datetime.now(timezone.utc).strftime(\"%Y%m%d%H%M%S%f\")\n file_path = component_temp_dir / f\"{timestamp}-{filename}\"\n\n return is_binary, file_path\n" }, - "curl": { + "curl_input": { "_input_type": "MultilineInput", "advanced": true, "copy_field": false, @@ -936,11 +936,11 @@ "list_add_label": "Add More", "load_from_db": false, "multiline": true, - "name": "curl", + "name": "curl_input", "placeholder": "", "real_time_refresh": true, "required": false, - "show": true, + "show": false, "title_case": false, "tool_mode": true, "trace_as_input": true, @@ -1065,6 +1065,27 @@ "type": "str", "value": "GET" }, + "mode": { + "_input_type": "TabInput", + "advanced": false, + "display_name": "Mode", + "dynamic": false, + "info": "Enable cURL mode to populate fields from a cURL command.", + "name": "mode", + "options": [ + "URL", + "cURL" + ], + "placeholder": "", + "real_time_refresh": true, + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "type": "tab", + "value": "URL" + }, "query_params": { "_input_type": "DataInput", "advanced": true, @@ -1197,19 +1218,19 @@ } ] }, - "urls": { + "url_input": { "_input_type": "MessageTextInput", "advanced": false, - "display_name": "URLs", + "display_name": "URL", "dynamic": false, - "info": "Enter one or more URLs, separated by commas.", + "info": "Enter the URL for the request.", "input_types": [ "Message" ], - "list": true, + "list": false, "list_add_label": "Add More", "load_from_db": false, - "name": "urls", + "name": "url_input", "placeholder": "", "required": false, "show": true, @@ -1219,25 +1240,6 @@ "trace_as_metadata": true, "type": "str", "value": "" - }, - "use_curl": { - "_input_type": "BoolInput", - "advanced": false, - "display_name": "Use cURL", - "dynamic": false, - "info": "Enable cURL mode to populate fields from a cURL command.", - "list": false, - "list_add_label": "Add More", - "name": "use_curl", - "placeholder": "", - "real_time_refresh": true, - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "type": "bool", - "value": false } }, "tool_mode": true diff --git a/src/backend/tests/unit/components/data/test_api_request_component.py b/src/backend/tests/unit/components/data/test_api_request_component.py index dd7d8210b..120a518ec 100644 --- a/src/backend/tests/unit/components/data/test_api_request_component.py +++ b/src/backend/tests/unit/components/data/test_api_request_component.py @@ -1,5 +1,4 @@ from pathlib import Path -from unittest.mock import patch import aiofiles import aiofiles.os @@ -8,7 +7,8 @@ import pytest import respx from httpx import Response 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 @@ -23,16 +23,17 @@ class TestAPIRequestComponent(ComponentTestBaseWithoutClient): def default_kwargs(self): """Return the default kwargs for the component.""" return { - "urls": ["https://example.com/api/test"], + "url_input": "https://example.com/api/test", "method": "GET", - "headers": [], + "headers": [{"key": "User-Agent", "value": "test-agent"}], "body": [], - "timeout": 5, + "timeout": 30, "follow_redirects": True, "save_to_file": False, "include_httpx_metadata": False, - "use_curl": False, - "curl": "", + "mode": "URL", + "curl_input": "", + "query_params": {}, } @pytest.fixture @@ -50,16 +51,18 @@ class TestAPIRequestComponent(ComponentTestBaseWithoutClient): curl_cmd = ( "curl -X GET https://example.com/api/test -H 'Content-Type: application/json' -d '{\"key\": \"value\"}'" ) - build_config = { - "method": {"value": ""}, - "urls": {"value": []}, - "headers": {}, - "body": {}, - } + build_config = dotdict( + { + "method": {"value": ""}, + "url_input": {"value": ""}, + "headers": {"value": []}, + "body": {"value": []}, + } + ) new_build_config = component.parse_curl(curl_cmd, build_config.copy()) 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["body"]["value"] == [{"key": "key", "value": "value"}] @@ -78,7 +81,6 @@ class TestAPIRequestComponent(ComponentTestBaseWithoutClient): assert isinstance(result, Data) assert result.data["source"] == url - # The JSON response is nested in the 'result' key assert "result" in result.data assert result.data["result"]["key"] == "value" @@ -145,6 +147,7 @@ class TestAPIRequestComponent(ComponentTestBaseWithoutClient): assert isinstance(result, Data) assert result.data["source"] == url + assert result.data["result"] == binary_content @respx.mock async def test_make_request_timeout(self, component): @@ -160,8 +163,8 @@ class TestAPIRequestComponent(ComponentTestBaseWithoutClient): ) assert isinstance(result, Data) - assert result.data["status_code"] == 408 - assert result.data["error"] == "Request timed out" + assert result.data["status_code"] == 500 + assert "Request timed out" in result.data["error"] @respx.mock async def test_make_request_with_redirects(self, component): @@ -235,55 +238,55 @@ class TestAPIRequestComponent(ComponentTestBaseWithoutClient): assert "param1=value1" in result assert "param2=value2" in result - async def test_output_formats(self, component): - # Test different output formats - with patch.object(component, "make_requests") as mock_make_requests: - mock_make_requests.return_value = [Data(data={"key": "value"})] + async def test_make_api_requests(self, component): + # Test making API requests + url = "https://example.com/api/test" + response_data = {"key": "value"} - # Test DataFrame output - df_result = await component.as_dataframe() - assert isinstance(df_result, DataFrame) + with respx.mock: + respx.get(url).mock(return_value=Response(200, json=response_data)) - # Test Data output - to_data returns a list of Data objects - test_data = {"test": "value"} - data_result = component.to_data(test_data) - assert isinstance(data_result, list) - assert all(isinstance(item, Data) for item in data_result) + result = await component.make_api_requests() + + assert isinstance(result, Data) + assert result.data["source"] == url + assert result.data["result"]["key"] == "value" async def test_invalid_urls(self, component): # Test invalid URL handling - component.urls = ["not_a_valid_url"] - with pytest.raises(ValueError, match="Invalid URLs provided"): - await component.make_requests() + component.url_input = "not_a_valid_url" + with pytest.raises(ValueError, match="Invalid URL provided"): + await component.make_api_requests() async def test_update_build_config(self, component): # Test build config updates - build_config = { - "method": {"value": "GET", "advanced": False}, - "urls": {"value": [], "advanced": False}, - "headers": {"value": [], "advanced": True}, - "body": {"value": [], "advanced": True}, - "use_curl": {"value": False, "advanced": False}, - "curl": {"value": "", "advanced": True}, - "timeout": {"value": 5, "advanced": True}, - "follow_redirects": {"value": True, "advanced": True}, - "save_to_file": {"value": False, "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" + build_config = dotdict( + { + "method": {"value": "GET", "advanced": False}, + "url_input": {"value": "", "advanced": False}, + "headers": {"value": [], "advanced": True}, + "body": {"value": [], "advanced": True}, + "mode": {"value": "URL", "advanced": False}, + "curl_input": {"value": "curl -X GET https://example.com/api/test", "advanced": True}, + "timeout": {"value": 30, "advanced": True}, + "follow_redirects": {"value": True, "advanced": True}, + "save_to_file": {"value": False, "advanced": True}, + "include_httpx_metadata": {"value": False, "advanced": True}, + "query_params": {"value": {}, "advanced": True}, + } ) - assert updated["curl"]["advanced"] is False - assert updated["urls"]["advanced"] is True - # Test method update - updated = component.update_build_config( - build_config=build_config.copy(), field_value="POST", field_name="method" - ) - assert updated["body"]["advanced"] is False + # Test URL mode + updated = component.update_build_config(build_config=build_config.copy(), field_value="URL", field_name="mode") + assert updated["curl_input"]["advanced"] is True + assert updated["url_input"]["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 async def test_error_handling(self, component): @@ -307,3 +310,24 @@ class TestAPIRequestComponent(ComponentTestBaseWithoutClient): method="INVALID", 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" diff --git a/src/frontend/tests/core/features/filterSidebar.spec.ts b/src/frontend/tests/core/features/filterSidebar.spec.ts index afb38e5fd..18f3d5d28 100644 --- a/src/frontend/tests/core/features/filterSidebar.spec.ts +++ b/src/frontend/tests/core/features/filterSidebar.spec.ts @@ -32,12 +32,12 @@ test( await adjustScreenView(page); await page.waitForSelector( - '[data-testid="handle-apirequest-shownode-urls-left"]', + '[data-testid="handle-apirequest-shownode-url-left"]', { timeout: 3000, }, ); - await page.getByTestId("handle-apirequest-shownode-urls-left").click(); + await page.getByTestId("handle-apirequest-shownode-url-left").click(); await page.waitForTimeout(500); diff --git a/src/frontend/tests/extended/features/edit-tools.spec.ts b/src/frontend/tests/extended/features/edit-tools.spec.ts index d55717228..70d456595 100644 --- a/src/frontend/tests/extended/features/edit-tools.spec.ts +++ b/src/frontend/tests/extended/features/edit-tools.spec.ts @@ -9,17 +9,17 @@ test( await page.getByTestId("blank-flow").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, }); await page - .getByTestId("dataAPI Request") + .getByTestId("dataURL") .hover() .then(async () => { - await page.getByTestId("add-component-button-api-request").click(); + await page.getByTestId("add-component-button-url").click(); }); await page.waitForSelector( @@ -44,7 +44,7 @@ test( 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(); @@ -200,7 +200,11 @@ test( await page.getByText("Close").last().click(); 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); }, );