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#[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 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 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 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}