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