Skip to main content

minco_plugin_feedback/
client.rs

1use crate::{
2    DeveloperReplyInput, FeedbackId, FeedbackListFilter, FeedbackMutationResult, FeedbackStatus,
3    FeedbackSummary, FeedbackThread, TransitionFeedbackInput,
4};
5use reqwest::{StatusCode, Url};
6use serde::de::DeserializeOwned;
7use std::{net::IpAddr, time::Duration};
8use uuid::Uuid;
9
10#[derive(Clone)]
11pub struct FeedbackApiClient {
12    client: reqwest::Client,
13    base_url: Url,
14    developer_token: String,
15}
16
17impl std::fmt::Debug for FeedbackApiClient {
18    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        formatter
20            .debug_struct("FeedbackApiClient")
21            .field("base_url", &self.base_url)
22            .field("developer_token", &"[REDACTED]")
23            .finish_non_exhaustive()
24    }
25}
26
27impl FeedbackApiClient {
28    pub fn new(
29        base_url: impl AsRef<str>,
30        developer_token: impl Into<String>,
31    ) -> Result<Self, FeedbackApiClientError> {
32        let mut base_url = Url::parse(base_url.as_ref())
33            .map_err(|error| FeedbackApiClientError::Url(error.to_string()))?;
34        if !base_url.username().is_empty()
35            || base_url.password().is_some()
36            || base_url.query().is_some()
37            || base_url.fragment().is_some()
38        {
39            return Err(FeedbackApiClientError::Configuration(
40                "feedback API URL must not contain credentials, a query, or a fragment".into(),
41            ));
42        }
43        if base_url.scheme() != "https" && !is_loopback_http(&base_url) {
44            return Err(FeedbackApiClientError::Configuration(
45                "feedback API URL must use HTTPS; HTTP is allowed only for loopback development"
46                    .into(),
47            ));
48        }
49        if !base_url.path().ends_with('/') {
50            let path = format!("{}/", base_url.path());
51            base_url.set_path(&path);
52        }
53        let developer_token = developer_token.into();
54        if developer_token.trim().is_empty() {
55            return Err(FeedbackApiClientError::Configuration(
56                "developer token must not be empty".into(),
57            ));
58        }
59        let client = reqwest::Client::builder()
60            .timeout(Duration::from_secs(30))
61            .build()
62            .map_err(|error| FeedbackApiClientError::Transport(error.to_string()))?;
63        Ok(Self {
64            client,
65            base_url,
66            developer_token,
67        })
68    }
69
70    pub async fn inbox(
71        &self,
72        filter: FeedbackListFilter,
73    ) -> Result<Vec<FeedbackSummary>, FeedbackApiClientError> {
74        let mut url = self.endpoint("developer/threads")?;
75        {
76            let mut query = url.query_pairs_mut();
77            if let Some(status) = filter.status {
78                query.append_pair("status", &status.to_string());
79            }
80            if let Some(project_id) = filter.project_id {
81                query.append_pair("project_id", &project_id);
82            }
83            query.append_pair("limit", &filter.limit.clamp(1, 200).to_string());
84        }
85        self.send_json(self.client.get(url)).await
86    }
87
88    pub async fn get(&self, id: FeedbackId) -> Result<FeedbackThread, FeedbackApiClientError> {
89        let url = self.endpoint(&format!("developer/threads/{id}"))?;
90        self.send_json(self.client.get(url)).await
91    }
92
93    pub async fn reply(
94        &self,
95        id: FeedbackId,
96        input: DeveloperReplyInput,
97    ) -> Result<FeedbackMutationResult, FeedbackApiClientError> {
98        let url = self.endpoint(&format!("developer/threads/{id}/messages"))?;
99        self.send_json(self.client.post(url).json(&input)).await
100    }
101
102    pub async fn transition(
103        &self,
104        id: FeedbackId,
105        status: FeedbackStatus,
106        resolution: Option<String>,
107        author_display: Option<String>,
108    ) -> Result<FeedbackMutationResult, FeedbackApiClientError> {
109        let url = self.endpoint(&format!("developer/threads/{id}/status"))?;
110        self.send_json(self.client.patch(url).json(&TransitionFeedbackInput {
111            status,
112            resolution,
113            author_display,
114        }))
115        .await
116    }
117
118    pub async fn ai_context_markdown(
119        &self,
120        id: FeedbackId,
121    ) -> Result<String, FeedbackApiClientError> {
122        let url = self.endpoint(&format!("developer/threads/{id}/ai-context"))?;
123        let response = self.authorized(self.client.get(url)).send().await?;
124        let status = response.status();
125        if !status.is_success() {
126            return Err(response_error(status, response).await);
127        }
128        response
129            .text()
130            .await
131            .map_err(|error| FeedbackApiClientError::Transport(error.to_string()))
132    }
133
134    pub async fn attachment(
135        &self,
136        id: FeedbackId,
137        attachment_id: Uuid,
138    ) -> Result<Vec<u8>, FeedbackApiClientError> {
139        let url = self.endpoint(&format!(
140            "developer/threads/{id}/attachments/{attachment_id}"
141        ))?;
142        let response = self.authorized(self.client.get(url)).send().await?;
143        let status = response.status();
144        if !status.is_success() {
145            return Err(response_error(status, response).await);
146        }
147        response
148            .bytes()
149            .await
150            .map(|value| value.to_vec())
151            .map_err(|error| FeedbackApiClientError::Transport(error.to_string()))
152    }
153
154    fn endpoint(&self, path: &str) -> Result<Url, FeedbackApiClientError> {
155        self.base_url
156            .join(path)
157            .map_err(|error| FeedbackApiClientError::Url(error.to_string()))
158    }
159
160    fn authorized(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
161        request.bearer_auth(&self.developer_token)
162    }
163
164    async fn send_json<T>(
165        &self,
166        request: reqwest::RequestBuilder,
167    ) -> Result<T, FeedbackApiClientError>
168    where
169        T: DeserializeOwned,
170    {
171        let response = self.authorized(request).send().await?;
172        let status = response.status();
173        if !status.is_success() {
174            return Err(response_error(status, response).await);
175        }
176        response
177            .json::<T>()
178            .await
179            .map_err(|error| FeedbackApiClientError::Protocol(error.to_string()))
180    }
181}
182
183fn is_loopback_http(url: &Url) -> bool {
184    if url.scheme() != "http" {
185        return false;
186    }
187    url.host_str().is_some_and(|host| {
188        let host = host
189            .strip_prefix('[')
190            .and_then(|host| host.strip_suffix(']'))
191            .unwrap_or(host);
192        host.eq_ignore_ascii_case("localhost")
193            || host
194                .parse::<IpAddr>()
195                .is_ok_and(|address| address.is_loopback())
196    })
197}
198
199async fn response_error(status: StatusCode, response: reqwest::Response) -> FeedbackApiClientError {
200    let detail = match response.json::<serde_json::Value>().await {
201        Ok(value) => value
202            .get("detail")
203            .or_else(|| value.get("title"))
204            .and_then(serde_json::Value::as_str)
205            .unwrap_or("feedback API request failed")
206            .to_owned(),
207        Err(_) => "feedback API request failed".into(),
208    };
209    FeedbackApiClientError::Remote { status, detail }
210}
211
212#[derive(Debug, thiserror::Error)]
213pub enum FeedbackApiClientError {
214    #[error("feedback client configuration is invalid: {0}")]
215    Configuration(String),
216    #[error("invalid feedback API URL: {0}")]
217    Url(String),
218    #[error("feedback API transport failed: {0}")]
219    Transport(String),
220    #[error("feedback API returned an invalid response: {0}")]
221    Protocol(String),
222    #[error("feedback API returned {status}: {detail}")]
223    Remote { status: StatusCode, detail: String },
224}
225
226impl From<reqwest::Error> for FeedbackApiClientError {
227    fn from(value: reqwest::Error) -> Self {
228        Self::Transport(value.to_string())
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn client_requires_a_nonempty_developer_token() {
238        assert!(FeedbackApiClient::new("https://example.test/_minco/feedback/", "").is_err());
239    }
240
241    #[test]
242    fn client_rejects_base_urls_that_can_expose_credentials_or_change_routing() {
243        for base_url in [
244            "https://user:password@example.test/_minco/feedback/",
245            "https://example.test/_minco/feedback/?token=secret",
246            "https://example.test/_minco/feedback/#developer",
247            "http://example.test/_minco/feedback/",
248            "ftp://example.test/_minco/feedback/",
249        ] {
250            assert!(
251                FeedbackApiClient::new(base_url, "developer-token").is_err(),
252                "{base_url}"
253            );
254        }
255    }
256
257    #[test]
258    fn client_allows_plaintext_only_for_loopback_development() {
259        for base_url in [
260            "http://localhost:3000/_minco/feedback/",
261            "http://127.0.0.1:3000/_minco/feedback/",
262            "http://[::1]:3000/_minco/feedback/",
263        ] {
264            assert!(
265                FeedbackApiClient::new(base_url, "developer-token").is_ok(),
266                "{base_url}"
267            );
268        }
269    }
270
271    #[test]
272    fn client_normalizes_the_base_url_for_relative_endpoints() {
273        let client =
274            FeedbackApiClient::new("https://example.test/_minco/feedback", "developer-token")
275                .unwrap();
276        assert_eq!(
277            client.endpoint("developer/threads").unwrap().as_str(),
278            "https://example.test/_minco/feedback/developer/threads"
279        );
280    }
281}