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