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