Skip to main content

featherbit/vars/
catalog.rs

1//! Machine-readable catalog of every variable [`super::resolve`] supports —
2//! the single source the Admin API (`GET /api/vars`), the UI autocomplete,
3//! and the var legend consume. Guarded against drift from the resolver by
4//! `test_catalog_matches_resolver`, which parses `resolve()`'s source.
5
6use serde::Serialize;
7
8/// Whether an entry is a fixed name or a `prefix_*` family.
9#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
10#[serde(rename_all = "lowercase")]
11pub enum VarKind {
12    Static,
13    Family,
14}
15
16/// One catalog row. `family_source` names the context collection that
17/// populates a family's live suggestions in the UI.
18#[derive(Debug, Serialize)]
19pub struct VarEntry {
20    pub name: &'static str,
21    pub kind: VarKind,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub family_source: Option<&'static str>,
24    pub description: &'static str,
25    pub example: &'static str,
26    /// Template path equivalent for the universal-templates feature.
27    /// Empty string means no direct template mapping (e.g., protocol, query_string, post_arg_*).
28    pub path: &'static str,
29}
30
31const S: VarKind = VarKind::Static;
32const F: VarKind = VarKind::Family;
33
34fn e(
35    name: &'static str,
36    kind: VarKind,
37    family_source: Option<&'static str>,
38    description: &'static str,
39    example: &'static str,
40    path: &'static str,
41) -> VarEntry {
42    VarEntry {
43        name,
44        kind,
45        family_source,
46        description,
47        example,
48        path,
49    }
50}
51
52/// Every variable `resolve()` accepts, statics first, then families.
53pub fn var_catalog() -> Vec<VarEntry> {
54    vec![
55        e(
56            "uri",
57            S,
58            None,
59            "Request path (no query string)",
60            "$uri",
61            "request.path",
62        ),
63        e(
64            "request_uri",
65            S,
66            None,
67            "Path plus ?query when query params exist",
68            "$request_uri",
69            "",
70        ),
71        e(
72            "method",
73            S,
74            None,
75            "HTTP method (alias: request_method)",
76            "$method",
77            "request.method",
78        ),
79        e(
80            "request_method",
81            S,
82            None,
83            "HTTP method (alias of method)",
84            "$request_method",
85            "request.method",
86        ),
87        e("host", S, None, "Request Host", "$host", "request.host"),
88        e(
89            "scheme",
90            S,
91            None,
92            "http or https",
93            "$scheme",
94            "request.scheme",
95        ),
96        e(
97            "protocol",
98            S,
99            None,
100            "HTTP protocol version (http1, http2, ...)",
101            "$protocol",
102            "",
103        ),
104        e(
105            "remote_addr",
106            S,
107            None,
108            "Client IP without port",
109            "$remote_addr",
110            "client.ip",
111        ),
112        e(
113            "remote_port",
114            S,
115            None,
116            "Client port",
117            "$remote_port",
118            "client.port",
119        ),
120        e(
121            "query_string",
122            S,
123            None,
124            "Full query string, rebuilt and sorted",
125            "$query_string",
126            "",
127        ),
128        e(
129            "status",
130            S,
131            None,
132            "Response status code",
133            "$status",
134            "response.status",
135        ),
136        e(
137            "resp_body",
138            S,
139            None,
140            "Response body (lossy UTF-8)",
141            "$resp_body",
142            "response.body",
143        ),
144        e(
145            "request_body",
146            S,
147            None,
148            "Request body (lossy UTF-8)",
149            "$request_body",
150            "request.body",
151        ),
152        e(
153            "consumer_name",
154            S,
155            None,
156            "Authenticated consumer name (set by auth plugins)",
157            "$consumer_name",
158            "message.consumer.name",
159        ),
160        e(
161            "consumer_group_id",
162            S,
163            None,
164            "Authenticated consumer group",
165            "$consumer_group_id",
166            "message.consumer.group",
167        ),
168        e(
169            "arg_*",
170            F,
171            Some("query_params"),
172            "First value of a query parameter",
173            "$arg_page",
174            "request.query.*",
175        ),
176        e(
177            "http_*",
178            F,
179            Some("request_headers"),
180            "First value of a request header (underscores map to dashes)",
181            "$http_user_agent",
182            "request.headers.*",
183        ),
184        e(
185            "cookie_*",
186            F,
187            Some("cookies"),
188            "Value from the Cookie request header",
189            "$cookie_session",
190            "request.cookies.*",
191        ),
192        e(
193            "post_arg_*",
194            F,
195            Some("form_body"),
196            "Form field from an application/x-www-form-urlencoded body",
197            "$post_arg_username",
198            "",
199        ),
200        e(
201            "msg_*",
202            F,
203            Some("message"),
204            "Any context.message key, stringified; dotted keys need ${msg_key.with.dots}",
205            "${msg_consumer.name}",
206            "message.*",
207        ),
208        e(
209            "sent_http_*",
210            F,
211            Some("response_headers"),
212            "First value of a response header (underscores map to dashes)",
213            "$sent_http_content_type",
214            "response.headers.*",
215        ),
216    ]
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use std::collections::BTreeSet;
223
224    /// The catalog must track resolve()'s source exactly, both directions.
225    /// Statics are quoted names on match-arm lines containing "=>"; families
226    /// are the strip_prefix("...") literals. Same source-parsing guard style
227    /// as KNOWN_PLUGIN_TYPES.
228    #[test]
229    fn test_catalog_matches_resolver() {
230        let src = include_str!("mod.rs");
231        // Limit the scan to resolve()'s body: from `pub fn resolve` to the
232        // next `pub fn` after it.
233        let start = src.find("pub fn resolve").expect("resolve fn present");
234        let rest = &src[start..];
235        let end = rest[10..]
236            .find("pub fn ")
237            .map(|i| i + 10)
238            .unwrap_or(rest.len());
239        let body = &rest[..end];
240
241        let mut from_source: BTreeSet<String> = BTreeSet::new();
242        for line in body.lines() {
243            let t = line.trim();
244            if t.contains("=>") {
245                // every quoted token on an arm line is a static var name
246                let mut s = t;
247                while let Some(open) = s.find('"') {
248                    let after = &s[open + 1..];
249                    if let Some(close) = after.find('"') {
250                        let name = &after[..close];
251                        if !name.is_empty()
252                            && name.chars().all(|c| c.is_ascii_lowercase() || c == '_')
253                        {
254                            from_source.insert(name.to_string());
255                        }
256                        s = &after[close + 1..];
257                    } else {
258                        break;
259                    }
260                }
261            }
262            if let Some(idx) = t.find("strip_prefix(\"") {
263                let after = &t[idx + 14..];
264                if let Some(close) = after.find('"') {
265                    from_source.insert(format!("{}*", &after[..close]));
266                }
267            }
268        }
269        // message_str constants referenced by consumer arms appear as quoted
270        // strings on arm lines ("consumer.name"/"consumer.group") — they are
271        // lookup keys, not var names; strip them.
272        from_source.remove("consumer.name");
273        from_source.remove("consumer.group");
274
275        let from_catalog: BTreeSet<String> =
276            var_catalog().iter().map(|v| v.name.to_string()).collect();
277
278        assert_eq!(from_catalog, from_source, "catalog drifted from resolve()");
279    }
280
281    #[test]
282    fn test_catalog_families_have_sources_and_statics_do_not() {
283        for v in var_catalog() {
284            match v.kind {
285                VarKind::Family => {
286                    assert!(
287                        v.family_source.is_some(),
288                        "{} missing family_source",
289                        v.name
290                    );
291                    assert!(
292                        v.name.ends_with("_*"),
293                        "{} family name must end in _*",
294                        v.name
295                    );
296                }
297                VarKind::Static => assert!(v.family_source.is_none(), "{}", v.name),
298            }
299        }
300    }
301}