//! HTTP 节点 — 发起 HTTP 请求(GET/POST/PUT/DELETE) //! //! 使用 reqwest(与 df-ai 同一版本 0.12)。config 提供 method/url/headers/body/timeout_secs。 //! 输出 status_code + body + (可选)响应头子集。 use std::time::Duration; use async_trait::async_trait; use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema}; /// HTTP 节点 pub struct HttpNode; /// 支持的 HTTP 方法 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HttpMethod { Get, Post, Put, Delete, } impl HttpMethod { /// 从 config.method 字符串解析,大小写不敏感,非法值返回 Err。缺省返回 GET。 pub fn parse(raw: Option<&str>) -> anyhow::Result { match raw.map(|s| s.to_ascii_uppercase()).as_deref() { None | Some("GET") => Ok(HttpMethod::Get), Some("POST") => Ok(HttpMethod::Post), Some("PUT") => Ok(HttpMethod::Put), Some("DELETE") => Ok(HttpMethod::Delete), Some(other) => anyhow::bail!( "HttpNode 非法 method: {}(合法值: GET|POST|PUT|DELETE)", other ), } } fn as_str(&self) -> &'static str { match self { HttpMethod::Get => "GET", HttpMethod::Post => "POST", HttpMethod::Put => "PUT", HttpMethod::Delete => "DELETE", } } } /// 从 NodeContext.config 解析出的 HttpNode 参数。 /// 抽离此结构便于单元测试 config 解析逻辑(无需发起真实网络请求)。 #[derive(Debug, Clone)] pub struct HttpParams { pub method: HttpMethod, pub url: String, /// 请求头(JSON 对象 → Vec<(name, value)>) pub headers: Vec<(String, String)>, /// 请求体(可选) pub body: Option, /// 超时秒数(缺省 30) pub timeout_secs: u64, } /// 默认超时 const DEFAULT_TIMEOUT_SECS: u64 = 30; /// 从 config JSON 解析 HttpParams。url 必填;method 缺省 GET;timeout 缺省 30。 pub fn parse_params(config: &serde_json::Value) -> anyhow::Result { let method = HttpMethod::parse(config.get("method").and_then(|v| v.as_str()))?; let url = config .get("url") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("HttpNode 缺少必填参数: url"))?; if url.trim().is_empty() { anyhow::bail!("HttpNode url 不能为空"); } // headers:JSON 对象 → 有序 (name, value) 列表。非对象/非字符串值忽略并 warn。 let mut headers = Vec::new(); if let Some(obj) = config.get("headers").and_then(|v| v.as_object()) { for (k, v) in obj { match v.as_str() { Some(s) => headers.push((k.clone(), s.to_string())), None => { tracing::warn!( header = %k, "HttpNode headers 中 header 值非字符串,忽略" ); } } } } let body = config .get("body") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let timeout_secs = config .get("timeout_secs") .and_then(|v| v.as_u64()) .unwrap_or(DEFAULT_TIMEOUT_SECS); Ok(HttpParams { method, url: url.to_string(), headers, body, timeout_secs, }) } #[async_trait] impl Node for HttpNode { async fn execute(&self, ctx: NodeContext) -> NodeResult { tracing::info!("HttpNode 执行: node_id={}", ctx.node_id); let params = parse_params(&ctx.config)?; tracing::info!( method = params.method.as_str(), url = %params.url, timeout_secs = params.timeout_secs, "HttpNode 发起请求" ); let client = reqwest::Client::builder() .timeout(Duration::from_secs(params.timeout_secs)) .build()?; let mut req = match params.method { HttpMethod::Get => client.get(¶ms.url), HttpMethod::Post => client.post(¶ms.url), HttpMethod::Put => client.put(¶ms.url), HttpMethod::Delete => client.delete(¶ms.url), }; for (name, value) in ¶ms.headers { req = req.header(name, value); } if let Some(body) = ¶ms.body { req = req.body(body.clone()); } let response = req.send().await?; let status_code = response.status().as_u16(); let content_type = response .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); let body_text = response.text().await?; tracing::info!( method = params.method.as_str(), url = %params.url, status_code, body_len = body_text.len(), "HttpNode 完成" ); Ok(NodeOutput::from_value(serde_json::json!({ "method": params.method.as_str(), "url": params.url, "status_code": status_code, "body": body_text, "content_type": content_type, }))) } fn schema(&self) -> NodeSchema { NodeSchema { params: serde_json::json!({ "type": "object", "properties": { "method": { "type": "string", "enum": ["GET", "POST", "PUT", "DELETE"], "default": "GET" }, "url": { "type": "string", "description": "请求 URL" }, "headers": { "type": "object", "description": "请求头 JSON 对象", "additionalProperties": { "type": "string" } }, "body": { "type": "string", "description": "请求体(可选)" }, "timeout_secs": { "type": "integer", "default": 30 } }, "required": ["url"] }), output: serde_json::json!({ "type": "object", "properties": { "method": { "type": "string" }, "url": { "type": "string" }, "status_code": { "type": "integer" }, "body": { "type": "string" }, "content_type": { "type": "string" } } }), } } fn node_type(&self) -> &str { "http" } } #[cfg(test)] mod tests { use super::*; use serde_json::json; // ── HttpMethod::parse ── #[test] fn method_defaults_to_get_when_absent() { assert_eq!(HttpMethod::parse(None).unwrap(), HttpMethod::Get); } #[test] fn method_case_insensitive() { assert_eq!(HttpMethod::parse(Some("get")).unwrap(), HttpMethod::Get); assert_eq!(HttpMethod::parse(Some("Post")).unwrap(), HttpMethod::Post); assert_eq!(HttpMethod::parse(Some("DELETE")).unwrap(), HttpMethod::Delete); assert_eq!(HttpMethod::parse(Some("pUt")).unwrap(), HttpMethod::Put); } #[test] fn method_invalid_errors() { let err = HttpMethod::parse(Some("PATCH")).unwrap_err().to_string(); assert!(err.contains("非法 method"), "实际: {}", err); assert!(err.contains("PATCH")); } // ── parse_params(config) ── #[test] fn params_missing_url_errors() { let err = parse_params(&json!({ "method": "GET" })) .unwrap_err() .to_string(); assert!(err.contains("url"), "实际: {}", err); } #[test] fn params_empty_url_errors() { let err = parse_params(&json!({ "url": " " })).unwrap_err().to_string(); assert!(err.contains("空"), "实际: {}", err); } #[test] fn params_defaults_method_and_timeout() { let p = parse_params(&json!({ "url": "https://example.com" })).unwrap(); assert_eq!(p.method, HttpMethod::Get); assert_eq!(p.timeout_secs, DEFAULT_TIMEOUT_SECS); assert!(p.headers.is_empty()); assert_eq!(p.body, None); } #[test] fn params_custom_timeout() { let p = parse_params(&json!({ "url": "https://x", "timeout_secs": 5 })).unwrap(); assert_eq!(p.timeout_secs, 5); } #[test] fn params_parses_headers_object() { let p = parse_params(&json!({ "url": "https://x", "headers": { "Authorization": "Bearer abc", "X-Trace-Id": "123" } })) .unwrap(); // headers 顺序由 serde_json BTreeMap 保证;只校验集合 let map: std::collections::HashMap<&str, &str> = p.headers.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); assert_eq!(map.get("Authorization"), Some(&"Bearer abc")); assert_eq!(map.get("X-Trace-Id"), Some(&"123")); } #[test] fn params_non_string_header_value_skipped() { // 非字符串值不应炸,应被跳过 let p = parse_params(&json!({ "url": "https://x", "headers": { "X-Num": 123, "X-Ok": "yes" } })) .unwrap(); let names: Vec<&str> = p.headers.iter().map(|(k, _)| k.as_str()).collect(); assert!(names.contains(&"X-Ok")); assert!(!names.contains(&"X-Num"), "非字符串 header 应被跳过"); } #[test] fn params_parses_body() { let p = parse_params(&json!({ "url": "https://x", "method": "POST", "body": "{\"k\":1}" })) .unwrap(); assert_eq!(p.method, HttpMethod::Post); assert_eq!(p.body.as_deref(), Some("{\"k\":1}")); } #[test] fn params_invalid_method_propagates() { let err = parse_params(&json!({ "url": "https://x", "method": "TRACE" })) .unwrap_err() .to_string(); assert!(err.contains("非法 method"), "实际: {}", err); } }