1use super::mapping::{self, Vars};
11use super::{KeySeq, PutOutcome, Store, StoreError};
12use crate::config::v2::{HttpOp, StoreHttp};
13use crate::net::http::{self, Url};
14use serde_json::{Value, json};
15use std::time::Duration;
16
17pub struct HttpStore {
18 cfg: StoreHttp,
19 timeout: Duration,
20}
21
22impl HttpStore {
23 pub fn new(cfg: StoreHttp, timeout: Duration) -> Result<HttpStore, StoreError> {
24 Url::parse(&cfg.base_url)
25 .map_err(|e| StoreError::Mapping(format!("store.http.base_url: {e}")))?;
26 if cfg.get.is_none() || cfg.put.is_none() {
27 return Err(StoreError::Mapping(
28 "store.http needs `get` and `put` operations".into(),
29 ));
30 }
31 Ok(HttpStore { cfg, timeout })
32 }
33
34 fn vars(&self, key: &str, seq: Option<u64>, envelope: Option<&Value>) -> Vars {
35 let mut parts = key.splitn(4, '/');
36 let prefix = parts.next().unwrap_or("");
37 let instance = parts.next().unwrap_or("");
38 let kind = parts.next().unwrap_or("");
39 let id = parts.next().unwrap_or("");
40 let mut v = mapping::store_vars(key, seq, prefix, instance, envelope, kind, id);
41 v.insert(
42 "base_url".into(),
43 Value::String(self.cfg.base_url.trim_end_matches('/').to_string()),
44 );
45 v
46 }
47
48 fn request(
50 &self,
51 op: &HttpOp,
52 vars: &Vars,
53 idempotency: Option<String>,
54 ) -> Result<(u16, Value), StoreError> {
55 let url_text = mapping::render_text(&op.url, vars)
56 .map_err(|e| StoreError::Mapping(format!("store.http url: {e}")))?;
57 let url = Url::parse(&url_text)
58 .map_err(|e| StoreError::Mapping(format!("store.http url {url_text:?}: {e}")))?;
59 let method = op.method.as_deref().unwrap_or("GET").to_ascii_uppercase();
60 let body: Vec<u8> = match &op.body {
61 Some(t) => {
62 let v = mapping::render_json(t, vars)
63 .map_err(|e| StoreError::Mapping(format!("store.http body: {e}")))?;
64 serde_json::to_vec(&v).unwrap_or_default()
65 }
66 None => Vec::new(),
67 };
68 let env = |k: &str| std::env::var(k).ok();
70 let mut headers: Vec<(String, String)> = Vec::new();
71 for (k, v) in &self.cfg.headers {
72 let val = crate::sec::secret::resolve(v, &env)
73 .map_err(|e| StoreError::Mapping(format!("store.http header {k}: {e}")))?;
74 headers.push((k.clone(), val));
75 }
76 if !body.is_empty() {
77 headers.push(("Content-Type".into(), "application/json".into()));
78 }
79 headers.push(("Accept".into(), "application/json".into()));
80 if let Some(idem) = idempotency {
81 headers.push(("Idempotency-Key".into(), idem));
82 }
83 let hdrs: Vec<(&str, &str)> = headers
84 .iter()
85 .map(|(k, v)| (k.as_str(), v.as_str()))
86 .collect();
87 let mut stream = connect(&url, self.timeout)?;
88 let resp = http::send(
89 stream.as_mut(),
90 &url.host_header(),
91 &method,
92 &url.path,
93 &hdrs,
94 &body,
95 )
96 .map_err(|e| StoreError::Io(format!("{method} {url_text}: {e}")))?;
97 let body_json: Value = serde_json::from_slice(&resp.body)
98 .unwrap_or_else(|_| Value::String(resp.body_str().into_owned()));
99 let hmap: serde_json::Map<String, Value> = resp
100 .headers
101 .iter()
102 .map(|(k, v)| (k.clone(), Value::String(v.clone())))
103 .collect();
104 Ok((
105 resp.status,
106 json!({ "body": body_json, "status": resp.status, "headers": hmap }),
107 ))
108 }
109}
110
111fn connect(url: &Url, timeout: Duration) -> Result<Box<dyn http::Stream>, StoreError> {
112 let tcp = http::connect_tcp(&url.host, url.port, timeout)
113 .map_err(|e| StoreError::Io(format!("connect {}: {e}", url.host)))?;
114 if url.is_tls() {
115 #[cfg(feature = "tls")]
116 {
117 let s = crate::net::tls::connect(tcp, &url.host, None)
118 .map_err(|e| StoreError::Io(format!("tls {}: {e}", url.host)))?;
119 return Ok(Box::new(s));
120 }
121 #[cfg(not(feature = "tls"))]
122 {
123 return Err(StoreError::Io("https store requires --features tls".into()));
124 }
125 }
126 Ok(Box::new(tcp))
127}
128
129impl Store for HttpStore {
130 fn put(&self, key: &str, seq: u64, envelope: &Value) -> Result<PutOutcome, StoreError> {
131 let op = self.cfg.put.as_ref().expect("validated");
132 let vars = self.vars(key, Some(seq), Some(envelope));
133 let (status, ctx) = self.request(op, &vars, Some(format!("{key}#{seq}")))?;
134 let conflict = op.conflict_status.unwrap_or(409);
135 if status == conflict {
136 let latest = ctx["body"]
137 .get("latest")
138 .or_else(|| ctx["body"].get("seq"))
139 .and_then(Value::as_u64);
140 return Ok(PutOutcome::Conflict { latest_seq: latest });
141 }
142 if (200..300).contains(&status) {
143 return Ok(PutOutcome::Ok);
144 }
145 Err(StoreError::Io(format!(
146 "put {key}: HTTP {status}: {}",
147 ctx["body"]
148 )))
149 }
150
151 fn get(&self, key: &str, seq: Option<u64>) -> Result<Option<Value>, StoreError> {
152 let op = self.cfg.get.as_ref().expect("validated");
153 let vars = self.vars(key, seq, None);
154 let (status, ctx) = self.request(op, &vars, None)?;
155 if status == 404 {
156 return Ok(None);
157 }
158 if !(200..300).contains(&status) {
159 return Err(StoreError::Io(format!("get {key}: HTTP {status}")));
160 }
161 let v = match &op.value {
162 Some(x) => mapping::extract(x, &ctx).map_err(|e| StoreError::Mapping(e.0))?,
163 None => Some(ctx["body"].clone()),
164 };
165 Ok(v.filter(|v| !v.is_null()))
166 }
167
168 fn list(&self, prefix: &str) -> Result<Vec<KeySeq>, StoreError> {
169 let Some(op) = &self.cfg.list else {
170 return Err(StoreError::Unsupported("list"));
171 };
172 let mut vars = self.vars(prefix, None, None);
173 vars.insert("prefix".into(), Value::String(prefix.to_string()));
174 let (status, ctx) = self.request(op, &vars, None)?;
175 if !(200..300).contains(&status) {
176 return Err(StoreError::Io(format!("list {prefix}: HTTP {status}")));
177 }
178 let keys = match &op.keys {
179 Some(x) => mapping::extract(x, &ctx).map_err(|e| StoreError::Mapping(e.0))?,
180 None => Some(ctx["body"]["keys"].clone()),
181 };
182 let mut out = Vec::new();
183 if let Some(Value::Array(items)) = keys {
184 for it in items {
185 match it {
186 Value::String(k) => out.push(KeySeq { key: k, seq: None }),
187 Value::Object(o) => {
188 if let Some(k) = o.get("key").and_then(Value::as_str) {
189 out.push(KeySeq {
190 key: k.to_string(),
191 seq: o.get("seq").and_then(Value::as_u64),
192 });
193 }
194 }
195 _ => {}
196 }
197 }
198 }
199 Ok(out)
200 }
201
202 fn delete(&self, key: &str) -> Result<(), StoreError> {
203 let Some(op) = &self.cfg.delete else {
204 return Err(StoreError::Unsupported("delete"));
205 };
206 let vars = self.vars(key, None, None);
207 let (status, _) = self.request(op, &vars, None)?;
208 if (200..300).contains(&status) || status == 404 {
209 return Ok(());
210 }
211 Err(StoreError::Io(format!("delete {key}: HTTP {status}")))
212 }
213
214 fn kind(&self) -> &'static str {
215 "http"
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use std::collections::BTreeMap;
223 use std::io::{BufRead, BufReader, Read, Write};
224 use std::net::TcpListener;
225 use std::sync::{Arc, Mutex};
226
227 type Seen = Arc<Mutex<Vec<(String, String, Vec<(String, String)>)>>>;
232
233 fn spawn_kv() -> (String, Seen) {
234 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
235 let addr = listener.local_addr().unwrap();
236 let seen: Seen = Arc::new(Mutex::new(Vec::new()));
237 let seen_t = seen.clone();
238 let data: Arc<Mutex<BTreeMap<String, (u64, Value)>>> =
239 Arc::new(Mutex::new(BTreeMap::new()));
240 std::thread::spawn(move || {
241 for conn in listener.incoming() {
242 let Ok(mut s) = conn else { continue };
243 let mut r = BufReader::new(s.try_clone().unwrap());
244 let mut line = String::new();
245 if r.read_line(&mut line).unwrap_or(0) == 0 {
246 continue;
247 }
248 let mut parts = line.split_whitespace();
249 let method = parts.next().unwrap_or("").to_string();
250 let target = parts.next().unwrap_or("").to_string();
251 let mut headers = Vec::new();
252 let mut clen = 0usize;
253 loop {
254 let mut h = String::new();
255 if r.read_line(&mut h).unwrap_or(0) == 0 {
256 break;
257 }
258 let t = h.trim_end();
259 if t.is_empty() {
260 break;
261 }
262 if let Some((k, v)) = t.split_once(':') {
263 let k = k.trim().to_ascii_lowercase();
264 if k == "content-length" {
265 clen = v.trim().parse().unwrap_or(0);
266 }
267 headers.push((k, v.trim().to_string()));
268 }
269 }
270 let mut body = vec![0u8; clen];
271 let _ = r.read_exact(&mut body);
272 seen_t
273 .lock()
274 .unwrap()
275 .push((method.clone(), target.clone(), headers));
276 let (path, query) = target
277 .split_once('?')
278 .map(|(p, q)| (p.to_string(), q.to_string()))
279 .unwrap_or((target.clone(), String::new()));
280 let mut d = data.lock().unwrap();
281 let (status, resp): (u16, Value) = match (method.as_str(), path.as_str()) {
282 ("PUT", p) if p.starts_with("/kv/") => {
283 let key = p.trim_start_matches("/kv/").to_string();
284 let seq: u64 = query.trim_start_matches("seq=").parse().unwrap_or(0);
285 if d.get(&key).is_some_and(|(cur, _)| *cur >= seq) {
286 (409, json!({"latest": d[&key].0}))
287 } else {
288 let v: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
289 d.insert(key, (seq, v));
290 (200, json!({"ok": true}))
291 }
292 }
293 ("GET", "/kv") => {
294 let prefix = query.trim_start_matches("prefix=").to_string();
295 let keys: Vec<Value> = d
296 .iter()
297 .filter(|(k, _)| k.starts_with(&prefix))
298 .map(|(k, (s, _))| json!({"key": k, "seq": s}))
299 .collect();
300 (200, json!({"keys": keys}))
301 }
302 ("GET", p) if p.starts_with("/kv/") => {
303 match d.get(p.trim_start_matches("/kv/")) {
304 Some((_, v)) => (200, v.clone()),
305 None => (404, json!({"error": "not found"})),
306 }
307 }
308 ("DELETE", p) if p.starts_with("/kv/") => {
309 d.remove(p.trim_start_matches("/kv/"));
310 (204, Value::Null)
311 }
312 _ => (400, json!({"error": "bad"})),
313 };
314 let text = if resp.is_null() {
315 String::new()
316 } else {
317 resp.to_string()
318 };
319 let head = format!(
320 "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
321 text.len()
322 );
323 let _ = s.write_all(head.as_bytes());
324 let _ = s.write_all(text.as_bytes());
325 }
326 });
327 (format!("http://{addr}"), seen)
328 }
329
330 #[test]
331 fn http_store_round_trips_with_conflicts_and_headers() {
332 unsafe { std::env::set_var("HTTP_STORE_TEST_TOKEN", "t0k") };
334 let (base, seen) = spawn_kv();
335 let cfg = StoreHttp {
336 base_url: base.clone(),
337 headers: [(
338 "authorization".to_string(),
339 "Bearer {{secret:HTTP_STORE_TEST_TOKEN}}".to_string(),
340 )]
341 .into_iter()
342 .collect(),
343 get: Some(HttpOp {
344 method: Some("GET".into()),
345 url: "{base_url}/kv/{key}".into(),
346 body: None,
347 value: Some("body".into()),
348 keys: None,
349 conflict_status: None,
350 }),
351 put: Some(HttpOp {
352 method: Some("PUT".into()),
353 url: "{base_url}/kv/{key}?seq={seq}".into(),
354 body: Some("{envelope}".into()),
355 value: None,
356 keys: None,
357 conflict_status: Some(409),
358 }),
359 list: Some(HttpOp {
360 method: Some("GET".into()),
361 url: "{base_url}/kv?prefix={prefix}".into(),
362 body: None,
363 value: None,
364 keys: Some("body.keys".into()),
365 conflict_status: None,
366 }),
367 delete: Some(HttpOp {
368 method: Some("DELETE".into()),
369 url: "{base_url}/kv/{key}".into(),
370 body: None,
371 value: None,
372 keys: None,
373 conflict_status: None,
374 }),
375 };
376 let s = HttpStore::new(cfg, Duration::from_secs(5)).unwrap();
377 let env = json!({"v": 2, "kind": "run", "id": "1", "seq": 1, "state": {"x": 1}});
378 assert_eq!(s.put("agentd/i/run/1", 1, &env).unwrap(), PutOutcome::Ok);
379 assert_eq!(
380 s.put("agentd/i/run/1", 1, &env).unwrap(),
381 PutOutcome::Conflict {
382 latest_seq: Some(1)
383 }
384 );
385 assert_eq!(s.get("agentd/i/run/1", None).unwrap(), Some(env.clone()));
386 assert_eq!(s.get("agentd/i/run/2", None).unwrap(), None);
387 let l = s.list("agentd/i/").unwrap();
388 assert_eq!(l.len(), 1);
389 assert_eq!(l[0].seq, Some(1));
390 s.delete("agentd/i/run/1").unwrap();
391 assert_eq!(s.get("agentd/i/run/1", None).unwrap(), None);
392 let seen = seen.lock().unwrap();
394 let (m, t, h) = &seen[0];
395 assert_eq!(m, "PUT");
396 assert!(t.contains("seq=1"));
397 assert!(
398 h.iter()
399 .any(|(k, v)| k == "authorization" && v == "Bearer t0k"),
400 "{h:?}"
401 );
402 assert!(
403 h.iter()
404 .any(|(k, v)| k == "idempotency-key" && v == "agentd/i/run/1#1"),
405 "{h:?}"
406 );
407 unsafe { std::env::remove_var("HTTP_STORE_TEST_TOKEN") };
408 }
409}