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