Skip to main content

agent_first_http/
types.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::collections::HashMap;
4
5// ---------------------------------------------------------------------------
6// Input types (stdin)
7// ---------------------------------------------------------------------------
8
9#[derive(Deserialize)]
10#[serde(tag = "code")]
11pub enum Input {
12    #[serde(rename = "request")]
13    Request {
14        id: String,
15        #[serde(default)]
16        tag: Option<String>,
17        method: String,
18        url: String,
19        #[serde(default)]
20        headers: HashMap<String, Value>,
21        body: Option<Value>,
22        body_base64: Option<String>,
23        body_file: Option<String>,
24        body_multipart: Option<Vec<MultipartPart>>,
25        body_urlencoded: Option<Vec<UrlencodedPart>>,
26        #[serde(default)]
27        options: RequestOptions,
28    },
29    #[serde(rename = "config")]
30    Config(ConfigPatch),
31    #[serde(rename = "ping")]
32    Ping,
33    #[serde(rename = "send")]
34    Send {
35        id: String,
36        data: Option<Value>,
37        data_base64: Option<String>,
38    },
39    #[serde(rename = "cancel")]
40    Cancel { id: String },
41    #[serde(rename = "close")]
42    Close,
43}
44
45/// All fields from a `{"code":"config",...}` command.
46/// Passed directly to RuntimeConfig::apply_update.
47#[derive(Deserialize, Default)]
48pub struct ConfigPatch {
49    pub response_save_dir: Option<String>,
50    pub response_save_above_bytes: Option<u64>,
51    pub request_concurrency_limit: Option<u64>,
52    pub timeout_connect_s: Option<u64>,
53    pub pool_idle_timeout_s: Option<u64>,
54    pub retry_base_delay_ms: Option<u64>,
55    pub proxy: Option<String>,
56    pub tls: Option<TlsConfigPartial>,
57    pub log: Option<Vec<String>>,
58    pub defaults: Option<RequestDefaultsPartial>,
59    pub host_defaults: Option<HashMap<String, HostDefaultsPartial>>,
60}
61
62pub enum WsCommand {
63    Send {
64        data: Option<Value>,
65        data_base64: Option<String>,
66    },
67    Close,
68}
69
70#[derive(Deserialize, Default)]
71pub struct RequestOptions {
72    pub timeout_idle_s: Option<u64>,
73    pub retry: Option<u32>,
74    pub response_redirect: Option<u32>,
75    pub response_parse_json: Option<bool>,
76    pub response_decompress: Option<bool>,
77    pub response_save_resume: Option<bool>,
78    #[serde(default)]
79    pub chunked: bool,
80    #[serde(default = "default_chunked_delimiter")]
81    pub chunked_delimiter: Value, // String = delimiter, Null = raw, absent = "\n"
82    pub response_save_file: Option<String>,
83    pub progress_bytes: Option<u64>,
84    pub progress_ms: Option<u64>,
85    pub retry_on_status: Option<Vec<u16>>,
86    pub response_max_bytes: Option<u64>,
87    pub upgrade: Option<String>,
88    /// Per-request TLS overrides — merged on top of global TLS config.
89    /// Builds a one-off HTTP client for this request (no shared connection pool).
90    pub tls: Option<TlsConfigPartial>,
91}
92
93#[derive(Deserialize)]
94pub struct MultipartPart {
95    pub name: String,
96    pub value: Option<String>,
97    pub value_base64: Option<String>,
98    pub file: Option<String>,
99    pub filename: Option<String>,
100    pub content_type: Option<String>,
101}
102
103#[derive(Deserialize)]
104pub struct UrlencodedPart {
105    pub name: String,
106    pub value: String,
107}
108
109#[derive(Deserialize, Default)]
110pub struct RequestDefaultsPartial {
111    pub headers_for_any_hosts: Option<HashMap<String, Value>>,
112    pub timeout_idle_s: Option<u64>,
113    pub retry: Option<u32>,
114    pub response_redirect: Option<u32>,
115    pub response_parse_json: Option<bool>,
116    pub response_decompress: Option<bool>,
117    pub response_save_resume: Option<bool>,
118    pub retry_on_status: Option<Vec<u16>>,
119}
120
121#[derive(Deserialize, Default)]
122pub struct HostDefaultsPartial {
123    pub headers: Option<HashMap<String, Value>>,
124}
125
126/// Partial TLS config used for both global config updates and per-request overrides.
127/// Inline fields (`cacert`, `cert`, `key`) take precedence over file-path fields
128/// (`cacert_file`, `cert_file`, `key_file`). Setting one clears the other.
129#[derive(Deserialize, Default, Clone)]
130pub struct TlsConfigPartial {
131    pub insecure: Option<bool>,
132    /// Inline CA certificate as PEM text. Takes precedence over `cacert_file`.
133    pub cacert_pem: Option<String>,
134    /// Path to CA certificate file (PEM) — like curl --cacert.
135    pub cacert_file: Option<String>,
136    /// Inline client certificate as PEM text. Takes precedence over `cert_file`.
137    pub cert_pem: Option<String>,
138    /// Path to client certificate file (PEM) — like curl --cert.
139    pub cert_file: Option<String>,
140    /// Inline client private key as PEM text (unencrypted). Takes precedence over `key_file`.
141    /// Named `_secret` — redacted in all config echo output.
142    pub key_pem_secret: Option<String>,
143    /// Path to client private key file (PEM, unencrypted) — like curl --key.
144    pub key_file: Option<String>,
145}
146
147// ---------------------------------------------------------------------------
148// Output types (stdout)
149// ---------------------------------------------------------------------------
150
151#[derive(Serialize)]
152#[serde(tag = "code")]
153pub enum Output {
154    #[serde(rename = "response")]
155    Response {
156        id: String,
157        #[serde(skip_serializing_if = "Option::is_none")]
158        tag: Option<String>,
159        status: u16,
160        headers: HashMap<String, Value>,
161        #[serde(skip_serializing_if = "Option::is_none")]
162        body: Option<Value>,
163        #[serde(skip_serializing_if = "Option::is_none")]
164        body_base64: Option<String>,
165        #[serde(skip_serializing_if = "Option::is_none")]
166        body_file: Option<String>,
167        /// true when Content-Type was application/json but the body failed JSON
168        /// parsing — body contains the raw text string instead.
169        #[serde(skip_serializing_if = "std::ops::Not::not")]
170        body_parse_failed: bool,
171        trace: Trace,
172    },
173
174    #[serde(rename = "error")]
175    Error {
176        #[serde(skip_serializing_if = "Option::is_none")]
177        id: Option<String>,
178        #[serde(skip_serializing_if = "Option::is_none")]
179        tag: Option<String>,
180        error: String,
181        error_code: String,
182        #[serde(skip_serializing_if = "Option::is_none")]
183        hint: Option<String>,
184        retryable: bool,
185        trace: Trace,
186    },
187
188    #[serde(rename = "dry_run")]
189    DryRun {
190        method: String,
191        url: String,
192        headers: HashMap<String, Value>,
193        #[serde(skip_serializing_if = "Option::is_none")]
194        body: Option<Value>,
195        trace: Trace,
196    },
197
198    #[serde(rename = "chunk_start")]
199    ChunkStart {
200        id: String,
201        #[serde(skip_serializing_if = "Option::is_none")]
202        tag: Option<String>,
203        status: u16,
204        headers: HashMap<String, Value>,
205        #[serde(skip_serializing_if = "Option::is_none")]
206        content_length_bytes: Option<u64>,
207    },
208
209    #[serde(rename = "chunk_data")]
210    ChunkData {
211        id: String,
212        #[serde(skip_serializing_if = "Option::is_none")]
213        data: Option<String>,
214        #[serde(skip_serializing_if = "Option::is_none")]
215        data_base64: Option<String>,
216    },
217
218    #[serde(rename = "chunk_end")]
219    ChunkEnd {
220        id: String,
221        #[serde(skip_serializing_if = "Option::is_none")]
222        tag: Option<String>,
223        #[serde(skip_serializing_if = "Option::is_none")]
224        body_file: Option<String>,
225        trace: Trace,
226    },
227
228    #[serde(rename = "config")]
229    Config(RuntimeConfig),
230
231    #[serde(rename = "pong")]
232    Pong { trace: PongTrace },
233
234    #[serde(rename = "close")]
235    Close { message: String, trace: CloseTrace },
236
237    #[serde(rename = "log")]
238    Log {
239        event: String,
240        #[serde(flatten)]
241        fields: HashMap<String, Value>,
242    },
243}
244
245// ---------------------------------------------------------------------------
246// Shared structs
247// ---------------------------------------------------------------------------
248
249#[derive(Serialize, Deserialize, Clone)]
250pub struct RuntimeConfig {
251    pub response_save_dir: String,
252    pub response_save_above_bytes: u64,
253    pub request_concurrency_limit: u64,
254    pub timeout_connect_s: u64,
255    pub pool_idle_timeout_s: u64,
256    pub retry_base_delay_ms: u64,
257    #[serde(skip_serializing_if = "Option::is_none")]
258    pub proxy: Option<String>,
259    pub tls: TlsConfig,
260    pub log: Vec<String>,
261    pub defaults: RequestDefaults,
262    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
263    pub host_defaults: HashMap<String, HostDefaults>,
264}
265
266#[derive(Serialize, Deserialize, Clone)]
267pub struct RequestDefaults {
268    #[serde(default)]
269    pub headers_for_any_hosts: HashMap<String, Value>,
270    pub timeout_idle_s: u64,
271    pub retry: u32,
272    pub response_redirect: u32,
273    pub response_parse_json: bool,
274    pub response_decompress: bool,
275    pub response_save_resume: bool,
276    #[serde(default)]
277    pub retry_on_status: Vec<u16>,
278}
279
280#[derive(Serialize, Deserialize, Clone, Default)]
281pub struct HostDefaults {
282    #[serde(default)]
283    pub headers: HashMap<String, Value>,
284}
285
286/// Stored TLS configuration (full, non-partial). Inline PEM fields (`*_pem`) take
287/// precedence over file-path fields (`*_file`).
288#[derive(Serialize, Deserialize, Clone)]
289pub struct TlsConfig {
290    #[serde(default)]
291    pub insecure: bool,
292    /// Inline CA certificate as PEM text
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub cacert_pem: Option<String>,
295    /// Path to CA certificate file (PEM)
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub cacert_file: Option<String>,
298    /// Inline client certificate as PEM text
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub cert_pem: Option<String>,
301    /// Path to client certificate file (PEM)
302    #[serde(skip_serializing_if = "Option::is_none")]
303    pub cert_file: Option<String>,
304    /// Inline client private key as PEM text (unencrypted). Redacted in config echo.
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub key_pem_secret: Option<String>,
307    /// Path to client private key file (PEM, unencrypted)
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub key_file: Option<String>,
310}
311
312#[derive(Serialize, Clone)]
313pub struct Trace {
314    pub duration_ms: u64,
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub http_version: Option<String>,
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub remote_addr: Option<String>,
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub sent_bytes: Option<u64>,
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub received_bytes: Option<u64>,
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub redirects: Option<u32>,
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub chunks: Option<u32>,
327}
328
329#[derive(Serialize)]
330pub struct PongTrace {
331    pub uptime_s: u64,
332    pub requests_total: u64,
333    pub connections_active: u64,
334}
335
336#[derive(Serialize)]
337pub struct CloseTrace {
338    pub uptime_s: u64,
339    pub requests_total: u64,
340}
341
342// ---------------------------------------------------------------------------
343// Resolved options (config defaults merged with per-request options)
344// ---------------------------------------------------------------------------
345
346pub struct ResolvedOptions {
347    pub timeout_idle_s: u64,
348    pub retry: u32,
349    pub response_redirect: u32,
350    pub response_parse_json: bool,
351    pub response_decompress: bool,
352    pub response_save_resume: bool,
353    pub chunked: bool,
354    pub chunked_delimiter: Option<String>, // None = raw
355    pub response_save_file: Option<String>,
356    pub progress_bytes: u64,
357    pub progress_ms: u64,
358    pub response_save_above_bytes: u64,
359    pub retry_base_delay_ms: u64,
360    pub retry_on_status: Vec<u16>,
361    pub response_max_bytes: Option<u64>,
362}
363
364fn default_chunked_delimiter() -> Value {
365    Value::String("\n".to_string())
366}
367
368impl Trace {
369    pub fn error_only(duration_ms: u64) -> Self {
370        Trace {
371            duration_ms,
372            http_version: None,
373            remote_addr: None,
374            sent_bytes: None,
375            received_bytes: None,
376            redirects: None,
377            chunks: None,
378        }
379    }
380}
381
382// ---------------------------------------------------------------------------
383// ErrorInfo — structured error classification
384// ---------------------------------------------------------------------------
385
386pub struct ErrorInfo {
387    pub error_code: &'static str,
388    pub error: String,
389    pub hint: Option<String>,
390    pub retryable: bool,
391}
392
393impl ErrorInfo {
394    pub fn invalid_request(detail: impl std::fmt::Display) -> Self {
395        ErrorInfo {
396            error_code: "invalid_request",
397            error: format!("{detail}"),
398            hint: None,
399            retryable: false,
400        }
401    }
402
403    pub fn cancelled() -> Self {
404        ErrorInfo {
405            error_code: "cancelled",
406            error: "cancelled".to_string(),
407            hint: None,
408            retryable: false,
409        }
410    }
411
412    pub fn too_many_redirects(max: u32) -> Self {
413        ErrorInfo {
414            error_code: "too_many_redirects",
415            error: format!("exceeded {max}"),
416            hint: Some("increase --response-redirect or check for redirect loops".into()),
417            retryable: false,
418        }
419    }
420
421    pub fn request_timeout(detail: impl std::fmt::Display) -> Self {
422        ErrorInfo {
423            error_code: "request_timeout",
424            error: format!("{detail}"),
425            hint: Some("increase --timeout-idle-s".into()),
426            retryable: false,
427        }
428    }
429
430    pub fn invalid_response(detail: impl std::fmt::Display) -> Self {
431        ErrorInfo {
432            error_code: "invalid_response",
433            error: format!("{detail}"),
434            hint: None,
435            retryable: false,
436        }
437    }
438
439    pub fn chunk_disconnected(detail: impl std::fmt::Display) -> Self {
440        ErrorInfo {
441            error_code: "chunk_disconnected",
442            error: format!("{detail}"),
443            hint: None,
444            retryable: false,
445        }
446    }
447
448    pub fn response_too_large(limit_bytes: u64) -> Self {
449        ErrorInfo {
450            error_code: "response_too_large",
451            error: format!("exceeded {limit_bytes} bytes"),
452            hint: Some("increase --response-max-bytes".into()),
453            retryable: false,
454        }
455    }
456
457    pub fn overloaded(detail: impl std::fmt::Display) -> Self {
458        ErrorInfo {
459            error_code: "overloaded",
460            error: format!("{detail}"),
461            hint: None,
462            retryable: true,
463        }
464    }
465
466    pub fn from_reqwest(e: &reqwest::Error) -> Self {
467        if e.is_timeout() {
468            if e.is_connect() {
469                return ErrorInfo {
470                    error_code: "connect_timeout",
471                    error: e.to_string(),
472                    hint: Some("increase --timeout-connect-s or check host reachability".into()),
473                    retryable: true,
474                };
475            }
476            return ErrorInfo {
477                error_code: "request_timeout",
478                error: e.to_string(),
479                hint: Some("increase --timeout-idle-s".into()),
480                retryable: false,
481            };
482        }
483        if e.is_connect() {
484            let msg = e.to_string().to_lowercase();
485            if msg.contains("dns") || msg.contains("resolve") || msg.contains("name") {
486                return ErrorInfo {
487                    error_code: "dns_failed",
488                    error: e.to_string(),
489                    hint: Some("check the hostname spelling".into()),
490                    retryable: true,
491                };
492            }
493            return ErrorInfo {
494                error_code: "connect_refused",
495                error: e.to_string(),
496                hint: None,
497                retryable: true,
498            };
499        }
500        let msg = e.to_string().to_lowercase();
501        if msg.contains("tls") || msg.contains("ssl") || msg.contains("certificate") {
502            return ErrorInfo {
503                error_code: "tls_error",
504                error: e.to_string(),
505                hint: None,
506                retryable: false,
507            };
508        }
509        if msg.contains("dns") || msg.contains("resolve") {
510            return ErrorInfo {
511                error_code: "dns_failed",
512                error: e.to_string(),
513                hint: Some("check the hostname spelling".into()),
514                retryable: true,
515            };
516        }
517        ErrorInfo {
518            error_code: "connect_refused",
519            error: e.to_string(),
520            hint: None,
521            retryable: true,
522        }
523    }
524}
525
526/// Helper to build Output::Error from ErrorInfo
527pub fn make_error(
528    id: Option<String>,
529    tag: Option<String>,
530    info: ErrorInfo,
531    trace: Trace,
532) -> Output {
533    Output::Error {
534        id,
535        tag,
536        error: info.error,
537        error_code: info.error_code.to_string(),
538        hint: info.hint,
539        retryable: info.retryable,
540        trace,
541    }
542}
543
544/// Helper to build Output::Log
545pub fn make_log(event: &str, fields: Vec<(&str, Value)>) -> Output {
546    Output::Log {
547        event: event.to_string(),
548        fields: fields
549            .into_iter()
550            .map(|(k, v)| (k.to_string(), v))
551            .collect(),
552    }
553}
554
555#[cfg(test)]
556#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
557mod tests {
558    use super::*;
559
560    #[test]
561    fn request_options_default_delimiter_is_newline() {
562        let opts: RequestOptions = serde_json::from_value(serde_json::json!({})).expect("opts");
563        assert_eq!(opts.chunked_delimiter, Value::String("\n".to_string()));
564        assert!(!opts.chunked);
565    }
566
567    #[test]
568    fn trace_error_only_sets_optional_fields_none() {
569        let t = Trace::error_only(12);
570        assert_eq!(t.duration_ms, 12);
571        assert!(t.http_version.is_none());
572        assert!(t.remote_addr.is_none());
573        assert!(t.sent_bytes.is_none());
574        assert!(t.received_bytes.is_none());
575        assert!(t.redirects.is_none());
576        assert!(t.chunks.is_none());
577    }
578
579    #[test]
580    fn error_info_builders_and_output_helpers() {
581        let version = env!("CARGO_PKG_VERSION");
582        let e = ErrorInfo::invalid_request("bad");
583        assert_eq!(e.error_code, "invalid_request");
584        assert!(!e.retryable);
585        let e = ErrorInfo::cancelled();
586        assert_eq!(e.error_code, "cancelled");
587        let e = ErrorInfo::too_many_redirects(5);
588        assert_eq!(e.error, "exceeded 5");
589        let e = ErrorInfo::request_timeout("timeout");
590        assert_eq!(e.error_code, "request_timeout");
591        let e = ErrorInfo::invalid_response("x");
592        assert_eq!(e.error_code, "invalid_response");
593        let e = ErrorInfo::chunk_disconnected("x");
594        assert_eq!(e.error_code, "chunk_disconnected");
595        let e = ErrorInfo::response_too_large(100);
596        assert_eq!(e.error, "exceeded 100 bytes");
597        let e = ErrorInfo::overloaded("busy");
598        assert_eq!(e.error_code, "overloaded");
599        assert!(e.retryable);
600
601        let out = make_error(
602            Some("id1".to_string()),
603            Some("tag1".to_string()),
604            ErrorInfo::invalid_request("bad"),
605            Trace::error_only(1),
606        );
607        match out {
608            Output::Error {
609                id,
610                tag,
611                error_code,
612                ..
613            } => {
614                assert_eq!(id.as_deref(), Some("id1"));
615                assert_eq!(tag.as_deref(), Some("tag1"));
616                assert_eq!(error_code, "invalid_request");
617            }
618            _ => panic!("expected Output::Error"),
619        }
620
621        let log = make_log(
622            "startup",
623            vec![("version", Value::String(version.to_string()))],
624        );
625        match log {
626            Output::Log { event, fields } => {
627                assert_eq!(event, "startup");
628                assert_eq!(fields.get("version"), Some(&Value::String(version.into())));
629            }
630            _ => panic!("expected Output::Log"),
631        }
632    }
633
634    #[tokio::test]
635    async fn from_reqwest_classifies_connect_and_dns_errors() {
636        let client = reqwest::Client::new();
637
638        let connect_err = client
639            .get("http://127.0.0.1:1")
640            .send()
641            .await
642            .expect_err("connect should fail");
643        let info = ErrorInfo::from_reqwest(&connect_err);
644        assert_eq!(info.error_code, "connect_refused");
645        assert!(info.retryable);
646
647        let dns_err = client
648            .get("http://definitely-not-a-real-host.invalid")
649            .send()
650            .await
651            .expect_err("dns should fail");
652        let info = ErrorInfo::from_reqwest(&dns_err);
653        assert!(matches!(info.error_code, "dns_failed" | "connect_refused"));
654        assert!(info.retryable);
655    }
656}