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                    diagnostic: None,
253                    message: format!("could not build the list endpoint: {error}"),
254                    success_status_received: false,
255                })?;
256        let mut segments = endpoint
257            .path_segments_mut()
258            .map_err(|()| ClientError::Protocol {
259                diagnostic: None,
260                message: "could not add identifiers to the list endpoint".to_owned(),
261                success_status_received: false,
262            })?;
263        segments.push(board_id);
264        segments.push("lists");
265        if let Some(list_id) = list_id {
266            segments.push(list_id);
267        }
268        drop(segments);
269        Ok(endpoint)
270    }
271}
272
273pub fn is_valid_list_color(value: &str) -> bool {
274    LIST_COLORS.contains(&value)
275        || value.len() == 7
276            && value.starts_with('#')
277            && value[1..].bytes().all(|byte| byte.is_ascii_hexdigit())
278}
279
280fn validate_list_summaries(lists: Vec<ListSummary>) -> Result<Vec<ListSummary>, ClientError> {
281    for list in &lists {
282        require_non_empty(&list.list_id, "board list collection", "list id")?;
283        require_non_empty(&list.title, "board list collection", "title")?;
284        validate_optional_date_time(
285            list.modified_at.as_deref(),
286            "modifiedAt",
287            "board list collection",
288        )?;
289        validate_optional_date_time(
290            list.cards_modified_at.as_deref(),
291            "cardsModifiedAt",
292            "board list collection",
293        )?;
294    }
295    Ok(lists)
296}
297
298fn deserialize_required_nullable<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
299where
300    D: Deserializer<'de>,
301{
302    Option::<String>::deserialize(deserializer)
303}
304
305fn validate_list_document(list: ListDocument) -> Result<ListDocument, ClientError> {
306    require_non_empty(&list.list_id, "list", "list id")?;
307    require_non_empty(&list.title, "list", "title")?;
308    require_non_empty(&list.board_id, "list", "board id")?;
309    validate_date_time(&list.created_at, "createdAt", "list")?;
310    validate_date_time(&list.modified_at, "modifiedAt", "list")?;
311    for (field, value) in [
312        ("archivedAt", list.archived_at.as_deref()),
313        ("deletedAt", list.deleted_at.as_deref()),
314        ("updatedAt", list.updated_at.as_deref()),
315        ("_updatedAt", list.position_updated_at.as_deref()),
316    ] {
317        validate_optional_date_time(value, field, "list")?;
318    }
319    if let Some(color) = &list.color
320        && !color.is_empty()
321        && !is_valid_list_color(color)
322    {
323        return Err(protocol_error(format!(
324            "the list response contained an invalid color `{color}`"
325        )));
326    }
327    if let Some(width) = &list.width {
328        let valid = width
329            .as_f64()
330            .is_some_and(|width| (100.0..=1000.0).contains(&width));
331        if !valid {
332            return Err(protocol_error(
333                "the list response contained a width outside 100 through 1000".to_owned(),
334            ));
335        }
336    }
337    Ok(list)
338}
339
340fn require_non_empty(value: &str, operation: &str, field: &str) -> Result<(), ClientError> {
341    if value.is_empty() {
342        return Err(protocol_error(format!(
343            "the {operation} response contained an empty {field}"
344        )));
345    }
346    Ok(())
347}
348
349fn validate_optional_date_time(
350    value: Option<&str>,
351    field: &str,
352    operation: &str,
353) -> Result<(), ClientError> {
354    if let Some(value) = value {
355        validate_date_time(value, field, operation)?;
356    }
357    Ok(())
358}
359
360fn validate_date_time(value: &str, field: &str, operation: &str) -> Result<(), ClientError> {
361    OffsetDateTime::parse(value, &Rfc3339)
362        .map(|_| ())
363        .map_err(|_| {
364            protocol_error(format!(
365                "the {operation} response contained an invalid {field} date-time"
366            ))
367        })
368}
369
370fn protocol_error(message: String) -> ClientError {
371    ClientError::Protocol {
372        diagnostic: None,
373        message,
374        success_status_received: true,
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use serde_json::json;
381
382    use super::{ListDocument, ListSummary, is_valid_list_color, validate_list_document};
383
384    #[test]
385    fn color_validation_matches_wekan_v11_06() {
386        for value in ["white", "silver", "indigo", "#12aBcF"] {
387            assert!(is_valid_list_color(value));
388        }
389        for value in ["", "belize", "#12345", "#12345g", "123456"] {
390            assert!(!is_valid_list_color(value));
391        }
392    }
393
394    #[test]
395    fn list_response_preserves_blank_unset_color() {
396        let list = serde_json::from_value::<ListDocument>(json!({
397            "_id": "list-1",
398            "title": "Todo",
399            "archived": false,
400            "boardId": "board-1",
401            "createdAt": "2030-01-02T03:04:05Z",
402            "modifiedAt": "2030-01-02T03:04:05Z",
403            "type": "list",
404            "color": ""
405        }))
406        .unwrap();
407
408        let validated = validate_list_document(list).unwrap();
409        assert_eq!(validated.color.as_deref(), Some(""));
410    }
411
412    #[test]
413    fn response_objects_reject_unmapped_fields() {
414        assert!(
415            serde_json::from_value::<ListSummary>(json!({
416                "_id": "list-1",
417                "title": "Todo",
418                "modifiedAt": null,
419                "cardsModifiedAt": null,
420                "unexpected": true
421            }))
422            .is_err()
423        );
424        assert!(
425            serde_json::from_value::<ListDocument>(json!({
426                "_id": "list-1",
427                "title": "Todo",
428                "archived": false,
429                "boardId": "board-1",
430                "createdAt": "2030-01-02T03:04:05Z",
431                "modifiedAt": "2030-01-02T03:04:05Z",
432                "type": "list",
433                "wipLimit": {
434                    "value": 2,
435                    "enabled": true,
436                    "soft": false,
437                    "unexpected": true
438                }
439            }))
440            .is_err()
441        );
442    }
443
444    #[test]
445    fn list_summary_requires_nullable_timestamp_fields_to_be_present() {
446        let missing_modified_at = serde_json::from_value::<ListSummary>(json!({
447            "_id": "list-1",
448            "title": "Todo",
449            "cardsModifiedAt": null
450        }));
451        let missing_cards_modified_at = serde_json::from_value::<ListSummary>(json!({
452            "_id": "list-1",
453            "title": "Todo",
454            "modifiedAt": null
455        }));
456
457        assert!(missing_modified_at.is_err());
458        assert!(missing_cards_modified_at.is_err());
459    }
460}