retailops_sdk/
client.rs

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 // 24 hours in seconds
26    }
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        // Create a cache directory in a standard location
53        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        // Create the directory if it doesn't exist
60        std::fs::create_dir_all(&cache_dir)?;
61
62        // Open the database
63        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                // If entry is expired, remove it from cache
87                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                // Get the error location and convert to 0-based index for string slicing
149                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, // Show 1-based index in message
155                    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        // this api call doesn't support filtering by user_id - so we have to do it
164        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        // find the user in the result
169        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        // Validate note length
185        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", &params).await?;
189        Ok(())
190    }
191}