1use reqwest::{StatusCode, header::ACCEPT};
2use secrecy::{ExposeSecret, SecretString};
3use serde::{Deserialize, Deserializer, Serialize};
4use time::{OffsetDateTime, format_description::well_known::Rfc3339};
5
6use super::{ClientError, WekanClient, transport::decode_success};
7
8#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
9#[serde(deny_unknown_fields)]
10pub struct CommentSummary {
11 #[serde(rename(deserialize = "_id", serialize = "comment_id"))]
12 pub comment_id: String,
13 #[serde(rename(deserialize = "comment", serialize = "text"))]
14 pub text: String,
15 #[serde(rename(deserialize = "authorId", serialize = "author_id"))]
16 pub author_id: String,
17}
18
19#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
20#[serde(
21 deny_unknown_fields,
22 rename_all(deserialize = "camelCase", serialize = "snake_case")
23)]
24pub struct CommentDocument {
25 #[serde(rename(deserialize = "_id", serialize = "comment_id"))]
26 pub comment_id: String,
27 pub board_id: String,
28 pub card_id: String,
29 pub text: String,
30 #[serde(default, deserialize_with = "deserialize_optional_non_empty")]
31 pub parent_id: Option<String>,
32 pub created_at: String,
33 pub modified_at: String,
34 #[serde(rename(deserialize = "userId", serialize = "author_id"))]
35 pub author_id: String,
36 pub sync_comment_id: Option<String>,
37}
38
39#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
40pub struct CreateCommentRequest {
41 #[serde(rename = "comment")]
42 pub text: String,
43}
44
45#[derive(Clone, Debug, Eq, PartialEq)]
46pub struct CreateCommentResult {
47 pub comment_id: String,
48}
49
50#[derive(Clone, Debug, Eq, PartialEq)]
51pub struct DeleteCommentResult {
52 pub card_id: String,
53}
54
55#[derive(Deserialize)]
56#[serde(deny_unknown_fields)]
57struct IdResponse {
58 #[serde(rename = "_id")]
59 id: String,
60}
61
62impl WekanClient {
63 pub async fn update_comment(
64 &self,
65 board_id: &str,
66 card_id: &str,
67 comment_id: &str,
68 request: &CreateCommentRequest,
69 token: &SecretString,
70 ) -> Result<CreateCommentResult, ClientError> {
71 let body = self
72 .execute_success_request(
73 self.http
74 .put(self.comment_endpoint(board_id, card_id, Some(comment_id))?)
75 .header(ACCEPT, "application/json")
76 .bearer_auth(token.expose_secret())
77 .json(request),
78 )
79 .await?;
80 let response: IdResponse = decode_success(&body, "comment update", true)?;
81 require_non_empty(&response.id, "comment update", "comment id")?;
82 if response.id != comment_id {
83 return Err(protocol_error(
84 "the updated comment ID did not match the requested comment".to_owned(),
85 ));
86 }
87 Ok(CreateCommentResult {
88 comment_id: response.id,
89 })
90 }
91 pub async fn card_comments(
92 &self,
93 board_id: &str,
94 card_id: &str,
95 token: &SecretString,
96 ) -> Result<Vec<CommentSummary>, ClientError> {
97 let body = self
98 .execute_success_request(
99 self.http
100 .get(self.comment_endpoint(board_id, card_id, None)?)
101 .header(ACCEPT, "application/json")
102 .bearer_auth(token.expose_secret()),
103 )
104 .await?;
105 let comments = decode_success(&body, "card comment collection", true)?;
106 validate_comment_summaries(comments)
107 }
108
109 pub async fn comment(
110 &self,
111 board_id: &str,
112 card_id: &str,
113 comment_id: &str,
114 token: &SecretString,
115 ) -> Result<CommentDocument, ClientError> {
116 let body = self
117 .execute_success_request(
118 self.http
119 .get(self.comment_endpoint(board_id, card_id, Some(comment_id))?)
120 .header(ACCEPT, "application/json")
121 .bearer_auth(token.expose_secret()),
122 )
123 .await?;
124 if body.is_empty() {
125 return Err(ClientError::EmptyNotFound {
126 http_status: StatusCode::OK,
127 });
128 }
129 let comment = validate_comment_document(decode_success(&body, "comment", true)?)?;
130 if comment.board_id != board_id
131 || comment.card_id != card_id
132 || comment.comment_id != comment_id
133 {
134 return Err(protocol_error(
135 "the comment response identifiers did not match the requested comment scope"
136 .to_owned(),
137 ));
138 }
139 Ok(comment)
140 }
141
142 pub async fn create_comment(
143 &self,
144 board_id: &str,
145 card_id: &str,
146 request: &CreateCommentRequest,
147 token: &SecretString,
148 ) -> Result<CreateCommentResult, ClientError> {
149 let body = self
150 .execute_success_request(
151 self.http
152 .post(self.comment_endpoint(board_id, card_id, None)?)
153 .header(ACCEPT, "application/json")
154 .bearer_auth(token.expose_secret())
155 .json(request),
156 )
157 .await?;
158 let response: IdResponse = decode_success(&body, "comment creation", true)?;
159 require_non_empty(&response.id, "comment creation", "comment id")?;
160 Ok(CreateCommentResult {
161 comment_id: response.id,
162 })
163 }
164
165 pub async fn delete_comment(
166 &self,
167 board_id: &str,
168 card_id: &str,
169 comment_id: &str,
170 token: &SecretString,
171 ) -> Result<DeleteCommentResult, ClientError> {
172 let body = self
173 .execute_success_request(
174 self.http
175 .delete(self.comment_endpoint(board_id, card_id, Some(comment_id))?)
176 .header(ACCEPT, "application/json")
177 .bearer_auth(token.expose_secret()),
178 )
179 .await?;
180 let response: IdResponse = decode_success(&body, "comment deletion", true)?;
181 require_non_empty(&response.id, "comment deletion", "card id")?;
182 Ok(DeleteCommentResult {
183 card_id: response.id,
184 })
185 }
186
187 fn comment_endpoint(
188 &self,
189 board_id: &str,
190 card_id: &str,
191 comment_id: Option<&str>,
192 ) -> Result<reqwest::Url, ClientError> {
193 let mut endpoint =
194 self.server()
195 .join("api/boards")
196 .map_err(|error| ClientError::Protocol {
197 diagnostic: None,
198 message: format!("could not build the comment endpoint: {error}"),
199 success_status_received: false,
200 })?;
201 let mut segments = endpoint
202 .path_segments_mut()
203 .map_err(|()| ClientError::Protocol {
204 diagnostic: None,
205 message: "could not add identifiers to the comment endpoint".to_owned(),
206 success_status_received: false,
207 })?;
208 segments.push(board_id);
209 segments.push("cards");
210 segments.push(card_id);
211 segments.push("comments");
212 if let Some(comment_id) = comment_id {
213 segments.push(comment_id);
214 }
215 drop(segments);
216 Ok(endpoint)
217 }
218}
219
220fn validate_comment_summaries(
221 comments: Vec<CommentSummary>,
222) -> Result<Vec<CommentSummary>, ClientError> {
223 for comment in &comments {
224 require_non_empty(&comment.comment_id, "card comment collection", "comment id")?;
225 require_comment_text(&comment.text, "card comment collection")?;
226 require_non_empty(&comment.author_id, "card comment collection", "author id")?;
227 }
228 Ok(comments)
229}
230
231fn validate_comment_document(comment: CommentDocument) -> Result<CommentDocument, ClientError> {
232 for (field, value) in [
233 ("comment id", comment.comment_id.as_str()),
234 ("board id", comment.board_id.as_str()),
235 ("card id", comment.card_id.as_str()),
236 ("author id", comment.author_id.as_str()),
237 ] {
238 require_non_empty(value, "comment", field)?;
239 }
240 require_comment_text(&comment.text, "comment")?;
241 if let Some(parent_id) = &comment.parent_id {
242 require_non_empty(parent_id, "comment", "parent id")?;
243 }
244 validate_date_time(&comment.created_at, "createdAt")?;
245 validate_date_time(&comment.modified_at, "modifiedAt")?;
246 Ok(comment)
247}
248
249fn deserialize_optional_non_empty<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
250where
251 D: Deserializer<'de>,
252{
253 Option::<String>::deserialize(deserializer).map(|value| value.filter(|value| !value.is_empty()))
254}
255
256fn require_non_empty(value: &str, operation: &str, field: &str) -> Result<(), ClientError> {
257 if value.trim().is_empty() {
258 return Err(protocol_error(format!(
259 "the {operation} response contained an empty {field}"
260 )));
261 }
262 Ok(())
263}
264
265pub(crate) fn is_blank_comment_text(value: &str) -> bool {
266 super::strings::trim_javascript_whitespace(value).is_empty()
267}
268
269fn require_comment_text(value: &str, operation: &str) -> Result<(), ClientError> {
270 if is_blank_comment_text(value) {
271 return Err(protocol_error(format!(
272 "the {operation} response contained an empty text"
273 )));
274 }
275 Ok(())
276}
277
278fn validate_date_time(value: &str, field: &str) -> Result<(), ClientError> {
279 OffsetDateTime::parse(value, &Rfc3339)
280 .map(|_| ())
281 .map_err(|_| {
282 protocol_error(format!(
283 "the comment response contained an invalid {field} date-time"
284 ))
285 })
286}
287
288fn protocol_error(message: String) -> ClientError {
289 ClientError::Protocol {
290 diagnostic: None,
291 message,
292 success_status_received: true,
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use serde_json::json;
299
300 use super::{CommentDocument, CommentSummary, validate_comment_document};
301
302 #[test]
303 fn response_objects_reject_unmapped_fields() {
304 assert!(
305 serde_json::from_value::<CommentSummary>(json!({
306 "_id": "comment-1",
307 "comment": "Hello",
308 "authorId": "user-1",
309 "unexpected": true
310 }))
311 .is_err()
312 );
313 assert!(
314 serde_json::from_value::<CommentDocument>(json!({
315 "_id": "comment-1",
316 "boardId": "board-1",
317 "cardId": "card-1",
318 "text": "Hello",
319 "createdAt": "2030-01-02T03:04:05Z",
320 "modifiedAt": "2030-01-02T03:04:05Z",
321 "userId": "user-1",
322 "unexpected": true
323 }))
324 .is_err()
325 );
326 }
327
328 #[test]
329 fn empty_parent_ids_are_normalized_to_absence() {
330 let comment = serde_json::from_value::<CommentDocument>(json!({
331 "_id": "comment-1",
332 "boardId": "board-1",
333 "cardId": "card-1",
334 "text": "Hello",
335 "parentId": "",
336 "createdAt": "2030-01-02T03:04:05Z",
337 "modifiedAt": "2030-01-02T03:04:05Z",
338 "userId": "user-1"
339 }))
340 .unwrap();
341
342 assert_eq!(validate_comment_document(comment).unwrap().parent_id, None);
343 }
344}