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