wekan-cli 2.0.0

Agent-first, human-friendly command-line client for Wekan
Documentation
pub mod inbound_email;
use reqwest::{Method, header::ACCEPT};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize, Serializer};

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

#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct IntegrationDocument {
    #[serde(rename = "_id")]
    pub integration_id: String,
    pub enabled: bool,
    pub title: Option<String>,
    #[serde(rename = "type")]
    pub integration_type: String,
    pub activities: Vec<String>,
    pub url: String,
    pub board_id: String,
    pub created_at: String,
    pub modified_at: String,
    pub user_id: String,
    pub notification_delivery: Option<WebhookNotificationDelivery>,
    pub mattermost_direct_messages: Option<bool>,
    pub bot: Option<bool>,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WebhookNotificationDelivery {
    pub webhook: Option<super::metadata::WebhookDelivery>,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct IntegrationIdResponse {
    #[serde(rename = "_id")]
    pub integration_id: String,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct IntegrationActivitiesResponse {
    #[serde(rename = "_id")]
    pub integration_id: String,
    pub activities: Vec<String>,
}

#[derive(Clone, Debug, Serialize)]
pub struct CreateIntegrationRequest {
    pub url: String,
}

#[derive(Debug, Default, Serialize)]
pub struct UpdateIntegrationRequest {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub enabled: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
    #[serde(
        skip_serializing_if = "Option::is_none",
        serialize_with = "serialize_token"
    )]
    pub token: Option<SecretString>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub activities: Option<Vec<String>>,
    #[serde(
        rename = "notificationDelivery",
        skip_serializing_if = "Option::is_none",
        serialize_with = "serialize_delivery"
    )]
    pub notification_delivery: Option<Option<WebhookNotificationDelivery>>,
}

fn serialize_delivery<S: Serializer>(
    value: &Option<Option<WebhookNotificationDelivery>>,
    serializer: S,
) -> Result<S::Ok, S::Error> {
    use serde::ser::SerializeMap;
    struct NativeChannel<'a>(&'a super::metadata::WebhookDelivery);
    impl Serialize for NativeChannel<'_> {
        fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
            let mut map = serializer.serialize_map(None)?;
            let d = self.0;
            macro_rules! field {
                ($key:literal, $field:ident) => {
                    if let Some(value) = &d.$field {
                        map.serialize_entry($key, value)?;
                    }
                };
            }
            field!("text", text);
            field!("fields", fields);
            field!("grouping", grouping);
            field!("schedule", schedule);
            field!("intervalMinutes", interval_minutes);
            field!("dailyTime", daily_time);
            field!("quietStart", quiet_start);
            field!("quietEnd", quiet_end);
            field!("timezone", timezone);
            map.end()
        }
    }
    match value.as_ref().and_then(Option::as_ref) {
        None => serializer.serialize_none(),
        Some(delivery) => {
            let mut map = serializer.serialize_map(None)?;
            if let Some(webhook) = &delivery.webhook {
                map.serialize_entry("webhook", &NativeChannel(webhook))?;
            }
            map.end()
        }
    }
}

fn serialize_token<S: Serializer>(token: &Option<SecretString>, s: S) -> Result<S::Ok, S::Error> {
    token.as_ref().map(ExposeSecret::expose_secret).serialize(s)
}

#[derive(Clone, Debug, Serialize)]
pub struct IntegrationActivitiesRequest {
    pub activities: Vec<String>,
}

impl WekanClient {
    pub async fn board_integrations(
        &self,
        board: &str,
        token: &SecretString,
    ) -> Result<Vec<IntegrationDocument>, ClientError> {
        let body = self
            .execute_success_request(
                self.http
                    .get(self.integration_endpoint(board, None, false)?)
                    .header(ACCEPT, "application/json")
                    .bearer_auth(token.expose_secret()),
            )
            .await?;
        let documents: Vec<IntegrationDocument> =
            decode_success(&body, "integration collection", true)?;
        for document in &documents {
            validate_document(document, board, None)?;
        }
        Ok(documents)
    }

    pub async fn integration(
        &self,
        board: &str,
        integration: &str,
        token: &SecretString,
    ) -> Result<Option<IntegrationDocument>, ClientError> {
        let body = self
            .execute_success_request(
                self.http
                    .get(self.integration_endpoint(board, Some(integration), false)?)
                    .header(ACCEPT, "application/json")
                    .bearer_auth(token.expose_secret()),
            )
            .await?;
        if body.is_empty() {
            return Ok(None);
        }
        let document: IntegrationDocument = decode_success(&body, "integration lookup", true)?;
        validate_document(&document, board, Some(integration))?;
        Ok(Some(document))
    }

    pub async fn create_integration(
        &self,
        board: &str,
        request: &CreateIntegrationRequest,
        token: &SecretString,
    ) -> Result<IntegrationIdResponse, ClientError> {
        let body = self
            .execute_success_request(
                self.http
                    .post(self.integration_endpoint(board, None, false)?)
                    .header(ACCEPT, "application/json")
                    .bearer_auth(token.expose_secret())
                    .json(request),
            )
            .await?;
        decode_id(&body, None)
    }

