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    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}