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