Skip to main content

wekan_cli/client/
lists.rs

1use reqwest::{StatusCode, header::ACCEPT};
2use secrecy::{ExposeSecret, SecretString};
3use serde::{Deserialize, Deserializer, 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::TransferListRequest;
11
12pub const LIST_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 ListSummary {
43    #[serde(rename = "_id")]
44    pub list_id: String,
45    pub title: String,
46    #[serde(deserialize_with = "deserialize_required_nullable")]
47    pub modified_at: Option<String>,
48    #[serde(deserialize_with = "deserialize_required_nullable")]
49    pub cards_modified_at: Option<String>,
50}
51
52#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
53#[serde(deny_unknown_fields, rename_all = "camelCase")]
54pub struct ListWipLimit {
55    pub value: Number,
56    pub enabled: bool,
57    pub soft: bool,
58}
59
60#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
61#[serde(
62    deny_unknown_fields,
63    rename_all(deserialize = "camelCase", serialize = "snake_case")
64)]
65pub struct ListDocument {
66    #[serde(rename(deserialize = "_id", serialize = "list_id"))]
67    pub list_id: String,
68    pub title: String,
69    #[serde(default, deserialize_with = "deserialize_watchers")]
70    pub watchers: Option<Vec<String>>,
71    pub starred: Option<bool>,
72    pub archived: bool,
73    pub archived_at: Option<String>,
74    pub deleted_at: Option<String>,
75    pub deleted_by: Option<String>,
76    pub delete_batch_id: Option<String>,
77    pub board_id: String,
78    pub swimlane_id: Option<String>,
79    pub created_at: String,
80    pub sort: Option<Number>,
81    pub updated_at: Option<String>,
82    pub modified_at: String,
83    #[serde(
84        rename(deserialize = "_updatedAt", serialize = "position_updated_at"),
85        default
86    )]
87    pub position_updated_at: Option<String>,
88    pub wip_limit: Option<ListWipLimit>,
89    pub color: Option<String>,
90    #[serde(rename(deserialize = "type", serialize = "list_type"))]
91    pub list_type: String,
92    pub width: Option<Number>,
93}
94
95#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
96#[serde(rename_all = "camelCase")]
97pub struct CreateListRequest {
98    pub title: String,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub swimlane_id: Option<String>,
101}
102
103#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
104#[serde(rename_all = "camelCase")]
105pub struct UpdateListRequest {
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub title: Option<String>,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub color: Option<String>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub starred: Option<Option<bool>>,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub wip_limit: Option<ListWipLimitInput>,
114}
115
116#[derive(Clone, Debug, Eq, PartialEq)]
117pub struct CreateListResult {
118    pub list_id: String,
119}
120
121#[derive(Clone, Debug, Eq, PartialEq)]
122pub struct UpdateListResult {
123    pub list_id: String,
124}
125
126#[derive(Clone, Debug, Eq, PartialEq)]
127pub struct DeleteListResult {
128    pub list_id: String,
129}
130
131#[derive(Deserialize)]
132#[serde(deny_unknown_fields)]
133struct IdResponse {
134    #[serde(rename = "_id")]
135    id: String,
136}
137
138impl WekanClient {
139    pub async fn board_lists(
140        &self,
141        board_id: &str,
142        token: &SecretString,
143    ) -> Result<Vec<ListSummary>, ClientError> {
144        let body = self
145            .execute_success_request(
146                self.http
147                    .get(self.list_endpoint(board_id, None)?)
148                    .header(ACCEPT, "application/json")
149                    .bearer_auth(token.expose_secret()),
150            )
151            .await?;
152        let lists = decode_success(&body, "board list collection", true)?;
153        validate_list_summaries(lists)
154    }
155
156    pub async fn list(
157        &self,
158        board_id: &str,
159        list_id: &str,
160        token: &SecretString,
161    ) -> Result<ListDocument, ClientError> {
162        let body = self
163            .execute_success_request(
164                self.http
165                    .get(self.list_endpoint(board_id, Some(list_id))?)
166                    .header(ACCEPT, "application/json")
167                    .bearer_auth(token.expose_secret()),
168            )
169            .await?;
170        if body.is_empty() {
171            return Err(ClientError::EmbeddedServer {
172                http_status: StatusCode::OK,
173                wekan_status_code: StatusCode::NOT_FOUND.as_u16(),
174                server_error: Some("list-not-found".to_owned()),
175                server_reason: Some("List not found".to_owned()),
176            });
177        }
178        let list = decode_success(&body, "list", true)?;
179        validate_list_document(list)
180    }
181
182    pub async fn create_list(
183        &self,
184        board_id: &str,
185        request: &CreateListRequest,
186        token: &SecretString,
187    ) -> Result<CreateListResult, ClientError> {
188        let body = self
189            .execute_success_request(
190                self.http
191                    .post(self.list_endpoint(board_id, None)?)
192                    .header(ACCEPT, "application/json")
193                    .bearer_auth(token.expose_secret())
194                    .json(request),
195            )
196            .await?;
197        let response: IdResponse = decode_success(&body, "list creation", true)?;
198        require_non_empty(&response.id, "list creation", "list id")?;
199        Ok(CreateListResult {
200            list_id: response.id,
201        })
202    }
203
204    pub async fn update_list(
205        &self,
206        board_id: &str,
207        list_id: &str,
208        request: &UpdateListRequest,
209        token: &SecretString,
210    ) -> Result<UpdateListResult, ClientError> {
211        let body = self
212            .execute_success_request(
213                self.http
214                    .put(self.list_endpoint(board_id, Some(list_id))?)
215                    .header(ACCEPT, "application/json")
216                    .bearer_auth(token.expose_secret())
217                    .json(request),
218            )
219            .await?;
220        let response: IdResponse = decode_success(&body, "list update", true)?;
221        require_non_empty(&response.id, "list update", "list id")?;
222        Ok(UpdateListResult {
223            list_id: response.id,
224        })
225    }
226
227    pub async fn delete_list(
228        &self,
229        board_id: &str,
230        list_id: &str,
231        token: &SecretString,
232    ) -> Result<DeleteListResult, ClientError> {
233        let body = self
234            .execute_success_request(
235                self.http
236                    .delete(self.list_endpoint(board_id, Some(list_id))?)
237                    .header(ACCEPT, "application/json")
238                    .bearer_auth(token.expose_secret()),
239            )
240            .await?;
241        let response: IdResponse = decode_success(&body, "list deletion", true)?;
242        require_non_empty(&response.id, "list deletion", "list id")?;
243        Ok(DeleteListResult {
244            list_id: response.id,
245        })
246    }
247
248    pub(super) fn list_endpoint(
249        &self,
250        board_id: &str,
251        list_id: Option<&str>,
252    ) -> Result<reqwest::Url, ClientError> {
253        let mut endpoint =
254            self.server()
255                .join("api/boards")
256                .map_err(|error| ClientError::Protocol {
257                    diagnostic: None,
258                    message: format!("could not build the list endpoint: {error}"),
259                    success_status_received: false,
260                })?;
261        let mut segments = endpoint
262            .path_segments_mut()
263            .map_err(|()| ClientError::Protocol {
264                diagnostic: None,
265                message: "could not add identifiers to the list endpoint".to_owned(),
266                success_status_received: false,
267            })?;
268        segments.push(board_id);
269        segments.push("lists");
270        if let Some(list_id) = list_id {
271            segments.push(list_id);
272        }
273        drop(segments);
274        Ok(endpoint)
275    }
276}
277
278pub fn is_valid_list_color(value: &str) -> bool {
279    LIST_COLORS.contains(&value)
280        || value.len() == 7
281            && value.starts_with('#')
282            && value[1..].bytes().all(|byte| byte.is_ascii_hexdigit())
283}
284
285fn validate_list_summaries(lists: Vec<ListSummary>) -> Result<Vec<ListSummary>, ClientError> {
286    for list in &lists {
287        require_non_empty(&list.list_id, "board list collection", "list id")?;
288        require_non_empty(&list.title, "board list collection", "title")?;
289        validate_optional_date_time(
290            list.modified_at.as_deref(),
291            "modifiedAt",
292            "board list collection",
293        )?;
294        validate_optional_date_time(
295            list.cards_modified_at.as_deref(),
296            "cardsModifiedAt",
297            "board list collection",
298        )?;
299    }
300    Ok(lists)
301}
302
303fn deserialize_required_nullable<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
304where
305    D: Deserializer<'de>,
306{
307    Option::<String>::deserialize(deserializer)
308}
309
310fn validate_list_document(list: ListDocument) -> Result<ListDocument, ClientError> {
311    require_non_empty(&list.list_id, "list", "list id")?;
312    require_non_empty(&list.title, "list", "title")?;
313    require_non_empty(&list.board_id, "list", "board id")?;
314    validate_date_time(&list.created_at, "createdAt", "list")?;
315    validate_date_time(&list.modified_at, "modifiedAt", "list")?;
316    for (field, value) in [
317        ("archivedAt", list.archived_at.as_deref()),
318        ("deletedAt", list.deleted_at.as_deref()),
319        ("updatedAt", list.updated_at.as_deref()),
320        ("_updatedAt", list.position_updated_at.as_deref()),
321    ] {
322        validate_optional_date_time(value, field, "list")?;
323    }
324    if let Some(color) = &list.color
325        && !color.is_empty()
326        && !is_valid_list_color(color)
327    {
328        return Err(protocol_error(format!(
329            "the list response contained an invalid color `{color}`"
330        )));
331    }
332    if let Some(width) = &list.width {
333        let valid = width
334            .as_f64()
335            .is_some_and(|width| (100.0..=1000.0).contains(&width));
336        if !valid {
337            return Err(protocol_error(
338                "the list response contained a width outside 100 through 1000".to_owned(),
339            ));
340        }
341    }
342    Ok(list)
343}
344
345fn require_non_empty(value: &str, operation: &str, field: &str) -> Result<(), ClientError> {
346    if value.is_empty() {
347        return Err(protocol_error(format!(
348            "the {operation} response contained an empty {field}"
349        )));
350    }
351    Ok(())
352}
353
354fn validate_optional_date_time(
355    value: Option<&str>,
356    field: &str,
357    operation: &str,
358) -> Result<(), ClientError> {
359    if let Some(value) = value {
360        validate_date_time(value, field, operation)?;
361    }
362    Ok(())
363}
364
365fn validate_date_time(value: &str, field: &str, operation: &str) -> Result<(), ClientError> {
366    OffsetDateTime::parse(value, &Rfc3339)
367        .map(|_| ())
368        .map_err(|_| {
369            protocol_error(format!(
370                "the {operation} response contained an invalid {field} date-time"
371            ))
372        })
373}
374
375fn protocol_error(message: String) -> ClientError {
376    ClientError::Protocol {
377        diagnostic: None,
378        message,
379        success_status_received: true,
380    }
381}
382
383fn deserialize_watchers<'de, D: serde::Deserializer<'de>>(
384    d: D,
385) -> Result<Option<Vec<String>>, D::Error> {
386    Vec::<String>::deserialize(d).map(Some)
387}
388
389#[cfg(test)]
390mod tests {
391    use serde_json::json;
392
393    use super::{ListDocument, ListSummary, is_valid_list_color, validate_list_document};
394
395    #[test]
396    fn color_validation_matches_wekan_v11_06() {
397        for value in ["white", "silver", "indigo", "#12aBcF"] {
398            assert!(is_valid_list_color(value));
399        }
400        for value in ["", "belize", "#12345", "#12345g", "123456"] {
401            assert!(!is_valid_list_color(value));
402        }
403    }
404
405    #[test]
406    fn list_response_preserves_blank_unset_color() {
407        let list = serde_json::from_value::<ListDocument>(json!({
408            "_id": "list-1",
409            "title": "Todo",
410            "archived": false,
411            "boardId": "board-1",
412            "createdAt": "2030-01-02T03:04:05Z",
413            "modifiedAt": "2030-01-02T03:04:05Z",
414            "type": "list",
415            "color": ""
416        }))
417        .unwrap();
418
419        let validated = validate_list_document(list).unwrap();
420        assert_eq!(validated.color.as_deref(), Some(""));
421    }
422
423    #[test]
424    fn response_objects_reject_unmapped_fields() {
425        assert!(
426            serde_json::from_value::<ListSummary>(json!({
427                "_id": "list-1",
428                "title": "Todo",
429                "modifiedAt": null,
430                "cardsModifiedAt": null,
431                "unexpected": true
432            }))
433            .is_err()
434        );
435        assert!(
436            serde_json::from_value::<ListDocument>(json!({
437                "_id": "list-1",
438                "title": "Todo",
439                "archived": false,
440                "boardId": "board-1",
441                "createdAt": "2030-01-02T03:04:05Z",
442                "modifiedAt": "2030-01-02T03:04:05Z",
443                "type": "list",
444                "wipLimit": {
445                    "value": 2,
446                    "enabled": true,
447                    "soft": false,
448                    "unexpected": true
449                }
450            }))
451            .is_err()
452        );
453    }
454
455    #[test]
456    fn list_summary_requires_nullable_timestamp_fields_to_be_present() {
457        let missing_modified_at = serde_json::from_value::<ListSummary>(json!({
458            "_id": "list-1",
459            "title": "Todo",
460            "cardsModifiedAt": null
461        }));
462        let missing_cards_modified_at = serde_json::from_value::<ListSummary>(json!({
463            "_id": "list-1",
464            "title": "Todo",
465            "modifiedAt": null
466        }));
467
468        assert!(missing_modified_at.is_err());
469        assert!(missing_cards_modified_at.is_err());
470    }
471}
472
473#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
474pub struct ListWipLimitInput {
475    #[serde(skip_serializing_if = "Option::is_none")]
476    pub value: Option<Number>,
477    #[serde(skip_serializing_if = "Option::is_none")]
478    pub enabled: Option<bool>,
479    #[serde(skip_serializing_if = "Option::is_none")]
480    pub soft: Option<bool>,
481}