Skip to main content

wekan_cli/client/
swimlanes.rs

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