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