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