1use reqwest::{Method, header::ACCEPT};
2use secrecy::{ExposeSecret, SecretString};
3use serde::{Deserialize, Serialize, Serializer};
4
5use super::{
6 ClientError, WekanClient, datetime::validate_response_date, transport::decode_success,
7};
8
9#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
10#[serde(deny_unknown_fields, rename_all = "camelCase")]
11pub struct IntegrationDocument {
12 #[serde(rename = "_id")]
13 pub integration_id: String,
14 pub enabled: bool,
15 #[serde(default, deserialize_with = "optional_title")]
16 pub title: Option<String>,
17 #[serde(rename = "type")]
18 pub integration_type: String,
19 pub activities: Vec<String>,
20 pub url: String,
21 pub board_id: String,
22 pub created_at: String,
23 pub modified_at: String,
24 pub user_id: String,
25}
26
27fn optional_title<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
28 String::deserialize(d).map(Some)
29}
30
31#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
32#[serde(deny_unknown_fields)]
33pub struct IntegrationIdResponse {
34 #[serde(rename = "_id")]
35 pub integration_id: String,
36}
37
38#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
39#[serde(deny_unknown_fields)]
40pub struct IntegrationActivitiesResponse {
41 #[serde(rename = "_id")]
42 pub integration_id: String,
43 pub activities: Vec<String>,
44}
45
46#[derive(Clone, Debug, Serialize)]
47pub struct CreateIntegrationRequest {
48 pub url: String,
49}
50
51#[derive(Debug, Default, Serialize)]
52pub struct UpdateIntegrationRequest {
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub enabled: Option<bool>,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub title: Option<String>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub url: Option<String>,
59 #[serde(
60 skip_serializing_if = "Option::is_none",
61 serialize_with = "serialize_token"
62 )]
63 pub token: Option<SecretString>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub activities: Option<Vec<String>>,
66}
67
68fn serialize_token<S: Serializer>(token: &Option<SecretString>, s: S) -> Result<S::Ok, S::Error> {
69 token.as_ref().map(ExposeSecret::expose_secret).serialize(s)
70}
71
72#[derive(Clone, Debug, Serialize)]
73pub struct IntegrationActivitiesRequest {
74 pub activities: Vec<String>,
75}
76
77impl WekanClient {
78 pub async fn board_integrations(
79 &self,
80 board: &str,
81 token: &SecretString,
82 ) -> Result<Vec<IntegrationDocument>, ClientError> {
83 let body = self
84 .execute_success_request(
85 self.http
86 .get(self.integration_endpoint(board, None, false)?)
87 .header(ACCEPT, "application/json")
88 .bearer_auth(token.expose_secret()),
89 )
90 .await?;
91 let documents: Vec<IntegrationDocument> =
92 decode_success(&body, "integration collection", true)?;
93 for document in &documents {
94 validate_document(document, board, None)?;
95 }
96 Ok(documents)
97 }
98
99 pub async fn integration(
100 &self,
101 board: &str,
102 integration: &str,
103 token: &SecretString,
104 ) -> Result<Option<IntegrationDocument>, ClientError> {
105 let body = self
106 .execute_success_request(
107 self.http
108 .get(self.integration_endpoint(board, Some(integration), false)?)
109 .header(ACCEPT, "application/json")
110 .bearer_auth(token.expose_secret()),
111 )
112 .await?;
113 if body.is_empty() {
114 return Ok(None);
115 }
116 let document: IntegrationDocument = decode_success(&body, "integration lookup", true)?;
117 validate_document(&document, board, Some(integration))?;
118 Ok(Some(document))
119 }
120
121 pub async fn create_integration(
122 &self,
123 board: &str,
124 request: &CreateIntegrationRequest,
125 token: &SecretString,
126 ) -> Result<IntegrationIdResponse, ClientError> {
127 let body = self
128 .execute_success_request(
129 self.http
130 .post(self.integration_endpoint(board, None, false)?)
131 .header(ACCEPT, "application/json")
132 .bearer_auth(token.expose_secret())
133 .json(request),
134 )
135 .await?;
136 decode_id(&body, None)
137 }
138
139 pub async fn update_integration(
140 &self,
141 board: &str,
142 integration: &str,
143 request: &UpdateIntegrationRequest,
144 token: &SecretString,
145 ) -> Result<IntegrationIdResponse, ClientError> {
146 let body = self
147 .execute_success_request(
148 self.http
149 .put(self.integration_endpoint(board, Some(integration), false)?)
150 .header(ACCEPT, "application/json")
151 .bearer_auth(token.expose_secret())
152 .json(request),
153 )
154 .await?;
155 decode_id(&body, Some(integration))
156 }
157
158 pub async fn delete_integration(
159 &self,
160 board: &str,
161 integration: &str,
162 token: &SecretString,
163 ) -> Result<IntegrationIdResponse, ClientError> {
164 let body = self
165 .execute_success_request(
166 self.http
167 .delete(self.integration_endpoint(board, Some(integration), false)?)
168 .header(ACCEPT, "application/json")
169 .bearer_auth(token.expose_secret()),
170 )
171 .await?;
172 decode_id(&body, Some(integration))
173 }
174
175 pub async fn add_integration_activities(
176 &self,
177 board: &str,
178 integration: &str,
179 request: &IntegrationActivitiesRequest,
180 token: &SecretString,
181 ) -> Result<Option<IntegrationActivitiesResponse>, ClientError> {
182 self.mutate_integration_activities(Method::POST, board, integration, request, token)
183 .await
184 }
185
186 pub async fn remove_integration_activities(
187 &self,
188 board: &str,
189 integration: &str,
190 request: &IntegrationActivitiesRequest,
191 token: &SecretString,
192 ) -> Result<Option<IntegrationActivitiesResponse>, ClientError> {
193 self.mutate_integration_activities(Method::DELETE, board, integration, request, token)
194 .await
195 }
196
197 async fn mutate_integration_activities(
198 &self,
199 method: Method,
200 board: &str,
201 integration: &str,
202 request: &IntegrationActivitiesRequest,
203 token: &SecretString,
204 ) -> Result<Option<IntegrationActivitiesResponse>, ClientError> {
205 let body = self
206 .execute_success_request(
207 self.http
208 .request(
209 method,
210 self.integration_endpoint(board, Some(integration), true)?,
211 )
212 .header(ACCEPT, "application/json")
213 .bearer_auth(token.expose_secret())
214 .json(request),
215 )
216 .await?;
217 if body.is_empty() {
218 return Ok(None);
219 }
220 let response: IntegrationActivitiesResponse =
221 decode_success(&body, "integration activities", true)?;
222 validate_response_id(&response.integration_id, Some(integration))?;
223 Ok(Some(response))
224 }
225
226 fn integration_endpoint(
227 &self,
228 board: &str,
229 integration: Option<&str>,
230 activities: bool,
231 ) -> Result<reqwest::Url, ClientError> {
232 for id in [Some(board), integration].into_iter().flatten() {
233 if id.trim().is_empty() || matches!(id, "." | "..") {
234 return Err(protocol_error(
235 "integration endpoint IDs must be nonempty and not dot-only; no request was sent",
236 false,
237 ));
238 }
239 }
240 let mut endpoint = self
241 .server()
242 .join("api/boards")
243 .map_err(|_| protocol_error("could not build the integration endpoint", false))?;
244 let mut segments = endpoint.path_segments_mut().map_err(|()| {
245 protocol_error("could not add integration endpoint identifiers", false)
246 })?;
247 segments.extend([board, "integrations"]);
248 if let Some(integration) = integration {
249 segments.push(integration);
250 }
251 if activities {
252 segments.push("activities");
253 }
254 drop(segments);
255 Ok(endpoint)
256 }
257}
258
259fn validate_document(
260 document: &IntegrationDocument,
261 board: &str,
262 integration: Option<&str>,
263) -> Result<(), ClientError> {
264 validate_response_id(&document.integration_id, integration)?;
265 for id in [&document.board_id, &document.user_id] {
266 validate_response_id(id, None)?;
267 }
268 if document.board_id != board {
269 return Err(protocol_error(
270 "the integration response board ID did not match the requested scope",
271 true,
272 ));
273 }
274 for date in [&document.created_at, &document.modified_at] {
275 validate_response_date(date).map_err(|()| {
276 protocol_error(
277 "the integration response contained an invalid date-time",
278 true,
279 )
280 })?;
281 }
282 Ok(())
283}
284
285fn decode_id(body: &[u8], expected: Option<&str>) -> Result<IntegrationIdResponse, ClientError> {
286 let response: IntegrationIdResponse =
287 decode_success(body, "integration acknowledgement", true)?;
288 validate_response_id(&response.integration_id, expected)?;
289 Ok(response)
290}
291
292fn validate_response_id(id: &str, expected: Option<&str>) -> Result<(), ClientError> {
293 if id.trim().is_empty() {
294 return Err(protocol_error(
295 "the integration response contained an empty identifier",
296 true,
297 ));
298 }
299 if expected.is_some_and(|expected| expected != id) {
300 return Err(protocol_error(
301 "Wekan returned a different integration ID than requested",
302 true,
303 ));
304 }
305 Ok(())
306}
307
308fn protocol_error(message: &str, success_status_received: bool) -> ClientError {
309 ClientError::Protocol {
310 diagnostic: None,
311 message: message.to_owned(),
312 success_status_received,
313 }
314}