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