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/// Maximum response body size read into memory for capture extraction or
10/// response template processing. Prevents OOM when a target server returns
11/// unexpectedly large responses (e.g. 100 VUs × 100 MB = 10 GB).
12const MAX_RESPONSE_BODY_SIZE: usize = 10 * 1024 * 1024; // 10 MiB
13
14// ── Body format ───────────────────────────────────────────────────────────────
15
16pub enum BodyFormat {
17    Json,
18}
19
20// ── Shared per-run config ─────────────────────────────────────────────────────
21
22pub struct RequestConfig {
23    pub client: reqwest::Client,
24    pub host: Arc<String>,
25    pub method: HttpMethod,
26    pub body: Arc<Option<Body>>,
27    pub tracked_fields: Option<Arc<Vec<TrackedField>>>,
28    /// Static headers applied to every request in the run.
29    /// Stored as `Arc` so the list is shared cheaply across concurrent tasks.
30    /// Values are `SensitiveString` so secrets are redacted in debug output.
31    pub headers: Arc<Vec<(String, SensitiveString)>>,
32}
33
34impl RequestConfig {
35    /// Resolves the body for a single request.
36    /// Returns `(content, content_type)`, or `None` if no body is configured.
37    /// `template_body` takes priority when provided (pre-generated template output).
38    pub fn resolve_body(&self, template_body: Option<String>) -> Option<(String, &'static str)> {
39        if let Some(content) = template_body {
40            return Some((content, "application/json"));
41        }
42        self.body.as_ref().as_ref().map(|b| match b {
43            Body::Formatted { content, format } => (
44                content.clone(),
45                match format {
46                    BodyFormat::Json => "application/json",
47                },
48            ),
49        })
50    }
51}
52
53// ── Result ────────────────────────────────────────────────────────────────────
54
55pub struct RequestResult {
56    pub duration: Duration,
57    /// Wall-clock instant at which the response was received (or the error occurred).
58    /// Used by the output module to bucket results into per-stage windows when
59    /// `mode == Curve`. Zero-cost in fixed mode where `curve_stages` is `None`.
60    pub completed_at: Instant,
61    pub success: bool,
62    pub status_code: Option<u16>,
63    pub response_body: Option<String>,
64}
65
66// ── RequestRecord ─────────────────────────────────────────────────────────────
67
68/// Lightweight per-request record sent from VU to coordinator over the channel.
69///
70/// Unlike `RequestResult`, carries no raw response body — any extraction is done
71/// inside the VU before sending, keeping KB-sized bodies off the channel.
72pub struct RequestRecord {
73    pub duration: std::time::Duration,
74    /// Wall-clock instant at which the response was received (or the error occurred).
75    /// Used by the drain task to bucket results into per-stage windows in curve mode.
76    pub completed_at: Instant,
77    pub success: bool,
78    pub status_code: Option<u16>,
79    /// Present only when a response template is active and extraction succeeded.
80    pub extraction: Option<crate::response_template::extractor::ExtractionResult>,
81    /// Optional scenario name associated with this request.
82    pub scenario: Option<Arc<str>>,
83    /// Optional step name inside a scenario.
84    pub step: Option<Arc<str>>,
85    /// True when this record represents a step that was skipped (not executed).
86    /// Skipped records count toward `total_requests` but do not contribute to
87    /// latency histograms or status code distributions.
88    pub skipped: bool,
89}
90
91impl RequestResult {
92    /// Constructs a `RequestResult` with all fields explicit.
93    ///
94    /// Use this constructor rather than struct literals so that adding fields in the
95    /// future causes a compile error at every call site, preventing silent omissions.
96    pub fn new(
97        duration: Duration,
98        success: bool,
99        status_code: Option<u16>,
100        response_body: Option<String>,
101    ) -> Self {
102        Self {
103            duration,
104            completed_at: Instant::now(),
105            success,
106            status_code,
107            response_body,
108        }
109    }
110}
111
112// ── Request builder ───────────────────────────────────────────────────────────
113
114pub struct Request {
115    client: reqwest::Client,
116    url: Arc<String>,
117    method: HttpMethod,
118    body: Option<(String, &'static str)>,
119    headers: Option<Arc<Vec<(String, String)>>>,
120    capture_response: bool,
121}
122
123impl Request {
124    pub fn new(client: reqwest::Client, url: Arc<String>, method: HttpMethod) -> Self {
125        Self {
126            client,
127            url,
128            method,
129            body: None,
130            headers: None,
131            capture_response: false,
132        }
133    }
134
135    pub fn body(mut self, content: String, content_type: &'static str) -> Self {
136        self.body = Some((content, content_type));
137        self
138    }
139
140    /// Attach a list of custom HTTP headers.
141    /// These are applied after the auto-set `Content-Type`, so a user-supplied
142    /// `Content-Type` header will override the auto-set one.
143    pub fn headers(mut self, headers: Arc<Vec<(String, String)>>) -> Self {
144        self.headers = Some(headers);
145        self
146    }
147
148    pub fn read_response(mut self) -> Self {
149        self.capture_response = true;
150        self
151    }
152
153    pub async fn execute(self) -> RequestResult {
154        let start = Instant::now();
155        let mut req = match self.method {
156            HttpMethod::Get => self.client.get(self.url.as_str()),
157            HttpMethod::Post => self.client.post(self.url.as_str()),
158            HttpMethod::Put => self.client.put(self.url.as_str()),
159            HttpMethod::Patch => self.client.patch(self.url.as_str()),
160            HttpMethod::Delete => self.client.delete(self.url.as_str()),
161        };
162        if let Some((content, content_type)) = self.body {
163            req = req.header("Content-Type", content_type).body(content);
164        }
165        // Apply user-supplied headers AFTER body/Content-Type so they take precedence.
166        if let Some(headers) = self.headers {
167            for (name, value) in headers.iter() {
168                req = req.header(name, value);
169            }
170        }
171
172        match req.send().await {
173            Ok(resp) => {
174                let status = resp.status();
175                let response_body = if self.capture_response {
176                    // Pre-check content-length when available to reject oversized
177                    // responses before buffering. Chunked responses without
178                    // content-length fall through to the post-read check.
179                    let too_large = resp
180                        .content_length()
181                        .is_some_and(|len| len > MAX_RESPONSE_BODY_SIZE as u64);
182                    if too_large {
183                        None
184                    } else {
185                        match resp.bytes().await {
186                            Ok(bytes) if bytes.len() <= MAX_RESPONSE_BODY_SIZE => {
187                                String::from_utf8(bytes.to_vec()).ok()
188                            }
189                            _ => None,
190                        }
191                    }
192                } else {
193                    None
194                };
195                let duration = start.elapsed();
196                let completed_at = Instant::now();
197                RequestResult {
198                    duration,
199                    completed_at,
200                    success: status.is_success(),
201                    status_code: Some(status.as_u16()),
202                    response_body,
203                }
204            }
205            Err(_) => {
206                let duration = start.elapsed();
207                let completed_at = Instant::now();
208                RequestResult {
209                    duration,
210                    completed_at,
211                    success: false,
212                    status_code: None,
213                    response_body: None,
214                }
215            }
216        }
217    }
218}