1use 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
13const 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
102pub(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}