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