Skip to main content

wekan_cli/client/
custom_fields.rs

1use reqwest::header::ACCEPT;
2use secrecy::{ExposeSecret, SecretString};
3use serde::{Deserialize, Serialize};
4
5use super::{
6    ClientError, WekanClient, datetime::validate_response_date, transport::decode_success,
7};
8
9#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
10#[serde(rename_all = "lowercase")]
11pub enum CustomFieldType {
12    Text,
13    Number,
14    Date,
15    Dropdown,
16    #[serde(rename = "dropdownMultiSelect")]
17    DropdownMultiSelect,
18    Checkbox,
19    Currency,
20    Stringtemplate,
21}
22
23#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
24#[serde(deny_unknown_fields)]
25pub struct CustomFieldSummary {
26    #[serde(rename = "_id")]
27    pub custom_field_id: String,
28    pub name: String,
29    #[serde(rename = "type")]
30    pub field_type: CustomFieldType,
31}
32
33#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
34#[serde(deny_unknown_fields)]
35pub struct CustomFieldDropdownItem {
36    #[serde(rename = "_id")]
37    pub item_id: String,
38    pub name: String,
39}
40
41/// Optional properties retain omission separately from an explicit JSON null.
42#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
43#[serde(deny_unknown_fields, rename_all = "camelCase")]
44pub struct CustomFieldSettings {
45    #[serde(
46        default,
47        skip_serializing_if = "Option::is_none",
48        deserialize_with = "nullable_setting"
49    )]
50    pub jira_time_field: Option<Option<String>>,
51    #[serde(
52        default,
53        skip_serializing_if = "Option::is_none",
54        deserialize_with = "nullable_setting"
55    )]
56    pub jira_estimate_field_id: Option<Option<String>>,
57    #[serde(
58        default,
59        skip_serializing_if = "Option::is_none",
60        deserialize_with = "nullable_setting"
61    )]
62    pub jira_estimate_unit: Option<Option<String>>,
63    #[serde(
64        default,
65        skip_serializing_if = "Option::is_none",
66        deserialize_with = "nullable_setting"
67    )]
68    pub currency_code: Option<Option<String>>,
69    #[serde(
70        default,
71        skip_serializing_if = "Option::is_none",
72        deserialize_with = "nullable_items"
73    )]
74    pub dropdown_items: Option<Option<Vec<CustomFieldDropdownItem>>>,
75    #[serde(
76        default,
77        skip_serializing_if = "Option::is_none",
78        deserialize_with = "nullable_setting"
79    )]
80    pub stringtemplate_format: Option<Option<String>>,
81    #[serde(
82        default,
83        skip_serializing_if = "Option::is_none",
84        deserialize_with = "nullable_setting"
85    )]
86    pub stringtemplate_separator: Option<Option<String>>,
87}
88
89impl CustomFieldSettings {
90    pub(crate) fn validate_dropdown_item_ids(&self) -> Result<(), &'static str> {
91        if let Some(Some(items)) = &self.dropdown_items {
92            for item in items {
93                // Wekan trims these IDs before storing them. Validate the cleaned
94                // value without changing the request or restricting read DTOs.
95                let id = super::strings::trim_javascript_whitespace(&item.item_id);
96                validate_custom_field_identifier(id).map_err(|_| {
97                    "dropdown item IDs must not be dot-only after Wekan trims whitespace; no request was sent"
98                })?;
99            }
100        }
101        Ok(())
102    }
103}
104
105pub(crate) fn validate_custom_field_identifier(value: &str) -> Result<(), &'static str> {
106    match value {
107        "." | ".." => {
108            Err("dot-only identifiers cannot be sent by the HTTP client; no request was sent")
109        }
110        _ => Ok(()),
111    }
112}
113
114pub(crate) fn custom_field_input_error(message: &str) -> ClientError {
115    ClientError::Protocol {
116        diagnostic: None,
117        message: message.to_owned(),
118        success_status_received: false,
119    }
120}
121
122#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
123#[serde(deny_unknown_fields, rename_all = "camelCase")]
124pub struct CustomFieldDocument {
125    #[serde(rename = "_id")]
126    pub custom_field_id: String,
127    pub board_ids: Vec<String>,
128    pub name: String,
129    #[serde(rename = "type")]
130    pub field_type: CustomFieldType,
131    #[serde(deserialize_with = "settings_object")]
132    pub settings: CustomFieldSettings,
133    pub show_on_card: bool,
134    pub automatically_on_card: bool,
135    pub always_on_card: bool,
136    pub show_label_on_mini_card: bool,
137    pub show_sum_at_top_of_list: bool,
138    pub created_at: Option<String>,
139    pub modified_at: String,
140    pub card_kinds: Option<Vec<String>>,
141    pub read_only: Option<bool>,
142    pub admin_only: Option<bool>,
143    pub sort: Option<serde_json::Number>,
144}
145
146#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
147#[serde(rename_all = "camelCase")]
148pub struct CreateCustomFieldRequest {
149    pub name: String,
150    #[serde(rename = "type")]
151    pub field_type: CustomFieldType,
152    pub settings: CustomFieldSettings,
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub show_on_card: Option<bool>,
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub automatically_on_card: Option<bool>,
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub show_label_on_mini_card: Option<bool>,
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub show_sum_at_top_of_list: Option<bool>,
161}
162
163#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
164#[serde(rename_all = "camelCase")]
165pub struct UpdateCustomFieldRequest {
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub name: Option<String>,
168    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
169    pub field_type: Option<CustomFieldType>,
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub settings: Option<CustomFieldSettings>,
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub show_on_card: Option<bool>,
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub automatically_on_card: Option<bool>,
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub always_on_card: Option<bool>,
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub show_label_on_mini_card: Option<bool>,
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub show_sum_at_top_of_list: Option<bool>,
182}
183
184#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
185pub struct CreateCustomFieldItemsRequest {
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub items: Option<serde_json::Value>,
188}
189
190#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
191pub struct UpdateCustomFieldItemRequest {
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub name: Option<String>,
194}
195
196#[derive(Clone, Debug, Eq, PartialEq, Deserialize)]
197#[serde(deny_unknown_fields)]
198pub struct CustomFieldIdResponse {
199    #[serde(rename = "_id")]
200    pub id: String,
201}
202
203impl WekanClient {
204    pub async fn board_custom_fields(
205        &self,
206        board: &str,
207        token: &SecretString,
208    ) -> Result<Vec<CustomFieldSummary>, ClientError> {
209        let body = self
210            .execute_success_request(
211                self.http
212                    .get(self.custom_field_endpoint(board, None, false, None)?)
213                    .header(ACCEPT, "application/json")
214                    .bearer_auth(token.expose_secret()),
215            )
216            .await?;
217        let fields: Vec<CustomFieldSummary> =
218            decode_success(&body, "custom-field collection", true)?;
219        for field in &fields {
220            require_id(&field.custom_field_id)?;
221        }
222        Ok(fields)
223    }
224
225    pub async fn custom_field(
226        &self,
227        board: &str,
228        field: &str,
229        token: &SecretString,
230    ) -> Result<Option<CustomFieldDocument>, ClientError> {
231        let body = self
232            .execute_success_request(
233                self.http
234                    .get(self.custom_field_endpoint(board, Some(field), false, None)?)
235                    .header(ACCEPT, "application/json")
236                    .bearer_auth(token.expose_secret()),
237            )
238            .await?;
239        if body.is_empty() {
240            // The pinned route sends HTTP 200 with no data when its scoped lookup misses.
241            return Ok(None);
242        }
243        let document: CustomFieldDocument = decode_success(&body, "custom field", true)?;
244        require_id(&document.custom_field_id)?;
245        if document.custom_field_id != field || !document.board_ids.iter().any(|id| id == board) {
246            return Err(protocol_error(
247                "the custom-field response identifiers did not match the requested scope",
248            ));
249        }
250        for id in &document.board_ids {
251            require_id(id)?;
252        }
253        if let Some(Some(items)) = &document.settings.dropdown_items {
254            for item in items {
255                require_id(&item.item_id)?;
256            }
257        }
258        for date in std::iter::once(&document.modified_at).chain(document.created_at.iter()) {
259            validate_response_date(date).map_err(|()| {
260                protocol_error("the custom-field response contained an invalid date-time")
261            })?;
262        }
263        Ok(Some(document))
264    }
265
266    pub async fn create_custom_field(
267        &self,
268        board: &str,
269        request: &CreateCustomFieldRequest,
270        token: &SecretString,
271    ) -> Result<CustomFieldIdResponse, ClientError> {
272        request
273            .settings
274            .validate_dropdown_item_ids()
275            .map_err(custom_field_input_error)?;
276        let body = self
277            .execute_success_request(
278                self.http
279                    .post(self.custom_field_endpoint(board, None, false, None)?)
280                    .header(ACCEPT, "application/json")
281                    .bearer_auth(token.expose_secret())
282                    .json(request),
283            )
284            .await?;
285        decode_id(&body, None)
286    }
287
288    pub async fn update_custom_field(
289        &self,
290        board: &str,
291        field: &str,
292        request: &UpdateCustomFieldRequest,
293        token: &SecretString,
294    ) -> Result<CustomFieldIdResponse, ClientError> {
295        if let Some(settings) = &request.settings {
296            settings
297                .validate_dropdown_item_ids()
298                .map_err(custom_field_input_error)?;
299        }
300        let body = self
301            .execute_success_request(
302                self.http
303                    .put(self.custom_field_endpoint(board, Some(field), false, None)?)
304                    .header(ACCEPT, "application/json")
305                    .bearer_auth(token.expose_secret())
306                    .json(request),
307            )
308            .await?;
309        decode_id(&body, Some(field))
310    }
311
312    pub async fn delete_custom_field(
313        &self,
314        board: &str,
315        field: &str,
316        token: &SecretString,
317    ) -> Result<CustomFieldIdResponse, ClientError> {
318        let body = self
319            .execute_success_request(
320                self.http
321                    .delete(self.custom_field_endpoint(board, Some(field), false, None)?)
322                    .header(ACCEPT, "application/json")
323                    .bearer_auth(token.expose_secret()),
324            )
325            .await?;
326        decode_id(&body, Some(field))
327    }
328
329    pub async fn create_custom_field_items(
330        &self,
331        board: &str,
332        field: &str,
333        request: &CreateCustomFieldItemsRequest,
334        token: &SecretString,
335    ) -> Result<CustomFieldIdResponse, ClientError> {
336        let body = self
337            .execute_success_request(
338                self.http
339                    .post(self.custom_field_endpoint(board, Some(field), true, None)?)
340                    .header(ACCEPT, "application/json")
341                    .bearer_auth(token.expose_secret())
342                    .json(request),
343            )
344            .await?;
345        decode_id(&body, Some(field))
346    }
347
348    pub async fn update_custom_field_item(
349        &self,
350        board: &str,
351        field: &str,
352        item: &str,
353        request: &UpdateCustomFieldItemRequest,
354        token: &SecretString,
355    ) -> Result<CustomFieldIdResponse, ClientError> {
356        let body = self
357            .execute_success_request(
358                self.http
359                    .put(self.custom_field_endpoint(board, Some(field), true, Some(item))?)
360                    .header(ACCEPT, "application/json")
361                    .bearer_auth(token.expose_secret())
362                    .json(request),
363            )
364            .await?;
365        decode_id(&body, Some(item))
366    }
367
368    pub async fn delete_custom_field_item(
369        &self,
370        board: &str,
371        field: &str,
372        item: &str,
373        token: &SecretString,
374    ) -> Result<CustomFieldIdResponse, ClientError> {
375        let body = self
376            .execute_success_request(
377                self.http
378                    .delete(self.custom_field_endpoint(board, Some(field), true, Some(item))?)
379                    .header(ACCEPT, "application/json")
380                    .bearer_auth(token.expose_secret()),
381            )
382            .await?;
383        // The pinned route acknowledges the parent field, not the dropdown item.
384        decode_id(&body, Some(field))
385    }
386
387    fn custom_field_endpoint(
388        &self,
389        board: &str,
390        field: Option<&str>,
391        dropdown: bool,
392        item: Option<&str>,
393    ) -> Result<reqwest::Url, ClientError> {
394        for id in [Some(board), field, item].into_iter().flatten() {
395            validate_custom_field_identifier(id).map_err(custom_field_input_error)?;
396        }
397        let mut endpoint = self
398            .server()
399            .join("api/boards")
400            .map_err(|_| ClientError::Protocol {
401                diagnostic: None,
402                message: "could not build the custom-field endpoint".to_owned(),
403                success_status_received: false,
404            })?;
405        let mut segments = endpoint
406            .path_segments_mut()
407            .map_err(|()| ClientError::Protocol {
408                diagnostic: None,
409                message: "could not add custom-field identifiers".to_owned(),
410                success_status_received: false,
411            })?;
412        segments.extend([board, "custom-fields"]);
413        if let Some(field) = field {
414            segments.push(field);
415        }
416        if dropdown {
417            segments.push("dropdown-items");
418        }
419        if let Some(item) = item {
420            segments.push(item);
421        }
422        drop(segments);
423        Ok(endpoint)
424    }
425}
426
427fn decode_id(body: &[u8], expected: Option<&str>) -> Result<CustomFieldIdResponse, ClientError> {
428    let response: CustomFieldIdResponse =
429        decode_success(body, "custom-field acknowledgement", true)?;
430    require_id(&response.id)?;
431    if expected.is_some_and(|id| id != response.id) {
432        return Err(protocol_error(
433            "Wekan acknowledged a different custom-field identifier than requested",
434        ));
435    }
436    Ok(response)
437}
438
439fn require_id(id: &str) -> Result<(), ClientError> {
440    if super::strings::trim_javascript_whitespace(id).is_empty() {
441        Err(protocol_error(
442            "the custom-field response contained an empty identifier",
443        ))
444    } else {
445        Ok(())
446    }
447}
448
449fn protocol_error(message: &str) -> ClientError {
450    ClientError::Protocol {
451        diagnostic: None,
452        message: message.to_owned(),
453        success_status_received: true,
454    }
455}
456
457fn nullable_setting<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(
458    deserializer: D,
459) -> Result<Option<Option<T>>, D::Error> {
460    Option::<T>::deserialize(deserializer).map(Some)
461}
462
463struct Object<T>(T);
464impl<'de, T: Deserialize<'de>> Deserialize<'de> for Object<T> {
465    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
466        struct Visitor<T>(std::marker::PhantomData<T>);
467        impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for Visitor<T> {
468            type Value = Object<T>;
469            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
470                f.write_str("a JSON object")
471            }
472            fn visit_map<M: serde::de::MapAccess<'de>>(
473                self,
474                map: M,
475            ) -> Result<Self::Value, M::Error> {
476                T::deserialize(serde::de::value::MapAccessDeserializer::new(map)).map(Object)
477            }
478        }
479        deserializer.deserialize_map(Visitor(std::marker::PhantomData))
480    }
481}
482fn settings_object<'de, D: serde::Deserializer<'de>>(
483    deserializer: D,
484) -> Result<CustomFieldSettings, D::Error> {
485    Object::<CustomFieldSettings>::deserialize(deserializer).map(|value| value.0)
486}
487fn nullable_items<'de, D: serde::Deserializer<'de>>(
488    deserializer: D,
489) -> Result<Option<Option<Vec<CustomFieldDropdownItem>>>, D::Error> {
490    Option::<Vec<Object<CustomFieldDropdownItem>>>::deserialize(deserializer)
491        .map(|items| Some(items.map(|items| items.into_iter().map(|item| item.0).collect())))
492}