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}
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}