//! Thin HTTP client the Tauri commands use to talk to the AppView. //! //! All four methods return parsed JSON or a stringified error that the //! Tauri command layer surfaces to the Svelte frontend as the //! `Result::Err` payload. use anyhow::{anyhow, Context, Result}; use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::time::Duration; /// One post as returned by the AppView read API. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PostDto { pub uri: String, pub did: String, pub handle: String, pub rkey: String, pub collection: String, pub text: String, pub cid: String, #[serde(default)] pub parent_uri: Option, #[serde(default)] pub root_uri: Option, #[serde(default)] pub embed: Option, #[serde(default)] pub langs: Vec, pub created_at: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TimelineResponse { pub posts: Vec, pub cursor: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProfileResponse { pub did: String, pub handle: String, pub posts: Vec, pub followers: i64, pub following: i64, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SearchResponse { pub posts: Vec, pub q: String, } /// `GET /api/post/{uri}` response. The server hands back the post plus /// its `parent_uri` and `root_uri` rows in one round trip so the UI can /// expand a thread without three sequential fetches. /// /// `like_count` and `repost_count` are included when the server /// resolves a real post; they're `None` for the "not in index" /// sentinel response (where `post` is null). The AppView has no /// auth yet, so we don't get `viewer_liked` / `viewer_reposted` /// from the server. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ThreadResponse { pub post: Option, pub thread: ThreadView, #[serde(default)] pub like_count: Option, #[serde(default)] pub repost_count: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ThreadView { pub parent: Option, pub root: Option, } #[derive(Clone)] pub struct AppViewClient { pub base_url: String, pub client: Client, } impl AppViewClient { pub fn new(base_url: impl Into) -> Self { Self { base_url: base_url.into(), client: Client::builder() .timeout(Duration::from_secs(10)) .build() .unwrap(), } } /// `GET /api/timeline/home?did=&limit=&cursor=` pub async fn fetch_timeline( &self, did: &str, cursor: Option<&str>, limit: u32, ) -> Result { let mut req = self .client .get(format!("{}/api/timeline/home", self.base_url)) .query(&[("did", did), ("limit", &limit.to_string())]); if let Some(c) = cursor { req = req.query(&[("cursor", c)]); } let resp = req .send() .await .context("appview: failed to send timeline request")?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); return Err(anyhow!( "appview: timeline home returned {}: {}", status, body )); } resp .json::() .await .context("appview: timeline home JSON parse") } /// `GET /api/profile/` — accepts `@handle` or `handle`, and /// accepts a bare DID (the path param is opaque to the server). /// For DIDs containing `:`, prefer [`Self::fetch_profile_by_did`] /// which uses the query-param form. pub async fn fetch_profile(&self, handle: &str) -> Result { let trimmed = handle.trim_start_matches('@'); // If it looks like a DID, prefer the query-param form so the // colons don't have to be URL-encoded in the path. if trimmed.starts_with("did:") { return self.fetch_profile_by_did(trimmed).await; } let url = format!("{}/api/profile/{}", self.base_url, trimmed); let resp = self .client .get(url) .send() .await .context("appview: failed to send profile request")?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); return Err(anyhow!( "appview: profile returned {}: {}", status, body )); } resp .json::() .await .context("appview: profile JSON parse") } /// `GET /api/profile?did=...` — safest way to fetch a profile by DID. pub async fn fetch_profile_by_did(&self, did: &str) -> Result { let resp = self .client .get(format!("{}/api/profile", self.base_url)) .query(&[("did", did)]) .send() .await .context("appview: failed to send profile-by-did request")?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); return Err(anyhow!( "appview: profile-by-did returned {}: {}", status, body )); } resp .json::() .await .context("appview: profile-by-did JSON parse") } /// `GET /api/search?q=&limit=` pub async fn fetch_search(&self, q: &str, limit: u32) -> Result { let resp = self .client .get(format!("{}/api/search", self.base_url)) .query(&[("q", q), ("limit", &limit.to_string())]) .send() .await .context("appview: failed to send search request")?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); return Err(anyhow!( "appview: search returned {}: {}", status, body )); } resp .json::() .await .context("appview: search JSON parse") } /// `GET /api/post/{uri}` — thread hydration in one round trip. /// /// `uri` is the verbatim `at://...` URI. We can't paste it directly /// into the path because the `://` looks like a scheme separator /// to the URL parser; instead we percent-encode the whole URI and /// append it as a single path segment. The server's /// `axum::extract::Path` decodes it back to the verbatim /// string. pub async fn fetch_post(&self, uri: &str) -> Result { let encoded = percent_encode_path(uri); let resp = self .client .get(format!("{}/api/post/{}", self.base_url, encoded)) .send() .await .context("appview: failed to send post request")?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); return Err(anyhow!( "appview: post returned {}: {}", status, body )); } resp .json::() .await .context("appview: post JSON parse") } } /// Percent-encode every byte of `s` for use as a URL path segment. /// `axum`'s path extractor will decode it back. We use this rather /// than `url::Url::parse(...).path_segments()` because AT-Protocol /// URIs contain `://` which the URL parser mistakes for a scheme. fn percent_encode_path(s: &str) -> String { let mut out = String::with_capacity(s.len() * 3); for b in s.bytes() { // RFC 3986 unreserved characters plus a few safe ones we want // to leave alone. Encode everything else to be conservative. let is_unreserved = matches!( b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' ); if is_unreserved { out.push(b as char); } else { out.push_str(&format!("%{:02X}", b)); } } out } #[cfg(test)] mod tests { use super::*; #[test] fn percent_encode_path_at_uri() { let s = "at://did:plc:abc/app.twi.post/3k2"; let e = percent_encode_path(s); assert_eq!( e, "at%3A%2F%2Fdid%3Aplc%3Aabc%2Fapp.twi.post%2F3k2" ); } }