Skip to main content

wekan_cli/client/
integrations.rs

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}