vapour-protocol 0.4.0

Steam client protocol implementation for native Rust applications
Documentation
use std::sync::Arc;

use tokio::sync::Mutex;

use crate::error::{Error, Result};

const CM_LIST_URL: &str = "https://api.steampowered.com/ISteamDirectory/GetCMListForConnect/v1/?cellid=0&cmtype=websockets&format=json";

#[derive(Debug, Clone, PartialEq)]
pub struct CmServer {
    pub endpoint: String,
    pub legacy_endpoint: Option<String>,
    pub data_center: Option<String>,
    pub realm: Option<String>,
    pub load: Option<u32>,
    pub weighted_load: Option<f64>,
}

impl CmServer {
    pub fn websocket_url(&self) -> String {
        format!("wss://{}/cmsocket/", self.endpoint)
    }
}

#[derive(Clone, Debug)]
pub struct ServerListCache {
    client: reqwest::Client,
    cache: Arc<Mutex<Vec<CmServer>>>,
}

impl Default for ServerListCache {
    fn default() -> Self {
        Self::new()
    }
}

impl ServerListCache {
    pub fn new() -> Self {
        Self {
            client: reqwest::Client::builder()
                .user_agent("vapour-protocol/0.1")
                .build()
                .expect("reqwest client builder is valid"),
            cache: Arc::new(Mutex::new(Vec::new())),
        }
    }

    pub async fn list(&self, force_refresh: bool) -> Result<Vec<CmServer>> {
        let mut cache = self.cache.lock().await;
        if force_refresh || cache.is_empty() {
            *cache = fetch_cm_list(&self.client).await?;
        }

        Ok(cache.clone())
    }
}

pub async fn fetch_cm_list(client: &reqwest::Client) -> Result<Vec<CmServer>> {
    let response = client.get(CM_LIST_URL).send().await?.error_for_status()?;
    let directory: DirectoryResponse = response.json().await?;

    if !directory.response.success {
        return Err(Error::Protocol(
            "Steam CM directory returned failure".to_owned(),
        ));
    }

    let mut servers: Vec<CmServer> = directory
        .response
        .serverlist
        .into_iter()
        .map(|server| CmServer {
            endpoint: server.endpoint,
            legacy_endpoint: server.legacy_endpoint,
            data_center: server.data_center,
            realm: server.realm,
            load: server.load,
            weighted_load: server.weighted_load,
        })
        .collect();

    servers.sort_by(|left, right| {
        left.weighted_load
            .partial_cmp(&right.weighted_load)
            .unwrap_or(std::cmp::Ordering::Equal)
    });

    if servers.is_empty() {
        return Err(Error::InvalidResponse(
            "Steam CM directory returned no servers",
        ));
    }

    Ok(servers)
}

#[derive(Debug, serde::Deserialize)]
struct DirectoryResponse {
    response: DirectoryBody,
}

#[derive(Debug, serde::Deserialize)]
struct DirectoryBody {
    #[serde(default)]
    serverlist: Vec<DirectoryServer>,
    success: bool,
}

#[derive(Debug, serde::Deserialize)]
struct DirectoryServer {
    endpoint: String,
    #[serde(default)]
    legacy_endpoint: Option<String>,
    #[serde(default, rename = "dc")]
    data_center: Option<String>,
    #[serde(default)]
    realm: Option<String>,
    #[serde(default)]
    load: Option<u32>,
    #[serde(default, rename = "wtd_load")]
    weighted_load: Option<f64>,
}