lirays 0.1.1

Rust client for LiRAYS-SCADA over WebSocket + Protobuf
Documentation
use tokio_tungstenite::tungstenite::{
    client::IntoClientRequest,
    http::{Request, header::AUTHORIZATION},
};
use url::Url;

use crate::types::errors::ClientError;

/// Connection settings used to build the websocket request.
///
/// # Example
/// ```rust
/// use lirays::ConnectionOptions;
///
/// let opts = ConnectionOptions::new("127.0.0.1", 8245, false, None);
/// assert_eq!(opts.ws_url().unwrap(), "ws://127.0.0.1:8245/ws");
/// ```
#[derive(Clone, Debug)]
pub struct ConnectionOptions {
    /// Server hostname or IP address.
    pub host: String,
    /// Server TCP port.
    pub port: i64,
    /// Enables `wss://` when `true`, otherwise uses `ws://`.
    pub tls: bool,
    /// Optional PAT token sent as `Authorization: Bearer <token>`.
    pub pat_token: Option<String>,
}

impl ConnectionOptions {
    /// Creates a new set of connection options.
    pub fn new(host: impl Into<String>, port: i64, tls: bool, pat_token: Option<String>) -> Self {
        Self {
            host: host.into(),
            port,
            tls,
            pat_token,
        }
    }

    /// Returns the final websocket URL in the form `ws(s)://host:port/ws`.
    pub fn ws_url(&self) -> Result<String, ClientError> {
        build_ws_url(&self.host, self.port, self.tls)
    }
}

/// Builds an HTTP upgrade request for websocket connection.
///
/// If `pat_token` is present, an `Authorization` header is attached.
pub(crate) fn build_ws_request(options: &ConnectionOptions) -> Result<Request<()>, ClientError> {
    let url = options.ws_url()?;
    let mut request = url
        .into_client_request()
        .map_err(|_| ClientError::InvalidInput("invalid host/port"))?;

    if let Some(token) = options.pat_token.as_deref() {
        let value = format!("Bearer {token}");
        let header_value = value
            .parse()
            .map_err(|_| ClientError::InvalidInput("invalid PAT token format"))?;
        request.headers_mut().insert(AUTHORIZATION, header_value);
    }

    Ok(request)
}

/// Composes and validates websocket URL from host/port/tls parts.
fn build_ws_url(host: &str, port: i64, tls: bool) -> Result<String, ClientError> {
    let scheme = if tls { "wss" } else { "ws" };
    let base = format!("{scheme}://{host}:{port}/ws");
    let url = Url::parse(&base).map_err(|_| ClientError::InvalidInput("invalid host/port"))?;
    Ok(url.into())
}

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

    #[test]
    fn request_includes_authorization_header_when_pat_is_set() {
        let options =
            ConnectionOptions::new("127.0.0.1", 8245, false, Some("pat_test.token".to_string()));
        let request = build_ws_request(&options).expect("request should build");
        let auth = request
            .headers()
            .get(AUTHORIZATION)
            .expect("authorization header missing")
            .to_str()
            .expect("header should be valid utf8");
        assert_eq!(auth, "Bearer pat_test.token");
    }

    #[test]
    fn request_omits_authorization_header_when_pat_is_not_set() {
        let options = ConnectionOptions::new("127.0.0.1", 8245, false, None);
        let request = build_ws_request(&options).expect("request should build");
        assert!(request.headers().get(AUTHORIZATION).is_none());
    }
}