mirror of
https://github.com/langgenius/dify.git
synced 2026-03-04 15:26:21 +08:00
261 lines
10 KiB
Python
261 lines
10 KiB
Python
import logging
|
|
import mimetypes
|
|
from collections.abc import Callable, Mapping, Sequence
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from dify_graph.enums import NodeType, WorkflowNodeExecutionStatus
|
|
from dify_graph.file import File, FileTransferMethod
|
|
from dify_graph.node_events import NodeRunResult
|
|
from dify_graph.nodes.base import variable_template_parser
|
|
from dify_graph.nodes.base.entities import VariableSelector
|
|
from dify_graph.nodes.base.node import Node
|
|
from dify_graph.nodes.http_request.executor import Executor
|
|
from dify_graph.nodes.protocols import FileManagerProtocol, HttpClientProtocol, ToolFileManagerProtocol
|
|
from dify_graph.variables.segments import ArrayFileSegment
|
|
from factories import file_factory
|
|
|
|
from .config import build_http_request_config, resolve_http_request_config
|
|
from .entities import (
|
|
HTTP_REQUEST_CONFIG_FILTER_KEY,
|
|
HttpRequestNodeConfig,
|
|
HttpRequestNodeData,
|
|
HttpRequestNodeTimeout,
|
|
Response,
|
|
)
|
|
from .exc import HttpRequestNodeError, RequestBodyError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
if TYPE_CHECKING:
|
|
from dify_graph.entities import GraphInitParams
|
|
from dify_graph.runtime import GraphRuntimeState
|
|
|
|
|
|
class HttpRequestNode(Node[HttpRequestNodeData]):
|
|
node_type = NodeType.HTTP_REQUEST
|
|
|
|
def __init__(
|
|
self,
|
|
id: str,
|
|
config: Mapping[str, Any],
|
|
graph_init_params: "GraphInitParams",
|
|
graph_runtime_state: "GraphRuntimeState",
|
|
*,
|
|
http_request_config: HttpRequestNodeConfig,
|
|
http_client: HttpClientProtocol,
|
|
tool_file_manager_factory: Callable[[], ToolFileManagerProtocol],
|
|
file_manager: FileManagerProtocol,
|
|
) -> None:
|
|
super().__init__(
|
|
id=id,
|
|
config=config,
|
|
graph_init_params=graph_init_params,
|
|
graph_runtime_state=graph_runtime_state,
|
|
)
|
|
|
|
self._http_request_config = http_request_config
|
|
self._http_client = http_client
|
|
self._tool_file_manager_factory = tool_file_manager_factory
|
|
self._file_manager = file_manager
|
|
|
|
@classmethod
|
|
def get_default_config(cls, filters: Mapping[str, object] | None = None) -> Mapping[str, object]:
|
|
if not filters or HTTP_REQUEST_CONFIG_FILTER_KEY not in filters:
|
|
http_request_config = build_http_request_config()
|
|
else:
|
|
http_request_config = resolve_http_request_config(filters)
|
|
default_timeout = http_request_config.default_timeout()
|
|
return {
|
|
"type": "http-request",
|
|
"config": {
|
|
"method": "get",
|
|
"authorization": {
|
|
"type": "no-auth",
|
|
},
|
|
"body": {"type": "none"},
|
|
"timeout": {
|
|
**default_timeout.model_dump(),
|
|
"max_connect_timeout": http_request_config.max_connect_timeout,
|
|
"max_read_timeout": http_request_config.max_read_timeout,
|
|
"max_write_timeout": http_request_config.max_write_timeout,
|
|
},
|
|
"ssl_verify": http_request_config.ssl_verify,
|
|
},
|
|
"retry_config": {
|
|
"max_retries": http_request_config.ssrf_default_max_retries,
|
|
"retry_interval": 0.5 * (2**2),
|
|
"retry_enabled": True,
|
|
},
|
|
}
|
|
|
|
@classmethod
|
|
def version(cls) -> str:
|
|
return "1"
|
|
|
|
def _run(self) -> NodeRunResult:
|
|
process_data = {}
|
|
try:
|
|
http_executor = Executor(
|
|
node_data=self.node_data,
|
|
timeout=self._get_request_timeout(self.node_data),
|
|
variable_pool=self.graph_runtime_state.variable_pool,
|
|
http_request_config=self._http_request_config,
|
|
max_retries=0,
|
|
ssl_verify=self.node_data.ssl_verify,
|
|
http_client=self._http_client,
|
|
file_manager=self._file_manager,
|
|
)
|
|
process_data["request"] = http_executor.to_log()
|
|
|
|
response = http_executor.invoke()
|
|
files = self.extract_files(url=http_executor.url, response=response)
|
|
if not response.response.is_success and (self.error_strategy or self.retry):
|
|
return NodeRunResult(
|
|
status=WorkflowNodeExecutionStatus.FAILED,
|
|
outputs={
|
|
"status_code": response.status_code,
|
|
"body": response.text if not files.value else "",
|
|
"headers": response.headers,
|
|
"files": files,
|
|
},
|
|
process_data={
|
|
"request": http_executor.to_log(),
|
|
},
|
|
error=f"Request failed with status code {response.status_code}",
|
|
error_type="HTTPResponseCodeError",
|
|
)
|
|
return NodeRunResult(
|
|
status=WorkflowNodeExecutionStatus.SUCCEEDED,
|
|
outputs={
|
|
"status_code": response.status_code,
|
|
"body": response.text if not files.value else "",
|
|
"headers": response.headers,
|
|
"files": files,
|
|
},
|
|
process_data={
|
|
"request": http_executor.to_log(),
|
|
},
|
|
)
|
|
except HttpRequestNodeError as e:
|
|
logger.warning("http request node %s failed to run: %s", self._node_id, e)
|
|
return NodeRunResult(
|
|
status=WorkflowNodeExecutionStatus.FAILED,
|
|
error=str(e),
|
|
process_data=process_data,
|
|
error_type=type(e).__name__,
|
|
)
|
|
|
|
def _get_request_timeout(self, node_data: HttpRequestNodeData) -> HttpRequestNodeTimeout:
|
|
default_timeout = self._http_request_config.default_timeout()
|
|
timeout = node_data.timeout
|
|
if timeout is None:
|
|
return default_timeout
|
|
|
|
return HttpRequestNodeTimeout(
|
|
connect=timeout.connect or default_timeout.connect,
|
|
read=timeout.read or default_timeout.read,
|
|
write=timeout.write or default_timeout.write,
|
|
)
|
|
|
|
@classmethod
|
|
def _extract_variable_selector_to_variable_mapping(
|
|
cls,
|
|
*,
|
|
graph_config: Mapping[str, Any],
|
|
node_id: str,
|
|
node_data: Mapping[str, Any],
|
|
) -> Mapping[str, Sequence[str]]:
|
|
# Create typed NodeData from dict
|
|
typed_node_data = HttpRequestNodeData.model_validate(node_data)
|
|
|
|
selectors: list[VariableSelector] = []
|
|
selectors += variable_template_parser.extract_selectors_from_template(typed_node_data.url)
|
|
selectors += variable_template_parser.extract_selectors_from_template(typed_node_data.headers)
|
|
selectors += variable_template_parser.extract_selectors_from_template(typed_node_data.params)
|
|
if typed_node_data.body:
|
|
body_type = typed_node_data.body.type
|
|
data = typed_node_data.body.data
|
|
match body_type:
|
|
case "none":
|
|
pass
|
|
case "binary":
|
|
if len(data) != 1:
|
|
raise RequestBodyError("invalid body data, should have only one item")
|
|
selector = data[0].file
|
|
selectors.append(VariableSelector(variable="#" + ".".join(selector) + "#", value_selector=selector))
|
|
case "json" | "raw-text":
|
|
if len(data) != 1:
|
|
raise RequestBodyError("invalid body data, should have only one item")
|
|
selectors += variable_template_parser.extract_selectors_from_template(data[0].key)
|
|
selectors += variable_template_parser.extract_selectors_from_template(data[0].value)
|
|
case "x-www-form-urlencoded":
|
|
for item in data:
|
|
selectors += variable_template_parser.extract_selectors_from_template(item.key)
|
|
selectors += variable_template_parser.extract_selectors_from_template(item.value)
|
|
case "form-data":
|
|
for item in data:
|
|
selectors += variable_template_parser.extract_selectors_from_template(item.key)
|
|
if item.type == "text":
|
|
selectors += variable_template_parser.extract_selectors_from_template(item.value)
|
|
elif item.type == "file":
|
|
selectors.append(
|
|
VariableSelector(variable="#" + ".".join(item.file) + "#", value_selector=item.file)
|
|
)
|
|
|
|
mapping = {}
|
|
for selector_iter in selectors:
|
|
mapping[node_id + "." + selector_iter.variable] = selector_iter.value_selector
|
|
|
|
return mapping
|
|
|
|
def extract_files(self, url: str, response: Response) -> ArrayFileSegment:
|
|
"""
|
|
Extract files from response by checking both Content-Type header and URL
|
|
"""
|
|
files: list[File] = []
|
|
is_file = response.is_file
|
|
content_type = response.content_type
|
|
content = response.content
|
|
parsed_content_disposition = response.parsed_content_disposition
|
|
content_disposition_type = None
|
|
|
|
if not is_file:
|
|
return ArrayFileSegment(value=[])
|
|
|
|
if parsed_content_disposition:
|
|
content_disposition_filename = parsed_content_disposition.get_filename()
|
|
if content_disposition_filename:
|
|
# If filename is available from content-disposition, use it to guess the content type
|
|
content_disposition_type = mimetypes.guess_type(content_disposition_filename)[0]
|
|
|
|
# Guess file extension from URL or Content-Type header
|
|
filename = url.split("?")[0].split("/")[-1] or ""
|
|
mime_type = (
|
|
content_disposition_type or content_type or mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
)
|
|
tool_file_manager = self._tool_file_manager_factory()
|
|
|
|
tool_file = tool_file_manager.create_file_by_raw(
|
|
user_id=self.user_id,
|
|
tenant_id=self.tenant_id,
|
|
conversation_id=None,
|
|
file_binary=content,
|
|
mimetype=mime_type,
|
|
)
|
|
|
|
mapping = {
|
|
"tool_file_id": tool_file.id,
|
|
"transfer_method": FileTransferMethod.TOOL_FILE,
|
|
}
|
|
file = file_factory.build_from_mapping(
|
|
mapping=mapping,
|
|
tenant_id=self.tenant_id,
|
|
)
|
|
files.append(file)
|
|
|
|
return ArrayFileSegment(value=files)
|
|
|
|
@property
|
|
def retry(self) -> bool:
|
|
return self.node_data.retry_config.retry_enabled
|