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