- template_loader.rs: YAML→DagDef加载器+校验(空节点/未知类型/依赖环/边引用) - GitNode: 分支/checkout/commit/merge/push/status/log(git CLI封装) - HTTPNode: GET/POST/PUT/DELETE(reqwest,30s超时) - NotifyNode: 桌面通知(日志)+ Webhook(飞书/钉钉/自定义) - 8个模板加载器测试+37个节点测试全绿
320 lines
9.9 KiB
Rust
320 lines
9.9 KiB
Rust
//! 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<Self> {
|
||
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<String>,
|
||
/// 超时秒数(缺省 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<HttpParams> {
|
||
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);
|
||
}
|
||
}
|