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