circles_profiles/
lib.rs

1//! Client for the Circles profile service (pin + fetch profile metadata).
2//! Mirrors the minimal behavior of the TypeScript `@profiles` package.
3
4pub use circles_types::{GroupProfile, Profile};
5use reqwest::{Client, StatusCode, Url};
6use thiserror::Error;
7
8/// Errors that can occur when interacting with the profile service.
9#[derive(Debug, Error)]
10pub enum ProfilesError {
11    /// The provided base URL was invalid.
12    #[error("invalid profile service url `{url}`: {source}")]
13    InvalidUrl {
14        url: String,
15        #[source]
16        source: url::ParseError,
17    },
18    /// The provided URL cannot be treated as a base for relative paths.
19    #[error("profile service url cannot be a base: {url}")]
20    CannotBeABase { url: String },
21    /// HTTP-layer error when sending or receiving requests.
22    #[error("request failed: {0}")]
23    Http(#[from] reqwest::Error),
24    /// The service returned a non-success status during profile creation.
25    #[error("profile creation failed (status {status}): {body}")]
26    CreateFailed { status: StatusCode, body: String },
27    /// The service responded with an unexpected payload.
28    #[error("unexpected response format (status {status}): {body}")]
29    DecodeFailed { status: StatusCode, body: String },
30}
31
32/// Thin wrapper over the Circles profile service.
33#[derive(Debug, Clone)]
34pub struct Profiles {
35    base_url: Url,
36    client: Client,
37}
38
39impl Profiles {
40    /// Build a client using the default Reqwest client.
41    pub fn new(profile_service_url: impl AsRef<str>) -> Result<Self, ProfilesError> {
42        Self::with_client(profile_service_url, Client::new())
43    }
44
45    /// Build a client using a provided Reqwest client (useful for custom middleware or mocks).
46    pub fn with_client(
47        profile_service_url: impl AsRef<str>,
48        client: Client,
49    ) -> Result<Self, ProfilesError> {
50        let base_url = normalize_base_url(profile_service_url.as_ref())?;
51        Ok(Self { base_url, client })
52    }
53
54    /// Create and pin a profile, returning its CID.
55    pub async fn create(&self, profile: &Profile) -> Result<String, ProfilesError> {
56        let url = endpoint(&self.base_url, "pin")?;
57        let response = self.client.post(url).json(profile).send().await?;
58        let status = response.status();
59        let body = response.text().await?;
60
61        if !status.is_success() {
62            return Err(ProfilesError::CreateFailed { status, body });
63        }
64
65        let cid = serde_json::from_str::<PinResponse>(&body)
66            .map_err(|_| ProfilesError::DecodeFailed { status, body })?;
67        Ok(cid.cid)
68    }
69
70    /// Retrieve a profile by CID. Returns `Ok(None)` if the service responds with a non-success
71    /// status or if the body cannot be parsed.
72    pub async fn get(&self, cid: &str) -> Result<Option<Profile>, ProfilesError> {
73        let mut url = endpoint(&self.base_url, "get")?;
74        url.query_pairs_mut().append_pair("cid", cid);
75
76        let response = self.client.get(url).send().await?;
77        let status = response.status();
78        let body = response.text().await?;
79
80        if !status.is_success() {
81            tracing::warn!(
82                %status,
83                cid,
84                body = body.as_str(),
85                "failed to retrieve profile"
86            );
87            return Ok(None);
88        }
89
90        match serde_json::from_str(&body) {
91            Ok(profile) => Ok(Some(profile)),
92            Err(err) => {
93                tracing::warn!(
94                    %status,
95                    cid,
96                    body = body.as_str(),
97                    error = %err,
98                    "failed to parse profile response"
99                );
100                Ok(None)
101            }
102        }
103    }
104}
105
106#[derive(Debug, serde::Deserialize)]
107struct PinResponse {
108    cid: String,
109}
110
111fn endpoint(base: &Url, path: &str) -> Result<Url, ProfilesError> {
112    base.join(path).map_err(|source| ProfilesError::InvalidUrl {
113        url: format!("{base}{path}"),
114        source,
115    })
116}
117
118fn normalize_base_url(raw: &str) -> Result<Url, ProfilesError> {
119    let mut url = Url::parse(raw).map_err(|source| ProfilesError::InvalidUrl {
120        url: raw.to_owned(),
121        source,
122    })?;
123
124    if !url.path().ends_with('/') {
125        url.path_segments_mut()
126            .map_err(|_| ProfilesError::CannotBeABase {
127                url: raw.to_owned(),
128            })?
129            .push("");
130    }
131
132    Ok(url)
133}
134
135#[cfg(test)]
136mod tests {
137    use super::normalize_base_url;
138
139    #[test]
140    fn ensures_trailing_slash() {
141        let normalized = normalize_base_url("https://example.com/api").unwrap();
142        assert_eq!(normalized.as_str(), "https://example.com/api/");
143    }
144
145    #[test]
146    fn keeps_existing_slash() {
147        let normalized = normalize_base_url("https://example.com/api/").unwrap();
148        assert_eq!(normalized.as_str(), "https://example.com/api/");
149    }
150}