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