Skip to main content

wekan_cli/client/
integrations.rs

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