guacamole-client 0.5.1

Rust client library for the Guacamole REST API
Documentation
use std::collections::HashMap;

use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::client::GuacamoleClient;
use crate::error::Result;
use crate::validation::{validate_connection_group_id, validate_query_param};

/// A Guacamole connection group.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ConnectionGroup {
    /// The unique identifier (numeric string or `"ROOT"`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub identifier: Option<String>,

    /// Identifier of the parent connection group.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub parent_identifier: Option<String>,

    /// Human-readable name of the connection group.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,

    /// The group type: `"ORGANIZATIONAL"` or `"BALANCING"`.
    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
    pub type_: Option<String>,

    /// Arbitrary connection group attributes.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub attributes: Option<HashMap<String, String>>,

    /// Number of active connections in this group.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub active_connections: Option<i32>,
}

impl GuacamoleClient {
    /// Lists all connection groups in the given data source.
    pub async fn list_connection_groups(
        &self,
        data_source: Option<&str>,
    ) -> Result<HashMap<String, ConnectionGroup>> {
        let ds = self.resolve_data_source(data_source)?;
        let url = self.url(&format!("/api/session/data/{ds}/connectionGroups"))?;
        let response = self.http.get(&url).send().await?;
        Self::parse_response(response, "connection groups").await
    }

    /// Retrieves a single connection group by its identifier.
    pub async fn get_connection_group(
        &self,
        data_source: Option<&str>,
        group_id: &str,
    ) -> Result<ConnectionGroup> {
        validate_connection_group_id(group_id)?;
        let ds = self.resolve_data_source(data_source)?;
        let url = self.url(&format!(
            "/api/session/data/{ds}/connectionGroups/{group_id}"
        ))?;
        let response = self.http.get(&url).send().await?;
        Self::parse_response(response, &format!("connection group {group_id}")).await
    }

    /// Retrieves the connection group tree starting at the given group.
    ///
    /// Returns `Value` since the tree is a recursive structure.
    /// Optionally filters by `permission` (e.g. `"READ"`).
    pub async fn get_connection_group_tree(
        &self,
        data_source: Option<&str>,
        group_id: &str,
        permission: Option<&str>,
    ) -> Result<Value> {
        validate_connection_group_id(group_id)?;
        let ds = self.resolve_data_source(data_source)?;
        let url = self.url(&format!(
            "/api/session/data/{ds}/connectionGroups/{group_id}/tree"
        ))?;

        let mut request = self.http.get(&url);
        if let Some(p) = permission {
            validate_query_param("permission", p)?;
            request = request.query(&[("permission", p)]);
        }

        let response = request.send().await?;
        Self::parse_response(response, &format!("connection group {group_id} tree")).await
    }

    /// Creates a new connection group. Returns the created group (with assigned identifier).
    pub async fn create_connection_group(
        &self,
        data_source: Option<&str>,
        group: &ConnectionGroup,
    ) -> Result<ConnectionGroup> {
        let ds = self.resolve_data_source(data_source)?;
        let url = self.url(&format!("/api/session/data/{ds}/connectionGroups"))?;
        let response = self.http.post(&url).json(group).send().await?;
        Self::parse_response(response, "create connection group").await
    }

    /// Updates an existing connection group. Returns `()` on success (204 No Content).
    pub async fn update_connection_group(
        &self,
        data_source: Option<&str>,
        group_id: &str,
        group: &ConnectionGroup,
    ) -> Result<()> {
        validate_connection_group_id(group_id)?;
        let ds = self.resolve_data_source(data_source)?;
        let url = self.url(&format!(
            "/api/session/data/{ds}/connectionGroups/{group_id}"
        ))?;
        let response = self.http.put(&url).json(group).send().await?;
        Self::handle_error(response, &format!("connection group {group_id}")).await?;
        Ok(())
    }

    /// Deletes a connection group by its identifier. Returns `()` on success (204 No Content).
    pub async fn delete_connection_group(
        &self,
        data_source: Option<&str>,
        group_id: &str,
    ) -> Result<()> {
        validate_connection_group_id(group_id)?;
        let ds = self.resolve_data_source(data_source)?;
        let url = self.url(&format!(
            "/api/session/data/{ds}/connectionGroups/{group_id}"
        ))?;
        let response = self.http.delete(&url).send().await?;
        Self::handle_error(response, &format!("connection group {group_id}")).await?;
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn connection_group_serde_roundtrip() {
        let group = ConnectionGroup {
            identifier: Some("1".to_string()),
            parent_identifier: Some("ROOT".to_string()),
            name: Some("Servers".to_string()),
            type_: Some("ORGANIZATIONAL".to_string()),
            attributes: Some(HashMap::new()),
            active_connections: Some(0),
        };
        let json = serde_json::to_string(&group).unwrap();
        let deserialized: ConnectionGroup = serde_json::from_str(&json).unwrap();
        assert_eq!(group, deserialized);
    }

    #[test]
    fn connection_group_camel_case_keys() {
        let group = ConnectionGroup {
            parent_identifier: Some("ROOT".to_string()),
            active_connections: Some(2),
            ..Default::default()
        };
        let json = serde_json::to_value(&group).unwrap();
        assert!(json.get("parentIdentifier").is_some());
        assert!(json.get("activeConnections").is_some());
    }

    #[test]
    fn connection_group_type_field_rename() {
        let group = ConnectionGroup {
            type_: Some("BALANCING".to_string()),
            ..Default::default()
        };
        let json = serde_json::to_value(&group).unwrap();
        assert!(json.get("type").is_some());
        assert_eq!(json.get("type").unwrap(), "BALANCING");
        assert!(json.get("type_").is_none(), "Rust field name must not leak into JSON");
    }

    #[test]
    fn connection_group_skip_none_fields() {
        let group = ConnectionGroup::default();
        let json = serde_json::to_value(&group).unwrap();
        let obj = json.as_object().unwrap();
        assert!(obj.is_empty());
    }

    #[test]
    fn deserialize_connection_group_from_api_json() {
        let json = r#"{
            "identifier": "1",
            "parentIdentifier": "ROOT",
            "name": "Servers",
            "type": "ORGANIZATIONAL",
            "attributes": {},
            "activeConnections": 3
        }"#;
        let group: ConnectionGroup = serde_json::from_str(json).unwrap();
        assert_eq!(group.identifier.as_deref(), Some("1"));
        assert_eq!(group.parent_identifier.as_deref(), Some("ROOT"));
        assert_eq!(group.name.as_deref(), Some("Servers"));
        assert_eq!(group.type_.as_deref(), Some("ORGANIZATIONAL"));
        assert_eq!(group.active_connections, Some(3));
    }

    #[test]
    fn deserialize_connection_group_unknown_fields_ignored() {
        let json = r#"{"identifier": "1", "unknownField": true}"#;
        let group: ConnectionGroup = serde_json::from_str(json).unwrap();
        assert_eq!(group.identifier.as_deref(), Some("1"));
    }
}