1pub use circles_types::{GroupProfile, Profile};
5use reqwest::{Client, StatusCode, Url};
6use thiserror::Error;
7
8#[derive(Debug, Error)]
10pub enum ProfilesError {
11 #[error("invalid profile service url `{url}`: {source}")]
13 InvalidUrl {
14 url: String,
15 #[source]
16 source: url::ParseError,
17 },
18 #[error("profile service url cannot be a base: {url}")]
20 CannotBeABase { url: String },
21 #[error("request failed: {0}")]
23 Http(#[from] reqwest::Error),
24 #[error("profile creation failed (status {status}): {body}")]
26 CreateFailed { status: StatusCode, body: String },
27 #[error("unexpected response format (status {status}): {body}")]
29 DecodeFailed { status: StatusCode, body: String },
30}
31
32#[derive(Debug, Clone)]
34pub struct Profiles {
35 base_url: Url,
36 client: Client,
37}
38
39impl Profiles {
40 pub fn new(profile_service_url: impl AsRef<str>) -> Result<Self, ProfilesError> {
42 Self::with_client(profile_service_url, Client::new())
43 }
44
45 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 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 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}