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