Skip to main content

wekan_cli/client/
swimlanes.rs

1use reqwest::{StatusCode, header::ACCEPT};
2use secrecy::{ExposeSecret, SecretString};
3use serde::{Deserialize, Serialize};
4use serde_json::Number;
5use time::{OffsetDateTime, format_description::well_known::Rfc3339};
6
7use super::{ClientError, WekanClient, transport::decode_success};
8
9mod transfer;
10pub use transfer::TransferSwimlaneRequest;
11
12pub const SWIMLANE_COLORS: &[&str] = &[
13    "white",
14    "green",
15    "yellow",
16    "orange",
17    "red",
18    "purple",
19    "blue",
20    "sky",
21    "lime",
22    "pink",
23    "black",
24    "silver",
25    "peachpuff",
26    "crimson",
27    "plum",
28    "darkgreen",
29    "slateblue",
30    "magenta",
31    "gold",
32    "navy",
33    "gray",
34    "saddlebrown",
35    "paleturquoise",
36    "mistyrose",
37    "indigo",
38];
39
40#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
41#[serde(deny_unknown_fields, rename_all = "camelCase")]
42pub struct SwimlaneSummary {
43    #[serde(rename = "_id")]
44    pub swimlane_id: String,
45    pub title: String,
46}
47
48#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
49#[serde(
50    deny_unknown_fields,
51    rename_all(deserialize = "camelCase", serialize = "snake_case")
52)]
53pub struct SwimlaneDocument {
54    #[serde(rename(deserialize = "_id", serialize = "swimlane_id"))]
55    pub swimlane_id: String,
56    pub title: String,
57    pub archived: bool,
58    pub archived_at: Option<String>,
59    pub board_id: String,
60    pub created_at: String,
61    pub sort: Option<Number>,
62    pub color: Option<String>,
63    pub updated_at: Option<String>,
64    pub modified_at: String,
65    #[serde(rename(deserialize = "type", serialize = "swimlane_type"))]
66    pub swimlane_type: String,
67    pub height: Option<Number>,
68}
69
70#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
71#[serde(rename_all = "camelCase")]
72pub struct CreateSwimlaneRequest {
73    pub title: String,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub sort: Option<Number>,
76}
77
78#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
79pub struct UpdateSwimlaneRequest {
80    pub title: String,
81}
82
83#[derive(Clone, Debug, Eq, PartialEq)]
84pub struct CreateSwimlaneResult {
85    pub swimlane_id: String,
86}
87
88#[derive(Clone, Debug, Eq, PartialEq)]
89pub struct UpdateSwimlaneResult {
90    pub swimlane_id: String,
91}
92
93#[derive(Clone, Debug, Eq, PartialEq)]
94pub struct DeleteSwimlaneResult {
95    pub swimlane_id: String,
96}
97
98#[derive(Deserialize)]
99#[serde(deny_unknown_fields)]
100struct IdResponse {
101    #[serde(rename = "_id")]
102    id: String,
103}
104
105impl WekanClient {
106    pub async fn board_swimlanes(
107        &self,
108        board_id: &str,
109        token: &SecretString,
110    ) -> Result<Vec<SwimlaneSummary>, ClientError> {
111        let body = self
112            .execute_success_request(
113                self.http
114                    .get(self.swimlane_endpoint(board_id, None)?)
115                    .header(ACCEPT, "application/json")
116                    .bearer_auth(token.expose_secret()),
117            )
118            .await?;
119        let swimlanes = decode_success(&body, "board swimlane collection", true)?;
120        validate_swimlane_summaries(swimlanes)
121    }
122
123    pub async fn swimlane(
124        &self,
125        board_id: &str,
126        swimlane_id: &str,
127        token: &SecretString,
128    ) -> Result<SwimlaneDocument, ClientError> {
129        let body = self
130            .execute_success_request(
131                self.http
132                    .get(self.swimlane_endpoint(board_id, Some(swimlane_id))?)
133                    .header(ACCEPT, "application/json")
134                    .bearer_auth(token.expose_secret()),
135            )
136            .await?;
137        if body.is_empty() {
138            return Err(ClientError::EmbeddedServer {
139                http_status: StatusCode::OK,
140                wekan_status_code: StatusCode::NOT_FOUND.as_u16(),
141                server_error: Some("swimlane-not-found".to_owned()),
142                server_reason: Some("Swimlane not found".to_owned()),
143            });
144        }
145        let swimlane = decode_success(&body, "swimlane", true)?;
146        validate_swimlane_document(swimlane)
147    }
148
149    pub async fn create_swimlane(
150        &self,
151        board_id: &str,
152        request: &CreateSwimlaneRequest,
153        token: &SecretString,
154    ) -> Result<CreateSwimlaneResult, ClientError> {
155        let body = self
156            .execute_success_request(
157                self.http
158                    .post(self.swimlane_endpoint(board_id, None)?)
159                    .header(ACCEPT, "application/json")
160                    .bearer_auth(token.expose_secret())
161                    .json(request),
162            )
163            .await?;
164        let response: IdResponse = decode_success(&body, "swimlane creation", true)?;
165        require_non_empty(&response.id, "swimlane creation", "swimlane id")?;
166        Ok(CreateSwimlaneResult {
167            swimlane_id: response.id,
168        })
169    }
170
171    pub async fn update_swimlane(
172        &self,
173        board_id: &str,
174        swimlane_id: &str,
175        request: &UpdateSwimlaneRequest,
176        token: &SecretString,
177    ) -> Result<UpdateSwimlaneResult, ClientError> {
178        let body = self
179            .execute_success_request(
180                self.http
181                    .put(self.swimlane_endpoint(board_id, Some(swimlane_id))?)
182                    .header(ACCEPT, "application/json")
183                    .bearer_auth(token.expose_secret())
184                    .json(request),
185            )
186            .await?;
187        let response: IdResponse = decode_success(&body, "swimlane update", true)?;
188        require_non_empty(&response.id, "swimlane update", "swimlane id")?;
189        Ok(UpdateSwimlaneResult {
190            swimlane_id: response.id,
191        })
192    }
193
194    pub async fn delete_swimlane(
195        &self,
196        board_id: &str,
197        swimlane_id: &str,
198        token: &SecretString,
199    ) -> Result<DeleteSwimlaneResult, ClientError> {
200        let body = self
201            .execute_success_request(
202                self.http
203                    .delete(self.swimlane_endpoint(board_id, Some(swimlane_id))?)
204                    .header(ACCEPT, "application/json")
205                    .bearer_auth(token.expose_secret()),
206            )
207            .await?;
208        let response: IdResponse = decode_success(&body, "swimlane deletion", true)?;
209        require_non_empty(&response.id, "swimlane deletion", "swimlane id")?;
210        Ok(DeleteSwimlaneResult {
211            swimlane_id: response.id,
212        })
213    }
214
215    pub(super) fn swimlane_endpoint(
216        &self,
217        board_id: &str,
218        swimlane_id: Option<&str>,
219    ) -> Result<reqwest::Url, ClientError> {
220        let mut endpoint =
221            self.server()
222                .join("api/boards")
223                .map_err(|error| ClientError::Protocol {
224                    diagnostic: None,
225                    message: format!("could not build the swimlane endpoint: {error}"),
226                    success_status_received: false,
227                })?;
228        let mut segments = endpoint
229            .path_segments_mut()
230            .map_err(|()| ClientError::Protocol {
231                diagnostic: None,
232                message: "could not add identifiers to the swimlane endpoint".to_owned(),
233                success_status_received: false,
234            })?;
235        segments.push(board_id);
236        segments.push("swimlanes");
237        if let Some(swimlane_id) = swimlane_id {
238            segments.push(swimlane_id);
239        }
240        drop(segments);
241        Ok(endpoint)
242    }
243}
244
245pub fn is_valid_swimlane_color(value: &str) -> bool {
246    SWIMLANE_COLORS.contains(&value)
247        || value.len() == 7
248            && value.starts_with('#')
249            && value[1..].bytes().all(|byte| byte.is_ascii_hexdigit())
250}
251
252fn validate_swimlane_summaries(
253    swimlanes: Vec<SwimlaneSummary>,
254) -> Result<Vec<SwimlaneSummary>, ClientError> {
255    for swimlane in &swimlanes {
256        require_non_empty(
257            &swimlane.swimlane_id,
258            "board swimlane collection",
259            "swimlane id",
260        )?;
261        require_non_empty(&swimlane.title, "board swimlane collection", "title")?;
262    }
263    Ok(swimlanes)
264}
265
266fn validate_swimlane_document(swimlane: SwimlaneDocument) -> Result<SwimlaneDocument, ClientError> {
267    require_non_empty(&swimlane.swimlane_id, "swimlane", "swimlane id")?;
268    require_non_empty(&swimlane.title, "swimlane", "title")?;
269    require_non_empty(&swimlane.board_id, "swimlane", "board id")?;
270    require_non_empty(&swimlane.swimlane_type, "swimlane", "type")?;
271    validate_date_time(&swimlane.created_at, "createdAt", "swimlane")?;
272    validate_date_time(&swimlane.modified_at, "modifiedAt", "swimlane")?;
273    for (field, value) in [
274        ("archivedAt", swimlane.archived_at.as_deref()),
275        ("updatedAt", swimlane.updated_at.as_deref()),
276    ] {
277        validate_optional_date_time(value, field, "swimlane")?;
278    }
279    if let Some(color) = &swimlane.color
280        && !color.is_empty()
281        && !is_valid_swimlane_color(color)
282    {
283        return Err(protocol_error(format!(
284            "the swimlane response contained an invalid color `{color}`"
285        )));
286    }
287    if let Some(height) = &swimlane.height {
288        let valid = height
289            .as_f64()
290            .is_some_and(|height| height == -1.0 || (50.0..=2000.0).contains(&height));
291        if !valid {
292            return Err(protocol_error(
293                "the swimlane response contained a height outside -1 or 50 through 2000".to_owned(),
294            ));
295        }
296    }
297    Ok(swimlane)
298}
299
300fn require_non_empty(value: &str, operation: &str, field: &str) -> Result<(), ClientError> {
301    if value.is_empty() {
302        return Err(protocol_error(format!(
303            "the {operation} response contained an empty {field}"
304        )));
305    }
306    Ok(())
307}
308
309fn validate_optional_date_time(
310    value: Option<&str>,
311    field: &str,
312    operation: &str,
313) -> Result<(), ClientError> {
314    if let Some(value) = value {
315        validate_date_time(value, field, operation)?;
316    }
317    Ok(())
318}
319
320fn validate_date_time(value: &str, field: &str, operation: &str) -> Result<(), ClientError> {
321    OffsetDateTime::parse(value, &Rfc3339)
322        .map(|_| ())
323        .map_err(|_| {
324            protocol_error(format!(
325                "the {operation} response contained an invalid {field} date-time"
326            ))
327        })
328}
329
330fn protocol_error(message: String) -> ClientError {
331    ClientError::Protocol {
332        diagnostic: None,
333        message,
334        success_status_received: true,
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use serde_json::json;
341
342    use super::{SwimlaneDocument, SwimlaneSummary, is_valid_swimlane_color};
343
344    #[test]
345    fn color_validation_matches_wekan_v11_06() {
346        for value in ["white", "silver", "indigo", "#12aBcF"] {
347            assert!(is_valid_swimlane_color(value));
348        }
349        for value in ["", "belize", "#12345", "#12345g", "123456"] {
350            assert!(!is_valid_swimlane_color(value));
351        }
352    }
353
354    #[test]
355    fn response_objects_reject_unmapped_fields() {
356        assert!(
357            serde_json::from_value::<SwimlaneSummary>(json!({
358                "_id": "swimlane-1",
359                "title": "Delivery",
360                "unexpected": true
361            }))
362            .is_err()
363        );
364        assert!(
365            serde_json::from_value::<SwimlaneDocument>(json!({
366                "_id": "swimlane-1",
367                "title": "Delivery",
368                "archived": false,
369                "boardId": "board-1",
370                "createdAt": "2030-01-02T03:04:05Z",
371                "modifiedAt": "2030-01-02T03:04:05Z",
372                "type": "swimlane",
373                "unexpected": true
374            }))
375            .is_err()
376        );
377    }
378}