Skip to main content

wekan_cli/client/
comments.rs

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}