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