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 message: format!("could not build the comment endpoint: {error}"),
172 success_status_received: false,
173 })?;
174 let mut segments = endpoint
175 .path_segments_mut()
176 .map_err(|()| ClientError::Protocol {
177 message: "could not add identifiers to the comment endpoint".to_owned(),
178 success_status_received: false,
179 })?;
180 segments.push(board_id);
181 segments.push("cards");
182 segments.push(card_id);
183 segments.push("comments");
184 if let Some(comment_id) = comment_id {
185 segments.push(comment_id);
186 }
187 drop(segments);
188 Ok(endpoint)
189 }
190}
191
192fn validate_comment_summaries(
193 comments: Vec<CommentSummary>,
194) -> Result<Vec<CommentSummary>, ClientError> {
195 for comment in &comments {
196 require_non_empty(&comment.comment_id, "card comment collection", "comment id")?;
197 require_non_empty(&comment.text, "card comment collection", "text")?;
198 require_non_empty(&comment.author_id, "card comment collection", "author id")?;
199 }
200 Ok(comments)
201}
202
203fn validate_comment_document(comment: CommentDocument) -> Result<CommentDocument, ClientError> {
204 for (field, value) in [
205 ("comment id", comment.comment_id.as_str()),
206 ("board id", comment.board_id.as_str()),
207 ("card id", comment.card_id.as_str()),
208 ("text", comment.text.as_str()),
209 ("author id", comment.author_id.as_str()),
210 ] {
211 require_non_empty(value, "comment", field)?;
212 }
213 if let Some(parent_id) = &comment.parent_id {
214 require_non_empty(parent_id, "comment", "parent id")?;
215 }
216 validate_date_time(&comment.created_at, "createdAt")?;
217 validate_date_time(&comment.modified_at, "modifiedAt")?;
218 Ok(comment)
219}
220
221fn deserialize_optional_non_empty<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
222where
223 D: Deserializer<'de>,
224{
225 Option::<String>::deserialize(deserializer).map(|value| value.filter(|value| !value.is_empty()))
226}
227
228fn require_non_empty(value: &str, operation: &str, field: &str) -> Result<(), ClientError> {
229 if value.trim().is_empty() {
230 return Err(protocol_error(format!(
231 "the {operation} response contained an empty {field}"
232 )));
233 }
234 Ok(())
235}
236
237fn validate_date_time(value: &str, field: &str) -> Result<(), ClientError> {
238 OffsetDateTime::parse(value, &Rfc3339)
239 .map(|_| ())
240 .map_err(|_| {
241 protocol_error(format!(
242 "the comment response contained an invalid {field} date-time"
243 ))
244 })
245}
246
247fn protocol_error(message: String) -> ClientError {
248 ClientError::Protocol {
249 message,
250 success_status_received: true,
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use serde_json::json;
257
258 use super::{CommentDocument, CommentSummary, validate_comment_document};
259
260 #[test]
261 fn response_objects_reject_unmapped_fields() {
262 assert!(
263 serde_json::from_value::<CommentSummary>(json!({
264 "_id": "comment-1",
265 "comment": "Hello",
266 "authorId": "user-1",
267 "unexpected": true
268 }))
269 .is_err()
270 );
271 assert!(
272 serde_json::from_value::<CommentDocument>(json!({
273 "_id": "comment-1",
274 "boardId": "board-1",
275 "cardId": "card-1",
276 "text": "Hello",
277 "createdAt": "2030-01-02T03:04:05Z",
278 "modifiedAt": "2030-01-02T03:04:05Z",
279 "userId": "user-1",
280 "unexpected": true
281 }))
282 .is_err()
283 );
284 }
285
286 #[test]
287 fn empty_parent_ids_are_normalized_to_absence() {
288 let comment = serde_json::from_value::<CommentDocument>(json!({
289 "_id": "comment-1",
290 "boardId": "board-1",
291 "cardId": "card-1",
292 "text": "Hello",
293 "parentId": "",
294 "createdAt": "2030-01-02T03:04:05Z",
295 "modifiedAt": "2030-01-02T03:04:05Z",
296 "userId": "user-1"
297 }))
298 .unwrap();
299
300 assert_eq!(validate_comment_document(comment).unwrap().parent_id, None);
301 }
302}