1use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use crate::errors::SeamError;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ContextFieldDef {
13 pub extract: String,
14 pub schema: Value,
15}
16
17pub type ContextConfig = BTreeMap<String, ContextFieldDef>;
19
20pub type RawContextMap = BTreeMap<String, Option<String>>;
23
24pub fn parse_extract_rule(rule: &str) -> Result<(&str, &str), SeamError> {
26 rule
27 .split_once(':')
28 .ok_or_else(|| SeamError::context_error(format!("Invalid extract rule: '{rule}'")))
29}
30
31pub fn context_has_extracts(config: &ContextConfig) -> bool {
33 !config.is_empty()
34}
35
36pub fn parse_cookie_header(header: &str) -> Vec<(&str, &str)> {
38 header
39 .split(';')
40 .filter_map(|pair| {
41 let pair = pair.trim();
42 let idx = pair.find('=')?;
43 if idx == 0 {
44 return None;
45 }
46 Some((&pair[..idx], &pair[idx + 1..]))
47 })
48 .collect()
49}
50
51pub fn extract_raw_context(
53 config: &ContextConfig,
54 headers: &[(String, String)],
55 cookie_header: Option<&str>,
56 query_string: Option<&str>,
57) -> RawContextMap {
58 let mut raw = RawContextMap::new();
59 let mut cookie_cache: Option<Vec<(&str, &str)>> = None;
60
61 for (ctx_key, field) in config {
62 let Ok((source, extract_key)) = parse_extract_rule(&field.extract) else {
63 raw.insert(ctx_key.clone(), None);
64 continue;
65 };
66 let value = match source {
67 "header" => {
68 let lower = extract_key.to_lowercase();
69 headers.iter().find(|(k, _)| k == &lower).map(|(_, v)| v.clone())
70 }
71 "cookie" => {
72 let cookies =
73 cookie_cache.get_or_insert_with(|| parse_cookie_header(cookie_header.unwrap_or("")));
74 cookies.iter().find(|(k, _)| *k == extract_key).map(|(_, v)| (*v).to_string())
75 }
76 "query" => query_string.and_then(|qs| form_urlencoded_get(qs, extract_key)),
77 _ => None,
78 };
79 raw.insert(ctx_key.clone(), value);
80 }
81 raw
82}
83
84fn form_urlencoded_get(qs: &str, key: &str) -> Option<String> {
86 for pair in qs.split('&') {
87 if let Some((k, v)) = pair.split_once('=') {
88 if k == key {
89 return Some(v.to_string());
90 }
91 } else if pair == key {
92 return Some(String::new());
93 }
94 }
95 None
96}
97
98pub fn resolve_context(
101 config: &ContextConfig,
102 raw: &RawContextMap,
103 requested_keys: &[String],
104) -> Result<Value, SeamError> {
105 let mut ctx = serde_json::Map::new();
106
107 for key in requested_keys {
108 let Some(_field_def) = config.get(key) else {
109 ctx.insert(key.clone(), Value::Null);
110 continue;
111 };
112
113 match raw.get(key) {
114 Some(Some(value)) => {
115 let parsed = serde_json::from_str(value).unwrap_or(Value::String(value.clone()));
117 ctx.insert(key.clone(), parsed);
118 }
119 _ => {
120 ctx.insert(key.clone(), Value::Null);
121 }
122 }
123 }
124
125 Ok(Value::Object(ctx))
126}
127
128pub fn context_keys_from_schema(schema: &Value) -> Vec<String> {
130 schema
131 .get("properties")
132 .and_then(|p| p.as_object())
133 .map(|obj| obj.keys().cloned().collect())
134 .unwrap_or_default()
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 #[test]
142 fn parse_extract_rule_valid() {
143 let (source, key) = parse_extract_rule("header:authorization").unwrap();
144 assert_eq!(source, "header");
145 assert_eq!(key, "authorization");
146 }
147
148 #[test]
149 fn parse_extract_rule_invalid() {
150 assert!(parse_extract_rule("no-colon").is_err());
151 }
152
153 #[test]
154 fn context_has_extracts_true() {
155 let mut config = ContextConfig::new();
156 config.insert(
157 "token".into(),
158 ContextFieldDef {
159 extract: "header:Authorization".into(),
160 schema: serde_json::json!({"type": "string"}),
161 },
162 );
163 assert!(context_has_extracts(&config));
164 }
165
166 #[test]
167 fn context_has_extracts_false() {
168 assert!(!context_has_extracts(&ContextConfig::new()));
169 }
170
171 #[test]
172 fn parse_cookie_header_basic() {
173 let cookies = parse_cookie_header("session=abc; lang=en");
174 assert_eq!(cookies.len(), 2);
175 assert!(cookies.contains(&("session", "abc")));
176 assert!(cookies.contains(&("lang", "en")));
177 }
178
179 #[test]
180 fn parse_cookie_header_empty() {
181 let cookies = parse_cookie_header("");
182 assert!(cookies.is_empty());
183 }
184
185 #[test]
186 fn extract_raw_context_header() {
187 let mut config = ContextConfig::new();
188 config.insert(
189 "token".into(),
190 ContextFieldDef {
191 extract: "header:authorization".into(),
192 schema: serde_json::json!({"type": "string"}),
193 },
194 );
195 let headers = vec![("authorization".into(), "Bearer abc".into())];
196 let raw = extract_raw_context(&config, &headers, None, None);
197 assert_eq!(raw["token"], Some("Bearer abc".into()));
198 }
199
200 #[test]
201 fn extract_raw_context_cookie() {
202 let mut config = ContextConfig::new();
203 config.insert(
204 "session".into(),
205 ContextFieldDef {
206 extract: "cookie:sid".into(),
207 schema: serde_json::json!({"type": "string"}),
208 },
209 );
210 let raw = extract_raw_context(&config, &[], Some("sid=abc123; other=x"), None);
211 assert_eq!(raw["session"], Some("abc123".into()));
212 }
213
214 #[test]
215 fn extract_raw_context_query() {
216 let mut config = ContextConfig::new();
217 config.insert(
218 "lang".into(),
219 ContextFieldDef {
220 extract: "query:lang".into(),
221 schema: serde_json::json!({"type": "string"}),
222 },
223 );
224 let raw = extract_raw_context(&config, &[], None, Some("lang=en&foo=bar"));
225 assert_eq!(raw["lang"], Some("en".into()));
226 }
227
228 #[test]
229 fn extract_raw_context_missing_returns_none() {
230 let mut config = ContextConfig::new();
231 config.insert(
232 "session".into(),
233 ContextFieldDef {
234 extract: "cookie:sid".into(),
235 schema: serde_json::json!({"type": "string"}),
236 },
237 );
238 config.insert(
239 "lang".into(),
240 ContextFieldDef {
241 extract: "query:lang".into(),
242 schema: serde_json::json!({"type": "string"}),
243 },
244 );
245 let raw = extract_raw_context(&config, &[], None, None);
246 assert_eq!(raw["session"], None);
247 assert_eq!(raw["lang"], None);
248 }
249
250 #[test]
251 fn resolve_context_string_value() {
252 let mut config = ContextConfig::new();
253 config.insert(
254 "token".into(),
255 ContextFieldDef {
256 extract: "header:authorization".into(),
257 schema: serde_json::json!({"type": "string"}),
258 },
259 );
260 let mut raw = RawContextMap::new();
261 raw.insert("token".into(), Some("Bearer abc".into()));
262
263 let ctx = resolve_context(&config, &raw, &["token".into()]).unwrap();
264 assert_eq!(ctx["token"], "Bearer abc");
265 }
266
267 #[test]
268 fn resolve_context_null_value() {
269 let mut config = ContextConfig::new();
270 config.insert(
271 "token".into(),
272 ContextFieldDef {
273 extract: "header:authorization".into(),
274 schema: serde_json::json!({"type": "string"}),
275 },
276 );
277 let raw = RawContextMap::new();
278
279 let ctx = resolve_context(&config, &raw, &["token".into()]).unwrap();
280 assert_eq!(ctx["token"], Value::Null);
281 }
282
283 #[test]
284 fn resolve_context_undefined_key() {
285 let config = ContextConfig::new();
286 let raw = RawContextMap::new();
287
288 let ctx = resolve_context(&config, &raw, &["missing".into()]).unwrap();
289 assert_eq!(ctx["missing"], Value::Null);
290 }
291
292 #[test]
293 fn context_keys_from_schema_extracts_properties() {
294 let schema = serde_json::json!({
295 "properties": {
296 "token": {"type": "string"},
297 "userId": {"type": "string"}
298 }
299 });
300 let mut keys = context_keys_from_schema(&schema);
301 keys.sort();
302 assert_eq!(keys, vec!["token", "userId"]);
303 }
304
305 #[test]
306 fn context_keys_from_schema_empty() {
307 let schema = serde_json::json!({"type": "string"});
308 let keys = context_keys_from_schema(&schema);
309 assert!(keys.is_empty());
310 }
311}