1use anyhow::{anyhow, Context, Result};
2use reqwest::Url;
3use serde::Deserialize;
4use serde_json::Value;
5
6#[derive(Clone)]
10pub struct UpstreamClient {
11 http: reqwest::Client,
12 base: String,
13}
14
15#[derive(Debug, Deserialize)]
16pub struct Snapshot {
17 #[allow(dead_code)]
18 pub prefix: String,
19 pub latest_event_id: i64,
20 pub items: Vec<SnapshotItem>,
21}
22
23#[derive(Debug, Deserialize)]
24pub struct SnapshotItem {
25 pub key: String,
26 pub value: Value,
27 pub event_id: i64,
28}
29
30#[derive(Debug, Deserialize)]
31pub struct GetResponse {
32 #[allow(dead_code)]
33 pub key: String,
34 pub value: Value,
35 pub event_id: i64,
36}
37
38impl UpstreamClient {
39 pub fn new(base_url: String) -> Self {
40 let http = reqwest::Client::builder()
41 .build()
42 .expect("build reqwest client");
43 Self {
44 http,
45 base: base_url.trim_end_matches('/').to_string(),
46 }
47 }
48
49 pub async fn snapshot(&self, prefix: &str) -> Result<Snapshot> {
51 let url = Url::parse_with_params(&format!("{}/v1/kv", self.base), &[("prefix", prefix)])
52 .context("build snapshot url")?;
53 let resp = self.http.get(url).send().await.context("snapshot request")?;
54 let status = resp.status();
55 if !status.is_success() {
56 let body = resp.text().await.unwrap_or_default();
57 return Err(anyhow!("snapshot failed: HTTP {status}: {body}"));
58 }
59 Ok(resp.json().await.context("decode snapshot response")?)
60 }
61
62 pub async fn get_key(&self, key: &str) -> Result<Option<GetResponse>> {
65 let url = format!("{}/v1/kv/{}", self.base, encode_key_path(key));
66 let resp = self.http.get(&url).send().await.context("get_key request")?;
67 let status = resp.status();
68 if status == reqwest::StatusCode::NOT_FOUND {
69 return Ok(None);
70 }
71 if !status.is_success() {
72 let body = resp.text().await.unwrap_or_default();
73 return Err(anyhow!("get_key failed: HTTP {status}: {body}"));
74 }
75 Ok(Some(resp.json().await.context("decode get response")?))
76 }
77
78 pub async fn open_watch(&self, prefix: &str, since: i64) -> Result<reqwest::Response> {
81 let url = Url::parse_with_params(
82 &format!("{}/v1/watch", self.base),
83 &[("prefix", prefix), ("since", &since.to_string())],
84 )
85 .context("build watch url")?;
86 let resp = self
87 .http
88 .get(url)
89 .header("Accept", "text/event-stream")
90 .send()
91 .await
92 .context("watch request")?;
93 let status = resp.status();
94 if !status.is_success() {
95 let body = resp.text().await.unwrap_or_default();
96 return Err(anyhow!("watch open failed: HTTP {status}: {body}"));
97 }
98 Ok(resp)
99 }
100}
101
102fn encode_key_path(key: &str) -> String {
106 use std::fmt::Write;
107 let mut s = String::with_capacity(key.len());
108 for b in key.bytes() {
109 let safe = matches!(b,
110 b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z'
111 | b'-' | b'.' | b'_' | b'~' | b'/'
112 );
113 if safe {
114 s.push(b as char);
115 } else {
116 let _ = write!(s, "%{:02X}", b);
117 }
118 }
119 s
120}
121
122#[cfg(test)]
123mod tests {
124 use super::encode_key_path;
125
126 #[test]
127 fn preserves_slashes() {
128 assert_eq!(encode_key_path("a/b/c"), "a/b/c");
129 }
130
131 #[test]
132 fn percent_encodes_unsafe() {
133 assert_eq!(encode_key_path("a b"), "a%20b");
134 assert_eq!(encode_key_path("?#&"), "%3F%23%26");
135 }
136}