1use crate::proto::api;
2use anyhow::{anyhow, Result};
3use serde::{de::DeserializeOwned, Serialize};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use std::sync::Arc;
7const API_BASE_URL: &str = "https://api.retailops.com";
8const USER_AGENT: &str = "Integration Layer";
9
10#[derive(serde::Serialize, serde::Deserialize)]
11struct CacheEntry<T> {
12 data: T,
13 timestamp: u64,
14}
15
16impl<T> CacheEntry<T> {
17 fn new(data: T) -> Self {
18 let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
19 Self { data, timestamp }
20 }
21
22 fn is_expired(&self) -> bool {
23 let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
24 let age = now - self.timestamp;
25 age > 24 * 60 * 60 }
27}
28
29#[derive(Clone)]
30pub struct ROClient {
31 client: Arc<reqwest::Client>,
32 cache: Arc<sled::Db>,
33}
34
35impl ROClient {
36 pub fn new(api_key: String) -> Result<Self> {
37 let mut headers = reqwest::header::HeaderMap::new();
38
39 let credential_id = api_key
40 .split("-")
41 .next()
42 .ok_or(anyhow!("Invalid API key - must be in format <credential_id>-<secret>"))?;
43
44 headers.insert("apikey", reqwest::header::HeaderValue::from_str(&api_key)?);
45 headers.insert(
46 reqwest::header::USER_AGENT,
47 reqwest::header::HeaderValue::from_static(USER_AGENT),
48 );
49
50 let client = reqwest::Client::builder().default_headers(headers).build()?;
51
52 let cache_dir = dirs::cache_dir()
54 .ok_or_else(|| anyhow!("Could not determine cache directory"))?
55 .join("retailops-api-cache")
56 .join(credential_id);
57
58 println!("Cache directory: {}", cache_dir.display());
59 std::fs::create_dir_all(&cache_dir)?;
61
62 let cache = sled::open(cache_dir)?;
64
65 Ok(Self {
66 client: Arc::new(client),
67 cache: Arc::new(cache),
68 })
69 }
70
71 fn set_cache<T: Serialize>(&self, key: &str, data: &T) -> Result<()> {
72 let entry = CacheEntry::new(data);
73 self.cache.insert(
74 key.as_bytes(),
75 serde_json::to_vec(&entry).map_err(|e| anyhow!("Failed to serialize: {}", e))?,
76 )?;
77 Ok(())
78 }
79
80 fn get_cache<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>> {
81 if let Some(cached) = self.cache.get(key.as_bytes())? {
82 if let Ok(entry) = serde_json::from_slice::<CacheEntry<T>>(&cached) {
83 if !entry.is_expired() {
84 return Ok(Some(entry.data));
85 }
86 let _ = self.cache.remove(key.as_bytes());
88 }
89 }
90 Ok(None)
91 }
92
93 pub fn clear_cache(&self) -> Result<()> {
94 self.cache.clear()?;
95 Ok(())
96 }
97
98 fn parse_action_path(path: &str) -> Result<(String, String, u32)> {
99 let parts: Vec<&str> = path.split('~').collect();
100 if parts.len() != 2 {
101 return Err(anyhow!(
102 "Action path must include version number after ~ (e.g. 'namespace.action~1')"
103 ));
104 }
105
106 let version = parts[1].parse::<u32>()?;
107 let action_parts: Vec<&str> = parts[0].split('.').collect();
108 if action_parts.len() < 2 {
109 return Err(anyhow!(
110 "Action path must have at least two parts before version (e.g. 'namespace.action~1')"
111 ));
112 }
113
114 let action = action_parts.last().unwrap().to_string();
115 let namespace = action_parts[..action_parts.len() - 1].join("/");
116
117 Ok((namespace, action, version))
118 }
119
120 fn format_error_context(text: &str, column: usize) -> String {
121 let start = column.saturating_sub(50);
122 let end = (column + 50).min(text.len());
123 let context = &text[start..end];
124
125 format!("...{}...\n{}^", context, " ".repeat(50.min(column - start)))
126 }
127
128 pub async fn call<T, R>(&self, action_path: &str, params: &T) -> Result<R>
129 where
130 T: Serialize + ?Sized,
131 R: DeserializeOwned,
132 {
133 let (namespace, action, version) = Self::parse_action_path(action_path)?;
134 let url = format!("{}/{}/{}~{}.json", API_BASE_URL, namespace, action, version);
135
136 let response = self.client.post(&url).json(params).send().await?;
137
138 if !response.status().is_success() {
139 return Err(anyhow!("API request failed with status: {}", response.status()));
140 }
141
142 let bytes = response.bytes().await?;
143 match serde_json::from_slice(&bytes) {
144 Ok(result) => Ok(result),
145 Err(e) => {
146 let text = String::from_utf8_lossy(&bytes);
147
148 let col = e.column().saturating_sub(1);
150 eprintln!(
151 "Failed to parse response from {}: {}\nContext at column {}:\n{}",
152 action_path,
153 e,
154 col + 1, Self::format_error_context(&text, col)
156 );
157 Err(e.into())
158 }
159 }
160 }
161
162 pub async fn get_user_by_id(&self, user_id: u32) -> Result<api::config::user::User> {
163 let blank_params = serde_json::Map::new();
165 use api::{config::user::User, ReadResponse};
166 let result: ReadResponse<User> = self.call("config.user.read~1", &blank_params).await?;
167
168 if let Some(user) = result.records.iter().find(|user| user.id == user_id) {
170 self.set_cache(&format!("user:{}", user_id), &user)?;
171 return Ok(user.clone());
172 }
173
174 Err(anyhow!("User not found"))
175 }
176
177 pub async fn annotate_record(&self, concept: &str, record_id: u32, note: &str) -> Result<()> {
178 let params = api::utility::eventlog::AnnotateRequest {
179 concept: concept.to_string(),
180 record_id,
181 note: note.to_string(),
182 };
183
184 if note.is_empty() || note.len() > 10000 {
186 return Err(anyhow!("Note must be between 1 and 10,000 characters"));
187 }
188 let _response: api::Response = self.call("utility.eventlog.annotate~1", ¶ms).await?;
189 Ok(())
190 }
191}