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