xui-rs 2.0.0

A modern, typed async Rust SDK for the 3x-ui panel API
Documentation
use std::{
    sync::atomic::{AtomicU64, Ordering},
    time::{Duration, Instant},
};

use reqwest::{Method, RequestBuilder, Response};
use tracing::debug;
use url::Url;

use crate::{Error, Result};

static NEXT_REQUEST_ID: AtomicU64 = AtomicU64::new(1);

#[derive(Clone, Copy)]
pub(crate) enum ErrorUrlPolicy {
    Preserve,
    Redact,
}

pub(crate) async fn send_request(
    request: RequestBuilder,
    method: &Method,
    diagnostic_url: &Url,
    error_url_policy: ErrorUrlPolicy,
) -> Result<Response> {
    let request_id = NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed);
    let started = Instant::now();
    debug!(
        target: "xui_rs::transport",
        request_id,
        method = %method,
        "sending 3x-ui HTTP request"
    );
    let response = match request.send().await {
        Ok(response) => response,
        Err(source) => {
            debug!(
                target: "xui_rs::transport",
                request_id,
                method = %method,
                elapsed_ms = elapsed_millis(started.elapsed()),
                outcome = "transport_error",
                "3x-ui HTTP request failed"
            );
            return Err(Error::Transport {
                method: method.clone(),
                url: Box::new(diagnostic_url.clone()),
                source: sanitize_reqwest_error(source, error_url_policy),
            });
        }
    };
    debug!(
        target: "xui_rs::transport",
        request_id,
        method = %method,
        status = response.status().as_u16(),
        elapsed_ms = elapsed_millis(started.elapsed()),
        outcome = "response",
        "received 3x-ui HTTP response headers"
    );
    Ok(response)
}

pub(crate) async fn read_response_body(
    mut response: Response,
    method: &Method,
    diagnostic_url: &Url,
    limit: usize,
    error_url_policy: ErrorUrlPolicy,
) -> Result<Vec<u8>> {
    if *method == Method::HEAD {
        return Ok(Vec::new());
    }

    let content_length = response.content_length();
    if content_length.is_some_and(|length| length > usize_as_u64(limit)) {
        return Err(response_too_large(
            method,
            diagnostic_url,
            limit,
            content_length,
        ));
    }
    let capacity = content_length
        .and_then(|length| usize::try_from(length).ok())
        .unwrap_or_default()
        .min(limit);
    let mut bytes = Vec::with_capacity(capacity);
    while let Some(chunk) = response.chunk().await.map_err(|source| Error::Transport {
        method: method.clone(),
        url: Box::new(diagnostic_url.clone()),
        source: sanitize_reqwest_error(source, error_url_policy),
    })? {
        if chunk.len() > limit.saturating_sub(bytes.len()) {
            return Err(response_too_large(
                method,
                diagnostic_url,
                limit,
                content_length,
            ));
        }
        bytes.extend_from_slice(&chunk);
    }
    Ok(bytes)
}

fn response_too_large(
    method: &Method,
    diagnostic_url: &Url,
    limit: usize,
    content_length: Option<u64>,
) -> Error {
    Error::ResponseTooLarge {
        method: method.clone(),
        url: Box::new(diagnostic_url.clone()),
        limit,
        content_length,
    }
}

fn sanitize_reqwest_error(source: reqwest::Error, policy: ErrorUrlPolicy) -> reqwest::Error {
    match policy {
        ErrorUrlPolicy::Preserve => source,
        ErrorUrlPolicy::Redact => source.without_url(),
    }
}

fn elapsed_millis(duration: Duration) -> u64 {
    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}

fn usize_as_u64(value: usize) -> u64 {
    u64::try_from(value).unwrap_or(u64::MAX)
}