Skip to main content

wekan_cli/client/
rules.rs

1//! Board automation rules for the pinned Wekan REST API.
2use reqwest::{Method, header::ACCEPT};
3use secrecy::{ExposeSecret, SecretString};
4use serde::{Deserialize, Serialize};
5
6use super::{
7    ClientError, WekanClient, datetime::validate_response_date, transport::decode_success,
8};
9
10mod payloads;
11pub use payloads::{RuleAction, RuleActionInput, RuleNumber, RuleTrigger, RuleTriggerInput};
12
13// Allow the 50 MiB input budget plus server defaults and response metadata.
14const MAX_RULE_RESPONSE_BYTES: usize = 64 * 1024 * 1024;
15
16#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
17#[serde(deny_unknown_fields)]
18pub struct RuleDocument {
19    pub enabled: bool,
20    #[serde(rename = "extraTriggers")]
21    pub extra_triggers: Option<Vec<RuleTrigger>>,
22    #[serde(rename = "extraActions")]
23    pub extra_actions: Option<Vec<RuleAction>>,
24    #[serde(rename = "_id")]
25    pub rule_id: String,
26    pub title: String,
27    #[serde(deserialize_with = "nullable_object")]
28    pub trigger: Option<RuleTrigger>,
29    #[serde(deserialize_with = "nullable_object")]
30    pub action: Option<RuleAction>,
31}
32
33#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
34#[serde(deny_unknown_fields, rename_all = "camelCase")]
35pub struct RuleCreateResponse {
36    #[serde(rename = "_id")]
37    pub rule_id: String,
38    pub trigger_id: String,
39    pub action_id: String,
40}
41
42#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
43#[serde(deny_unknown_fields)]
44pub struct RuleIdResponse {
45    #[serde(rename = "_id")]
46    pub rule_id: String,
47}
48
49#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
50#[serde(deny_unknown_fields)]
51pub struct CreateRuleRequest {
52    #[serde(
53        skip_serializing_if = "Option::is_none",
54        default,
55        deserialize_with = "present_string"
56    )]
57    pub title: Option<String>,
58    #[serde(deserialize_with = "object")]
59    pub trigger: RuleTriggerInput,
60    #[serde(deserialize_with = "object")]
61    pub action: RuleActionInput,
62}
63
64#[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
65#[serde(deny_unknown_fields)]
66pub struct UpdateRuleRequest {
67    #[serde(
68        default,
69        deserialize_with = "super::decoding::optional_non_null",
70        skip_serializing_if = "Option::is_none"
71    )]
72    pub enabled: Option<bool>,
73    #[serde(
74        skip_serializing_if = "Option::is_none",
75        default,
76        deserialize_with = "present_string"
77    )]
78    pub title: Option<String>,
79    #[serde(
80        skip_serializing_if = "Option::is_none",
81        default,
82        deserialize_with = "optional_object"
83    )]
84    pub trigger: Option<RuleTriggerInput>,
85    #[serde(
86        skip_serializing_if = "Option::is_none",
87        default,
88        deserialize_with = "optional_object"
89    )]
90    pub action: Option<RuleActionInput>,
91}
92
93fn present_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
94    String::deserialize(d).map(Some)
95}
96fn present_nullable<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(
97    d: D,
98) -> Result<Option<Option<T>>, D::Error> {
99    Option::<T>::deserialize(d).map(Some)
100}
101
102// Named structs otherwise accept positional arrays through Serde's sequence visitor.
103pub(crate) struct Object<T>(pub(crate) T);
104impl<'de, T: Deserialize<'de>> Deserialize<'de> for Object<T> {
105    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
106        struct Visitor<T>(std::marker::PhantomData<T>);
107        impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for Visitor<T> {
108            type Value = Object<T>;
109            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110                f.write_str("a JSON object")
111            }
112            fn visit_map<M: serde::de::MapAccess<'de>>(
113                self,
114                map: M,
115            ) -> Result<Self::Value, M::Error> {
116                T::deserialize(serde::de::value::MapAccessDeserializer::new(map)).map(Object)
117            }
118        }
119        d.deserialize_map(Visitor(std::marker::PhantomData))
120    }
121}
122fn object<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result<T, D::Error> {
123    Object::<T>::deserialize(d).map(|v| v.0)
124}
125fn nullable_object<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(
126    d: D,
127) -> Result<Option<T>, D::Error> {
128    Option::<Object<T>>::deserialize(d).map(|v| v.map(|v| v.0))
129}
130fn optional_object<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(
131    d: D,
132) -> Result<Option<T>, D::Error> {
133    object(d).map(Some)
134}
135
136impl WekanClient {
137    pub async fn board_rules(
138        &self,
139        board: &str,
140        token: &SecretString,
141    ) -> Result<Vec<RuleDocument>, ClientError> {
142        let body = self.rule_request(Method::GET, board, None, token).await?;
143        let documents: Vec<Object<RuleDocument>> = decode_success(&body, "rule collection", true)?;
144        let documents: Vec<_> = documents.into_iter().map(|v| v.0).collect();
145        for d in &documents {
146            validate_document(d, None)?;
147        }
148        Ok(documents)
149    }
150    pub async fn rule(
151        &self,
152        board: &str,
153        rule: &str,
154        token: &SecretString,
155    ) -> Result<RuleDocument, ClientError> {
156        let body = self
157            .rule_request(Method::GET, board, Some(rule), token)
158            .await?;
159        let document: Object<RuleDocument> = decode_success(&body, "rule lookup", true)?;
160        validate_document(&document.0, Some(rule))?;
161        Ok(document.0)
162    }
163    pub async fn create_rule(
164        &self,
165        board: &str,
166        request: &CreateRuleRequest,
167        token: &SecretString,
168    ) -> Result<RuleCreateResponse, ClientError> {
169        let body = self
170            .execute_success_request(
171                self.http
172                    .post(self.rule_endpoint(board, None)?)
173                    .header(ACCEPT, "application/json")
174                    .bearer_auth(token.expose_secret())
175                    .json(request),
176            )
177            .await?;
178        let response: Object<RuleCreateResponse> = decode_success(&body, "rule creation", true)?;
179        for id in [
180            &response.0.rule_id,
181            &response.0.trigger_id,
182            &response.0.action_id,
183        ] {
184            validate_id(id, None)?;
185        }
186        Ok(response.0)
187    }
188    pub async fn update_rule(
189        &self,
190        board: &str,
191        rule: &str,
192        request: &UpdateRuleRequest,
193        token: &SecretString,
194    ) -> Result<RuleIdResponse, ClientError> {
195        let body = self
196            .execute_success_request(
197                self.http
198                    .put(self.rule_endpoint(board, Some(rule))?)
199                    .header(ACCEPT, "application/json")
200                    .bearer_auth(token.expose_secret())
201                    .json(request),
202            )
203            .await?;
204        decode_id(&body, rule)
205    }
206    pub async fn delete_rule(
207        &self,
208        board: &str,
209        rule: &str,
210        token: &SecretString,
211    ) -> Result<RuleIdResponse, ClientError> {
212        decode_id(
213            &self
214                .rule_request(Method::DELETE, board, Some(rule), token)
215                .await?,
216            rule,
217        )
218    }
219    async fn rule_request(
220        &self,
221        method: Method,
222        board: &str,
223        rule: Option<&str>,
224        token: &SecretString,
225    ) -> Result<Vec<u8>, ClientError> {
226        let is_read = method == Method::GET;
227        let request = self
228            .http
229            .request(method, self.rule_endpoint(board, rule)?)
230            .header(ACCEPT, "application/json")
231            .bearer_auth(token.expose_secret());
232        if is_read {
233            self.execute_success_request_with_limit(request, MAX_RULE_RESPONSE_BYTES)
234                .await
235        } else {
236            self.execute_success_request(request).await
237        }
238    }
239    fn rule_endpoint(&self, board: &str, rule: Option<&str>) -> Result<url::Url, ClientError> {
240        for id in [Some(board), rule].into_iter().flatten() {
241            if super::trim_javascript_whitespace(id).is_empty() || matches!(id, "." | "..") {
242                return Err(ClientError::Protocol {
243                    diagnostic: None,
244                    message:
245                        "rule endpoint IDs must be nonblank and not dot-only; no request was sent"
246                            .into(),
247                    success_status_received: false,
248                });
249            }
250        }
251        let mut endpoint = self
252            .server()
253            .join("api/boards")
254            .map_err(|_| protocol_error("could not build the rule endpoint"))?;
255        let mut segments = endpoint
256            .path_segments_mut()
257            .map_err(|()| protocol_error("the Wekan URL cannot contain API paths"))?;
258        segments.extend([board, "rules"]);
259        if let Some(rule) = rule {
260            segments.push(rule);
261        }
262        drop(segments);
263        Ok(endpoint)
264    }
265}
266fn decode_id(body: &[u8], expected: &str) -> Result<RuleIdResponse, ClientError> {
267    let d: Object<RuleIdResponse> = decode_success(body, "rule acknowledgement", true)?;
268    validate_id(&d.0.rule_id, Some(expected))?;
269    Ok(d.0)
270}
271fn validate_id(id: &str, expected: Option<&str>) -> Result<(), ClientError> {
272    if super::trim_javascript_whitespace(id).is_empty() {
273        return Err(protocol_error(
274            "the rule response contained an empty identifier",
275        ));
276    }
277    if expected.is_some_and(|v| id != v) {
278        return Err(protocol_error(
279            "Wekan returned a different rule ID than requested",
280        ));
281    }
282    Ok(())
283}
284fn validate_document(d: &RuleDocument, expected: Option<&str>) -> Result<(), ClientError> {
285    validate_id(&d.rule_id, expected)?;
286    if let Some(Some(date)) = d.trigger.as_ref().and_then(|t| t.last_run_at.as_ref()) {
287        validate_response_date(date).map_err(|()| {
288            protocol_error("the rule response contained an invalid lastRunAt date-time")
289        })?;
290    }
291    Ok(())
292}
293fn protocol_error(message: &str) -> ClientError {
294    ClientError::Protocol {
295        diagnostic: None,
296        message: message.into(),
297        success_status_received: true,
298    }
299}