i2pd-exporter 2.2.0

Prometheus exporter for i2pd (C++ via I2PControl). Not for Java I2P.
Documentation
// Generic JSON-RPC client for I2PControl

use reqwest::header::{CONTENT_LENGTH, CONTENT_TYPE};
use serde::de::DeserializeOwned;
use serde::Deserialize;
use std::time::Duration;
use thiserror::Error;

// Local utility: truncate string to at most `max` chars, respecting Unicode boundaries
fn truncate_chars(s: &str, max: usize) -> String {
    let t: String = s.chars().take(max).collect();
    if s.chars().count() <= max {
        s.to_owned()
    } else {
        t
    }
}

// Represents an error in a JSON-RPC response
#[derive(Debug, Deserialize)]
pub struct RpcError {
    pub code: i32,
    pub message: String,
}

// Public error type for rpc_call so callers can match on structured failures
#[derive(Debug, Error)]
pub enum RpcCallError {
    #[error("transport error: {0}")]
    Transport(#[from] reqwest::Error),

    #[error("error encoding request body for {method}: {error}")]
    Encode { error: String, method: String },

    #[error("HTTP {status} calling {method}: body: {body_snippet}")]
    Http {
        status: reqwest::StatusCode,
        method: String,
        body_snippet: String,
    },

    #[error("{method} error {code}: {message}")]
    Rpc {
        code: i32,
        message: String,
        method: String,
    },

    #[error("error decoding response body for {method}: {error}; body: {body_snippet}")]
    Decode {
        error: String,
        method: String,
        body_snippet: String,
    },
}

// Exact-one-of JSON-RPC outcome
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum RpcOutcome<T> {
    Ok { result: T },
    Err { error: RpcError },
}

// Generic JSON-RPC call helper
pub async fn rpc_call<T: DeserializeOwned>(
    client: &reqwest::Client,
    url: &str,
    method: &str,
    params: serde_json::Value,
    timeout: Duration,
) -> Result<T, RpcCallError> {
    let req = serde_json::json!({
        "id": 1,
        "jsonrpc": "2.0",
        "method": method,
        "params": params,
    });
    // Serialize up front so we always send a fixed-length body (no chunked
    // transfer) — some I2PControl servers reject chunked requests as malformed
    // JSON.
    let body = serde_json::to_vec(&req).map_err(|e| RpcCallError::Encode {
        error: e.to_string(),
        method: method.to_string(),
    })?;

    if std::env::var("DEBUG_I2PCONTROL_REQ").ok().as_deref() == Some("1") {
        if let Ok(body_str) = serde_json::to_string(&req) {
            log::info!("{} request body: {}", method, body_str);
        }
    }

    let content_length = body.len() as u64;

    let resp = client
        .post(url)
        .header(CONTENT_TYPE, "application/json")
        .header(CONTENT_LENGTH, content_length)
        .body(body)
        .timeout(timeout)
        .send()
        .await?;
    if !resp.status().is_success() {
        let status = resp.status();
        let body = resp.text().await.unwrap_or_default();
        let body_snippet = if body.chars().count() > 2048 {
            truncate_chars(&body, 2048)
        } else {
            body.clone()
        };

        return Err(RpcCallError::Http {
            status,
            method: method.to_string(),
            body_snippet,
        });
    }
    let text = resp.text().await?;
    if std::env::var("DEBUG_I2PCONTROL_BODY").ok().as_deref() == Some("1") {
        // Truncate to avoid excessive logs
        let snippet = if text.chars().count() > 4096 {
            truncate_chars(&text, 4096)
        } else {
            text.clone()
        };
        log::debug!("{} response body: {}", method, snippet);
    }
    let parsed: Result<RpcOutcome<T>, _> = serde_json::from_str(&text);
    match parsed {
        Ok(RpcOutcome::Ok { result }) => Ok(result),
        Ok(RpcOutcome::Err { error }) => Err(RpcCallError::Rpc {
            code: error.code,
            message: error.message,
            method: method.to_string(),
        }),
        Err(e) => {
            let body_snippet = if text.chars().count() > 2048 {
                truncate_chars(&text, 2048)
            } else {
                text.clone()
            };
            Err(RpcCallError::Decode {
                error: e.to_string(),
                method: method.to_string(),
                body_snippet,
            })
        }
    }
}

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

    #[test]
    fn test_truncate_chars() {
        assert_eq!(truncate_chars("abcd", 10), "abcd");
        assert_eq!(truncate_chars("abcdef", 4), "abcd");
        assert_eq!(truncate_chars("éèà", 2), "éè");
    }
}