Skip to main content

wekan_cli/client/
cards.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 archival;
10pub use archival::{ArchiveCardResult, RestoreCardResult};
11mod inputs;
12pub use inputs::{
13    CardCustomFieldInput, CardLocationInput, CardPokerInput, CardStickerInput, CardVoteInput,
14};
15
16pub const CARD_COLORS: &[&str] = &[
17    "white",
18    "green",
19    "yellow",
20    "orange",
21    "red",
22    "purple",
23    "blue",
24    "sky",
25    "lime",
26    "pink",
27    "black",
28    "silver",
29    "peachpuff",
30    "crimson",
31    "plum",
32    "darkgreen",
33    "slateblue",
34    "magenta",
35    "gold",
36    "navy",
37    "gray",
38    "saddlebrown",
39    "paleturquoise",
40    "mistyrose",
41    "indigo",
42];
43
44#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
45#[serde(
46    deny_unknown_fields,
47    rename_all(deserialize = "camelCase", serialize = "snake_case")
48)]
49pub struct CardSummary {
50    #[serde(rename(deserialize = "_id", serialize = "card_id"))]
51    pub card_id: String,
52    pub title: Option<String>,
53    pub description: Option<String>,
54    pub swimlane_id: Option<String>,
55    pub received_at: Option<String>,
56    pub start_at: Option<String>,
57    pub due_at: Option<String>,
58    pub end_at: Option<String>,
59    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
60    pub assignees: Vec<String>,
61    pub sort: Option<Number>,
62}
63
64#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
65#[serde(untagged)]
66pub enum CardCustomFieldValue {
67    String(String),
68    Number(Number),
69    Boolean(bool),
70    Strings(Vec<Option<String>>),
71}
72
73#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
74#[serde(
75    deny_unknown_fields,
76    rename_all(deserialize = "camelCase", serialize = "snake_case")
77)]
78pub struct CardCustomField {
79    #[serde(rename(deserialize = "_id", serialize = "custom_field_id"))]
80    pub custom_field_id: Option<String>,
81    pub value: Option<CardCustomFieldValue>,
82}
83
84#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
85#[serde(rename_all = "lowercase")]
86pub enum CardStickerHighlight {
87    Underline,
88    Round,
89}
90
91#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
92#[serde(deny_unknown_fields, rename_all = "snake_case")]
93pub struct CardSticker {
94    pub icon: Option<String>,
95    pub name: Option<String>,
96    pub highlight: Option<CardStickerHighlight>,
97    pub position: Option<Number>,
98}
99
100#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
101#[serde(
102    deny_unknown_fields,
103    rename_all(deserialize = "camelCase", serialize = "snake_case")
104)]
105pub struct CardLocation {
106    #[serde(rename(deserialize = "_id", serialize = "location_id"))]
107    pub location_id: String,
108    pub name: Option<String>,
109    pub address: Option<String>,
110    pub latitude: Option<Number>,
111    pub longitude: Option<Number>,
112}
113
114#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
115#[serde(
116    deny_unknown_fields,
117    rename_all(deserialize = "camelCase", serialize = "snake_case")
118)]
119pub struct CardDependency {
120    pub card_id: String,
121    #[serde(rename(deserialize = "type", serialize = "dependency_type"))]
122    pub dependency_type: Option<String>,
123    pub color: Option<String>,
124    pub icon: Option<String>,
125}
126
127#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
128#[serde(
129    deny_unknown_fields,
130    rename_all(deserialize = "camelCase", serialize = "snake_case")
131)]
132pub struct CardVote {
133    pub question: String,
134    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
135    pub positive: Vec<String>,
136    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
137    pub negative: Vec<String>,
138    pub end: Option<String>,
139    pub public: bool,
140    pub allow_non_board_members: bool,
141}
142
143#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
144#[serde(
145    deny_unknown_fields,
146    rename_all(deserialize = "camelCase", serialize = "snake_case")
147)]
148pub struct CardPoker {
149    pub question: Option<bool>,
150    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
151    pub one: Vec<String>,
152    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
153    pub two: Vec<String>,
154    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
155    pub three: Vec<String>,
156    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
157    pub five: Vec<String>,
158    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
159    pub eight: Vec<String>,
160    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
161    pub thirteen: Vec<String>,
162    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
163    pub twenty: Vec<String>,
164    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
165    pub forty: Vec<String>,
166    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
167    pub one_hundred: Vec<String>,
168    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
169    pub unsure: Vec<String>,
170    pub end: Option<String>,
171    pub allow_non_board_members: Option<bool>,
172    pub estimation: Option<Number>,
173}
174
175#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
176#[serde(
177    deny_unknown_fields,
178    rename_all(deserialize = "camelCase", serialize = "snake_case")
179)]
180pub struct CardDocument {
181    #[serde(rename(deserialize = "_id", serialize = "card_id"))]
182    pub card_id: String,
183    pub title: Option<String>,
184    pub archived: bool,
185    pub archived_at: Option<String>,
186    pub deleted_at: Option<String>,
187    pub deleted_by: Option<String>,
188    pub delete_batch_id: Option<String>,
189    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
190    pub parent_id: Option<String>,
191    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
192    pub list_id: Option<String>,
193    pub swimlane_id: String,
194    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
195    pub board_id: Option<String>,
196    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
197    pub cover_id: Option<String>,
198    pub color: Option<String>,
199    pub created_at: String,
200    pub modified_at: String,
201    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
202    pub custom_fields: Vec<CardCustomField>,
203    pub date_last_activity: String,
204    pub description: Option<String>,
205    pub requested_by: Option<String>,
206    pub assigned_by: Option<String>,
207    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
208    pub label_ids: Vec<String>,
209    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
210    pub members: Vec<String>,
211    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
212    pub assignees: Vec<String>,
213    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
214    pub requesters: Vec<String>,
215    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
216    pub assigners: Vec<String>,
217    pub received_at: Option<String>,
218    pub start_at: Option<String>,
219    pub due_at: Option<String>,
220    pub end_at: Option<String>,
221    pub due_complete: Option<bool>,
222    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
223    pub stickers: Vec<CardSticker>,
224    pub location_name: Option<String>,
225    pub location_address: Option<String>,
226    pub location_latitude: Option<Number>,
227    pub location_longitude: Option<Number>,
228    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
229    pub locations: Vec<CardLocation>,
230    pub spent_time: Option<Number>,
231    pub is_overtime: Option<bool>,
232    pub user_id: String,
233    pub sort: Option<Number>,
234    pub subtask_sort: Option<Number>,
235    #[serde(rename(deserialize = "type", serialize = "card_type"))]
236    pub card_type: String,
237    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
238    pub linked_id: Option<String>,
239    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
240    pub card_dependencies: Vec<CardDependency>,
241    pub vote: Option<CardVote>,
242    pub poker: Option<CardPoker>,
243    #[serde(
244        rename(deserialize = "targetId_gantt", serialize = "target_id_gantt"),
245        default,
246        deserialize_with = "deserialize_nullable_vec"
247    )]
248    pub target_id_gantt: Vec<String>,
249    #[serde(
250        rename(deserialize = "linkType_gantt", serialize = "link_type_gantt"),
251        default,
252        deserialize_with = "deserialize_nullable_vec"
253    )]
254    pub link_type_gantt: Vec<Number>,
255    #[serde(
256        rename(deserialize = "linkId_gantt", serialize = "link_id_gantt"),
257        default,
258        deserialize_with = "deserialize_nullable_vec"
259    )]
260    pub link_id_gantt: Vec<String>,
261    pub card_number: Option<Number>,
262    pub show_activities: bool,
263    pub show_list_on_minicard: Option<bool>,
264    pub show_checklist_at_minicard: Option<bool>,
265    pub hide_finished_checklist_if_items_are_hidden: Option<bool>,
266}
267
268#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
269#[serde(rename_all = "camelCase")]
270pub struct CreateCardRequest {
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub title: Option<String>,
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub linked_id: Option<String>,
275    pub swimlane_id: String,
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub description: Option<String>,
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub members: Option<Vec<String>>,
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub assignees: Option<Vec<String>>,
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub received_at: Option<String>,
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub start_at: Option<String>,
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub due_at: Option<String>,
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub end_at: Option<String>,
290}
291
292#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
293#[serde(rename_all = "camelCase")]
294pub struct UpdateCardRequest {
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub title: Option<String>,
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub sort: Option<Number>,
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub parent_id: Option<String>,
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub description: Option<String>,
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub color: Option<String>,
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub label_ids: Option<Vec<String>>,
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub requested_by: Option<String>,
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub assigned_by: Option<String>,
311    #[serde(skip_serializing_if = "Option::is_none")]
312    pub received_at: Option<String>,
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub start_at: Option<String>,
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub due_at: Option<String>,
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub end_at: Option<String>,
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub spent_time: Option<Number>,
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub is_over_time: Option<bool>,
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub members: Option<Vec<String>>,
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub assignees: Option<Vec<String>>,
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub due_complete: Option<bool>,
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub swimlane_id: Option<String>,
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub list_id: Option<String>,
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub new_board_id: Option<String>,
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub new_swimlane_id: Option<String>,
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub new_list_id: Option<String>,
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub archive: Option<bool>,
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub vote: Option<CardVoteInput>,
343    #[serde(skip_serializing_if = "Option::is_none")]
344    pub poker: Option<CardPokerInput>,
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub custom_fields: Option<Vec<CardCustomFieldInput>>,
347    #[serde(skip_serializing_if = "Option::is_none")]
348    pub stickers: Option<Vec<CardStickerInput>>,
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub locations: Option<Vec<CardLocationInput>>,
351}
352
353#[derive(Clone, Debug, Eq, PartialEq)]
354pub struct CreateCardResult {
355    pub card_id: String,
356}
357
358#[derive(Clone, Debug, Eq, PartialEq)]
359pub struct UpdateCardResult {
360    pub card_id: String,
361}
362
363#[derive(Clone, Debug, Eq, PartialEq)]
364pub struct DeleteCardResult {
365    pub card_id: String,
366}
367
368#[derive(Deserialize)]
369#[serde(deny_unknown_fields)]
370struct IdResponse {
371    #[serde(rename = "_id")]
372    id: String,
373}
374
375impl WekanClient {
376    pub async fn board_list_cards(
377        &self,
378        board_id: &str,
379        list_id: &str,
380        token: &SecretString,
381    ) -> Result<Vec<CardSummary>, ClientError> {
382        let body = self
383            .execute_success_request(
384                self.http
385                    .get(self.card_endpoint(board_id, list_id, None)?)
386                    .header(ACCEPT, "application/json")
387                    .bearer_auth(token.expose_secret()),
388            )
389            .await?;
390        let cards = decode_success(&body, "board list card collection", true)?;
391        validate_card_summaries(cards)
392    }
393
394    pub async fn card(
395        &self,
396        board_id: &str,
397        list_id: &str,
398        card_id: &str,
399        token: &SecretString,
400    ) -> Result<CardDocument, ClientError> {
401        let body = self
402            .execute_success_request(
403                self.http
404                    .get(self.card_endpoint(board_id, list_id, Some(card_id))?)
405                    .header(ACCEPT, "application/json")
406                    .bearer_auth(token.expose_secret()),
407            )
408            .await?;
409        if body.is_empty() {
410            return Err(ClientError::EmbeddedServer {
411                http_status: StatusCode::OK,
412                wekan_status_code: StatusCode::NOT_FOUND.as_u16(),
413                server_error: Some("card-not-found".to_owned()),
414                server_reason: Some("Card not found".to_owned()),
415            });
416        }
417        let card = decode_success(&body, "card", true)?;
418        validate_card_document(card)
419    }
420
421    pub async fn create_card(
422        &self,
423        board_id: &str,
424        list_id: &str,
425        request: &CreateCardRequest,
426        token: &SecretString,
427    ) -> Result<CreateCardResult, ClientError> {
428        let body = self
429            .execute_success_request(
430                self.http
431                    .post(self.card_endpoint(board_id, list_id, None)?)
432                    .header(ACCEPT, "application/json")
433                    .bearer_auth(token.expose_secret())
434                    .json(request),
435            )
436            .await?;
437        let response: IdResponse = decode_success(&body, "card creation", true)?;
438        require_non_empty(&response.id, "card creation", "card id")?;
439        Ok(CreateCardResult {
440            card_id: response.id,
441        })
442    }
443
444    pub async fn update_card(
445        &self,
446        board_id: &str,
447        list_id: &str,
448        card_id: &str,
449        request: &UpdateCardRequest,
450        token: &SecretString,
451    ) -> Result<UpdateCardResult, ClientError> {
452        let body = self
453            .execute_success_request(
454                self.http
455                    .put(self.card_endpoint(board_id, list_id, Some(card_id))?)
456                    .header(ACCEPT, "application/json")
457                    .bearer_auth(token.expose_secret())
458                    .json(request),
459            )
460            .await?;
461        let response: IdResponse = decode_success(&body, "card update", true)?;
462        require_non_empty(&response.id, "card update", "card id")?;
463        Ok(UpdateCardResult {
464            card_id: response.id,
465        })
466    }
467
468    pub async fn delete_card(
469        &self,
470        board_id: &str,
471        list_id: &str,
472        card_id: &str,
473        token: &SecretString,
474    ) -> Result<DeleteCardResult, ClientError> {
475        let body = self
476            .execute_success_request(
477                self.http
478                    .delete(self.card_endpoint(board_id, list_id, Some(card_id))?)
479                    .header(ACCEPT, "application/json")
480                    .bearer_auth(token.expose_secret()),
481            )
482            .await?;
483        let response: IdResponse = decode_success(&body, "card deletion", true)?;
484        require_non_empty(&response.id, "card deletion", "card id")?;
485        Ok(DeleteCardResult {
486            card_id: response.id,
487        })
488    }
489
490    fn card_endpoint(
491        &self,
492        board_id: &str,
493        list_id: &str,
494        card_id: Option<&str>,
495    ) -> Result<reqwest::Url, ClientError> {
496        let mut endpoint =
497            self.server()
498                .join("api/boards")
499                .map_err(|error| ClientError::Protocol {
500                    diagnostic: None,
501                    message: format!("could not build the card endpoint: {error}"),
502                    success_status_received: false,
503                })?;
504        let mut segments = endpoint
505            .path_segments_mut()
506            .map_err(|()| ClientError::Protocol {
507                diagnostic: None,
508                message: "could not add identifiers to the card endpoint".to_owned(),
509                success_status_received: false,
510            })?;
511        segments.push(board_id);
512        segments.push("lists");
513        segments.push(list_id);
514        segments.push("cards");
515        if let Some(card_id) = card_id {
516            segments.push(card_id);
517        }
518        drop(segments);
519        Ok(endpoint)
520    }
521}
522
523pub fn is_valid_card_color(value: &str) -> bool {
524    CARD_COLORS.contains(&value)
525        || value.len() == 7
526            && value.starts_with('#')
527            && value[1..].bytes().all(|byte| byte.is_ascii_hexdigit())
528}
529
530fn validate_card_summaries(cards: Vec<CardSummary>) -> Result<Vec<CardSummary>, ClientError> {
531    for card in &cards {
532        require_non_empty(&card.card_id, "board list card collection", "card id")?;
533        validate_optional_id(
534            card.swimlane_id.as_deref(),
535            "board list card collection",
536            "swimlane id",
537        )?;
538        validate_ids(&card.assignees, "board list card collection", "assignee id")?;
539        for (field, value) in [
540            ("receivedAt", card.received_at.as_deref()),
541            ("startAt", card.start_at.as_deref()),
542            ("dueAt", card.due_at.as_deref()),
543            ("endAt", card.end_at.as_deref()),
544        ] {
545            validate_optional_date_time(value, field, "board list card collection")?;
546        }
547    }
548    Ok(cards)
549}
550
551fn validate_card_document(card: CardDocument) -> Result<CardDocument, ClientError> {
552    require_non_empty(&card.card_id, "card", "card id")?;
553    require_non_empty(&card.swimlane_id, "card", "swimlane id")?;
554    require_non_empty(&card.user_id, "card", "user id")?;
555    if !matches!(
556        card.card_type.as_str(),
557        "cardType-card" | "cardType-linkedCard" | "cardType-linkedBoard" | "template-card"
558    ) {
559        return Err(protocol_error(format!(
560            "the card response contained an invalid type `{}`",
561            card.card_type
562        )));
563    }
564    validate_date_time(&card.created_at, "createdAt", "card")?;
565    validate_date_time(&card.modified_at, "modifiedAt", "card")?;
566    validate_date_time(&card.date_last_activity, "dateLastActivity", "card")?;
567    for (field, value) in [
568        ("archivedAt", card.archived_at.as_deref()),
569        ("deletedAt", card.deleted_at.as_deref()),
570        ("receivedAt", card.received_at.as_deref()),
571        ("startAt", card.start_at.as_deref()),
572        ("dueAt", card.due_at.as_deref()),
573        ("endAt", card.end_at.as_deref()),
574        (
575            "vote.end",
576            card.vote.as_ref().and_then(|vote| vote.end.as_deref()),
577        ),
578        (
579            "poker.end",
580            card.poker.as_ref().and_then(|poker| poker.end.as_deref()),
581        ),
582    ] {
583        validate_optional_date_time(value, field, "card")?;
584    }
585    for (field, value) in [
586        ("parent id", card.parent_id.as_deref()),
587        ("list id", card.list_id.as_deref()),
588        ("board id", card.board_id.as_deref()),
589        ("cover id", card.cover_id.as_deref()),
590        ("linked id", card.linked_id.as_deref()),
591    ] {
592        validate_optional_id(value, "card", field)?;
593    }
594    if let Some(color) = &card.color
595        && !color.is_empty()
596        && !is_valid_card_color(color)
597    {
598        return Err(protocol_error(format!(
599            "the card response contained an invalid color `{color}`"
600        )));
601    }
602    for field in &card.custom_fields {
603        validate_optional_id(field.custom_field_id.as_deref(), "card", "custom field id")?;
604    }
605    for location in &card.locations {
606        require_non_empty(&location.location_id, "card", "location id")?;
607    }
608    for dependency in &card.card_dependencies {
609        require_non_empty(&dependency.card_id, "card", "dependency card id")?;
610        if let Some(dependency_type) = &dependency.dependency_type
611            && !matches!(
612                dependency_type.as_str(),
613                "related-to" | "blocks" | "is-blocked-by" | "fixes" | "is-fixed-by"
614            )
615        {
616            return Err(protocol_error(format!(
617                "the card response contained an invalid dependency type `{dependency_type}`"
618            )));
619        }
620    }
621    for (values, field) in [
622        (&card.label_ids, "label id"),
623        (&card.members, "member id"),
624        (&card.assignees, "assignee id"),
625        (&card.requesters, "requester id"),
626        (&card.assigners, "assigner id"),
627        (&card.target_id_gantt, "Gantt target id"),
628        (&card.link_id_gantt, "Gantt link id"),
629    ] {
630        validate_ids(values, "card", field)?;
631    }
632    if let Some(vote) = &card.vote {
633        validate_ids(&vote.positive, "card", "positive voter id")?;
634        validate_ids(&vote.negative, "card", "negative voter id")?;
635    }
636    if let Some(poker) = &card.poker {
637        for (values, field) in [
638            (&poker.one, "poker voter id"),
639            (&poker.two, "poker voter id"),
640            (&poker.three, "poker voter id"),
641            (&poker.five, "poker voter id"),
642            (&poker.eight, "poker voter id"),
643            (&poker.thirteen, "poker voter id"),
644            (&poker.twenty, "poker voter id"),
645            (&poker.forty, "poker voter id"),
646            (&poker.one_hundred, "poker voter id"),
647            (&poker.unsure, "poker voter id"),
648        ] {
649            validate_ids(values, "card", field)?;
650        }
651    }
652    Ok(card)
653}
654
655fn deserialize_nullable_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
656where
657    D: Deserializer<'de>,
658    T: Deserialize<'de>,
659{
660    Option::<Vec<T>>::deserialize(deserializer).map(Option::unwrap_or_default)
661}
662
663fn deserialize_optional_non_empty_string<'de, D>(
664    deserializer: D,
665) -> Result<Option<String>, D::Error>
666where
667    D: Deserializer<'de>,
668{
669    Option::<String>::deserialize(deserializer).map(|value| value.filter(|value| !value.is_empty()))
670}
671
672fn validate_ids(values: &[String], operation: &str, field: &str) -> Result<(), ClientError> {
673    for value in values {
674        require_non_empty(value, operation, field)?;
675    }
676    Ok(())
677}
678
679fn validate_optional_id(
680    value: Option<&str>,
681    operation: &str,
682    field: &str,
683) -> Result<(), ClientError> {
684    if let Some(value) = value {
685        require_non_empty(value, operation, field)?;
686    }
687    Ok(())
688}
689
690fn require_non_empty(value: &str, operation: &str, field: &str) -> Result<(), ClientError> {
691    if value.trim().is_empty() {
692        return Err(protocol_error(format!(
693            "the {operation} response contained an empty {field}"
694        )));
695    }
696    Ok(())
697}
698
699fn validate_optional_date_time(
700    value: Option<&str>,
701    field: &str,
702    operation: &str,
703) -> Result<(), ClientError> {
704    if let Some(value) = value {
705        validate_date_time(value, field, operation)?;
706    }
707    Ok(())
708}
709
710fn validate_date_time(value: &str, field: &str, operation: &str) -> Result<(), ClientError> {
711    OffsetDateTime::parse(value, &Rfc3339)
712        .map(|_| ())
713        .map_err(|_| {
714            protocol_error(format!(
715                "the {operation} response contained an invalid {field} date-time"
716            ))
717        })
718}
719
720fn protocol_error(message: String) -> ClientError {
721    ClientError::Protocol {
722        diagnostic: None,
723        message,
724        success_status_received: true,
725    }
726}
727
728#[cfg(test)]
729mod tests {
730    use serde_json::json;
731
732    use super::{CardDocument, CardSummary, is_valid_card_color};
733
734    #[test]
735    fn color_validation_matches_wekan_v11_06() {
736        for value in ["white", "silver", "indigo", "#12aBcF"] {
737            assert!(is_valid_card_color(value));
738        }
739        for value in ["", "belize", "#12345", "#12345g", "123456"] {
740            assert!(!is_valid_card_color(value));
741        }
742    }
743
744    #[test]
745    fn response_objects_reject_unmapped_fields() {
746        assert!(
747            serde_json::from_value::<CardSummary>(json!({
748                "_id": "card-1",
749                "future": true
750            }))
751            .is_err()
752        );
753        assert!(
754            serde_json::from_value::<CardDocument>(json!({
755                "_id": "card-1",
756                "archived": false,
757                "swimlaneId": "swimlane-1",
758                "createdAt": "2030-01-02T03:04:05Z",
759                "modifiedAt": "2030-01-02T03:04:05Z",
760                "dateLastActivity": "2030-01-02T03:04:05Z",
761                "userId": "user-1",
762                "type": "cardType-card",
763                "showActivities": false,
764                "vote": {"future": true}
765            }))
766            .is_err()
767        );
768    }
769}