src/http/vars.rs 12.1 K raw
1
//! `{{variable}}` substitution for paths, params, headers and bodies.
2
//!
3
//! Two kinds of variable resolve here:
4
//!
5
//! - **Collection variables** — looked up by (trimmed) name in the Variables tab.
6
//! - **Dynamic variables** — computed at send time: `{{uuid}}`, `{{timestamp}}`,
7
//!   `{{randomInt(1,100)}}` … see [`DYNAMIC_VARS`].
8
//!
9
//! A collection variable shadows a dynamic one of the same name, so `uuid` can
10
//! be pinned to a fixed value for a debugging session. Prefixing with `$`
11
//! (`{{$uuid}}`, Postman's spelling) always takes the dynamic one.
12
//!
13
//! Unknown variables are left untouched so the user can see what failed to
14
//! resolve.
15
16
use std::collections::HashMap;
17
use std::time::{SystemTime, UNIX_EPOCH};
18
19
use uuid::Uuid;
20
21
/// Dynamic variable names and their help text, in the order the help popup
22
/// lists them. Names are matched case-insensitively, ignoring `_`, so
23
/// `isoTimestamp`, `iso_timestamp` and `ISOTIMESTAMP` are the same variable.
24
pub const DYNAMIC_VARS: [(&str, &str); 8] = [
25
    ("uuid", "UUID v4, fresh per occurrence"),
26
    ("timestamp", "Unix time in seconds"),
27
    ("timestampMs", "Unix time in milliseconds"),
28
    ("isoTimestamp", "RFC 3339 UTC, e.g. 2026-08-06T12:34:56Z"),
29
    ("randomInt", "0–1000, or randomInt(min,max) inclusive"),
30
    ("randomHex", "16 hex chars, or randomHex(n)"),
31
    ("randomString", "16 alphanumerics, or randomString(n)"),
32
    ("randomBool", "true or false"),
33
];
34
35
pub fn substitute(input: &str, vars: &HashMap<String, String>) -> String {
36
    let mut out = String::with_capacity(input.len());
37
    let mut rest = input;
38
    while let Some(start) = rest.find("{{") {
39
        out.push_str(&rest[..start]);
40
        let after = &rest[start + 2..];
41
        match after.find("}}") {
42
            Some(end) => {
43
                let key = after[..end].trim();
44
                match resolve(key, vars) {
45
                    Some(v) => out.push_str(&v),
46
                    // Unknown variable: keep the placeholder as-is.
47
                    None => out.push_str(&rest[..start + 2 + end + 2]),
48
                }
49
                rest = &after[end + 2..];
50
            }
51
            None => {
52
                out.push_str(&rest[start..]);
53
                rest = "";
54
            }
55
        }
56
    }
57
    out.push_str(rest);
58
    out
59
}
60
61
/// A `$` prefix forces the dynamic variable; otherwise the collection wins.
62
fn resolve(key: &str, vars: &HashMap<String, String>) -> Option<String> {
63
    match key.strip_prefix('$') {
64
        Some(name) => dynamic(name.trim()),
65
        None => vars.get(key).cloned().or_else(|| dynamic(key)),
66
    }
67
}
68
69
/// Evaluate a dynamic variable, with optional `name(arg,arg)` arguments.
70
/// Returns `None` for an unknown name or unusable arguments — the caller then
71
/// leaves the placeholder visible rather than silently emitting junk.
72
fn dynamic(spec: &str) -> Option<String> {
73
    let (name, args) = split_call(spec)?;
74
    let name = normalize(&name);
75
    Some(match (name.as_str(), args.as_slice()) {
76
        ("uuid", []) => Uuid::new_v4().to_string(),
77
        ("timestamp", []) => unix_secs().to_string(),
78
        ("timestampms", []) => unix_millis().to_string(),
79
        ("isotimestamp", []) => iso_timestamp(unix_secs()),
80
        ("randomint", []) => random_int(0, 1000).to_string(),
81
        ("randomint", [min, max]) => {
82
            let (min, max) = (min.parse::<i64>().ok()?, max.parse::<i64>().ok()?);
83
            if min > max {
84
                return None;
85
            }
86
            random_int(min, max).to_string()
87
        }
88
        ("randomhex", []) => random_hex(16),
89
        ("randomhex", [n]) => random_hex(parse_len(n)?),
90
        ("randomstring", []) => random_string(16),
91
        ("randomstring", [n]) => random_string(parse_len(n)?),
92
        ("randombool", []) => (random_int(0, 1) == 1).to_string(),
93
        _ => return None,
94
    })
95
}
96
97
/// `name` or `name(a, b)` → `("name", ["a", "b"])`. Empty args are rejected so
98
/// `randomHex()` doesn't quietly mean `randomHex`.
99
fn split_call(spec: &str) -> Option<(String, Vec<String>)> {
100
    let Some(open) = spec.find('(') else {
101
        return Some((spec.to_string(), Vec::new()));
102
    };
103
    let inner = spec.strip_suffix(')')?.get(open + 1..)?;
104
    let args: Vec<String> = inner.split(',').map(|a| a.trim().to_string()).collect();
105
    if args.iter().any(|a| a.is_empty()) {
106
        return None;
107
    }
108
    Some((spec[..open].trim().to_string(), args))
109
}
110
111
fn normalize(name: &str) -> String {
112
    name.chars()
113
        .filter(|c| *c != '_')
114
        .flat_map(char::to_lowercase)
115
        .collect()
116
}
117
118
/// Length arguments are capped: a stray `randomString(999999999)` shouldn't
119
/// build a gigabyte of request body.
120
fn parse_len(s: &str) -> Option<usize> {
121
    let n = s.parse::<usize>().ok()?;
122
    (1..=4096).contains(&n).then_some(n)
123
}
124
125
// ----- clock -----
126
127
fn unix_millis() -> u128 {
128
    SystemTime::now()
129
        .duration_since(UNIX_EPOCH)
130
        .map(|d| d.as_millis())
131
        .unwrap_or(0)
132
}
133
134
fn unix_secs() -> i64 {
135
    (unix_millis() / 1000) as i64
136
}
137
138
/// RFC 3339 in UTC. Hand-rolled rather than pulling in a date crate: the only
139
/// calendar work cielago does is stamping a request.
140
fn iso_timestamp(secs: i64) -> String {
141
    let days = secs.div_euclid(86_400);
142
    let time = secs.rem_euclid(86_400);
143
    let (y, m, d) = civil_from_days(days);
144
    let (h, min, s) = (time / 3600, (time % 3600) / 60, time % 60);
145
    format!("{y:04}-{m:02}-{d:02}T{h:02}:{min:02}:{s:02}Z")
146
}
147
148
/// Days since 1970-01-01 → (year, month, day). Hinnant's civil-from-days.
149
fn civil_from_days(z: i64) -> (i64, u32, u32) {
150
    let z = z + 719_468;
151
    let era = z.div_euclid(146_097);
152
    let doe = z.rem_euclid(146_097); // [0, 146096]
153
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399]
154
    let y = yoe + era * 400;