    pub async fn update_integration(
        &self,
        board: &str,
        integration: &str,
        request: &UpdateIntegrationRequest,
        token: &SecretString,
    ) -> Result<IntegrationIdResponse, ClientError> {
        let body = self
            .execute_success_request(
                self.http
                    .put(self.integration_endpoint(board, Some(integration), false)?)
                    .header(ACCEPT, "application/json")
                    .bearer_auth(token.expose_secret())
                    .json(request),
            )
            .await?;
        decode_id(&body, Some(integration))
    }

    pub async fn delete_integration(
        &self,
        board: &str,
        integration: &str,
        token: &SecretString,
    ) -> Result<IntegrationIdResponse, ClientError> {
        let body = self
            .execute_success_request(
                self.http
                    .delete(self.integration_endpoint(board, Some(integration), false)?)
                    .header(ACCEPT, "application/json")
                    .bearer_auth(token.expose_secret()),
            )
            .await?;
        decode_id(&body, Some(integration))
    }

    pub async fn add_integration_activities(
        &self,
        board: &str,
        integration: &str,
        request: &IntegrationActivitiesRequest,
        token: &SecretString,
    ) -> Result<Option<IntegrationActivitiesResponse>, ClientError> {
        self.mutate_integration_activities(Method::POST, board, integration, request, token)
            .await
    }

    pub async fn remove_integration_activities(
        &self,
        board: &str,
        integration: &str,
        request: &IntegrationActivitiesRequest,
        token: &SecretString,
    ) -> Result<Option<IntegrationActivitiesResponse>, ClientError> {
        self.mutate_integration_activities(Method::DELETE, board, integration, request, token)
            .await
    }

    async fn mutate_integration_activities(
        &self,
        method: Method,
        board: &str,
        integration: &str,
        request: &IntegrationActivitiesRequest,
        token: &SecretString,
    ) -> Result<Option<IntegrationActivitiesResponse>, ClientError> {
        let body = self
            .execute_success_request(
                self.http
                    .request(
                        method,
                        self.integration_endpoint(board, Some(integration), true)?,
                    )
                    .header(ACCEPT, "application/json")
                    .bearer_auth(token.expose_secret())
                    .json(request),
            )
            .await?;
        if body.is_empty() {
            return Ok(None);
        }
        let response: IntegrationActivitiesResponse =
            decode_success(&body, "integration activities", true)?;
        validate_response_id(&response.integration_id, Some(integration))?;
        Ok(Some(response))
    }

    fn integration_endpoint(
        &self,
        board: &str,
        integration: Option<&str>,
        activities: bool,
    ) -> Result<reqwest::Url, ClientError> {
        for id in [Some(board), integration].into_iter().flatten() {
            if id.trim().is_empty() || matches!(id, "." | "..") {
                return Err(protocol_error(
                    "integration endpoint IDs must be nonempty and not dot-only; no request was sent",
                    false,
                ));
            }
        }
        let mut endpoint = self
            .server()
            .join("api/boards")
            .map_err(|_| protocol_error("could not build the integration endpoint", false))?;
        let mut segments = endpoint.path_segments_mut().map_err(|()| {
            protocol_error("could not add integration endpoint identifiers", false)
        })?;
        segments.extend([board, "integrations"]);
        if let Some(integration) = integration {
            segments.push(integration);
        }
        if activities {
            segments.push("activities");
        }
        drop(segments);
        Ok(endpoint)
    }
}

fn validate_document(
    document: &IntegrationDocument,
    board: &str,
    integration: Option<&str>,
) -> Result<(), ClientError> {
    validate_response_id(&document.integration_id, integration)?;
    for id in [&document.board_id, &document.user_id] {
        validate_response_id(id, None)?;
    }
    if document.board_id != board {
        return Err(protocol_error(
            "the integration response board ID did not match the requested scope",
            true,
        ));
    }
    for date in [&document.created_at, &document.modified_at] {
        validate_response_date(date).map_err(|()| {
            protocol_error(
                "the integration response contained an invalid date-time",
                true,
            )
        })?;
    }
    Ok(())
}

fn decode_id(body: &[u8], expected: Option<&str>) -> Result<IntegrationIdResponse, ClientError> {
    let response: IntegrationIdResponse =
        decode_success(body, "integration acknowledgement", true)?;
    validate_response_id(&response.integration_id, expected)?;
    Ok(response)
}

fn validate_response_id(id: &str, expected: Option<&str>) -> Result<(), ClientError> {
    if id.trim().is_empty() {
        return Err(protocol_error(
            "the integration response contained an empty identifier",
            true,
        ));
    }
    if expected.is_some_and(|expected| expected != id) {
        return Err(protocol_error(
            "Wekan returned a different integration ID than requested",
            true,
        ));
    }
    Ok(())
}

fn protocol_error(message: &str, success_status_received: bool) -> ClientError {
    ClientError::Protocol {
        diagnostic: None,
        message: message.to_owned(),
        success_status_received,
    }
}