Skip to main content

atproto_tap/
client.rs

1//! HTTP client for TAP management API.
2//!
3//! This module provides [`TapClient`] for interacting with the TAP service's
4//! HTTP management endpoints, including adding/removing tracked repositories.
5
6use 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/// HTTP client for TAP management API.
14///
15/// Provides methods for managing which repositories the TAP service tracks,
16/// checking service health, and querying repository status.
17///
18/// # Example
19///
20/// ```ignore
21/// use atproto_tap::TapClient;
22///
23/// let client = TapClient::new("localhost:2480", Some("admin_password".to_string()));
24///
25/// // Add repositories to track
26/// client.add_repos(&["did:plc:xyz123", "did:plc:abc456"]).await?;
27///
28/// // Check health
29/// if client.health().await? {
30///     println!("TAP service is healthy");
31/// }
32/// ```
33#[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    /// Create a new TAP management client.
42    ///
43    /// # Arguments
44    ///
45    /// * `hostname` - TAP service hostname (e.g., "localhost:2480")
46    /// * `admin_password` - Optional admin password for authentication
47    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    /// Create default headers for requests.
62    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    /// Add repositories to track.
72    ///
73    /// Sends a POST request to `/repos/add` with the list of DIDs.
74    ///
75    /// # Arguments
76    ///
77    /// * `dids` - Slice of DID strings to track
78    ///
79    /// # Example
80    ///
81    /// ```ignore
82    /// client.add_repos(&[
83    ///     "did:plc:z72i7hdynmk6r22z27h6tvur",
84    ///     "did:plc:ewvi7nxzyoun6zhxrhs64oiz",
85    /// ]).await?;
86    /// ```
87    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    /// Remove repositories from tracking.
112    ///
113    /// Sends a POST request to `/repos/remove` with the list of DIDs.
114    ///
115    /// # Arguments
116    ///
117    /// * `dids` - Slice of DID strings to stop tracking
118    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    /// Check service health.
143    ///
144    /// Sends a GET request to `/health`.
145    ///
146    /// # Returns
147    ///
148    /// `true` if the service is healthy, `false` otherwise.
149    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    /// Resolve a DID to its DID document.
163    ///
164    /// Sends a GET request to `/resolve/:did`.
165    ///
166    /// # Arguments
167    ///
168    /// * `did` - The DID to resolve
169    ///
170    /// # Returns
171    ///
172    /// The DID document for the identity.
173    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    /// Get info about a tracked repository.
194    ///
195    /// Sends a GET request to `/info/:did`.
196    ///
197    /// # Arguments
198    ///
199    /// * `did` - The DID to get info for
200    ///
201    /// # Returns
202    ///
203    /// Repository tracking information.
204    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/// Request body for adding/removing repositories.
226#[derive(Debug, Serialize)]
227struct AddReposRequest {
228    dids: Vec<String>,
229}
230
231/// Repository tracking information.
232#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct RepoInfo {
234    /// The repository DID.
235    pub did: Box<str>,
236    /// Current sync state.
237    pub state: RepoState,
238    /// The handle for the repository.
239    #[serde(default)]
240    pub handle: Option<Box<str>>,
241    /// Number of records in the repository.
242    #[serde(default)]
243    pub records: u64,
244    /// Current repository revision.
245    #[serde(default)]
246    pub rev: Option<Box<str>>,
247    /// Number of retries for syncing.
248    #[serde(default)]
249    pub retries: u32,
250    /// Error message if any.
251    #[serde(default)]
252    pub error: Option<Box<str>>,
253    /// Additional fields may be present depending on TAP version.
254    #[serde(flatten)]
255    pub extra: serde_json::Value,
256}
257
258/// Repository sync state.
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
260#[serde(rename_all = "lowercase")]
261pub enum RepoState {
262    /// Repository is active and synced.
263    Active,
264    /// Repository is currently syncing.
265    Syncing,
266    /// Repository is fully synced.
267    Synced,
268    /// Sync failed for this repository.
269    Failed,
270    /// Repository is queued for sync.
271    Queued,
272    /// Unknown state.
273    #[serde(other)]
274    Unknown,
275}
276
277/// Deprecated alias for RepoState.
278#[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        // Empty string deserializes as Some("")
344        assert_eq!(info.error.as_deref(), Some(""));
345    }
346
347    #[test]
348    fn test_repo_info_deserialize_minimal() {
349        // Test with only required fields
350        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}