155
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
156
    let mp = (5 * doy + 2) / 153; // [0, 11], March-based
157
    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
158
    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
159
    (if m <= 2 { y + 1 } else { y }, m, d)
160
}
161
162
// ----- randomness -----
163
164
/// Random bytes borrowed from UUID v4 generation, so no extra RNG dependency.
165
/// Bytes 6 and 8 carry the version/variant bits and are dropped.
166
fn random_bytes(n: usize) -> Vec<u8> {
167
    let mut out = Vec::with_capacity(n + 14);
168
    while out.len() < n {
169
        let id = *Uuid::new_v4().as_bytes();
170
        out.extend(
171
            id.iter()
172
                .enumerate()
173
                .filter(|(i, _)| *i != 6 && *i != 8)
174
                .map(|(_, b)| *b),
175
        );
176
    }
177
    out.truncate(n);
178
    out
179
}
180
181
fn random_u64() -> u64 {
182
    let b = random_bytes(8);
183
    u64::from_le_bytes(b.try_into().expect("8 bytes requested"))
184
}
185
186
/// Uniform-ish over `[min, max]`; the modulo bias is irrelevant for test data.
187
/// Widened to i128 so a full-range `randomInt(i64::MIN, i64::MAX)` can't wrap.
188
fn random_int(min: i64, max: i64) -> i64 {
189
    let span = (max as i128 - min as i128 + 1) as u128;
190
    (min as i128 + (random_u64() as u128 % span) as i128) as i64
191
}
192
193
fn random_hex(n: usize) -> String {
194
    random_bytes(n.div_ceil(2))
195
        .iter()
196
        .map(|b| format!("{b:02x}"))
197
        .collect::<String>()
198
        .chars()
199
        .take(n)
200
        .collect()
201
}
202
203
fn random_string(n: usize) -> String {
204
    const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
205
    random_bytes(n)
206
        .iter()
207
        .map(|b| ALPHABET[*b as usize % ALPHABET.len()] as char)
208
        .collect()
209
}
210
211
#[cfg(test)]
212
mod tests {
213
    use super::*;
214
215
    fn vars() -> HashMap<String, String> {
216
        HashMap::from([
217
            ("tenant".to_string(), "acme".to_string()),
218
            ("version".to_string(), "v2".to_string()),
219
        ])
220
    }
221
222
    #[test]
223
    fn substitutes_named_vars() {
224
        assert_eq!(
225
            substitute("/{{tenant}}/{{ version }}/x", &vars()),
226
            "/acme/v2/x"
227
        );
228
    }
229
230
    #[test]
231
    fn uuid_is_fresh_per_occurrence() {
232
        let out = substitute("{{uuid}}-{{uuid}}", &vars());
233
        assert_eq!(out.len(), 36 + 1 + 36);
234
        let (first, rest) = out.split_at(36);
235
        let second = &rest[1..];
236
        assert_eq!(&rest[..1], "-");
237
        assert!(uuid::Uuid::parse_str(first).is_ok());
238
        assert!(uuid::Uuid::parse_str(second).is_ok());
239
        assert_ne!(first, second);
240
    }
241
242
    #[test]
243
    fn unknown_vars_left_intact() {
244
        assert_eq!(substitute("{{nope}}", &vars()), "{{nope}}");
245
        assert_eq!(substitute("{{$nope}}", &vars()), "{{$nope}}");
246
    }
247
248
    #[test]
249
    fn unclosed_brace_left_intact() {
250
        assert_eq!(substitute("a {{oops", &vars()), "a {{oops");
251
    }
252
253
    #[test]
254
    fn collection_var_shadows_dynamic_unless_dollar_prefixed() {
255
        let vars = HashMap::from([("uuid".to_string(), "pinned".to_string())]);
256
        assert_eq!(substitute("{{uuid}}", &vars), "pinned");
257
        assert_eq!(substitute("{{$uuid}}", &vars).len(), 36);
258
    }
259
260
    #[test]
261
    fn timestamps_are_plausible() {
262
        let secs: i64 = substitute("{{timestamp}}", &vars()).parse().unwrap();
263
        // Somewhere after 2020 and before 2100.
264
        assert!((1_577_836_800..4_102_444_800).contains(&secs));
265
        let ms: i64 = substitute("{{timestampMs}}", &vars()).parse().unwrap();
266
        assert_eq!(ms / 1000, secs);
267
268
        let iso = substitute("{{isoTimestamp}}", &vars());
269
        assert_eq!(iso.len(), 20, "{iso}");
270
        assert!(iso.ends_with('Z'));
271
        assert_eq!(&iso[4..5], "-");
272
        assert_eq!(&iso[10..11], "T");
273
    }
274
275
    #[test]
276
    fn iso_timestamp_matches_known_instants() {
277
        assert_eq!(iso_timestamp(0), "1970-01-01T00:00:00Z");
278
        assert_eq!(iso_timestamp(1_000_000_000), "2001-09-09T01:46:40Z");
279
        // Leap day.
280
        assert_eq!(iso_timestamp(1_709_164_800), "2024-02-29T00:00:00Z");
281
        assert_eq!(iso_timestamp(1_754_484_896), "2025-08-06T12:54:56Z");
282
    }
283
284
    #[test]
285
    fn name_matching_ignores_case_and_underscores() {
286
        for name in ["isoTimestamp", "iso_timestamp", "ISO_TIMESTAMP"] {
287
            assert!(
288
                substitute(&format!("{{{{{name}}}}}"), &vars()).ends_with('Z'),
289
                "{name}"
290
            );
291
        }
292
    }
293
294
    #[test]
295
    fn random_int_respects_bounds() {
296
        for _ in 0..200 {
297
            let n: i64 = substitute("{{randomInt(5, 7)}}", &vars()).parse().unwrap();
298
            assert!((5..=7).contains(&n), "{n}");
299
        }
300
        assert_eq!(substitute("{{randomInt(-1,-1)}}", &vars()), "-1");
301
        let d: i64 = substitute("{{randomInt}}", &vars()).parse().unwrap();
302
        assert!((0..=1000).contains(&d));
303
    }
304
305
    #[test]
306
    fn random_strings_have_requested_length() {
307
        assert_eq!(substitute("{{randomHex}}", &vars()).len(), 16);
308
        assert_eq!(substitute("{{randomHex(7)}}", &vars()).len(), 7);
309
        assert_eq!(substitute("{{randomString}}", &vars()).len(), 16);
310
        assert_eq!(substitute("{{randomString(40)}}", &vars()).len(), 40);
311
        assert!(
312
            substitute("{{randomHex(9)}}", &vars())
313
                .chars()
314
                .all(|c| c.is_ascii_hexdigit())
315
        );
316
        assert!(
317
            substitute("{{randomString(64)}}", &vars())
318
                .chars()
319
                .all(|c| c.is_ascii_alphanumeric())
320
        );
321
    }
322
323
    #[test]
324
    fn random_bool_is_a_bool_and_varies() {
325
        let mut seen = std::collections::HashSet::new();
326
        for _ in 0..100 {
327
            let v = substitute("{{randomBool}}", &vars());
328
            assert!(v == "true" || v == "false", "{v}");
329
            seen.insert(v);
330
        }
331
        assert_eq!(seen.len(), 2, "randomBool never flipped");
332
    }
333
334
    #[test]
335
    fn bad_arguments_leave_the_placeholder() {
336
        for bad in [
337
            "{{randomInt(9,1)}}",
338
            "{{randomInt(a,b)}}",
339
            "{{randomInt(1)}}",
340
            "{{randomHex(0)}}",
341
            "{{randomHex(99999)}}",
342
            "{{randomString()}}",
343
            "{{uuid(2)}}",
344
        ] {
345
            assert_eq!(substitute(bad, &vars()), bad);
346
        }
347
    }
348
349
    #[test]
350
    fn every_documented_dynamic_var_resolves() {
351
        for (name, _) in DYNAMIC_VARS {
352
            let out = substitute(&format!("{{{{${name}}}}}"), &vars());
353
            assert!(!out.contains("{{"), "{name} did not resolve: {out}");
354
        }
355
    }
356
}