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