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