This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 89ec844b [core] Unify the REST User-Agent format (#992)
89ec844b is described below
commit 89ec844b7cb8e7bf6dd7031a75b4901903942e38
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Fri Oct 2 10:07:03 2026 +0800
[core] Unify the REST User-Agent format (#992)
---
crates/paimon/src/api/auth/dlf_provider.rs | 39 +++++++
crates/paimon/src/api/mod.rs | 1 +
crates/paimon/src/api/rest_api.rs | 8 +-
crates/paimon/src/api/rest_client.rs | 165 +++++++++++++++++++++++++++--
crates/paimon/src/api/user_agent.rs | 90 ++++++++++++++++
5 files changed, 292 insertions(+), 11 deletions(-)
diff --git a/crates/paimon/src/api/auth/dlf_provider.rs
b/crates/paimon/src/api/auth/dlf_provider.rs
index fc95eea9..18b0ccc6 100644
--- a/crates/paimon/src/api/auth/dlf_provider.rs
+++ b/crates/paimon/src/api/auth/dlf_provider.rs
@@ -27,6 +27,7 @@ use serde::{Deserialize, Serialize};
use super::base::{AuthProvider, RESTAuthParameter, AUTHORIZATION_HEADER_KEY};
use super::dlf_signer::{DLFRequestSigner, DLFSignerFactory};
+use crate::api::user_agent;
use crate::common::{CatalogOptions, Options};
use crate::error::Error;
use crate::Result;
@@ -474,6 +475,7 @@ impl TokenHTTPClient {
let client = Client::builder()
.timeout(read_timeout)
.connect_timeout(connect_timeout)
+ .user_agent(user_agent::default_rest_user_agent())
.build()
.expect("Failed to create HTTP client");
@@ -520,6 +522,43 @@ impl TokenHTTPClient {
mod tests {
use super::*;
+ #[tokio::test]
+ async fn test_ecs_token_loader_sends_rest_user_agent() {
+ use axum::http::{header, HeaderMap};
+ use axum::routing::get;
+ use axum::Router;
+ use std::sync::Mutex;
+
+ let user_agents = Arc::new(Mutex::new(Vec::new()));
+ let recorded = user_agents.clone();
+ let app = Router::new().route(
+ "/role",
+ get(move |headers: HeaderMap| async move {
+ recorded.lock().unwrap().extend(
+ headers
+ .get_all(header::USER_AGENT)
+ .iter()
+ .map(|value| value.to_str().unwrap().to_string()),
+ );
+
r#"{"AccessKeyId":"ak","AccessKeySecret":"sk","SecurityToken":"st"}"#
+ }),
+ );
+ let listener =
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let address = listener.local_addr().unwrap();
+ tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
+
+ let loader = DLFECSTokenLoader::new(format!("http://{address}/"),
Some("role".to_string()));
+ loader.load_token().await.unwrap();
+
+ assert_eq!(
+ *user_agents.lock().unwrap(),
+ vec![format!(
+ "paimon-rust/{}(reqwest)",
+ env!("CARGO_PKG_VERSION")
+ )]
+ );
+ }
+
#[test]
fn test_extract_host() {
let uri = "http://dlf-abcdfgerrf.net/api/v1";
diff --git a/crates/paimon/src/api/mod.rs b/crates/paimon/src/api/mod.rs
index 55a1dd85..cffb8865 100644
--- a/crates/paimon/src/api/mod.rs
+++ b/crates/paimon/src/api/mod.rs
@@ -29,6 +29,7 @@ pub mod rest_error;
pub mod rest_util;
mod api_response;
+mod user_agent;
// Re-export request types
pub use api_request::{
diff --git a/crates/paimon/src/api/rest_api.rs
b/crates/paimon/src/api/rest_api.rs
index bf48c2ac..b2d9a29f 100644
--- a/crates/paimon/src/api/rest_api.rs
+++ b/crates/paimon/src/api/rest_api.rs
@@ -146,7 +146,11 @@ impl RESTApi {
// Create auth function first, before making any requests
let rest_auth_function = RESTAuthFunction::new(base_headers.clone(),
auth_provider);
- let mut client = HttpClient::new(uri, Some(rest_auth_function))?;
+ let mut client = HttpClient::with_user_agent(
+ uri,
+ Some(rest_auth_function),
+ &super::user_agent::rest_user_agent(&options),
+ )?;
let options = if config_required {
let warehouse =
options.get(CatalogOptions::WAREHOUSE).ok_or_else(|| {
@@ -178,6 +182,8 @@ impl RESTApi {
let rest_auth_function = RESTAuthFunction::new(base_headers,
auth_provider);
client.set_auth_function(rest_auth_function);
+ // Server config may carry user-agent.* options; header.User-Agent
still wins per request.
+
client.set_user_agent(&super::user_agent::rest_user_agent(&merged))?;
merged
} else {
diff --git a/crates/paimon/src/api/rest_client.rs
b/crates/paimon/src/api/rest_client.rs
index 194d7374..5448d47c 100644
--- a/crates/paimon/src/api/rest_client.rs
+++ b/crates/paimon/src/api/rest_client.rs
@@ -19,8 +19,10 @@
use super::auth::{RESTAuthFunction, RESTAuthParameter};
use super::rest_error::RestError;
+use super::user_agent;
use crate::Error;
use crate::Result;
+use reqwest::header::HeaderValue;
use serde::de::DeserializeOwned;
use std::collections::HashMap;
use std::time::Duration;
@@ -42,20 +44,45 @@ impl HttpClient {
/// # Returns
/// A new HttpClient instance.
pub fn new(base_url: &str, auth_function: Option<RESTAuthFunction>) ->
Result<Self> {
- let final_url = Self::normalize_uri(base_url)?;
+ Self::with_user_agent(
+ base_url,
+ auth_function,
+ &user_agent::default_rest_user_agent(),
+ )
+ }
+
+ /// Like [`HttpClient::new`], sending `agent` unless a request sets its
own User-Agent.
+ pub(crate) fn with_user_agent(
+ base_url: &str,
+ auth_function: Option<RESTAuthFunction>,
+ agent: &str,
+ ) -> Result<Self> {
+ Ok(HttpClient {
+ client: Self::build_client(agent)?,
+ base_url: Self::normalize_uri(base_url)?,
+ auth_function,
+ })
+ }
- let client = reqwest::Client::builder()
+ /// Replace the default User-Agent, e.g. after options are merged with the
server config.
+ pub(crate) fn set_user_agent(&mut self, agent: &str) -> Result<()> {
+ self.client = Self::build_client(agent)?;
+ Ok(())
+ }
+
+ fn build_client(agent: &str) -> Result<reqwest::Client> {
+ let agent = HeaderValue::from_str(agent).unwrap_or_else(|_| {
+ log::warn!("Invalid REST User-Agent {agent:?}, using the default");
+ HeaderValue::from_str(&user_agent::default_rest_user_agent())
+ .expect("the default User-Agent is visible ASCII")
+ });
+ reqwest::Client::builder()
.timeout(Duration::from_secs(30))
+ .user_agent(agent)
.build()
.map_err(|e| Error::ConfigInvalid {
message: format!("Failed to create HTTP client: {e}"),
- })?;
-
- Ok(HttpClient {
- client,
- base_url: final_url,
- auth_function,
- })
+ })
}
/// Normalize and validate a URI.
@@ -283,7 +310,7 @@ mod tests {
use async_trait::async_trait;
use axum::extract::State;
- use axum::http::Uri;
+ use axum::http::{header, HeaderMap, Uri};
use axum::routing::get;
use axum::{Json, Router};
@@ -312,6 +339,124 @@ mod tests {
Json(serde_json::json!({}))
}
+ async fn record_user_agents(
+ State(user_agents): State<Arc<Mutex<Vec<Vec<String>>>>>,
+ headers: HeaderMap,
+ ) -> Json<serde_json::Value> {
+ let values = headers
+ .get_all(header::USER_AGENT)
+ .iter()
+ .map(|value| value.to_str().unwrap().to_string())
+ .collect();
+ user_agents.lock().unwrap().push(values);
+ Json(serde_json::json!({"databases": [], "nextPageToken": null}))
+ }
+
+ /// The User-Agent headers a REST catalog sends to list databases, with
`extra` catalog options.
+ async fn sent_user_agents(extra: &[(&str, &str)]) -> Vec<String> {
+ sent_user_agents_with_config(extra, None).await
+ }
+
+ /// Like [`sent_user_agents`], bootstrapping from a server returning
`config` when set.
+ async fn sent_user_agents_with_config(
+ extra: &[(&str, &str)],
+ config: Option<serde_json::Value>,
+ ) -> Vec<String> {
+ let user_agents = Arc::new(Mutex::new(Vec::new()));
+ let config_required = config.is_some();
+ let config = config.unwrap_or_else(|| serde_json::json!({}));
+ let app = Router::new()
+ .route("/v1/databases", get(record_user_agents))
+ .route("/v1/config", get(move || async move { Json(config) }))
+ .with_state(user_agents.clone());
+ let listener =
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let address = listener.local_addr().unwrap();
+ let _server = tokio::spawn(async move { axum::serve(listener,
app).await.unwrap() });
+
+ let mut options = crate::common::Options::new();
+ options.set("uri", format!("http://{address}"));
+ options.set("warehouse", "warehouse");
+ options.set("token.provider", "bear");
+ options.set("token", "token");
+ for (key, value) in extra {
+ options.set(*key, *value);
+ }
+ let api = crate::api::rest_api::RESTApi::new(options, config_required)
+ .await
+ .unwrap();
+ api.list_databases().await.unwrap();
+
+ let mut user_agents = user_agents.lock().unwrap();
+ assert_eq!(user_agents.len(), 1);
+ user_agents.pop().unwrap()
+ }
+
+ #[tokio::test]
+ async fn test_default_user_agent_is_sent() {
+ assert_eq!(
+ sent_user_agents(&[]).await,
+ vec![format!(
+ "paimon-rust/{}(reqwest)",
+ env!("CARGO_PKG_VERSION")
+ )]
+ );
+ }
+
+ #[tokio::test]
+ async fn test_user_agent_options_are_sent() {
+ let options = [
+ ("user-agent.features", "Flink"),
+ ("user-agent.extended", "vvr"),
+ ];
+ assert_eq!(
+ sent_user_agents(&options).await,
+ vec![format!(
+ "paimon-rust/{}(reqwest;Flink) vvr",
+ env!("CARGO_PKG_VERSION")
+ )]
+ );
+ }
+
+ #[tokio::test]
+ async fn test_user_agent_header_option_wins() {
+ let options = [
+ ("header.User-Agent", "starrocks/user"),
+ ("user-agent.features", "Flink"),
+ ];
+ assert_eq!(
+ sent_user_agents(&options).await,
+ vec!["starrocks/user".to_string()]
+ );
+ }
+
+ #[tokio::test]
+ async fn test_user_agent_options_from_server_config_are_sent() {
+ let config = serde_json::json!({
+ "defaults": {"user-agent.features": "ServerFeature"},
+ "overrides": {"user-agent.extended": "catalog-tag"},
+ });
+ assert_eq!(
+ sent_user_agents_with_config(&[], Some(config)).await,
+ vec![format!(
+ "paimon-rust/{}(reqwest;ServerFeature) catalog-tag",
+ env!("CARGO_PKG_VERSION")
+ )]
+ );
+ }
+
+ #[tokio::test]
+ async fn test_user_agent_header_option_wins_over_server_config() {
+ let config = serde_json::json!({
+ "defaults": {"user-agent.features": "ServerFeature"},
+ "overrides": {"user-agent.extended": "catalog-tag"},
+ });
+ assert_eq!(
+ sent_user_agents_with_config(&[("header.User-Agent",
"starrocks/user")], Some(config))
+ .await,
+ vec!["starrocks/user".to_string()]
+ );
+ }
+
fn canonical(pairs: impl Iterator<Item = String>) -> String {
let mut parts: Vec<String> = pairs.collect();
parts.sort();
diff --git a/crates/paimon/src/api/user_agent.rs
b/crates/paimon/src/api/user_agent.rs
new file mode 100644
index 00000000..d8c9ee20
--- /dev/null
+++ b/crates/paimon/src/api/user_agent.rs
@@ -0,0 +1,90 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Paimon's unified User-Agent for REST requests:
+//! `<module>(<transport>[;feature...])[ <extended>]`.
+
+use crate::common::Options;
+
+/// Replaces the default `paimon-rust/<version>` module; shared with object
storage requests.
+pub(crate) const USER_AGENT_MODULE: &str = "user-agent.module";
+
+/// Space-separated features, rendered after the transport and separated by
`;`.
+pub(crate) const USER_AGENT_FEATURES: &str = "user-agent.features";
+
+/// Free text after the parentheses.
+pub(crate) const USER_AGENT_EXTENDED: &str = "user-agent.extended";
+
+/// The User-Agent of REST requests without options,
`paimon-rust/<version>(reqwest)`.
+pub(crate) fn default_rest_user_agent() -> String {
+ rest_user_agent(&Options::new())
+}
+
+/// Builds the REST User-Agent from the `user-agent.*` catalog options.
+pub(crate) fn rest_user_agent(options: &Options) -> String {
+ let non_blank = |key: &str| {
+ options
+ .get(key)
+ .map(|value| value.trim())
+ .filter(|value| !value.is_empty())
+ };
+
+ let mut user_agent = match non_blank(USER_AGENT_MODULE) {
+ Some(module) => module.to_string(),
+ None => format!("paimon-rust/{}", env!("CARGO_PKG_VERSION")),
+ };
+ user_agent.push_str("(reqwest");
+ for feature in non_blank(USER_AGENT_FEATURES)
+ .into_iter()
+ .flat_map(|features| features.split_whitespace())
+ {
+ user_agent.push(';');
+ user_agent.push_str(feature);
+ }
+ user_agent.push(')');
+ if let Some(extended) = non_blank(USER_AGENT_EXTENDED) {
+ user_agent.push(' ');
+ user_agent.push_str(extended);
+ }
+ user_agent
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_rest_user_agent() {
+ let version = env!("CARGO_PKG_VERSION");
+ assert_eq!(
+ default_rest_user_agent(),
+ format!("paimon-rust/{version}(reqwest)")
+ );
+
+ let mut options = Options::new();
+ options.set(USER_AGENT_FEATURES, " Flink PVFS ");
+ options.set(USER_AGENT_EXTENDED, " vvr ");
+ assert_eq!(
+ rest_user_agent(&options),
+ format!("paimon-rust/{version}(reqwest;Flink;PVFS) vvr")
+ );
+
+ options.set(USER_AGENT_MODULE, "MyApp/1.0");
+ options.set(USER_AGENT_EXTENDED, " ");
+ assert_eq!(rest_user_agent(&options), "MyApp/1.0(reqwest;Flink;PVFS)");
+ }
+}