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