1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::collections::HashMap;
4
5#[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#[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, 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 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#[derive(Deserialize, Default, Clone)]
130pub struct TlsConfigPartial {
131 pub insecure: Option<bool>,
132 pub cacert_pem: Option<String>,
134 pub cacert_file: Option<String>,
136 pub cert_pem: Option<String>,
138 pub cert_file: Option<String>,
140 pub key_pem_secret: Option<String>,
143 pub key_file: Option<String>,
145}
146
147#[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 #[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#[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#[derive(Serialize, Deserialize, Clone)]
289pub struct TlsConfig {
290 #[serde(default)]
291 pub insecure: bool,
292 #[serde(skip_serializing_if = "Option::is_none")]
294 pub cacert_pem: Option<String>,
295 #[serde(skip_serializing_if = "Option::is_none")]
297 pub cacert_file: Option<String>,
298 #[serde(skip_serializing_if = "Option::is_none")]
300 pub cert_pem: Option<String>,
301 #[serde(skip_serializing_if = "Option::is_none")]
303 pub cert_file: Option<String>,
304 #[serde(skip_serializing_if = "Option::is_none")]
306 pub key_pem_secret: Option<String>,
307 #[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
342pub 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>, 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
382pub 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
526pub 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
544pub 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}