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 pub archived: bool,
208 pub archived_at: Option<String>,
209 pub deleted_at: Option<String>,
210 pub deleted_by: Option<String>,
211 pub delete_batch_id: Option<String>,
212 #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
213 pub parent_id: Option<String>,
214 #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
215 pub list_id: Option<String>,
216 pub swimlane_id: String,
217 #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
218 pub board_id: Option<String>,
219 #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
220 pub cover_id: Option<String>,
221 pub color: Option<String>,
222 pub created_at: String,
223 pub modified_at: String,
224 #[serde(default, deserialize_with = "deserialize_nullable_vec")]
225 pub custom_fields: Vec<CardCustomField>,
226 pub date_last_activity: String,
227 pub description: Option<String>,
228 pub requested_by: Option<String>,
229 pub assigned_by: Option<String>,
230 #[serde(default, deserialize_with = "deserialize_nullable_vec")]
231 pub label_ids: Vec<Option<String>>,
232 #[serde(default, deserialize_with = "deserialize_nullable_vec")]
233 pub members: Vec<String>,
234 #[serde(default, deserialize_with = "deserialize_nullable_vec")]
235 pub assignees: Vec<String>,
236 #[serde(default, deserialize_with = "deserialize_nullable_vec")]
237 pub requesters: Vec<String>,
238 #[serde(default, deserialize_with = "deserialize_nullable_vec")]
239 pub assigners: Vec<String>,
240 pub received_at: Option<String>,
241 pub start_at: Option<String>,
242 pub due_at: Option<String>,
243 pub end_at: Option<String>,
244 pub due_complete: Option<bool>,
245 #[serde(default, deserialize_with = "deserialize_nullable_vec")]
246 pub stickers: Vec<CardSticker>,
247 pub location_name: Option<String>,
248 pub location_address: Option<String>,
249 pub location_latitude: Option<Number>,
250 pub location_longitude: Option<Number>,
251 #[serde(default, deserialize_with = "deserialize_nullable_vec")]
252 pub locations: Vec<CardLocation>,
253 pub spent_time: Option<Number>,
254 pub is_overtime: Option<bool>,
255 pub user_id: String,
256 pub sort: Option<Number>,
257 pub subtask_sort: Option<Number>,
258 #[serde(rename(deserialize = "type", serialize = "card_type"))]
259 pub card_type: String,
260 #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
261 pub linked_id: Option<String>,
262 #[serde(default, deserialize_with = "deserialize_nullable_vec")]
263 pub card_dependencies: Vec<CardDependency>,
264 pub vote: Option<CardVote>,
265 pub poker: Option<CardPoker>,
266 #[serde(
267 rename(deserialize = "targetId_gantt", serialize = "target_id_gantt"),
268 default,
269 deserialize_with = "deserialize_nullable_vec"
270 )]
271 pub target_id_gantt: Vec<String>,
272 #[serde(
273 rename(deserialize = "linkType_gantt", serialize = "link_type_gantt"),
274 default,
275 deserialize_with = "deserialize_nullable_vec"
276 )]
277 pub link_type_gantt: Vec<Number>,
278 #[serde(
279 rename(deserialize = "linkId_gantt", serialize = "link_id_gantt"),
280 default,
281 deserialize_with = "deserialize_nullable_vec"
282 )]
283 pub link_id_gantt: Vec<String>,
284 pub card_number: Option<Number>,
285 pub show_activities: bool,
286 pub show_list_on_minicard: Option<bool>,
287 pub show_checklist_at_minicard: Option<bool>,
288 pub hide_finished_checklist_if_items_are_hidden: Option<bool>,
289}
290
291#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
292#[serde(rename_all = "camelCase")]
293pub struct CreateCardRequest {
294 #[serde(skip_serializing_if = "Option::is_none")]
295 pub title: Option<Option<String>>,
296 #[serde(skip_serializing_if = "Option::is_none")]
297 pub linked_id: Option<String>,
298 pub swimlane_id: String,
299 #[serde(skip_serializing_if = "Option::is_none")]
300 pub description: Option<Option<String>>,
301 #[serde(skip_serializing_if = "Option::is_none")]
302 pub members: Option<Vec<String>>,
303 #[serde(skip_serializing_if = "Option::is_none")]
304 pub assignees: Option<Vec<String>>,
305 #[serde(skip_serializing_if = "Option::is_none")]
306 pub received_at: Option<CardDateInput>,
307 #[serde(skip_serializing_if = "Option::is_none")]
308 pub start_at: Option<CardDateInput>,
309 #[serde(skip_serializing_if = "Option::is_none")]
310 pub due_at: Option<CardDateInput>,
311 #[serde(skip_serializing_if = "Option::is_none")]
312 pub end_at: Option<CardDateInput>,
313}
314
315#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
316#[serde(rename_all = "camelCase")]
317pub struct UpdateCardRequest {
318 #[serde(skip_serializing_if = "Option::is_none")]
319 pub title: Option<String>,
320 #[serde(skip_serializing_if = "Option::is_none")]
321 pub sort: Option<Number>,
322 #[serde(skip_serializing_if = "Option::is_none")]
323 pub parent_id: Option<String>,
324 #[serde(skip_serializing_if = "Option::is_none")]
325 pub description: Option<String>,
326 #[serde(skip_serializing_if = "Option::is_none")]
327 pub color: Option<String>,
328 #[serde(skip_serializing_if = "Option::is_none")]
329 pub label_ids: Option<Vec<Option<String>>>,
330 #[serde(skip_serializing_if = "Option::is_none")]
331 pub requested_by: Option<String>,
332 #[serde(skip_serializing_if = "Option::is_none")]
333 pub assigned_by: Option<String>,
334 #[serde(skip_serializing_if = "Option::is_none")]
335 pub received_at: Option<CardDateInput>,
336 #[serde(skip_serializing_if = "Option::is_none")]
337 pub start_at: Option<CardDateInput>,
338 #[serde(skip_serializing_if = "Option::is_none")]
339 pub due_at: Option<CardDateInput>,
340 #[serde(skip_serializing_if = "Option::is_none")]
341 pub end_at: Option<CardDateInput>,
342 #[serde(skip_serializing_if = "Option::is_none")]
343 pub spent_time: Option<Number>,
344 #[serde(skip_serializing_if = "Option::is_none")]
345 pub is_over_time: Option<bool>,
346 #[serde(skip_serializing_if = "Option::is_none")]
347 pub members: Option<Vec<String>>,
348 #[serde(skip_serializing_if = "Option::is_none")]
349 pub assignees: Option<Vec<String>>,
350 #[serde(skip_serializing_if = "Option::is_none")]
351 pub due_complete: Option<bool>,
352 #[serde(skip_serializing_if = "Option::is_none")]
353 pub swimlane_id: Option<String>,
354 #[serde(skip_serializing_if = "Option::is_none")]
355 pub list_id: Option<String>,
356 #[serde(skip_serializing_if = "Option::is_none")]
357 pub new_board_id: Option<String>,
358 #[serde(skip_serializing_if = "Option::is_none")]
359 pub new_swimlane_id: Option<String>,
360 #[serde(skip_serializing_if = "Option::is_none")]
361 pub new_list_id: Option<String>,
362 #[serde(skip_serializing_if = "Option::is_none")]
363 pub archive: Option<bool>,
364 #[serde(skip_serializing_if = "Option::is_none")]
365 pub vote: Option<CardVoteInput>,
366 #[serde(skip_serializing_if = "Option::is_none")]
367 pub poker: Option<CardPokerInput>,
368 #[serde(skip_serializing_if = "Option::is_none")]
369 pub custom_fields: Option<Vec<CardCustomFieldInput>>,
370 #[serde(skip_serializing_if = "Option::is_none")]
371 pub stickers: Option<Vec<CardStickerInput>>,
372 #[serde(skip_serializing_if = "Option::is_none")]
373 pub locations: Option<Vec<CardLocationInput>>,
374}
375
376#[derive(Clone, Debug, Eq, PartialEq)]
377pub struct CreateCardResult {
378 pub card_id: String,
379}
380
381#[derive(Clone, Debug, Eq, PartialEq)]
382pub struct UpdateCardResult {
383 pub card_id: String,
384}
385
386#[derive(Clone, Debug, Eq, PartialEq)]
387pub struct DeleteCardResult {
388 pub card_id: String,
389}
390
391#[derive(Deserialize)]
392#[serde(deny_unknown_fields)]
393struct IdResponse {
394 #[serde(rename = "_id")]
395 id: String,
396}
397
398impl WekanClient {
399 pub async fn board_list_cards(
400 &self,
401 board_id: &str,
402 list_id: &str,
403 token: &SecretString,
404 ) -> Result<Vec<CardSummary>, ClientError> {
405 let body = self
406 .execute_success_request(
407 self.http
408 .get(self.card_endpoint(board_id, list_id, None)?)
409 .header(ACCEPT, "application/json")
410 .bearer_auth(token.expose_secret()),
411 )
412 .await?;
413 let cards = decode_success(&body, "board list card collection", true)?;
414 validate_card_summaries(cards)
415 }
416
417 pub async fn card(
418 &self,
419 board_id: &str,
420 list_id: &str,
421 card_id: &str,
422 token: &SecretString,
423 ) -> Result<CardDocument, ClientError> {
424 let body = self
425 .execute_success_request(
426 self.http
427 .get(self.card_endpoint(board_id, list_id, Some(card_id))?)
428 .header(ACCEPT, "application/json")
429 .bearer_auth(token.expose_secret()),
430 )
431 .await?;
432 if body.is_empty() {
433 return Err(ClientError::EmbeddedServer {
434 http_status: StatusCode::OK,
435 wekan_status_code: StatusCode::NOT_FOUND.as_u16(),
436 server_error: Some("card-not-found".to_owned()),
437 server_reason: Some("Card not found".to_owned()),
438 });
439 }
440 let card = decode_success(&body, "card", true)?;
441 validate_card_document(card)
442 }
443
444 pub async fn create_card(
445 &self,
446 board_id: &str,
447 list_id: &str,
448 request: &CreateCardRequest,
449 token: &SecretString,
450 ) -> Result<CreateCardResult, ClientError> {
451 let body = self
452 .execute_success_request(
453 self.http
454 .post(self.card_endpoint(board_id, list_id, None)?)
455 .header(ACCEPT, "application/json")
456 .bearer_auth(token.expose_secret())
457 .json(request),
458 )
459 .await?;
460 let response: IdResponse = decode_success(&body, "card creation", true)?;
461 require_non_empty(&response.id, "card creation", "card id")?;
462 Ok(CreateCardResult {
463 card_id: response.id,
464 })
465 }
466
467 pub async fn update_card(
468 &self,
469 board_id: &str,
470 list_id: &str,
471 card_id: &str,
472 request: &UpdateCardRequest,
473 token: &SecretString,
474 ) -> Result<UpdateCardResult, ClientError> {
475 let body = self
476 .execute_success_request(
477 self.http
478 .put(self.card_endpoint(board_id, list_id, Some(card_id))?)
479 .header(ACCEPT, "application/json")
480 .bearer_auth(token.expose_secret())
481 .json(request),
482 )
483 .await?;
484 let response: IdResponse = decode_success(&body, "card update", true)?;
485 require_non_empty(&response.id, "card update", "card id")?;
486 Ok(UpdateCardResult {
487 card_id: response.id,
488 })
489 }
490
491 pub async fn delete_card(
492 &self,
493 board_id: &str,
494 list_id: &str,
495 card_id: &str,
496 token: &SecretString,
497 ) -> Result<DeleteCardResult, ClientError> {
498 let body = self
499 .execute_success_request(
500 self.http
501 .delete(self.card_endpoint(board_id, list_id, Some(card_id))?)
502 .header(ACCEPT, "application/json")
503 .bearer_auth(token.expose_secret()),
504 )
505 .await?;
506 let response: IdResponse = decode_success(&body, "card deletion", true)?;
507 require_non_empty(&response.id, "card deletion", "card id")?;
508 Ok(DeleteCardResult {
509 card_id: response.id,
510 })
511 }
512
513 fn card_endpoint(
514 &self,
515 board_id: &str,
516 list_id: &str,
517 card_id: Option<&str>,
518 ) -> Result<reqwest::Url, ClientError> {
519 let mut endpoint =
520 self.server()
521 .join("api/boards")
522 .map_err(|error| ClientError::Protocol {
523 diagnostic: None,
524 message: format!("could not build the card endpoint: {error}"),
525 success_status_received: false,
526 })?;
527 let mut segments = endpoint
528 .path_segments_mut()
529 .map_err(|()| ClientError::Protocol {
530 diagnostic: None,
531 message: "could not add identifiers to the card endpoint".to_owned(),
532 success_status_received: false,
533 })?;
534 segments.push(board_id);
535 segments.push("lists");
536 segments.push(list_id);
537 segments.push("cards");
538 if let Some(card_id) = card_id {
539 segments.push(card_id);
540 }
541 drop(segments);
542 Ok(endpoint)
543 }
544}
545
546pub fn is_valid_card_color(value: &str) -> bool {
547 CARD_COLORS.contains(&value)
548 || value.len() == 7
549 && value.starts_with('#')
550 && value[1..].bytes().all(|byte| byte.is_ascii_hexdigit())
551}
552
553fn validate_card_summaries(cards: Vec<CardSummary>) -> Result<Vec<CardSummary>, ClientError> {
554 for card in &cards {
555 require_non_empty(&card.card_id, "board list card collection", "card id")?;
556 validate_optional_id(
557 card.swimlane_id.as_deref(),
558 "board list card collection",
559 "swimlane id",
560 )?;
561 validate_ids(&card.assignees, "board list card collection", "assignee id")?;
562 for (field, value) in [
563 ("receivedAt", card.received_at.as_deref()),
564 ("startAt", card.start_at.as_deref()),
565 ("dueAt", card.due_at.as_deref()),
566 ("endAt", card.end_at.as_deref()),
567 ] {
568 validate_optional_date_time(value, field, "board list card collection")?;
569 }
570 }
571 Ok(cards)
572}
573
574fn validate_card_document(card: CardDocument) -> Result<CardDocument, ClientError> {
575 require_non_empty(&card.card_id, "card", "card id")?;
576 require_non_empty(&card.swimlane_id, "card", "swimlane id")?;
577 require_non_empty(&card.user_id, "card", "user id")?;
578 if !matches!(
579 card.card_type.as_str(),
580 "cardType-card" | "cardType-linkedCard" | "cardType-linkedBoard" | "template-card"
581 ) {
582 return Err(protocol_error(format!(
583 "the card response contained an invalid type `{}`",
584 card.card_type
585 )));
586 }
587 validate_date_time(&card.created_at, "createdAt", "card")?;
588 validate_date_time(&card.modified_at, "modifiedAt", "card")?;
589 validate_date_time(&card.date_last_activity, "dateLastActivity", "card")?;
590 for (field, value) in [
591 ("archivedAt", card.archived_at.as_deref()),
592 ("deletedAt", card.deleted_at.as_deref()),
593 ("receivedAt", card.received_at.as_deref()),
594 ("startAt", card.start_at.as_deref()),
595 ("dueAt", card.due_at.as_deref()),
596 ("endAt", card.end_at.as_deref()),
597 (
598 "vote.end",
599 card.vote.as_ref().and_then(|vote| vote.end.as_deref()),
600 ),
601 (
602 "poker.end",
603 card.poker.as_ref().and_then(|poker| poker.end.as_deref()),
604 ),
605 ] {
606 validate_optional_date_time(value, field, "card")?;
607 }
608 for (field, value) in [
609 ("parent id", card.parent_id.as_deref()),
610 ("list id", card.list_id.as_deref()),
611 ("board id", card.board_id.as_deref()),
612 ("cover id", card.cover_id.as_deref()),
613 ("linked id", card.linked_id.as_deref()),
614 ] {
615 validate_optional_id(value, "card", field)?;
616 }
617 if let Some(color) = &card.color
618 && !color.is_empty()
619 && !is_valid_card_color(color)
620 {
621 return Err(protocol_error(format!(
622 "the card response contained an invalid color `{color}`"
623 )));
624 }
625 for location in &card.locations {
626 require_non_empty(&location.location_id, "card", "location id")?;
627 }
628 for dependency in &card.card_dependencies {
629 require_non_empty(&dependency.card_id, "card", "dependency card id")?;
630 }
631 for (values, field) in [
632 (&card.members, "member id"),
633 (&card.assignees, "assignee id"),
634 (&card.requesters, "requester id"),
635 (&card.assigners, "assigner id"),
636 (&card.target_id_gantt, "Gantt target id"),
637 (&card.link_id_gantt, "Gantt link id"),
638 ] {
639 validate_ids(values, "card", field)?;
640 }
641 for label_id in card.label_ids.iter().flatten() {
642 require_non_empty(label_id, "card", "label id")?;
643 }
644 if let Some(vote) = &card.vote {
645 validate_ids(&vote.positive, "card", "positive voter id")?;
646 validate_ids(&vote.negative, "card", "negative voter id")?;
647 }
648 if let Some(poker) = &card.poker {
649 for (values, field) in [
650 (&poker.one, "poker voter id"),
651 (&poker.two, "poker voter id"),
652 (&poker.three, "poker voter id"),
653 (&poker.five, "poker voter id"),
654 (&poker.eight, "poker voter id"),
655 (&poker.thirteen, "poker voter id"),
656 (&poker.twenty, "poker voter id"),
657 (&poker.forty, "poker voter id"),
658 (&poker.one_hundred, "poker voter id"),
659 (&poker.unsure, "poker voter id"),
660 ] {
661 validate_ids(values, "card", field)?;
662 }
663 }
664 Ok(card)
665}
666
667fn deserialize_nullable_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
668where
669 D: Deserializer<'de>,
670 T: Deserialize<'de>,
671{
672 Option::<Vec<T>>::deserialize(deserializer).map(Option::unwrap_or_default)
673}
674
675fn deserialize_optional_non_empty_string<'de, D>(
676 deserializer: D,
677) -> Result<Option<String>, D::Error>
678where
679 D: Deserializer<'de>,
680{
681 Option::<String>::deserialize(deserializer).map(|value| value.filter(|value| !value.is_empty()))
682}
683
684fn validate_ids(values: &[String], operation: &str, field: &str) -> Result<(), ClientError> {
685 for value in values {
686 require_non_empty(value, operation, field)?;
687 }
688 Ok(())
689}
690
691fn validate_optional_id(
692 value: Option<&str>,
693 operation: &str,
694 field: &str,
695) -> Result<(), ClientError> {
696 if let Some(value) = value {
697 require_non_empty(value, operation, field)?;
698 }
699 Ok(())
700}
701
702fn require_non_empty(value: &str, operation: &str, field: &str) -> Result<(), ClientError> {
703 if value.trim().is_empty() {
704 return Err(protocol_error(format!(
705 "the {operation} response contained an empty {field}"
706 )));
707 }
708 Ok(())
709}
710
711fn validate_optional_date_time(
712 value: Option<&str>,
713 field: &str,
714 operation: &str,
715) -> Result<(), ClientError> {
716 if let Some(value) = value {
717 validate_date_time(value, field, operation)?;
718 }
719 Ok(())
720}
721
722fn validate_date_time(value: &str, field: &str, operation: &str) -> Result<(), ClientError> {
723 super::datetime::validate_response_date(value).map_err(|_| {
724 protocol_error(format!(
725 "the {operation} response contained an invalid {field} date-time"
726 ))
727 })
728}
729
730fn protocol_error(message: String) -> ClientError {
731 ClientError::Protocol {
732 diagnostic: None,
733 message,
734 success_status_received: true,
735 }
736}
737
738#[cfg(test)]
739mod tests {
740 use serde_json::json;
741
742 use super::{CardDocument, CardSummary, is_valid_card_color};
743
744 #[test]
745 fn color_validation_matches_wekan_v11_06() {
746 for value in ["white", "silver", "indigo", "#12aBcF"] {
747 assert!(is_valid_card_color(value));
748 }
749 for value in ["", "belize", "#12345", "#12345g", "123456"] {
750 assert!(!is_valid_card_color(value));
751 }
752 }
753
754 #[test]
755 fn response_objects_reject_unmapped_fields() {
756 assert!(
757 serde_json::from_value::<CardSummary>(json!({
758 "_id": "card-1",
759 "future": true
760 }))
761 .is_err()
762 );
763 assert!(
764 serde_json::from_value::<CardDocument>(json!({
765 "_id": "card-1",
766 "archived": false,
767 "swimlaneId": "swimlane-1",
768 "createdAt": "2030-01-02T03:04:05Z",
769 "modifiedAt": "2030-01-02T03:04:05Z",
770 "dateLastActivity": "2030-01-02T03:04:05Z",
771 "userId": "user-1",
772 "type": "cardType-card",
773 "showActivities": false,
774 "vote": {"future": true}
775 }))
776 .is_err()
777 );
778 }
779}