use crate::error::{BotError, Result, http_error_from_status};
use crate::models::api::{ApiError, RateLimit};
use crate::token::Token;
use reqwest::{
Client, Method, Response, StatusCode,
header::{HeaderMap, HeaderValue},
};
use serde::Serialize;
use serde_json::Value;
use std::sync::{Arc, RwLock};
use std::time::Duration;
use tracing::{debug, error, warn};
#[derive(Clone)]
pub struct HttpClient {
client: Client,
base_url: String,
is_sandbox: bool,
timeout: Duration,
last_trace_id: Arc<RwLock<Option<String>>>,
debug: bool,
union_app_id: Option<String>,
}
impl HttpClient {
pub fn new(timeout: u64, is_sandbox: bool) -> Result<Self> {
let client = Client::builder()
.timeout(Duration::from_secs(timeout))
.user_agent(format!("BotRS/{}", crate::VERSION))
.build()
.map_err(BotError::Http)?;
let base_url = if is_sandbox {
crate::SANDBOX_API_URL.to_string()
} else {
crate::DEFAULT_API_URL.to_string()
};
Ok(Self {
client,
base_url,
is_sandbox,
timeout: Duration::from_secs(timeout),
last_trace_id: Arc::new(RwLock::new(None)),
debug: false,
union_app_id: None,
})
}
fn clone_with_client(&self, client: Client, timeout: Duration, debug: bool) -> Self {
Self {
client,
base_url: self.base_url.clone(),
is_sandbox: self.is_sandbox,
timeout,
last_trace_id: Arc::clone(&self.last_trace_id),
debug,
union_app_id: self.union_app_id.clone(),
}
}
pub fn with_timeout(&self, timeout: Duration) -> Result<Self> {
let client = Client::builder()
.timeout(timeout)
.user_agent(format!("BotRS/{}", crate::VERSION))
.build()
.map_err(BotError::Http)?;
Ok(self.clone_with_client(client, timeout, self.debug))
}
pub fn with_debug(&self, debug: bool) -> Self {
Self {
debug,
..self.clone()
}
}
pub fn with_sandbox(&self, is_sandbox: bool) -> Result<Self> {
Self::new(self.timeout.as_secs(), is_sandbox).map(|client| {
if self.debug {
client.with_debug(true)
} else {
client
}
})
}
pub fn with_union_app_id(&self, app_id: impl Into<String>) -> Self {
Self {
union_app_id: Some(app_id.into()),
..self.clone()
}
}
pub async fn get<Q>(
&self,
token: &Token,
path: &str,
query: Option<&Q>,
) -> Result<serde_json::Value>
where
Q: Serialize + ?Sized,
{
self.request(Method::GET, token, path, query, None::<&()>)
.await
}
pub async fn post<Q, B>(
&self,
token: &Token,
path: &str,
query: Option<&Q>,
body: Option<&B>,
) -> Result<serde_json::Value>
where
Q: Serialize + ?Sized,
B: Serialize + ?Sized,
{
self.request(Method::POST, token, path, query, body).await
}
pub(crate) async fn request_json_url<Q, B>(
&self,
token: &Token,
method: Method,
url: &str,
query: Option<&Q>,
body: Option<&B>,
) -> Result<serde_json::Value>
where
Q: Serialize + ?Sized,
B: Serialize + ?Sized,
{
self.request_json_url_with_headers(method, token, url, query, body, HeaderMap::new())
.await
}
pub async fn put<Q, B>(
&self,
token: &Token,
path: &str,
query: Option<&Q>,
body: Option<&B>,
) -> Result<serde_json::Value>
where
Q: Serialize + ?Sized,
B: Serialize + ?Sized,
{
self.request(Method::PUT, token, path, query, body).await
}
pub async fn put_with_headers<Q, B>(
&self,
token: &Token,
path: &str,
query: Option<&Q>,
body: Option<&B>,
headers: HeaderMap,
) -> Result<serde_json::Value>
where
Q: Serialize + ?Sized,
B: Serialize + ?Sized,
{
self.request_with_headers(Method::PUT, token, path, query, body, headers)
.await
}
pub async fn put_raw_with_headers<Q>(
&self,
token: &Token,
path: &str,
query: Option<&Q>,
body: impl Into<String>,
headers: HeaderMap,
) -> Result<serde_json::Value>
where
Q: Serialize + ?Sized,
{
self.request_raw_with_headers(Method::PUT, token, path, query, body, headers)
.await
}
pub async fn delete<Q>(
&self,
token: &Token,
path: &str,
query: Option<&Q>,
) -> Result<serde_json::Value>
where
Q: Serialize + ?Sized,
{
self.request(Method::DELETE, token, path, query, None::<&()>)
.await
}
pub async fn delete_with_body<Q, B>(
&self,
token: &Token,
path: &str,
query: Option<&Q>,
body: Option<&B>,
) -> Result<serde_json::Value>
where
Q: Serialize + ?Sized,
B: Serialize + ?Sized,
{
self.request(Method::DELETE, token, path, query, body).await
}
pub async fn patch<Q, B>(
&self,
token: &Token,
path: &str,
query: Option<&Q>,
body: Option<&B>,
) -> Result<serde_json::Value>
where
Q: Serialize + ?Sized,
B: Serialize + ?Sized,
{
self.request(Method::PATCH, token, path, query, body).await
}
pub async fn transport<B>(
&self,
token: &Token,
method: Method,
url: &str,
body: Option<&B>,
) -> Result<Vec<u8>>
where
B: Serialize + ?Sized,
{
let mut headers = self.authorized_headers(token, HeaderMap::new()).await?;
if body.is_some() {
headers.insert("Content-Type", "application/json".parse().unwrap());
}
let mut context = crate::openapi::FilterContext::request(method.clone(), url, headers);
crate::openapi::DoReqFilterChains(&mut context)?;
let request_headers = context.request_headers.clone();
let mut request = self
.client
.request(method.clone(), url)
.headers(request_headers.clone());
if let Some(body) = body {
request = request.json(body);
}
let response = request.send().await.map_err(BotError::Http)?;
self.handle_bytes_response(response, method, url, request_headers)
.await
}
async fn request<Q, B>(
&self,
method: Method,
token: &Token,
path: &str,
query: Option<&Q>,
body: Option<&B>,
) -> Result<serde_json::Value>
where
Q: Serialize + ?Sized,
B: Serialize + ?Sized,
{
self.request_with_headers(method, token, path, query, body, HeaderMap::new())
.await
}
async fn request_with_headers<Q, B>(
&self,
method: Method,
token: &Token,
path: &str,
query: Option<&Q>,
body: Option<&B>,
headers: HeaderMap,
) -> Result<serde_json::Value>
where
Q: Serialize + ?Sized,
B: Serialize + ?Sized,
{
let url = format!("{}{}", self.base_url, path);
self.request_json_url_with_headers(method, token, &url, query, body, headers)
.await
}
async fn request_json_url_with_headers<Q, B>(
&self,
method: Method,
token: &Token,
url: &str,
query: Option<&Q>,
body: Option<&B>,
headers: HeaderMap,
) -> Result<serde_json::Value>
where
Q: Serialize + ?Sized,
B: Serialize + ?Sized,
{
debug!("Making {} request to: {}", method, url);
let mut headers = self.authorized_headers(token, headers).await?;
if body.is_some() {
headers.insert("Content-Type", "application/json".parse().unwrap());
}
let mut context = crate::openapi::FilterContext::request(method.clone(), url, headers);
crate::openapi::DoReqFilterChains(&mut context)?;
let request_headers = context.request_headers.clone();
let mut request = self
.client
.request(method.clone(), url)
.headers(request_headers.clone());
if let Some(q) = query {
request = request.query(q);
}
if let Some(b) = body {
request = request.json(b);
}
let response = request.send().await.map_err(BotError::Http)?;
self.handle_response(response, method, url, request_headers)
.await
}
async fn request_raw_with_headers<Q>(
&self,
method: Method,
token: &Token,
path: &str,
query: Option<&Q>,
body: impl Into<String>,
headers: HeaderMap,
) -> Result<serde_json::Value>
where
Q: Serialize + ?Sized,
{
let url = format!("{}{}", self.base_url, path);
debug!("Making {} request to: {}", method, url);
let mut headers = self.authorized_headers(token, headers).await?;
headers.insert("Content-Type", "application/json".parse().unwrap());
let mut context = crate::openapi::FilterContext::request(method.clone(), &url, headers);
crate::openapi::DoReqFilterChains(&mut context)?;
let request_headers = context.request_headers.clone();
let mut request = self
.client
.request(method.clone(), &url)
.headers(request_headers.clone());
if let Some(q) = query {
request = request.query(q);
}
let response = request
.body(body.into())
.send()
.await
.map_err(BotError::Http)?;
self.handle_response(response, method, &url, request_headers)
.await
}
async fn authorized_headers(&self, token: &Token, mut headers: HeaderMap) -> Result<HeaderMap> {
headers.insert(
"Authorization",
token.authorization_header().await?.parse().map_err(|err| {
BotError::internal(format!("invalid authorization header: {err}"))
})?,
);
let app_id = self
.union_app_id
.as_deref()
.unwrap_or_else(|| token.app_id());
if !app_id.is_empty() {
headers.insert(
"X-Union-Appid",
HeaderValue::from_str(app_id).map_err(|err| {
BotError::internal(format!("invalid X-Union-Appid header: {err}"))
})?,
);
}
Ok(headers)
}
async fn handle_response(
&self,
response: Response,
method: Method,
url: &str,
request_headers: HeaderMap,
) -> Result<serde_json::Value> {
let status = response.status();
let mut headers = response.headers().clone();
let mut context =
crate::openapi::FilterContext::response(method, url, request_headers, status, headers);
crate::openapi::DoRespFilterChains(&mut context)?;
headers = context.response_headers;
self.store_trace_id(&headers);
if status == StatusCode::TOO_MANY_REQUESTS {
let retry_after = headers
.get("retry-after")
.and_then(|h| h.to_str().ok())
.and_then(|s| s.parse().ok())
.unwrap_or(60);
warn!("Rate limited, retry after {} seconds", retry_after);
return Err(BotError::rate_limit(retry_after));
}
let body = response.text().await.map_err(BotError::Http)?;
let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
error!("Failed to parse JSON response: {}", e);
error!("Response body: {}", body);
BotError::Json(e)
})?;
if !crate::openapi::IsSuccessStatus(status.as_u16()) {
let api_error = self.parse_api_error(status, &json)?;
error!("API error: {}", api_error);
return Err(http_error_from_status(status.as_u16(), api_error.message));
}
if let Some(rate_limit) = self.parse_rate_limit(&headers) {
debug!("Rate limit info: {:?}", rate_limit);
}
debug!("Request successful, response: {}", json);
Ok(json)
}
async fn handle_bytes_response(
&self,
response: Response,
method: Method,
url: &str,
request_headers: HeaderMap,
) -> Result<Vec<u8>> {
let status = response.status();
let mut headers = response.headers().clone();
let mut context =
crate::openapi::FilterContext::response(method, url, request_headers, status, headers);
crate::openapi::DoRespFilterChains(&mut context)?;
headers = context.response_headers;
self.store_trace_id(&headers);
if status == StatusCode::TOO_MANY_REQUESTS {
let retry_after = headers
.get("retry-after")
.and_then(|h| h.to_str().ok())
.and_then(|s| s.parse().ok())
.unwrap_or(60);
return Err(BotError::rate_limit(retry_after));
}
let body = response.bytes().await.map_err(BotError::Http)?.to_vec();
if !crate::openapi::IsSuccessStatus(status.as_u16()) {
let message = serde_json::from_slice::<Value>(&body)
.ok()
.and_then(|json| {
json.get("message")
.and_then(|value| value.as_str())
.map(ToOwned::to_owned)
})
.unwrap_or_else(|| String::from_utf8_lossy(&body).into_owned());
return Err(http_error_from_status(status.as_u16(), message));
}
Ok(body)
}
fn store_trace_id(&self, headers: &reqwest::header::HeaderMap) {
let trace_id = headers
.get(crate::constant::HeaderTraceID)
.or_else(|| headers.get("x-tps-trace-id"))
.and_then(|value| value.to_str().ok())
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
if let Some(trace_id) = trace_id
&& let Ok(mut last_trace_id) = self.last_trace_id.write()
{
*last_trace_id = Some(trace_id);
}
}
fn parse_api_error(&self, status: StatusCode, json: &serde_json::Value) -> Result<ApiError> {
if let Ok(error) = serde_json::from_value::<ApiError>(json.clone()) {
return Ok(error);
}
let code = json
.get("code")
.and_then(|c| c.as_u64())
.map(|c| c as u32)
.unwrap_or(status.as_u16() as u32);
let message = json
.get("message")
.and_then(|m| m.as_str())
.or_else(|| json.get("error").and_then(|e| e.as_str()))
.unwrap_or_else(|| status.canonical_reason().unwrap_or("Unknown error"))
.to_string();
let trace_id = json
.get("trace_id")
.and_then(|t| t.as_str())
.map(|s| s.to_string());
Ok(ApiError {
code,
message,
errors: Some(json.clone()),
trace_id,
})
}
fn parse_rate_limit(&self, headers: &reqwest::header::HeaderMap) -> Option<RateLimit> {
let limit = headers
.get("x-ratelimit-limit")
.and_then(|h| h.to_str().ok())
.and_then(|s| s.parse().ok())?;
let remaining = headers
.get("x-ratelimit-remaining")
.and_then(|h| h.to_str().ok())
.and_then(|s| s.parse().ok())?;
let reset = headers
.get("x-ratelimit-reset")
.and_then(|h| h.to_str().ok())
.and_then(|s| s.parse().ok())?;
let bucket = headers
.get("x-ratelimit-bucket")
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
let retry_after = headers
.get("retry-after")
.and_then(|h| h.to_str().ok())
.and_then(|s| s.parse().ok());
Some(RateLimit {
bucket,
limit,
remaining,
reset,
retry_after,
})
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn is_sandbox(&self) -> bool {
self.is_sandbox
}
pub fn timeout(&self) -> Duration {
self.timeout
}
pub fn trace_id(&self) -> String {
self.last_trace_id
.read()
.ok()
.and_then(|trace_id| trace_id.clone())
.unwrap_or_default()
}
pub fn debug_enabled(&self) -> bool {
self.debug
}
pub fn union_app_id(&self) -> Option<&str> {
self.union_app_id.as_deref()
}
pub async fn close(&self) {
debug!("HTTP client closed");
}
}
impl std::fmt::Debug for HttpClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HttpClient")
.field("base_url", &self.base_url)
.field("is_sandbox", &self.is_sandbox)
.field("timeout", &self.timeout)
.field("debug", &self.debug)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_http_client_creation() {
let client = HttpClient::new(30, false).unwrap();
assert!(!client.is_sandbox());
assert_eq!(client.timeout(), Duration::from_secs(30));
assert_eq!(client.base_url(), crate::DEFAULT_API_URL);
let sandbox_client = HttpClient::new(60, true).unwrap();
assert!(sandbox_client.is_sandbox());
assert_eq!(sandbox_client.base_url(), crate::SANDBOX_API_URL);
}
#[test]
fn test_api_error_parsing() {
let client = HttpClient::new(30, false).unwrap();
let json = serde_json::json!({
"code": 404,
"message": "Not found",
"trace_id": "test-trace"
});
let error = client
.parse_api_error(StatusCode::NOT_FOUND, &json)
.unwrap();
assert_eq!(error.code, 404);
assert_eq!(error.message, "Not found");
assert_eq!(error.trace_id, Some("test-trace".to_string()));
}
#[test]
fn test_rate_limit_parsing() {
let client = HttpClient::new(30, false).unwrap();
let mut headers = reqwest::header::HeaderMap::new();
headers.insert("x-ratelimit-limit", "100".parse().unwrap());
headers.insert("x-ratelimit-remaining", "50".parse().unwrap());
headers.insert("x-ratelimit-reset", "1234567890".parse().unwrap());
headers.insert("x-ratelimit-bucket", "global".parse().unwrap());
let rate_limit = client.parse_rate_limit(&headers).unwrap();
assert_eq!(rate_limit.limit, 100);
assert_eq!(rate_limit.remaining, 50);
assert_eq!(rate_limit.reset, 1234567890);
assert_eq!(rate_limit.bucket, Some("global".to_string()));
}
#[test]
fn test_trace_id_storage() {
let client = HttpClient::new(30, false).unwrap();
let mut headers = reqwest::header::HeaderMap::new();
headers.insert("X-Tps-trace-ID", "trace-123".parse().unwrap());
client.store_trace_id(&headers);
assert_eq!(client.trace_id(), "trace-123");
}
#[tokio::test]
async fn authorized_headers_include_union_app_id() {
let client = HttpClient::new(30, false)
.unwrap()
.with_union_app_id("openapi-app");
let token = Token::new("token-app", "secret");
token.set_cached_access_token_for_test("cached-token").await;
let headers = client
.authorized_headers(&token, reqwest::header::HeaderMap::new())
.await
.unwrap();
assert_eq!(
headers
.get("Authorization")
.and_then(|value| value.to_str().ok()),
Some("QQBot cached-token")
);
assert_eq!(
headers
.get("X-Union-Appid")
.and_then(|value| value.to_str().ok()),
Some("openapi-app")
);
}
#[tokio::test]
async fn authorized_headers_fall_back_to_token_app_id() {
let client = HttpClient::new(30, false).unwrap();
let token = Token::new("token-app", "secret");
token.set_cached_access_token_for_test("cached-token").await;
let headers = client
.authorized_headers(&token, reqwest::header::HeaderMap::new())
.await
.unwrap();
assert_eq!(
headers
.get("X-Union-Appid")
.and_then(|value| value.to_str().ok()),
Some("token-app")
);
}
}