Skip to main content

ironflow_engine/config/
http.rs

1//! [`HttpConfig`] — serializable configuration for an HTTP step.
2
3use ironflow_core::retry::RetryPolicy;
4use ironflow_core::trace_context::WorkflowTraceContext;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8/// Serializable configuration for an HTTP step.
9///
10/// # Examples
11///
12/// ```
13/// use ironflow_engine::config::HttpConfig;
14///
15/// let config = HttpConfig::get("https://api.example.com/health");
16/// ```
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct HttpConfig {
19    /// HTTP method (GET, POST, PUT, PATCH, DELETE).
20    pub method: String,
21    /// Request URL.
22    pub url: String,
23    /// Request headers.
24    pub headers: Vec<(String, String)>,
25    /// Request body as JSON.
26    pub body: Option<Value>,
27    /// Timeout in seconds (default: 30).
28    pub timeout_secs: Option<u64>,
29    /// When `true`, a failure of this step does not fail the run.
30    #[serde(default)]
31    pub allow_failure: bool,
32    /// Optional step-level retry policy.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub retry: Option<RetryPolicy>,
35    /// Optional W3C trace context for distributed tracing propagation.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub trace_context: Option<WorkflowTraceContext>,
38    /// Hosts this step may reach even when they are internal. See
39    /// [`allow_host`](Self::allow_host).
40    #[serde(default, skip_serializing_if = "Vec::is_empty")]
41    pub allowed_hosts: Vec<String>,
42}
43
44impl HttpConfig {
45    /// Create a GET request config.
46    ///
47    /// # Examples
48    ///
49    /// ```
50    /// use ironflow_engine::config::HttpConfig;
51    ///
52    /// let config = HttpConfig::get("https://example.com");
53    /// assert_eq!(config.method, "GET");
54    /// ```
55    pub fn get(url: &str) -> Self {
56        Self::new("GET", url)
57    }
58
59    /// Create a POST request config.
60    pub fn post(url: &str) -> Self {
61        Self::new("POST", url)
62    }
63
64    /// Create a PUT request config.
65    pub fn put(url: &str) -> Self {
66        Self::new("PUT", url)
67    }
68
69    /// Create a PATCH request config.
70    pub fn patch(url: &str) -> Self {
71        Self::new("PATCH", url)
72    }
73
74    /// Create a DELETE request config.
75    pub fn delete(url: &str) -> Self {
76        Self::new("DELETE", url)
77    }
78
79    fn new(method: &str, url: &str) -> Self {
80        Self {
81            method: method.to_string(),
82            url: url.to_string(),
83            headers: Vec::new(),
84            body: None,
85            timeout_secs: None,
86            allow_failure: false,
87            retry: None,
88            trace_context: None,
89            allowed_hosts: Vec::new(),
90        }
91    }
92
93    /// Add a request header.
94    pub fn header(mut self, name: &str, value: &str) -> Self {
95        self.headers.push((name.to_string(), value.to_string()));
96        self
97    }
98
99    /// Set the request body as JSON.
100    pub fn json(mut self, body: Value) -> Self {
101        self.body = Some(body);
102        self
103    }
104
105    /// Set the timeout in seconds.
106    pub fn timeout_secs(mut self, secs: u64) -> Self {
107        self.timeout_secs = Some(secs);
108        self
109    }
110
111    /// Mark this step as allowed to fail without stopping the run.
112    ///
113    /// # Examples
114    ///
115    /// ```
116    /// use ironflow_engine::config::HttpConfig;
117    ///
118    /// let config = HttpConfig::get("https://example.com").allow_failure();
119    /// assert!(config.allow_failure);
120    /// ```
121    pub fn allow_failure(mut self) -> Self {
122        self.allow_failure = true;
123        self
124    }
125
126    /// Set a step-level retry policy.
127    ///
128    /// # Examples
129    ///
130    /// ```
131    /// use ironflow_core::retry::RetryPolicy;
132    /// use ironflow_engine::config::HttpConfig;
133    ///
134    /// let config = HttpConfig::get("https://api.example.com")
135    ///     .retry_policy(RetryPolicy::new(5));
136    /// assert!(config.retry.is_some());
137    /// ```
138    pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
139        self.retry = Some(policy);
140        self
141    }
142
143    /// Let this step reach `host` even when it is, or resolves to, a private, loopback,
144    /// link-local or cloud metadata address.
145    ///
146    /// Without it, the step fails before anything is sent. Hosts allowed for the whole
147    /// deployment go in the worker's `IRONFLOW_HTTP_ALLOWED_HOSTS` environment variable
148    /// instead. See [`Http::allow_host`](ironflow_core::operations::http::Http::allow_host).
149    ///
150    /// # Examples
151    ///
152    /// ```
153    /// use ironflow_engine::config::HttpConfig;
154    ///
155    /// let config = HttpConfig::get("http://billing.internal:8080/invoices")
156    ///     .allow_host("billing.internal");
157    /// assert_eq!(config.allowed_hosts, ["billing.internal"]);
158    /// ```
159    pub fn allow_host(mut self, host: &str) -> Self {
160        self.allowed_hosts.push(host.to_string());
161        self
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use serde_json::json;
169
170    #[test]
171    fn methods() {
172        assert_eq!(HttpConfig::get("http://x").method, "GET");
173        assert_eq!(HttpConfig::post("http://x").method, "POST");
174        assert_eq!(HttpConfig::put("http://x").method, "PUT");
175        assert_eq!(HttpConfig::patch("http://x").method, "PATCH");
176        assert_eq!(HttpConfig::delete("http://x").method, "DELETE");
177    }
178
179    #[test]
180    fn builder() {
181        let config = HttpConfig::post("http://api.example.com")
182            .header("Authorization", "Bearer token")
183            .json(json!({"key": "value"}))
184            .timeout_secs(10);
185
186        assert_eq!(config.headers.len(), 1);
187        assert!(config.body.is_some());
188        assert_eq!(config.timeout_secs, Some(10));
189    }
190
191    #[test]
192    fn a_config_predating_retry_still_deserializes() {
193        let config: HttpConfig = serde_json::from_str(
194            r#"{"url":"http://x","method":"GET","headers":[],"body":null,"timeout_secs":null}"#,
195        )
196        .expect("deserialize");
197        assert!(config.retry.is_none());
198    }
199
200    #[test]
201    fn retry_policy_roundtrip() {
202        use ironflow_core::retry::RetryPolicy;
203
204        let config = HttpConfig::get("http://api").retry_policy(RetryPolicy::new(3));
205        let json = serde_json::to_string(&config).expect("serialize");
206        let back: HttpConfig = serde_json::from_str(&json).expect("deserialize");
207        assert_eq!(back.retry.as_ref().unwrap().max_retries(), 3);
208    }
209
210    #[test]
211    fn a_config_predating_allowed_hosts_still_deserializes() {
212        let config: HttpConfig = serde_json::from_str(
213            r#"{"url":"http://x","method":"GET","headers":[],"body":null,"timeout_secs":null}"#,
214        )
215        .expect("deserialize");
216        assert!(config.allowed_hosts.is_empty());
217    }
218
219    #[test]
220    fn allowed_hosts_roundtrip_and_stay_out_of_json_when_empty() {
221        let config = HttpConfig::get("http://billing.internal")
222            .allow_host("billing.internal")
223            .allow_host("10.0.0.5");
224        let json = serde_json::to_string(&config).expect("serialize");
225        let back: HttpConfig = serde_json::from_str(&json).expect("deserialize");
226        assert_eq!(back.allowed_hosts, ["billing.internal", "10.0.0.5"]);
227
228        let json = serde_json::to_string(&HttpConfig::get("http://x")).expect("serialize");
229        assert!(!json.contains("allowed_hosts"), "{json}");
230    }
231}