1use crate::errors::TapError;
7use atproto_identity::model::Document;
8use base64::Engine;
9use base64::engine::general_purpose::STANDARD as BASE64;
10use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone)]
34pub struct TapClient {
35 http_client: reqwest::Client,
36 base_url: String,
37 auth_header: Option<HeaderValue>,
38}
39
40impl TapClient {
41 pub fn new(hostname: &str, admin_password: Option<String>) -> Self {
48 let auth_header = admin_password.map(|password| {
49 let credentials = format!("admin:{}", password);
50 let encoded = BASE64.encode(credentials.as_bytes());
51 HeaderValue::from_str(&format!("Basic {}", encoded)).expect("Invalid auth header value")
52 });
53
54 Self {
55 http_client: reqwest::Client::new(),
56 base_url: format!("http://{}", hostname),
57 auth_header,
58 }
59 }
60
61 fn default_headers(&self) -> HeaderMap {
63 let mut headers = HeaderMap::new();
64 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
65 if let Some(auth) = &self.auth_header {
66 headers.insert(AUTHORIZATION, auth.clone());
67 }
68 headers
69 }
70
71 pub async fn add_repos(&self, dids: &[&str]) -> Result<(), TapError> {
88 let url = format!("{}/repos/add", self.base_url);
89 let body = AddReposRequest {
90 dids: dids.iter().map(|s| s.to_string()).collect(),
91 };
92
93 let response = self
94 .http_client
95 .post(&url)
96 .headers(self.default_headers())
97 .json(&body)
98 .send()
99 .await?;
100
101 if response.status().is_success() {
102 tracing::debug!(count = dids.len(), "Added repositories to TAP");
103 Ok(())
104 } else {
105 let status = response.status().as_u16();
106 let message = response.text().await.unwrap_or_default();
107 Err(TapError::HttpResponseError { status, message })
108 }
109 }
110
111 pub async fn remove_repos(&self, dids: &[&str]) -> Result<(), TapError> {
119 let url = format!("{}/repos/remove", self.base_url);
120 let body = AddReposRequest {
121 dids: dids.iter().map(|s| s.to_string()).collect(),
122 };
123
124 let response = self
125 .http_client
126 .post(&url)
127 .headers(self.default_headers())
128 .json(&body)
129 .send()
130 .await?;
131
132 if response.status().is_success() {
133 tracing::debug!(count = dids.len(), "Removed repositories from TAP");
134 Ok(())
135 } else {
136 let status = response.status().as_u16();
137 let message = response.text().await.unwrap_or_default();
138 Err(TapError::HttpResponseError { status, message })
139 }
140 }
141
142 pub async fn health(&self) -> Result<bool, TapError> {
150 let url = format!("{}/health", self.base_url);
151
152 let response = self
153 .http_client
154 .get(&url)
155 .headers(self.default_headers())
156 .send()
157 .await?;
158
159 Ok(response.status().is_success())
160 }
161
162 pub async fn resolve(&self, did: &str) -> Result<Document, TapError> {
174 let url = format!("{}/resolve/{}", self.base_url, did);
175
176 let response = self
177 .http_client
178 .get(&url)
179 .headers(self.default_headers())
180 .send()
181 .await?;
182
183 if response.status().is_success() {
184 let doc: Document = response.json().await?;
185 Ok(doc)
186 } else {
187 let status = response.status().as_u16();
188 let message = response.text().await.unwrap_or_default();
189 Err(TapError::HttpResponseError { status, message })
190 }
191 }
192
193 pub async fn info(&self, did: &str) -> Result<RepoInfo, TapError> {
205 let url = format!("{}/info/{}", self.base_url, did);
206
207 let response = self
208 .http_client
209 .get(&url)
210 .headers(self.default_headers())
211 .send()
212 .await?;
213
214 if response.status().is_success() {
215 let info: RepoInfo = response.json().await?;
216 Ok(info)
217 } else {
218 let status = response.status().as_u16();
219 let message = response.text().await.unwrap_or_default();
220 Err(TapError::HttpResponseError { status, message })
221 }
222 }
223}
224
225#[derive(Debug, Serialize)]
227struct AddReposRequest {
228 dids: Vec<String>,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct RepoInfo {
234 pub did: Box<str>,
236 pub state: RepoState,
238 #[serde(default)]
240 pub handle: Option<Box<str>>,
241 #[serde(default)]
243 pub records: u64,
244 #[serde(default)]
246 pub rev: Option<Box<str>>,
247 #[serde(default)]
249 pub retries: u32,
250 #[serde(default)]
252 pub error: Option<Box<str>>,
253 #[serde(flatten)]
255 pub extra: serde_json::Value,
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
260#[serde(rename_all = "lowercase")]
261pub enum RepoState {
262 Active,
264 Syncing,
266 Synced,
268 Failed,
270 Queued,
272 #[serde(other)]
274 Unknown,
275}
276
277#[deprecated(since = "0.13.0", note = "Use RepoState instead")]
279pub type RepoStatus = RepoState;
280
281impl std::fmt::Display for RepoState {
282 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
283 match self {
284 RepoState::Active => write!(f, "active"),
285 RepoState::Syncing => write!(f, "syncing"),
286 RepoState::Synced => write!(f, "synced"),
287 RepoState::Failed => write!(f, "failed"),
288 RepoState::Queued => write!(f, "queued"),
289 RepoState::Unknown => write!(f, "unknown"),
290 }
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 #[test]
299 fn test_client_creation() {
300 let client = TapClient::new("localhost:2480", None);
301 assert_eq!(client.base_url, "http://localhost:2480");
302 assert!(client.auth_header.is_none());
303
304 let client = TapClient::new("localhost:2480", Some("secret".to_string()));
305 assert!(client.auth_header.is_some());
306 }
307
308 #[test]
309 fn test_repo_state_display() {
310 assert_eq!(RepoState::Active.to_string(), "active");
311 assert_eq!(RepoState::Syncing.to_string(), "syncing");
312 assert_eq!(RepoState::Synced.to_string(), "synced");
313 assert_eq!(RepoState::Failed.to_string(), "failed");
314 assert_eq!(RepoState::Queued.to_string(), "queued");
315 assert_eq!(RepoState::Unknown.to_string(), "unknown");
316 }
317
318 #[test]
319 fn test_repo_state_deserialize() {
320 let json = r#""active""#;
321 let state: RepoState = serde_json::from_str(json).unwrap();
322 assert_eq!(state, RepoState::Active);
323
324 let json = r#""syncing""#;
325 let state: RepoState = serde_json::from_str(json).unwrap();
326 assert_eq!(state, RepoState::Syncing);
327
328 let json = r#""some_new_state""#;
329 let state: RepoState = serde_json::from_str(json).unwrap();
330 assert_eq!(state, RepoState::Unknown);
331 }
332
333 #[test]
334 fn test_repo_info_deserialize() {
335 let json = r#"{"did":"did:plc:cbkjy5n7bk3ax2wplmtjofq2","error":"","handle":"ngerakines.me","records":21382,"retries":0,"rev":"3mam4aazabs2m","state":"active"}"#;
336 let info: RepoInfo = serde_json::from_str(json).unwrap();
337 assert_eq!(&*info.did, "did:plc:cbkjy5n7bk3ax2wplmtjofq2");
338 assert_eq!(info.state, RepoState::Active);
339 assert_eq!(info.handle.as_deref(), Some("ngerakines.me"));
340 assert_eq!(info.records, 21382);
341 assert_eq!(info.retries, 0);
342 assert_eq!(info.rev.as_deref(), Some("3mam4aazabs2m"));
343 assert_eq!(info.error.as_deref(), Some(""));
345 }
346
347 #[test]
348 fn test_repo_info_deserialize_minimal() {
349 let json = r#"{"did":"did:plc:test","state":"syncing"}"#;
351 let info: RepoInfo = serde_json::from_str(json).unwrap();
352 assert_eq!(&*info.did, "did:plc:test");
353 assert_eq!(info.state, RepoState::Syncing);
354 assert_eq!(info.handle, None);
355 assert_eq!(info.records, 0);
356 assert_eq!(info.retries, 0);
357 assert_eq!(info.rev, None);
358 assert_eq!(info.error, None);
359 }
360
361 #[test]
362 fn test_add_repos_request_serialize() {
363 let req = AddReposRequest {
364 dids: vec!["did:plc:xyz".to_string(), "did:plc:abc".to_string()],
365 };
366 let json = serde_json::to_string(&req).unwrap();
367 assert!(json.contains("dids"));
368 assert!(json.contains("did:plc:xyz"));
369 }
370}