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}