Skip to main content

lmn_core/http/
request.rs

1use std::sync::Arc;
2use std::time::Duration;
3use std::time::Instant;
4
5use crate::command::{Body, HttpMethod};
6use crate::config::secret::SensitiveString;
7use crate::response_template::field::TrackedField;
8
9// ── Body format ───────────────────────────────────────────────────────────────
10
11pub enum BodyFormat {
12    Json,
13}
14
15// ── Shared per-run config ─────────────────────────────────────────────────────
16
17pub struct RequestConfig {
18    pub client: reqwest::Client,
19    pub host: Arc<String>,
20    pub method: HttpMethod,
21    pub body: Arc<Option<Body>>,
22    pub tracked_fields: Option<Arc<Vec<TrackedField>>>,
23    /// Static headers applied to every request in the run.
24    /// Stored as `Arc` so the list is shared cheaply across concurrent tasks.
25    /// Values are `SensitiveString` so secrets are redacted in debug output.
26    pub headers: Arc<Vec<(String, SensitiveString)>>,
27}
28
29impl RequestConfig {
30    /// Resolves the body for a single request.
31    /// Returns `(content, content_type)`, or `None` if no body is configured.
32    /// `template_body` takes priority when provided (pre-generated template output).
33    pub fn resolve_body(&self, template_body: Option<String>) -> Option<(String, &'static str)> {
34        if let Some(content) = template_body {
35            return Some((content, "application/json"));
36        }
37        self.body.as_ref().as_ref().map(|b| match b {
38            Body::Formatted { content, format } => (
39                content.clone(),
40                match format {
41                    BodyFormat::Json => "application/json",
42                },
43            ),
44        })
45    }
46}
47
48// ── Result ────────────────────────────────────────────────────────────────────
49
50pub struct RequestResult {
51    pub duration: Duration,
52    /// Wall-clock instant at which the response was received (or the error occurred).
53    /// Used by the output module to bucket results into per-stage windows when
54    /// `mode == Curve`. Zero-cost in fixed mode where `curve_stages` is `None`.
55    pub completed_at: Instant,
56    pub success: bool,
57    pub status_code: Option<u16>,
58    pub response_body: Option<String>,
59}
60
61impl RequestResult {
62    /// Constructs a `RequestResult` with all fields explicit.
63    ///
64    /// Use this constructor rather than struct literals so that adding fields in the
65    /// future causes a compile error at every call site, preventing silent omissions.
66    pub fn new(
67        duration: Duration,
68        success: bool,
69        status_code: Option<u16>,
70        response_body: Option<String>,
71    ) -> Self {
72        Self {
73            duration,
74            completed_at: Instant::now(),
75            success,
76            status_code,
77            response_body,
78        }
79    }
80}
81
82// ── Request builder ───────────────────────────────────────────────────────────
83
84pub struct Request {
85    client: reqwest::Client,
86    url: String,
87    method: HttpMethod,
88    body: Option<(String, &'static str)>,
89    headers: Vec<(String, String)>,
90    capture_response: bool,
91}
92
93impl Request {
94    pub fn new(client: reqwest::Client, url: String, method: HttpMethod) -> Self {
95        Self {
96            client,
97            url,
98            method,
99            body: None,
100            headers: Vec::new(),
101            capture_response: false,
102        }
103    }
104
105    pub fn body(mut self, content: String, content_type: &'static str) -> Self {
106        self.body = Some((content, content_type));
107        self
108    }
109
110    /// Attach a list of custom HTTP headers.
111    /// These are applied after the auto-set `Content-Type`, so a user-supplied
112    /// `Content-Type` header will override the auto-set one.
113    pub fn headers(mut self, headers: Vec<(String, String)>) -> Self {
114        self.headers = headers;
115        self
116    }
117
118    pub fn read_response(mut self) -> Self {
119        self.capture_response = true;
120        self
121    }
122
123    pub async fn execute(self) -> RequestResult {
124        let start = Instant::now();
125        let mut req = match self.method {
126            HttpMethod::Get => self.client.get(&self.url),
127            HttpMethod::Post => self.client.post(&self.url),
128            HttpMethod::Put => self.client.put(&self.url),
129            HttpMethod::Patch => self.client.patch(&self.url),
130            HttpMethod::Delete => self.client.delete(&self.url),
131        };
132        if let Some((content, content_type)) = self.body {
133            req = req.header("Content-Type", content_type).body(content);
134        }
135        // Apply user-supplied headers AFTER body/Content-Type so they take precedence.
136        for (name, value) in self.headers {
137            req = req.header(name, value);
138        }
139        match req.send().await {
140            Ok(resp) => {
141                let status = resp.status();
142                let response_body = if self.capture_response {
143                    resp.text().await.ok()
144                } else {
145                    None
146                };
147                let duration = start.elapsed();
148                let completed_at = Instant::now();
149                RequestResult {
150                    duration,
151                    completed_at,
152                    success: status.is_success(),
153                    status_code: Some(status.as_u16()),
154                    response_body,
155                }
156            }
157            Err(_) => {
158                let duration = start.elapsed();
159                let completed_at = Instant::now();
160                RequestResult {
161                    duration,
162                    completed_at,
163                    success: false,
164                    status_code: None,
165                    response_body: None,
166                }
167            }
168        }
169    }
170}