guacamole-client 0.5.1

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

use serde::{Deserialize, Serialize};

use crate::client::GuacamoleClient;
use crate::error::Result;
use crate::history::HistoryEntry;
use crate::patch::PatchOperation;
use crate::sharing_profile::SharingProfileSummary;
use crate::validation::validate_connection_id;

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

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

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

    /// Protocol used by this connection (e.g. `"ssh"`, `"rdp"`, `"vnc"`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub protocol: Option<String>,

    /// Connection parameters (hostname, port, username, etc.).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub parameters: Option<HashMap<String, String>>,

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

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

/// An active Guacamole connection (a session currently in use).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ActiveConnection {
    /// The unique identifier for this active connection.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub identifier: Option<String>,

    /// The identifier of the underlying connection definition.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub connection_identifier: Option<String>,

    /// Start timestamp in milliseconds since epoch.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub start_date: Option<i64>,

    /// Remote host of the client.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub remote_host: Option<String>,

    /// Username of the user who opened this connection.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub username: Option<String>,
}

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

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

    /// Retrieves the connection parameters (hostname, port, credentials, etc.).
    pub async fn get_connection_parameters(
        &self,
        data_source: Option<&str>,
        connection_id: &str,
    ) -> Result<HashMap<String, String>> {
        validate_connection_id(connection_id)?;
        let ds = self.resolve_data_source(data_source)?;
        let url = self.url(&format!(
            "/api/session/data/{ds}/connections/{connection_id}/parameters"
        ))?;
        let response = self.http.get(&url).send().await?;
        Self::parse_response(response, &format!("connection {connection_id} parameters"))
            .await
    }

    /// Retrieves the history for a specific connection.
    pub async fn get_connection_history(
        &self,
        data_source: Option<&str>,
        connection_id: &str,
    ) -> Result<Vec<HistoryEntry>> {
        validate_connection_id(connection_id)?;
        let ds = self.resolve_data_source(data_source)?;
        let url = self.url(&format!(
            "/api/session/data/{ds}/connections/{connection_id}/history"
        ))?;
        let response = self.http.get(&url).send().await?;
        Self::parse_response(response, &format!("connection {connection_id} history"))
            .await
    }

    /// Retrieves the sharing profiles associated with a connection.
    pub async fn get_connection_sharing_profiles(
        &self,
        data_source: Option<&str>,
        connection_id: &str,
    ) -> Result<HashMap<String, SharingProfileSummary>> {
        validate_connection_id(connection_id)?;
        let ds = self.resolve_data_source(data_source)?;
        let url = self.url(&format!(
            "/api/session/data/{ds}/connections/{connection_id}/sharingProfiles"
        ))?;
        let response = self.http.get(&url).send().await?;
        Self::parse_response(
            response,
            &format!("connection {connection_id} sharing profiles"),
        )
        .await
    }

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

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

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

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

    /// Kills active connections using JSON Patch operations.
    pub async fn kill_connections(
        &self,
        data_source: Option<&str>,
        patches: &[PatchOperation],
    ) -> Result<()> {
        let ds = self.resolve_data_source(data_source)?;
        let url = self.url(&format!(
            "/api/session/data/{ds}/activeConnections"
        ))?;
        let response = self.http.patch(&url).json(patches).send().await?;
        Self::handle_error(response, "kill connections").await?;
        Ok(())
    }
}

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

    #[test]
    fn connection_serde_roundtrip() {
        let conn = Connection {
            identifier: Some("1".to_string()),
            parent_identifier: Some("ROOT".to_string()),
            name: Some("my-server".to_string()),
            protocol: Some("ssh".to_string()),
            parameters: Some(HashMap::from([
                ("hostname".to_string(), "10.0.0.5".to_string()),
                ("port".to_string(), "22".to_string()),
            ])),
            attributes: Some(HashMap::new()),
            active_connections: Some(0),
        };
        let json = serde_json::to_string(&conn).unwrap();
        let deserialized: Connection = serde_json::from_str(&json).unwrap();
        assert_eq!(conn, deserialized);
    }

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

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

    #[test]
    fn deserialize_connection_from_api_json() {
        let json = r#"{
            "identifier": "1",
            "parentIdentifier": "ROOT",
            "name": "my-server",
            "protocol": "ssh",
            "parameters": {},
            "attributes": {},
            "activeConnections": 0
        }"#;
        let conn: Connection = serde_json::from_str(json).unwrap();
        assert_eq!(conn.identifier.as_deref(), Some("1"));
        assert_eq!(conn.parent_identifier.as_deref(), Some("ROOT"));
        assert_eq!(conn.protocol.as_deref(), Some("ssh"));
        assert_eq!(conn.active_connections, Some(0));
    }

    #[test]
    fn active_connection_serde_roundtrip() {
        let ac = ActiveConnection {
            identifier: Some("abc-123".to_string()),
            connection_identifier: Some("1".to_string()),
            start_date: Some(1_700_000_000_000),
            remote_host: Some("10.0.0.1".to_string()),
            username: Some("admin".to_string()),
        };
        let json = serde_json::to_string(&ac).unwrap();
        let deserialized: ActiveConnection = serde_json::from_str(&json).unwrap();
        assert_eq!(ac, deserialized);
    }

    #[test]
    fn active_connection_camel_case_keys() {
        let ac = ActiveConnection {
            connection_identifier: Some("1".to_string()),
            start_date: Some(1),
            remote_host: Some("host".to_string()),
            ..Default::default()
        };
        let json = serde_json::to_value(&ac).unwrap();
        assert!(json.get("connectionIdentifier").is_some());
        assert!(json.get("startDate").is_some());
        assert!(json.get("remoteHost").is_some());
    }

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

    #[test]
    fn deserialize_connection_unknown_fields_ignored() {
        let json = r#"{"identifier": "1", "unknownField": "value"}"#;
        let conn: Connection = serde_json::from_str(json).unwrap();
        assert_eq!(conn.identifier.as_deref(), Some("1"));
    }

    #[test]
    fn deserialize_active_connection_from_api_json() {
        let json = r#"{
            "identifier": "abc-def-123",
            "connectionIdentifier": "42",
            "startDate": 1700000000000,
            "remoteHost": "192.168.1.100",
            "username": "guacadmin"
        }"#;
        let ac: ActiveConnection = serde_json::from_str(json).unwrap();
        assert_eq!(ac.identifier.as_deref(), Some("abc-def-123"));
        assert_eq!(ac.connection_identifier.as_deref(), Some("42"));
        assert_eq!(ac.start_date, Some(1_700_000_000_000));
        assert_eq!(ac.remote_host.as_deref(), Some("192.168.1.100"));
        assert_eq!(ac.username.as_deref(), Some("guacadmin"));
    }

    #[test]
    fn active_connection_unknown_fields_ignored() {
        let json = r#"{"identifier": "abc", "unknownField": 99}"#;
        let ac: ActiveConnection = serde_json::from_str(json).unwrap();
        assert_eq!(ac.identifier.as_deref(), Some("abc"));
    }
}