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