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 #[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
91pub(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}