Skip to main content

seam_server/
context.rs

1/* src/server/core/rust/src/context.rs */
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use crate::errors::SeamError;
9
10/// Definition for a single context field: where to extract it and its schema.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ContextFieldDef {
13	pub extract: String,
14	pub schema: Value,
15}
16
17/// Map of context key -> field definition.
18pub type ContextConfig = BTreeMap<String, ContextFieldDef>;
19
20/// Raw extracted values from HTTP request (e.g. headers).
21/// `None` means the source was not present.
22pub type RawContextMap = BTreeMap<String, Option<String>>;
23
24/// Parse an extract rule like "header:authorization" into (source, key).
25pub 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
31/// Check whether any context fields are defined.
32pub fn context_has_extracts(config: &ContextConfig) -> bool {
33	!config.is_empty()
34}
35
36/// Parse a Cookie header into key-value pairs.
37pub 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
51/// Build a RawContextMap keyed by config key from request headers, cookies, and query string.
52pub 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
84/// Simple query string parameter lookup without pulling in the `url` crate.
85fn 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
98/// Resolve context values from raw extracted data for the given requested keys.
99/// Returns a JSON object with the requested context fields.
100pub 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				// Try JSON parse for complex types, fallback to string
116				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
128/// Extract property key names from a JTD schema's `properties` field.
129pub 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}