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
61// ── RequestRecord ─────────────────────────────────────────────────────────────
62
63/// Lightweight per-request record sent from VU to coordinator over the channel.
64///
65/// Unlike `RequestResult`, carries no raw response body — any extraction is done
66/// inside the VU before sending, keeping KB-sized bodies off the channel.
67pub struct RequestRecord {
68    pub duration: std::time::Duration,
69    /// Wall-clock instant at which the response was received (or the error occurred).
70    /// Used by the drain task to bucket results into per-stage windows in curve mode.
71    pub completed_at: Instant,
72    pub success: bool,
73    pub status_code: Option<u16>,
74    /// Present only when a response template is active and extraction succeeded.
75    pub extraction: Option<crate::response_template::extractor::ExtractionResult>,
76}
77
78impl RequestResult {
79    /// Constructs a `RequestResult` with all fields explicit.
80    ///
81    /// Use this constructor rather than struct literals so that adding fields in the
82    /// future causes a compile error at every call site, preventing silent omissions.
83    pub fn new(
84        duration: Duration,
85        success: bool,
86        status_code: Option<u16>,
87        response_body: Option<String>,
88    ) -> Self {
89        Self {
90            duration,
91            completed_at: Instant::now(),
92            success,
93            status_code,
94            response_body,
95        }
96    }
97}
98
99// ── Request builder ───────────────────────────────────────────────────────────
100
101pub struct Request {
102    client: reqwest::Client,
103    url: Arc<String>,
104    method: HttpMethod,
105    body: Option<(String, &'static str)>,
106    headers: Option<Arc<Vec<(String, String)>>>,
107    capture_response: bool,
108}
109
110impl Request {
111    pub fn new(client: reqwest::Client, url: Arc<String>, method: HttpMethod) -> Self {
112        Self {
113            client,
114            url,
115            method,
116            body: None,
117            headers: None,
118            capture_response: false,
119        }
120    }
121
122    pub fn body(mut self, content: String, content_type: &'static str) -> Self {
123        self.body = Some((content, content_type));
124        self
125    }
126
127    /// Attach a list of custom HTTP headers.
128    /// These are applied after the auto-set `Content-Type`, so a user-supplied
129    /// `Content-Type` header will override the auto-set one.
130    pub fn headers(mut self, headers: Arc<Vec<(String, String)>>) -> Self {
131        self.headers = Some(headers);
132        self
133    }
134
135    pub fn read_response(mut self) -> Self {
136        self.capture_response = true;
137        self
138    }
139
140    pub async fn execute(self) -> RequestResult {
141        let start = Instant::now();
142        let mut req = match self.method {
143            HttpMethod::Get => self.client.get(self.url.as_str()),
144            HttpMethod::Post => self.client.post(self.url.as_str()),
145            HttpMethod::Put => self.client.put(self.url.as_str()),
146            HttpMethod::Patch => self.client.patch(self.url.as_str()),
147            HttpMethod::Delete => self.client.delete(self.url.as_str()),
148        };
149        if let Some((content, content_type)) = self.body {
150            req = req.header("Content-Type", content_type).body(content);
151        }
152        // Apply user-supplied headers AFTER body/Content-Type so they take precedence.
153        if let Some(headers) = self.headers {
154            for (name, value) in headers.iter() {
155                req = req.header(name, value);
156            }
157        }
158
159        match req.send().await {
160            Ok(resp) => {
161                let status = resp.status();
162                let response_body = if self.capture_response {
163                    resp.text().await.ok()
164                } else {
165                    None
166                };
167                let duration = start.elapsed();
168                let completed_at = Instant::now();
169                RequestResult {
170                    duration,
171                    completed_at,
172                    success: status.is_success(),
173                    status_code: Some(status.as_u16()),
174                    response_body,
175                }
176            }
177            Err(_) => {
178                let duration = start.elapsed();
179                let completed_at = Instant::now();
180                RequestResult {
181                    duration,
182                    completed_at,
183                    success: false,
184                    status_code: None,
185                    response_body: None,
186                }
187            }
188        }
189    }
190}