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