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