Skip to main content

wekan_cli/client/
boards.rs

1use crate::client::metadata::*;
2use reqwest::header::ACCEPT;
3use secrecy::{ExposeSecret, SecretString};
4use serde::{Deserialize, Serialize};
5use serde_json::Number;
6use time::{OffsetDateTime, format_description::well_known::Rfc3339};
7
8use super::{ClientError, WekanClient, transport::decode_success};
9
10pub mod attachments;
11pub mod card_settings;
12mod copy;
13pub mod domains;
14mod export;
15pub mod import;
16pub mod presentation;
17pub mod trello_import;
18pub use copy::CopyBoardRequest;
19pub mod labels;
20pub mod members;
21
22#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
23#[serde(rename_all = "lowercase")]
24pub enum BoardPermission {
25    #[default]
26    Private,
27    Public,
28    Instance,
29}
30
31impl BoardPermission {
32    pub(crate) const fn as_str(self) -> &'static str {
33        match self {
34            Self::Private => "private",
35            Self::Public => "public",
36            Self::Instance => "instance",
37        }
38    }
39}
40
41#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
42#[serde(rename_all = "lowercase")]
43pub enum BoardColor {
44    #[default]
45    Belize,
46    Nephritis,
47    Pomegranate,
48    Pumpkin,
49    Wisteria,
50    Moderatepink,
51    Strongcyan,
52    Limegreen,
53    Midnight,
54    Dark,
55    Relax,
56    Corteza,
57    Appleglasspastel,
58    Clearblue,
59    Cleargreen,
60    Clearorange,
61    Clearpink,
62    Clearpurple,
63    Clearred,
64    Natural,
65    Modern,
66    Moderndark,
67    Exodark,
68    Cleandark,
69    Cleanlight,
70}
71
72impl BoardColor {
73    pub(crate) const fn as_str(self) -> &'static str {
74        match self {
75            Self::Belize => "belize",
76            Self::Nephritis => "nephritis",
77            Self::Pomegranate => "pomegranate",
78            Self::Pumpkin => "pumpkin",
79            Self::Wisteria => "wisteria",
80            Self::Moderatepink => "moderatepink",
81            Self::Strongcyan => "strongcyan",
82            Self::Limegreen => "limegreen",
83            Self::Midnight => "midnight",
84            Self::Dark => "dark",
85            Self::Relax => "relax",
86            Self::Corteza => "corteza",
87            Self::Appleglasspastel => "appleglasspastel",
88            Self::Clearblue => "clearblue",
89            Self::Cleargreen => "cleargreen",
90            Self::Clearorange => "clearorange",
91            Self::Clearpink => "clearpink",
92            Self::Clearpurple => "clearpurple",
93            Self::Clearred => "clearred",
94            Self::Natural => "natural",
95            Self::Modern => "modern",
96            Self::Moderndark => "moderndark",
97            Self::Exodark => "exodark",
98            Self::Cleandark => "cleandark",
99            Self::Cleanlight => "cleanlight",
100        }
101    }
102}
103
104#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
105#[serde(rename_all = "kebab-case")]
106pub enum PresentParentTask {
107    PrefixWithFullPath,
108    PrefixWithParent,
109    SubtextWithFullPath,
110    SubtextWithParent,
111    NoParent,
112}
113
114impl PresentParentTask {
115    pub(crate) const fn as_str(self) -> &'static str {
116        match self {
117            Self::PrefixWithFullPath => "prefix-with-full-path",
118            Self::PrefixWithParent => "prefix-with-parent",
119            Self::SubtextWithFullPath => "subtext-with-full-path",
120            Self::SubtextWithParent => "subtext-with-parent",
121            Self::NoParent => "no-parent",
122        }
123    }
124}
125
126#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
127#[serde(rename_all = "kebab-case")]
128pub enum BoardType {
129    Board,
130    TemplateBoard,
131    TemplateContainer,
132}
133
134impl BoardType {
135    pub(crate) const fn as_str(self) -> &'static str {
136        match self {
137            Self::Board => "board",
138            Self::TemplateBoard => "template-board",
139            Self::TemplateContainer => "template-container",
140        }
141    }
142}
143
144#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
145#[serde(rename_all = "lowercase")]
146pub enum BoardWatchLevel {
147    Watching,
148    Tracking,
149    Muted,
150}
151
152impl BoardWatchLevel {
153    pub(crate) const fn as_str(self) -> &'static str {
154        match self {
155            Self::Watching => "watching",
156            Self::Tracking => "tracking",
157            Self::Muted => "muted",
158        }
159    }
160}
161
162#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
163#[serde(rename_all = "camelCase")]
164pub struct CreateBoardRequest {
165    pub title: String,
166    pub permission: BoardPermission,
167    pub color: BoardColor,
168}
169
170#[derive(Clone, Debug, Eq, PartialEq)]
171pub struct CreateBoardResult {
172    pub board_id: String,
173    pub default_swimlane_id: String,
174}
175
176#[derive(Clone, Debug, Eq, PartialEq)]
177pub struct RenameBoardResult {
178    pub board_id: String,
179    pub title: String,
180}
181
182#[derive(Clone, Debug, Eq, PartialEq)]
183pub struct DeleteBoardResult {
184    pub board_id: String,
185}
186
187#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
188#[serde(deny_unknown_fields)]
189pub struct BoardCounts {
190    pub instance: u64,
191    pub private: u64,
192    pub public: u64,
193}
194
195#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
196#[serde(deny_unknown_fields, rename_all = "camelCase")]
197pub struct BoardSummary {
198    #[serde(rename = "_id")]
199    pub board_id: String,
200    pub title: String,
201}
202
203#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
204#[serde(
205    deny_unknown_fields,
206    rename_all(deserialize = "camelCase", serialize = "snake_case")
207)]
208pub struct BoardMember {
209    #[serde(default, deserialize_with = "super::decoding::optional_non_null")]
210    pub granted_by: Option<Vec<String>>,
211    #[serde(default, deserialize_with = "super::decoding::optional_non_null")]
212    pub custom_role_id: Option<String>,
213    pub user_id: String,
214    pub is_admin: bool,
215    pub is_active: bool,
216    pub is_no_comments: Option<bool>,
217    pub is_comment_only: Option<bool>,
218    pub is_worker: Option<bool>,
219    pub is_normal_assigned_only: Option<bool>,
220    pub is_comment_assigned_only: Option<bool>,
221    pub is_read_only: Option<bool>,
222    pub is_read_assigned_only: Option<bool>,
223}
224
225#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
226#[serde(
227    deny_unknown_fields,
228    rename_all(deserialize = "camelCase", serialize = "snake_case")
229)]
230pub struct BoardWatcher {
231    pub user_id: String,
232    pub level: BoardWatchLevel,
233}
234
235#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
236#[serde(
237    deny_unknown_fields,
238    rename_all(deserialize = "camelCase", serialize = "snake_case")
239)]
240pub struct BoardLabel {
241    #[serde(default, deserialize_with = "super::decoding::optional_non_null")]
242    pub due_at: Option<String>,
243    #[serde(default, deserialize_with = "super::decoding::optional_non_null")]
244    pub shared_label_id: Option<String>,
245    #[serde(rename(deserialize = "_id", serialize = "label_id"))]
246    pub label_id: String,
247    pub name: Option<String>,
248    pub color: String,
249}
250
251#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
252#[serde(
253    deny_unknown_fields,
254    rename_all(deserialize = "camelCase", serialize = "snake_case")
255)]
256pub struct BoardOrganization {
257    pub org_id: String,
258    pub org_display_name: String,
259    pub is_active: bool,
260}
261
262#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
263#[serde(
264    deny_unknown_fields,
265    rename_all(deserialize = "camelCase", serialize = "snake_case")
266)]
267pub struct BoardTeam {
268    pub team_id: String,
269    pub team_display_name: String,
270    pub is_active: bool,
271}
272
273#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
274#[serde(
275    deny_unknown_fields,
276    rename_all(deserialize = "camelCase", serialize = "snake_case")
277)]
278pub struct BoardDomain {
279    pub domain: String,
280    pub is_active: bool,
281}
282
283#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
284#[serde(
285    deny_unknown_fields,
286    rename_all(deserialize = "camelCase", serialize = "snake_case")
287)]
288pub struct BoardDocument {
289    pub scrum: Option<Box<ScrumSettings>>,
290    pub scrum_import_losses: Option<Vec<ScrumImportLoss>>,
291    pub announcement: Option<BoardAnnouncement>,
292    pub wip_limit_groups: Option<Vec<BoardWipGroup>>,
293    pub email_to_card: Option<Box<BoardEmailToCard>>,
294    pub notification_delivery: Option<Box<NotificationDelivery>>,
295    pub column_sets: Option<Vec<BoardColumnSet>>,
296
297    pub scrum_revision: Option<serde_json::Number>,
298    pub allows_instance_editing: Option<bool>,
299    pub import_run_id: Option<String>,
300    pub sync_effects_enabled: Option<bool>,
301    pub map_image_attachment_id: Option<String>,
302    pub auto_archive_inactive_days: Option<Number>,
303    pub rules_visible_to_members: Option<bool>,
304    pub card_field_order: Option<Vec<String>>,
305    pub minicard_field_order: Option<Vec<String>>,
306    pub notify_override_tray: Option<bool>,
307    pub notify_override_email: Option<bool>,
308    pub due_reminder_days: Option<Vec<Number>>,
309    pub due_reminder_webhook: Option<bool>,
310    pub labels_above_title_on_card: Option<bool>,
311    pub allows_checklist_due_date_on_minicard: Option<bool>,
312    pub allows_location_on_minicard: Option<bool>,
313    pub allows_swimlane_name_on_card: Option<bool>,
314    pub allows_flowtime_on_minicard: Option<bool>,
315    pub allows_pomodoro_on_minicard: Option<bool>,
316    pub allows_attachment_list_on_minicard: Option<bool>,
317    pub allows_text_notes_on_minicard: Option<bool>,
318    pub allows_comment_count_on_card: Option<bool>,
319    pub allows_activities_on_minicard: Option<bool>,
320    pub allows_card_buttons_on_minicard: Option<bool>,
321    pub allows_description_badge_on_card: Option<bool>,
322    pub allows_unread_comments_on_card: Option<bool>,
323    pub allows_due_from_title_on_card: Option<bool>,
324    pub allows_due_from_title_on_minicard: Option<bool>,
325    pub allows_similar_cards_on_card: Option<bool>,
326    pub allows_similar_cards_on_minicard: Option<bool>,
327    pub allows_at_risk_on_card: Option<bool>,
328    pub allows_at_risk_on_minicard: Option<bool>,
329    pub allows_suggested_members_on_card: Option<bool>,
330    pub allows_forecast_on_card: Option<bool>,
331    pub allows_forecast_on_minicard: Option<bool>,
332    pub allows_checklist_from_description_on_card: Option<bool>,
333    pub allows_action_items_on_card: Option<bool>,
334    pub allows_whiteboard_on_card: Option<bool>,
335    pub shared_list_headers: Option<bool>,
336    pub board_view_settings: Option<
337        std::collections::BTreeMap<
338            String,
339            crate::client::boards::presentation::StoredBoardViewVisibility,
340        >,
341    >,
342    pub board_view_order: Option<Vec<String>>,
343    pub date_format_override: Option<bool>,
344    pub date_format: Option<String>,
345    pub ask_for_move_reason: Option<bool>,
346    pub subtasks_default_swimlane_id: Option<String>,
347    pub allows_swimlane_dragging: Option<bool>,
348    pub allows_list_dragging: Option<bool>,
349    pub allows_card_dragging: Option<bool>,
350    pub allows_checklist_dragging: Option<bool>,
351    pub allows_checklist_item_dragging: Option<bool>,
352    pub allows_subtask_dragging: Option<bool>,
353    pub allows_minicard_collapse: Option<bool>,
354    pub labels_above_title_on_minicard: Option<bool>,
355    pub allows_checklist_due_date: Option<bool>,
356    pub allows_checklist_title: Option<bool>,
357    pub allows_comments_on_minicard: Option<bool>,
358    pub show_label_text: Option<bool>,
359    pub allows_swimlane_name_on_minicard: Option<bool>,
360    pub allows_spent_time: Option<bool>,
361    pub allows_spent_time_on_minicard: Option<bool>,
362    pub allows_stickers: Option<bool>,
363    pub allows_stickers_on_minicard: Option<bool>,
364    pub allows_location: Option<bool>,
365    pub allows_dependencies: Option<bool>,
366    pub allows_dependencies_on_minicard: Option<bool>,
367    pub allows_flowtime: Option<bool>,
368    pub allows_pomodoro: Option<bool>,
369    pub allows_vote: Option<bool>,
370    pub allows_vote_on_minicard: Option<bool>,
371    pub allows_poker: Option<bool>,
372    pub allows_poker_on_minicard: Option<bool>,
373    pub allows_text_notes: Option<bool>,
374    pub allows_comment_count_on_minicard: Option<bool>,
375    pub allows_card_collapse: Option<bool>,
376    pub allows_checklist_title_on_minicard: Option<bool>,
377    pub allows_label_text_on_card: Option<bool>,
378    pub allows_card_color_on_card: Option<bool>,
379    pub allows_card_color_on_minicard: Option<bool>,
380    pub allows_parent_card_on_card: Option<bool>,
381    pub allows_parent_card_on_minicard: Option<bool>,
382    pub allows_linked_card_on_card: Option<bool>,
383    pub allows_linked_card_on_minicard: Option<bool>,
384    pub allows_card_buttons_on_card: Option<bool>,
385    pub allows_description_badge_on_minicard: Option<bool>,
386    pub allows_unread_comments_on_minicard: Option<bool>,
387    pub allows_issue_type_on_minicard: Option<bool>,
388    pub sticky_list_headers: Option<bool>,
389    pub list_width_resize_locked: Option<bool>,
390    pub swimlane_height_resize_locked: Option<bool>,
391    pub default_public_board_view: Option<String>,
392    pub default_private_board_view: Option<String>,
393    pub same_width_for_all_lists: Option<bool>,
394    pub same_width_for_all_lists_value: Option<Number>,
395    pub key: Option<String>,
396    #[serde(rename(deserialize = "_id", serialize = "board_id"))]
397    pub board_id: String,
398    pub title: String,
399    pub slug: Option<String>,
400    pub archived: Option<bool>,
401    pub archived_at: Option<String>,
402    pub created_at: Option<String>,
403    pub modified_at: Option<String>,
404    pub stars: Option<Number>,
405    #[serde(default)]
406    pub labels: Vec<BoardLabel>,
407    pub members: Vec<BoardMember>,
408    #[serde(default)]
409    pub watchers: Vec<BoardWatcher>,
410    pub permission: Option<BoardPermission>,
411    #[serde(default)]
412    pub orgs: Vec<BoardOrganization>,
413    #[serde(default)]
414    pub teams: Vec<BoardTeam>,
415    #[serde(default)]
416    pub domains: Vec<BoardDomain>,
417    #[serde(default)]
418    pub import_usernames: Vec<String>,
419    pub color: Option<BoardColor>,
420    #[serde(default)]
421    pub custom_theme_colors: Vec<String>,
422    #[serde(rename(deserialize = "backgroundImageURL", serialize = "background_image_url"))]
423    pub background_image_url: Option<String>,
424    pub background_image_id: Option<String>,
425    pub allows_card_counter_list: Option<bool>,
426    pub card_aging: Option<bool>,
427    pub show_dependencies: Option<bool>,
428    pub card_aging_days1: Option<Number>,
429    pub card_aging_days2: Option<Number>,
430    pub card_aging_days3: Option<Number>,
431    pub allows_board_member_list: Option<bool>,
432    pub description: Option<String>,
433    pub subtasks_default_board_id: Option<String>,
434    pub migration_version: Option<Number>,
435    pub subtasks_default_list_id: Option<String>,
436    pub date_settings_default_board_id: Option<String>,
437    pub date_settings_default_list_id: Option<String>,
438    pub allows_subtasks: Option<bool>,
439    pub allows_subtasks_on_minicard: Option<bool>,
440    pub allows_attachments: Option<bool>,
441    pub allows_attachments_on_minicard: Option<bool>,
442    pub allows_checklists: Option<bool>,
443    pub allows_checklists_on_minicard: Option<bool>,
444    pub allows_custom_fields: Option<bool>,
445    pub allows_custom_fields_on_minicard: Option<bool>,
446    pub allows_checklist_count_badge_on_minicard: Option<bool>,
447    pub allows_comments: Option<bool>,
448    pub allows_description_title: Option<bool>,
449    pub allows_description_title_on_minicard: Option<bool>,
450    pub allows_description_text: Option<bool>,
451    pub allows_description_text_on_minicard: Option<bool>,
452    pub allows_cover_attachment_on_minicard: Option<bool>,
453    pub allows_cover_attachment_on_card: Option<bool>,
454    pub allows_badge_attachment_on_minicard: Option<bool>,
455    pub allows_attachment_count_on_card: Option<bool>,
456    pub allows_checklist_count_badge_on_card: Option<bool>,
457    pub allows_card_sorting_by_number_on_minicard: Option<bool>,
458    pub allows_card_number: Option<bool>,
459    pub allows_card_number_on_minicard: Option<bool>,
460    pub allows_activities: Option<bool>,
461    pub allows_labels: Option<bool>,
462    pub allows_labels_on_minicard: Option<bool>,
463    pub allows_creator: Option<bool>,
464    pub allows_creator_on_minicard: Option<bool>,
465    pub allows_assignee: Option<bool>,
466    pub allows_assignee_on_minicard: Option<bool>,
467    pub allows_members: Option<bool>,
468    pub allows_members_on_minicard: Option<bool>,
469    pub allows_requested_by: Option<bool>,
470    pub allows_requested_by_on_minicard: Option<bool>,
471    pub allows_card_sorting_by_number: Option<bool>,
472    pub allows_show_lists: Option<bool>,
473    pub allows_assigned_by: Option<bool>,
474    pub allows_assigned_by_on_minicard: Option<bool>,
475    pub allows_show_lists_on_minicard: Option<bool>,
476    pub allows_checklist_at_minicard: Option<bool>,
477    pub allows_received_date: Option<bool>,
478    pub restrict_comment_editing: Option<bool>,
479    pub allows_personal_list_width: Option<bool>,
480    pub auto_width: Option<bool>,
481    pub allows_received_date_on_minicard: Option<bool>,
482    pub allows_start_date: Option<bool>,
483    pub allows_start_date_on_minicard: Option<bool>,
484    pub allows_end_date: Option<bool>,
485    pub allows_end_date_on_minicard: Option<bool>,
486    pub allows_due_date: Option<bool>,
487    pub allows_due_date_on_minicard: Option<bool>,
488    pub allows_due_complete: Option<bool>,
489    pub allows_due_complete_on_minicard: Option<bool>,
490    pub present_parent_task: Option<PresentParentTask>,
491    pub received_at: Option<String>,
492    pub start_at: Option<String>,
493    pub due_at: Option<String>,
494    pub end_at: Option<String>,
495    pub spent_time: Option<Number>,
496    pub is_overtime: Option<bool>,
497    #[serde(rename(deserialize = "type", serialize = "board_type"))]
498    pub board_type: Option<BoardType>,
499    pub sort: Option<Number>,
500    pub show_activities: Option<bool>,
501}
502
503#[derive(Deserialize)]
504#[serde(deny_unknown_fields, rename_all = "camelCase")]
505struct CreateBoardResponse {
506    #[serde(rename = "_id")]
507    board_id: String,
508    default_swimlane_id: String,
509}
510
511#[derive(Serialize)]
512struct RenameBoardRequest<'a> {
513    title: &'a str,
514}
515
516#[derive(Deserialize)]
517#[serde(deny_unknown_fields)]
518struct RenameBoardResponse {
519    #[serde(rename = "_id")]
520    board_id: String,
521    title: String,
522}
523
524#[derive(Deserialize)]
525#[serde(deny_unknown_fields)]
526struct IdResponse {
527    #[serde(rename = "_id")]
528    id: String,
529}
530
531impl WekanClient {
532    pub async fn public_boards(
533        &self,
534        token: &SecretString,
535    ) -> Result<Vec<BoardSummary>, ClientError> {
536        let body = self
537            .execute_success_request(
538                self.http
539                    .get(self.boards_api_endpoint("api/boards", "public boards")?)
540                    .header(ACCEPT, "application/json")
541                    .bearer_auth(token.expose_secret()),
542            )
543            .await?;
544        let boards = decode_success(&body, "public boards", true)?;
545        validate_board_summaries(boards, "public boards")
546    }
547
548    pub async fn board_counts(&self, token: &SecretString) -> Result<BoardCounts, ClientError> {
549        let body = self
550            .execute_success_request(
551                self.http
552                    .get(self.boards_api_endpoint("api/boards_count", "board counts")?)
553                    .header(ACCEPT, "application/json")
554                    .bearer_auth(token.expose_secret()),
555            )
556            .await?;
557        decode_success(&body, "board counts", true)
558    }
559
560    pub async fn board(
561        &self,
562        board_id: &str,
563        token: &SecretString,
564    ) -> Result<BoardDocument, ClientError> {
565        let body = self
566            .execute_success_request(
567                self.http
568                    .get(self.board_endpoint(board_id, None)?)
569                    .header(ACCEPT, "application/json")
570                    .bearer_auth(token.expose_secret()),
571            )
572            .await?;
573        let board = decode_success(&body, "board", true)?;
574        validate_board_document(board)
575    }
576
577    pub async fn create_board(
578        &self,
579        request: &CreateBoardRequest,
580        token: &SecretString,
581    ) -> Result<CreateBoardResult, ClientError> {
582        let body = self
583            .execute_success_request(
584                self.http
585                    .post(self.boards_api_endpoint("api/boards", "board creation")?)
586                    .header(ACCEPT, "application/json")
587                    .bearer_auth(token.expose_secret())
588                    .json(request),
589            )
590            .await?;
591        let response: CreateBoardResponse = decode_success(&body, "board creation", true)?;
592        require_non_empty(&response.board_id, "board creation", "board id")?;
593        require_non_empty(
594            &response.default_swimlane_id,
595            "board creation",
596            "default swimlane id",
597        )?;
598        Ok(CreateBoardResult {
599            board_id: response.board_id,
600            default_swimlane_id: response.default_swimlane_id,
601        })
602    }
603
604    pub async fn rename_board(
605        &self,
606        board_id: &str,
607        title: &str,
608        token: &SecretString,
609    ) -> Result<RenameBoardResult, ClientError> {
610        let body = self
611            .execute_success_request(
612                self.http
613                    .put(self.board_endpoint(board_id, Some("title"))?)
614                    .header(ACCEPT, "application/json")
615                    .bearer_auth(token.expose_secret())
616                    .json(&RenameBoardRequest { title }),
617            )
618            .await?;
619        let response: RenameBoardResponse = decode_success(&body, "board rename", true)?;
620        require_non_empty(&response.board_id, "board rename", "board id")?;
621        require_non_empty(&response.title, "board rename", "title")?;
622        Ok(RenameBoardResult {
623            board_id: response.board_id,
624            title: response.title,
625        })
626    }
627
628    pub async fn delete_board(
629        &self,
630        board_id: &str,
631        token: &SecretString,
632    ) -> Result<DeleteBoardResult, ClientError> {
633        let body = self
634            .execute_success_request(
635                self.http
636                    .delete(self.board_endpoint(board_id, None)?)
637                    .header(ACCEPT, "application/json")
638                    .bearer_auth(token.expose_secret()),
639            )
640            .await?;
641        let response: IdResponse = decode_success(&body, "board deletion", true)?;
642        require_non_empty(&response.id, "board deletion", "board id")?;
643        Ok(DeleteBoardResult {
644            board_id: response.id,
645        })
646    }
647
648    fn boards_api_endpoint(
649        &self,
650        path: &str,
651        operation: &str,
652    ) -> Result<reqwest::Url, ClientError> {
653        self.server()
654            .join(path)
655            .map_err(|error| ClientError::Protocol {
656                diagnostic: None,
657                message: format!("could not build the {operation} endpoint: {error}"),
658                success_status_received: false,
659            })
660    }
661
662    pub(super) fn board_endpoint(
663        &self,
664        board_id: &str,
665        suffix: Option<&str>,
666    ) -> Result<reqwest::Url, ClientError> {
667        let mut endpoint = self.boards_api_endpoint("api/boards", "board")?;
668        let mut segments = endpoint
669            .path_segments_mut()
670            .map_err(|()| ClientError::Protocol {
671                diagnostic: None,
672                message: "could not add the board id to the endpoint".to_owned(),
673                success_status_received: false,
674            })?;
675        segments.push(board_id);
676        if let Some(suffix) = suffix {
677            segments.push(suffix);
678        }
679        drop(segments);
680        Ok(endpoint)
681    }
682}
683
684fn validate_board_summaries(
685    boards: Vec<BoardSummary>,
686    operation: &str,
687) -> Result<Vec<BoardSummary>, ClientError> {
688    for board in &boards {
689        require_non_empty(&board.board_id, operation, "board id")?;
690        require_non_empty(&board.title, operation, "title")?;
691    }
692    Ok(boards)
693}
694
695fn validate_board_document(board: BoardDocument) -> Result<BoardDocument, ClientError> {
696    require_non_empty(&board.board_id, "board", "board id")?;
697    require_non_empty(&board.title, "board", "title")?;
698    for label in &board.labels {
699        if let Some(value) = &label.due_at {
700            validate_date_time(value, "label dueAt")?;
701        }
702    }
703    for (field, value) in [
704        ("archivedAt", board.archived_at.as_deref()),
705        ("createdAt", board.created_at.as_deref()),
706        ("modifiedAt", board.modified_at.as_deref()),
707        ("receivedAt", board.received_at.as_deref()),
708        ("startAt", board.start_at.as_deref()),
709        ("dueAt", board.due_at.as_deref()),
710        ("endAt", board.end_at.as_deref()),
711    ] {
712        if let Some(value) = value {
713            validate_date_time(value, field)?;
714        }
715    }
716    Ok(board)
717}
718
719fn validate_date_time(value: &str, field: &str) -> Result<(), ClientError> {
720    OffsetDateTime::parse(value, &Rfc3339)
721        .map(|_| ())
722        .map_err(|_| ClientError::Protocol {
723            diagnostic: None,
724            message: format!("the board response contained an invalid {field} date-time"),
725            success_status_received: true,
726        })
727}
728
729fn require_non_empty(value: &str, operation: &str, field: &str) -> Result<(), ClientError> {
730    if value.is_empty() {
731        Err(ClientError::Protocol {
732            diagnostic: None,
733            message: format!("the {operation} response contained an empty {field}"),
734            success_status_received: true,
735        })
736    } else {
737        Ok(())
738    }
739}