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