wekan-cli 2.0.0

Agent-first, human-friendly command-line client for Wekan
Documentation
use reqwest::{StatusCode, header::ACCEPT};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Deserializer, Serialize};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};

use super::{ClientError, WekanClient, transport::decode_success};

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct CommentSummary {
    #[serde(rename(deserialize = "_id", serialize = "comment_id"))]
    pub comment_id: String,
    #[serde(rename(deserialize = "comment", serialize = "text"))]
    pub text: String,
    #[serde(rename(deserialize = "authorId", serialize = "author_id"))]
    pub author_id: String,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(
    deny_unknown_fields,
    rename_all(deserialize = "camelCase", serialize = "snake_case")
)]
pub struct CommentDocument {
    #[serde(rename(deserialize = "_id", serialize = "comment_id"))]
    pub comment_id: String,
    pub board_id: String,
    pub card_id: String,
    pub text: String,
    #[serde(default, deserialize_with = "deserialize_optional_non_empty")]
    pub parent_id: Option<String>,
    pub created_at: String,
    pub modified_at: String,
    #[serde(rename(deserialize = "userId", serialize = "author_id"))]
    pub author_id: String,
    pub sync_comment_id: Option<String>,
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct CreateCommentRequest {
    #[serde(rename = "comment")]
    pub text: String,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CreateCommentResult {
    pub comment_id: String,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DeleteCommentResult {
    pub card_id: String,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct IdResponse {
    #[serde(rename = "_id")]
    id: String,
}

impl WekanClient {
    pub async fn update_comment(
        &self,
        board_id: &str,
        card_id: &str,
        comment_id: &str,
        request: &CreateCommentRequest,
        token: &SecretString,
    ) -> Result<CreateCommentResult, ClientError> {
        let body = self
            .execute_success_request(
                self.http
                    .put(self.comment_endpoint(board_id, card_id, Some(comment_id))?)
                    .header(ACCEPT, "application/json")
                    .bearer_auth(token.expose_secret())
                    .json(request),
            )
            .await?;
        let response: IdResponse = decode_success(&body, "comment update", true)?;
        require_non_empty(&response.id, "comment update", "comment id")?;
        if response.id != comment_id {
            return Err(protocol_error(
                "the updated comment ID did not match the requested comment".to_owned(),
            ));
        }
        Ok(CreateCommentResult {
            comment_id: response.id,
        })
    }
    pub async fn card_comments(
        &self,
        board_id: &str,
        card_id: &str,
        token: &SecretString,
    ) -> Result<Vec<CommentSummary>, ClientError> {
        let body = self
            .execute_success_request(
                self.http
                    .get(self.comment_endpoint(board_id, card_id, None)?)
                    .header(ACCEPT, "application/json")
                    .bearer_auth(token.expose_secret()),
            )
            .await?;
        let comments = decode_success(&body, "card comment collection", true)?;
        validate_comment_summaries(comments)
    }

    pub async fn comment(
        &self,
        board_id: &str,
        card_id: &str,
        comment_id: &str,
        token: &SecretString,
    ) -> Result<CommentDocument, ClientError> {
        let body = self
            .execute_success_request(
                self.http
                    .get(self.comment_endpoint(board_id, card_id, Some(comment_id))?)
                    .header(ACCEPT, "application/json")
                    .bearer_auth(token.expose_secret()),
            )
            .await?;
        if body.is_empty() {
            return Err(ClientError::EmptyNotFound {
                http_status: StatusCode::OK,
            });
        }
        let comment = validate_comment_document(decode_success(&body, "comment", true)?)?;
        if comment.board_id != board_id
            || comment.card_id != card_id
            || comment.comment_id != comment_id
        {
            return Err(protocol_error(
                "the comment response identifiers did not match the requested comment scope"
                    .to_owned(),
            ));
        }
        Ok(comment)
    }

    pub async fn create_comment(
        &self,
        board_id: &str,
        card_id: &str,
        request: &CreateCommentRequest,
        token: &SecretString,
    ) -> Result<CreateCommentResult, ClientError> {
        let body = self
            .execute_success_request(
                self.http
                    .post(self.comment_endpoint(board_id, card_id, None)?)
                    .header(ACCEPT, "application/json")
                    .bearer_auth(token.expose_secret())
                    .json(request),
            )
            .await?;
        let response: IdResponse = decode_success(&body, "comment creation", true)?;
        require_non_empty(&response.id, "comment creation", "comment id")?;
        Ok(CreateCommentResult {
            comment_id: response.id,
        })
    }

    pub async fn delete_comment(
        &self,
        board_id: &str,
        card_id: &str,
        comment_id: &str,
        token: &SecretString,
    ) -> Result<DeleteCommentResult, ClientError> {
        let body = self
            .execute_success_request(
                self.http
                    .delete(self.comment_endpoint(board_id, card_id, Some(comment_id))?)
                    .header(ACCEPT, "application/json")
                    .bearer_auth(token.expose_secret()),
            )
            .await?;
        let response: IdResponse = decode_success(&body, "comment deletion", true)?;
        require_non_empty(&response.id, "comment deletion", "card id")?;
        Ok(DeleteCommentResult {
            card_id: response.id,
        })
    }

    fn comment_endpoint(
        &self,
        board_id: &str,
        card_id: &str,
        comment_id: Option<&str>,
    ) -> Result<reqwest::Url, ClientError> {
        let mut endpoint =
            self.server()
                .join("api/boards")
                .map_err(|error| ClientError::Protocol {
                    diagnostic: None,
                    message: format!("could not build the comment endpoint: {error}"),
                    success_status_received: false,
                })?;
        let mut segments = endpoint
            .path_segments_mut()
            .map_err(|()| ClientError::Protocol {
                diagnostic: None,
                message: "could not add identifiers to the comment endpoint".to_owned(),
                success_status_received: false,
            })?;
        segments.push(board_id);
        segments.push("cards");
        segments.push(card_id);
        segments.push("comments");
        if let Some(comment_id) = comment_id {
            segments.push(comment_id);
        }
        drop(segments);
        Ok(endpoint)
    }
}

fn validate_comment_summaries(
    comments: Vec<CommentSummary>,
) -> Result<Vec<CommentSummary>, ClientError> {
    for comment in &comments {
        require_non_empty(&comment.comment_id, "card comment collection", "comment id")?;
        require_comment_text(&comment.text, "card comment collection")?;
        require_non_empty(&comment.author_id, "card comment collection", "author id")?;
    }
    Ok(comments)
}

fn validate_comment_document(comment: CommentDocument) -> Result<CommentDocument, ClientError> {
    for (field, value) in [
        ("comment id", comment.comment_id.as_str()),
        ("board id", comment.board_id.as_str()),
        ("card id", comment.card_id.as_str()),
        ("author id", comment.author_id.as_str()),
    ] {
        require_non_empty(value, "comment", field)?;
    }
    require_comment_text(&comment.text, "comment")?;
    if let Some(parent_id) = &comment.parent_id {
        require_non_empty(parent_id, "comment", "parent id")?;
    }
    validate_date_time(&comment.created_at, "createdAt")?;
    validate_date_time(&comment.modified_at, "modifiedAt")?;
    Ok(comment)
}

fn deserialize_optional_non_empty<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
    D: Deserializer<'de>,
{
    Option::<String>::deserialize(deserializer).map(|value| value.filter(|value| !value.is_empty()))
}

fn require_non_empty(value: &str, operation: &str, field: &str) -> Result<(), ClientError> {
    if value.trim().is_empty() {
        return Err(protocol_error(format!(
            "the {operation} response contained an empty {field}"
        )));
    }
    Ok(())
}

pub(crate) fn is_blank_comment_text(value: &str) -> bool {
    super::strings::trim_javascript_whitespace(value).is_empty()
}

fn require_comment_text(value: &str, operation: &str) -> Result<(), ClientError> {
    if is_blank_comment_text(value) {
        return Err(protocol_error(format!(
            "the {operation} response contained an empty text"
        )));
    }
    Ok(())
}

fn validate_date_time(value: &str, field: &str) -> Result<(), ClientError> {
    OffsetDateTime::parse(value, &Rfc3339)
        .map(|_| ())
        .map_err(|_| {
            protocol_error(format!(
                "the comment response contained an invalid {field} date-time"
            ))
        })
}

fn protocol_error(message: String) -> ClientError {
    ClientError::Protocol {
        diagnostic: None,
        message,
        success_status_received: true,
    }
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::{CommentDocument, CommentSummary, validate_comment_document};

    #[test]
    fn response_objects_reject_unmapped_fields() {
        assert!(
            serde_json::from_value::<CommentSummary>(json!({
                "_id": "comment-1",
                "comment": "Hello",
                "authorId": "user-1",
                "unexpected": true
            }))
            .is_err()
        );
        assert!(
            serde_json::from_value::<CommentDocument>(json!({
                "_id": "comment-1",
                "boardId": "board-1",
                "cardId": "card-1",
                "text": "Hello",
                "createdAt": "2030-01-02T03:04:05Z",
                "modifiedAt": "2030-01-02T03:04:05Z",
                "userId": "user-1",
                "unexpected": true
            }))
            .is_err()
        );
    }

    #[test]
    fn empty_parent_ids_are_normalized_to_absence() {
        let comment = serde_json::from_value::<CommentDocument>(json!({
            "_id": "comment-1",
            "boardId": "board-1",
            "cardId": "card-1",
            "text": "Hello",
            "parentId": "",
            "createdAt": "2030-01-02T03:04:05Z",
            "modifiedAt": "2030-01-02T03:04:05Z",
            "userId": "user-1"
        }))
        .unwrap();

        assert_eq!(validate_comment_document(comment).unwrap().parent_id, None);
    }
}