use http_body_util::{BodyExt, Full};
use hyper::Request;
use hyper::body::Bytes;
use hyper::client::conn::http1;
use hyper_util::rt::TokioIo;
use serde::de::DeserializeOwned;
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct AdminClient {
base_url: String,
api_key: String,
timeout: Duration,
}
impl AdminClient {
#[must_use]
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>) -> Self {
Self {
base_url: base_url.into().trim_end_matches('/').to_string(),
api_key: api_key.into(),
timeout: Duration::from_secs(10),
}
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, AdminClientError> {
let body = self.send(hyper::Method::GET, path, None).await?;
serde_json::from_slice(&body).map_err(|e| AdminClientError::Decode(e.to_string()))
}
async fn send(
&self,
method: hyper::Method,
path: &str,
json_body: Option<serde_json::Value>,
) -> Result<Bytes, AdminClientError> {
if self.base_url.starts_with("https://") {
return Err(AdminClientError::InvalidBaseUrl(
"https:// base_url is not supported: this client speaks plaintext \
HTTP/1 only (no TLS connector); terminate TLS at a reverse proxy \
and use http://"
.to_string(),
));
}
let (host_name, port) = self.host()?;
let host_header = if host_name.contains(':') {
format!("[{host_name}]:{port}")
} else {
format!("{host_name}:{port}")
};
let uri = format!("{}{}", self.base_url, path);
let mut builder = Request::builder()
.method(method)
.uri(&uri)
.header(
hyper::header::AUTHORIZATION,
format!("Bearer {}", self.api_key),
)
.header(hyper::header::HOST, host_header);
if json_body.is_some() {
builder = builder.header(hyper::header::CONTENT_TYPE, "application/json");
}
let body_bytes = json_body
.map(|v| serde_json::to_vec(&v))
.transpose()
.map_err(|e| AdminClientError::Encode(e.to_string()))?
.unwrap_or_default();
let request = builder
.body(Full::new(Bytes::from(body_bytes)))
.map_err(|e| AdminClientError::Request(e.to_string()))?;
let connect = tokio::net::TcpStream::connect((&*host_name, port));
let stream = tokio::time::timeout(self.timeout, connect)
.await
.map_err(|_| AdminClientError::Timeout)?
.map_err(|e| AdminClientError::Connect(e.to_string()))?;
let (mut sender, connection) =
tokio::time::timeout(self.timeout, http1::handshake(TokioIo::new(stream)))
.await
.map_err(|_| AdminClientError::Timeout)?
.map_err(|e| AdminClientError::Connect(e.to_string()))?;
tokio::spawn(async move {
let _ = connection.await;
});
let response = tokio::time::timeout(self.timeout, sender.send_request(request))
.await
.map_err(|_| AdminClientError::Timeout)?
.map_err(|e| AdminClientError::Request(e.to_string()))?;
let status = response.status();
let bytes = response
.into_body()
.collect()
.await
.map_err(|e| AdminClientError::Request(e.to_string()))?
.to_bytes();
if !status.is_success() {
return Err(AdminClientError::Status {
status: status.to_string(),
body: String::from_utf8_lossy(&bytes).into_owned(),
});
}
Ok(bytes)
}
fn host(&self) -> Result<(String, u16), AdminClientError> {
let rest = self.base_url.trim_start_matches("http://");
if let Some(inner) = rest.strip_prefix('[') {
let Some((h, tail)) = inner.split_once(']') else {
return Err(AdminClientError::InvalidBaseUrl(format!(
"base_url has unterminated IPv6 bracket: {}",
self.base_url
)));
};
let port = match tail.strip_prefix(':') {
Some(p) => p.parse().map_err(|_| self.invalid_port_error(p))?,
None => 80,
};
return Ok((h.to_string(), port));
}
match rest.split_once(':') {
Some((h, p)) => {
let port = p.parse().map_err(|_| self.invalid_port_error(p))?;
Ok((h.to_string(), port))
}
None => Ok((rest.to_string(), 80)),
}
}
fn invalid_port_error(&self, port: &str) -> AdminClientError {
AdminClientError::InvalidBaseUrl(format!(
"base_url has invalid port {port:?}: {}",
self.base_url
))
}
pub async fn status(&self) -> Result<ApiResponse, AdminClientError> {
self.get_json("/api/v1/status").await
}
pub async fn introspect(&self) -> Result<serde_json::Value, AdminClientError> {
self.get_json("/api/v1/introspect").await
}
pub async fn circuit_breaker_status(&self) -> Result<ApiResponse, AdminClientError> {
self.get_json("/api/v1/status/circuit-breaker").await
}
pub async fn healthz(&self) -> Result<serde_json::Value, AdminClientError> {
self.get_json("/healthz").await
}
pub async fn create_ban(
&self,
req: &serde_json::Value,
) -> Result<ApiResponse, AdminClientError> {
let body = self
.send(hyper::Method::POST, "/api/v1/ban", Some(req.clone()))
.await?;
serde_json::from_slice(&body).map_err(|e| AdminClientError::Decode(e.to_string()))
}
pub async fn delete_ban(
&self,
target: &str,
target_type: Option<&str>,
reason: Option<&str>,
) -> Result<ApiResponse, AdminClientError> {
let path = match target_type {
Some(t) => format!(
"/api/v1/ban/{}?type={}",
encode_path(target),
encode_path(t)
),
None => format!("/api/v1/ban/{}", encode_path(target)),
};
let body = self
.send(
hyper::Method::DELETE,
&path,
Some(serde_json::json!({ "reason": reason })),
)
.await?;
serde_json::from_slice(&body).map_err(|e| AdminClientError::Decode(e.to_string()))
}
pub async fn reset_quota(
&self,
user_id: &str,
resource: &str,
) -> Result<ApiResponse, AdminClientError> {
let body = self
.send(
hyper::Method::PUT,
&format!("/api/v1/quota/{}", encode_path(user_id)),
Some(serde_json::json!({ "resource": resource, "new_limit": 0 })),
)
.await?;
serde_json::from_slice(&body).map_err(|e| AdminClientError::Decode(e.to_string()))
}
pub async fn apply_config(
&self,
config: &serde_json::Value,
) -> Result<serde_json::Value, AdminClientError> {
let body = self
.send(hyper::Method::POST, "/api/v1/config", Some(config.clone()))
.await?;
serde_json::from_slice(&body).map_err(|e| AdminClientError::Decode(e.to_string()))
}
pub async fn check_batch(
&self,
requests: &[serde_json::Value],
) -> Result<serde_json::Value, AdminClientError> {
let body = self
.send(
hyper::Method::POST,
"/api/v1/check/batch",
Some(serde_json::json!({ "requests": requests })),
)
.await?;
serde_json::from_slice(&body).map_err(|e| AdminClientError::Decode(e.to_string()))
}
}
fn encode_path(segment: &str) -> String {
let mut out = String::with_capacity(segment.len());
for byte in segment.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'-' | b'_' | b'~' => {
out.push(byte as char);
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct ApiResponse {
pub success: bool,
pub message: String,
pub data: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdminClientError {
InvalidBaseUrl(String),
Connect(String),
Request(String),
Encode(String),
Decode(String),
Status { status: String, body: String },
Timeout,
}
impl std::fmt::Display for AdminClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidBaseUrl(m) => write!(f, "invalid base_url: {m}"),
Self::Connect(m) | Self::Request(m) | Self::Encode(m) | Self::Decode(m) => {
write!(f, "{m}")
}
Self::Status { status, body } => write!(f, "HTTP {status}: {body}"),
Self::Timeout => write!(f, "request timed out"),
}
}
}
impl std::error::Error for AdminClientError {}
impl AdminClientError {
#[must_use]
pub fn status_text(&self) -> Option<&str> {
match self {
Self::Status { status, .. } => Some(status.as_str()),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::admin::{AdminApiConfig, AdminServer};
async fn spawn_admin_server() -> (String, &'static str) {
let governor = std::sync::Arc::new(crate::admin::handlers::tests::make_governor().await);
let config = AdminApiConfig {
enabled: true,
api_key: "test-api-key-16chars!!".to_string(),
..Default::default()
};
let server = AdminServer::new(governor, config);
let router = server.into_router().expect("valid config yields router");
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback");
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
(format!("http://{addr}"), "test-api-key-16chars!!")
}
#[tokio::test]
async fn healthz_and_status_roundtrip() {
let (base, key) = spawn_admin_server().await;
let client = AdminClient::new(base, key);
let health = client.healthz().await.unwrap();
assert_eq!(health["status"], "ok");
let status = client.status().await.unwrap();
assert!(status.success, "status response should succeed: {status:?}");
let data = status.data.expect("status data");
assert_eq!(data["total_requests"], 0);
}
#[tokio::test]
async fn bad_key_yields_status_error() {
let (base, _) = spawn_admin_server().await;
let client = AdminClient::new(base, "wrong-key-16chars!!!");
let err = client.status().await.unwrap_err();
assert_eq!(err.status_text(), Some("401 Unauthorized"));
}
#[tokio::test]
async fn check_batch_roundtrip() {
let (base, key) = spawn_admin_server().await;
let client = AdminClient::new(base, key);
let result = client
.check_batch(&[serde_json::json!({"user_id": "u1", "ip": "10.0.0.1"})])
.await
.unwrap();
assert_eq!(
result["data"]["results"].as_array().expect("results").len(),
1
);
}
#[tokio::test]
async fn connect_failure_is_explicit() {
let client = AdminClient::new("http://127.0.0.1:1", "k");
let err = client.status().await.unwrap_err();
assert!(matches!(err, AdminClientError::Connect(_)), "got {err:?}");
}
#[test]
fn path_encoding_blocks_traversal() {
assert_eq!(encode_path("1.2.3.4"), "1.2.3.4");
assert_eq!(encode_path("../etc"), "..%2Fetc");
assert_eq!(encode_path("a b/c"), "a%20b%2Fc");
}
#[test]
fn query_value_encoding_neutralizes_injection_chars() {
assert_eq!(encode_path("a&b=c#d"), "a%26b%3Dc%23d");
assert_eq!(encode_path("ip"), "ip");
}
#[test]
fn host_parsing_supports_ipv6_and_rejects_bad_ports() {
let c = AdminClient::new("http://[::1]:9090", "k");
assert_eq!(c.host().unwrap(), ("::1".to_string(), 9090));
let c = AdminClient::new("http://[::1]", "k");
assert_eq!(c.host().unwrap(), ("::1".to_string(), 80));
let c = AdminClient::new("http://admin.internal:9090", "k");
assert_eq!(c.host().unwrap(), ("admin.internal".to_string(), 9090));
let c = AdminClient::new("http://admin.internal", "k");
assert_eq!(c.host().unwrap(), ("admin.internal".to_string(), 80));
let c = AdminClient::new("http://admin.internal:foo", "k");
assert!(
matches!(c.host(), Err(AdminClientError::InvalidBaseUrl(_))),
"port parse failure must error explicitly: {:?}",
c.host()
);
let c = AdminClient::new("http://[::1:9090", "k");
assert!(matches!(c.host(), Err(AdminClientError::InvalidBaseUrl(_))));
}
#[tokio::test]
async fn https_base_url_fails_fast_without_io() {
let client = AdminClient::new("https://admin.example.com", "secret-key");
let err = client.status().await.unwrap_err();
assert!(
matches!(err, AdminClientError::InvalidBaseUrl(ref m) if m.contains("https")),
"https base_url must error explicitly: {err:?}"
);
}
}