mod attachment;
pub(crate) mod auth;
pub(crate) use auth::{detect_server_settings, DetectedServerSettings};
mod bug;
mod classification;
mod comment;
mod component;
mod field;
pub(crate) use field::FIELD_ALIASES;
mod group;
mod product;
mod server;
mod user;
mod version;
use reqwest::header::HeaderValue;
use reqwest::RequestBuilder;
use serde::Deserialize;
use crate::error::{BzrError, Result};
use crate::http::{build_http_client, AUTH_HEADER_NAME, AUTH_QUERY_PARAM};
use crate::types::BugzillaUser;
use crate::types::{ApiMode, AuthMethod};
use crate::xmlrpc::client::XmlRpcClient;
pub(super) const USER_FIELDS_BASIC: &str = "id,name,real_name,email,groups";
pub(super) const USER_FIELDS_DETAILED: &str = "id,name,real_name,email,can_login,groups";
#[derive(Deserialize)]
pub(super) struct UserSearchResponse {
pub(super) users: Vec<BugzillaUser>,
}
pub(super) fn encode_path(segment: &str) -> String {
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
utf8_percent_encode(segment, NON_ALPHANUMERIC).to_string()
}
enum PreparedAuth {
Header(HeaderValue),
QueryParam(String),
}
pub struct BugzillaClient {
pub(super) http: reqwest::Client,
pub(super) base_url: String,
auth: PreparedAuth,
pub(super) api_key: String,
pub(super) api_mode: ApiMode,
pub(super) xmlrpc: Option<XmlRpcClient>,
email_hint: Option<String>,
}
#[derive(Deserialize)]
pub(super) struct IdResponse {
pub id: u64,
}
#[derive(Deserialize)]
struct ErrorResponse {
#[serde(default)]
error: bool,
#[serde(default, deserialize_with = "deserialize_code")]
code: i64,
#[serde(default)]
message: Option<String>,
}
fn deserialize_code<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<i64, D::Error> {
use serde::de;
struct CodeVisitor;
impl de::Visitor<'_> for CodeVisitor {
type Value = i64;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("an integer or string-encoded integer")
}
fn visit_i64<E: de::Error>(self, v: i64) -> std::result::Result<i64, E> {
Ok(v)
}
fn visit_u64<E: de::Error>(self, v: u64) -> std::result::Result<i64, E> {
i64::try_from(v).map_err(E::custom)
}
fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<i64, E> {
v.parse::<i64>().map_err(E::custom)
}
}
deserializer.deserialize_any(CodeVisitor)
}
const DATA_KEYS: &[&str] = &[
"bugs",
"comments",
"attachments",
"products",
"groups",
"users",
"fields",
"extensions",
"classifications",
"ids",
];
impl BugzillaClient {
fn has_data_fields(map: &serde_json::Map<String, serde_json::Value>) -> bool {
DATA_KEYS.iter().any(|key| map.contains_key(*key))
}
pub fn new(
base_url: &str,
api_key: &str,
auth_method: AuthMethod,
api_mode: ApiMode,
email_hint: Option<&str>,
tls_insecure: bool,
) -> Result<Self> {
let auth = match auth_method {
AuthMethod::Header => {
let value = HeaderValue::from_str(api_key)
.map_err(|_| BzrError::config("invalid API key characters"))?;
PreparedAuth::Header(value)
}
AuthMethod::QueryParam => PreparedAuth::QueryParam(api_key.to_string()),
};
let http = build_http_client(tls_insecure).map_err(BzrError::Http)?;
if api_mode != ApiMode::Rest && auth_method == AuthMethod::Header {
tracing::info!(
"XML-RPC always sends API key in request body, \
overriding configured header auth for XML-RPC calls"
);
}
let xmlrpc = Some(XmlRpcClient::new(http.clone(), base_url, api_key));
tracing::debug!(base_url, %auth_method, %api_mode, "created Bugzilla client");
Ok(BugzillaClient {
http,
base_url: base_url.trim_end_matches('/').to_string(),
auth,
api_key: api_key.to_string(),
api_mode,
xmlrpc,
email_hint: email_hint.map(String::from),
})
}
pub(super) fn url(&self, path: &str) -> String {
format!("{}/rest/{}", self.base_url, path.trim_start_matches('/'))
}
pub(super) fn xmlrpc_client(&self) -> Result<&XmlRpcClient> {
self.xmlrpc.as_ref().ok_or_else(|| {
BzrError::Config(
"XML-RPC client not initialized — set api_mode to 'xmlrpc' or 'hybrid'".into(),
)
})
}
pub(super) async fn get_json<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
let req = self.apply_auth(self.http.get(self.url(path)));
let resp = self.send(req).await?;
self.parse_json(resp).await
}
pub(super) async fn get_json_query<T: serde::de::DeserializeOwned>(
&self,
path: &str,
query: &[(&str, &str)],
) -> Result<T> {
let req = self.apply_auth(self.http.get(self.url(path)).query(query));
let resp = self.send(req).await?;
self.parse_json(resp).await
}
pub(super) async fn post_json_id(
&self,
path: &str,
body: &impl serde::Serialize,
) -> Result<u64> {
let req = self.apply_auth(self.http.post(self.url(path)).json(body));
let resp = self.send(req).await?;
let data: IdResponse = self.parse_json(resp).await?;
Ok(data.id)
}
pub(super) async fn put_json(&self, path: &str, body: &impl serde::Serialize) -> Result<()> {
let req = self.apply_auth(self.http.put(self.url(path)).json(body));
self.send(req).await?;
Ok(())
}
pub(super) async fn put_json_response<T: serde::de::DeserializeOwned>(
&self,
path: &str,
body: &impl serde::Serialize,
) -> Result<T> {
let req = self.apply_auth(self.http.put(self.url(path)).json(body));
let resp = self.send(req).await?;
self.parse_json(resp).await
}
pub(super) fn apply_auth(&self, builder: RequestBuilder) -> RequestBuilder {
match &self.auth {
PreparedAuth::Header(value) => {
crate::http::apply_auth_to_request(builder, Some(value), None)
}
PreparedAuth::QueryParam(key) => {
crate::http::apply_auth_to_request(builder, None, Some(key))
}
}
}
pub(super) async fn send(&self, builder: RequestBuilder) -> Result<reqwest::Response> {
let retry_builder = builder.try_clone();
let resp = builder.send().await?;
tracing::debug!(
url = Self::safe_url(resp.url()),
status = %resp.status(),
"API response"
);
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
if let Some(retried) = self.retry_with_alternate_auth(retry_builder).await? {
return Ok(retried);
}
}
self.check_response_status(resp).await
}
async fn retry_with_alternate_auth(
&self,
retry_builder: Option<RequestBuilder>,
) -> Result<Option<reqwest::Response>> {
let Some(clone) = retry_builder else {
return Ok(None);
};
tracing::debug!("401 received, retrying with alternate auth method");
let retried = self.apply_alternate_auth(clone)?.send().await?;
tracing::debug!(
url = Self::safe_url(retried.url()),
status = %retried.status(),
"auth fallback response"
);
if retried.status().is_success() {
return Ok(Some(retried));
}
tracing::debug!("auth fallback also failed, returning original 401");
Ok(None)
}
fn apply_alternate_auth(&self, builder: RequestBuilder) -> Result<RequestBuilder> {
match &self.auth {
PreparedAuth::Header(_) => Ok(builder.query(&[(AUTH_QUERY_PARAM, &self.api_key)])),
PreparedAuth::QueryParam(_) => {
let value = HeaderValue::from_str(&self.api_key).map_err(|e| {
BzrError::Config(format!("API key contains invalid header characters: {e}"))
})?;
Ok(builder.header(AUTH_HEADER_NAME, value))
}
}
}
fn safe_url(url: &reqwest::Url) -> String {
format!("{}{}", url.origin().ascii_serialization(), url.path())
}
pub(super) async fn parse_json<T: serde::de::DeserializeOwned>(
&self,
resp: reqwest::Response,
) -> Result<T> {
let safe_url = Self::safe_url(resp.url());
let body = resp.text().await?;
tracing::trace!(
url = safe_url,
body = &body[..body.len().min(2048)],
"response body"
);
let value: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
tracing::debug!(
url = safe_url,
error = %e,
body_preview = &body[..body.len().min(512)],
"JSON deserialization failed"
);
BzrError::Deserialize(format!("failed to parse response from {safe_url}: {e}"))
})?;
Self::check_bugzilla_200_error(&value, &safe_url)?;
serde_json::from_value(value).map_err(|e| {
BzrError::Deserialize(format!(
"failed to deserialize response from {safe_url}: {e}"
))
})
}
fn check_bugzilla_200_error(value: &serde_json::Value, url: &str) -> Result<()> {
let Some(map) = value.as_object() else {
return Ok(());
};
let is_error = map
.get("error")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
if !is_error {
return Ok(());
}
let code = map
.get("code")
.and_then(|v| {
v.as_i64()
.or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
})
.unwrap_or(-1);
let message = map
.get("message")
.and_then(|v| v.as_str())
.map(String::from);
let has_data = Self::has_data_fields(map);
tracing::debug!(
url,
code,
message = message.as_deref().unwrap_or("unknown"),
has_data,
"error payload in 200 response"
);
if !has_data {
return Err(BzrError::Api {
code,
message: message.unwrap_or_else(|| "unknown API error".into()),
});
}
tracing::warn!(url, "server returned error alongside data; using data");
Ok(())
}
async fn check_response_status(
&self,
response: reqwest::Response,
) -> Result<reqwest::Response> {
if response.status().is_client_error() || response.status().is_server_error() {
let status = response.status();
let body = response.text().await.unwrap_or_else(|e| {
tracing::warn!("failed to read error response body: {e}");
String::new()
});
tracing::debug!(
%status,
body = &body[..body.len().min(512)],
"API error response"
);
if let Ok(err) = serde_json::from_str::<ErrorResponse>(&body) {
if err.error {
return Err(BzrError::Api {
code: err.code,
message: err.message.unwrap_or_else(|| status.to_string()),
});
}
}
return Err(BzrError::HttpStatus {
status: status.as_u16(),
body,
});
}
Ok(response)
}
}
#[cfg(test)]
#[expect(clippy::unwrap_used)]
pub(super) mod test_helpers {
use super::*;
pub fn test_http_client() -> reqwest::Client {
crate::http::build_http_client(false).unwrap()
}
pub fn test_client(base_url: &str) -> BugzillaClient {
BugzillaClient::new(
base_url,
"test-key",
AuthMethod::Header,
ApiMode::Rest,
None,
false,
)
.unwrap()
}
pub fn test_client_hybrid(base_url: &str) -> BugzillaClient {
BugzillaClient::new(
base_url,
"test-key",
AuthMethod::Header,
ApiMode::Hybrid,
None,
false,
)
.unwrap()
}
pub fn test_client_query_param(base_url: &str) -> BugzillaClient {
BugzillaClient::new(
base_url,
"test-key",
AuthMethod::QueryParam,
ApiMode::Rest,
None,
false,
)
.unwrap()
}
}
#[cfg(test)]
#[expect(clippy::unwrap_used)]
mod tests {
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
use test_helpers::{test_client, test_client_query_param};
#[test]
fn safe_url_strips_query_params() {
let url = reqwest::Url::parse(&format!(
"https://bugzilla.example.com/rest/bug/1?{}=secret",
crate::http::AUTH_QUERY_PARAM
))
.unwrap();
let safe = BugzillaClient::safe_url(&url);
assert!(
!safe.contains("secret"),
"API key should be stripped: {safe}"
);
assert!(
safe.contains("/rest/bug/1"),
"path should be preserved: {safe}"
);
}
#[test]
fn safe_url_preserves_path() {
let url = reqwest::Url::parse("https://bugzilla.example.com/rest/bug/42").unwrap();
let safe = BugzillaClient::safe_url(&url);
assert_eq!(safe, "https://bugzilla.example.com/rest/bug/42");
}
#[test]
fn new_trims_trailing_slash_and_keeps_email_hint() {
let client = BugzillaClient::new(
"https://bugzilla.example.com/",
"test-key",
AuthMethod::Header,
ApiMode::Rest,
Some("user@example.com"),
false,
)
.unwrap();
assert_eq!(client.base_url, "https://bugzilla.example.com");
assert_eq!(client.email_hint.as_deref(), Some("user@example.com"));
}
#[test]
fn apply_auth_adds_query_param_credentials() {
let client = test_client_query_param("https://bugzilla.example.com");
let request = client
.apply_auth(client.http.get(client.url("bug")))
.build()
.unwrap();
let expected_query = format!("{AUTH_QUERY_PARAM}=test-key");
assert_eq!(request.url().query(), Some(expected_query.as_str()));
}
#[test]
fn alternate_auth_rejects_invalid_header_characters() {
let client = BugzillaClient::new(
"https://bugzilla.example.com",
"bad\nkey",
AuthMethod::QueryParam,
ApiMode::Rest,
None,
false,
)
.unwrap();
let builder = client.http.get(client.url("bug"));
let err = client.apply_alternate_auth(builder).unwrap_err();
assert!(err.to_string().contains("invalid header characters"));
}
#[tokio::test]
async fn api_error_with_200_status() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/product"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"error": true,
"code": 301,
"message": "You are not authorized to access that product."
})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let err = client.get_product("Secret").await.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("301"), "expected error code 301: {msg}");
assert!(
msg.contains("not authorized"),
"expected auth error message: {msg}"
);
}
#[tokio::test]
async fn api_error_with_200_and_data_returns_data() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"error": true,
"code": 100_500,
"message": "MirrorTool internal error",
"bugs": [{"id": 42, "summary": "test bug", "status": "NEW"}]
})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let bug = client.get_bug("42", None, None).await.unwrap();
assert_eq!(bug.id, 42);
assert_eq!(bug.summary, "test bug");
}
#[tokio::test]
async fn http_500_returns_error() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/user"))
.respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let err = client.search_users("anyone", false).await.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("500") || msg.contains("Internal Server Error"),
"expected 500 error: {msg}"
);
}
#[tokio::test]
async fn auth_fallback_header_to_query_param_on_401() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/user"))
.and(query_param(crate::http::AUTH_QUERY_PARAM, "test-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"users": [{"id": 1, "name": "alice@example.com"}]
})))
.expect(1)
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/rest/user"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"error": true,
"code": 410,
"message": "You must log in."
})))
.up_to_n_times(1)
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let users = client.search_users("alice", false).await.unwrap();
assert_eq!(users.len(), 1);
assert_eq!(users[0].name, "alice@example.com");
}
#[tokio::test]
async fn auth_fallback_query_param_to_header_on_401() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/user"))
.and(wiremock::matchers::header(
crate::http::AUTH_HEADER_NAME,
"test-key",
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"users": [{"id": 2, "name": "bob@example.com"}]
})))
.expect(1)
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/rest/user"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"error": true,
"code": 410,
"message": "You must log in."
})))
.up_to_n_times(1)
.expect(1)
.mount(&mock)
.await;
let client = test_client_query_param(&mock.uri());
let users = client.search_users("bob", false).await.unwrap();
assert_eq!(users.len(), 1);
assert_eq!(users[0].name, "bob@example.com");
}
#[tokio::test]
async fn auth_fallback_both_fail_returns_original_error() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/user"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"error": true,
"code": 410,
"message": "You must log in."
})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let err = client.search_users("anyone", false).await.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("410") || msg.contains("log in"),
"expected auth error: {msg}"
);
}
#[tokio::test]
async fn non_401_errors_do_not_trigger_fallback() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/user"))
.respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({
"error": true,
"code": 51,
"message": "You are not authorized."
})))
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let err = client.search_users("anyone", false).await.unwrap_err();
assert!(err.to_string().contains("not authorized"));
}
#[tokio::test]
async fn api_error_with_string_code_parsed_correctly() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/group"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
"error": true,
"code": "32610",
"message": "For security reasons, you must use HTTP POST."
})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let resp = client
.http
.get(format!("{}/rest/group", mock.uri()))
.send()
.await
.unwrap();
let err = client.check_response_status(resp).await.unwrap_err();
assert!(
matches!(&err, crate::error::BzrError::Api { code: 32610, .. }),
"expected Api error with code 32610, got: {err}"
);
}
#[tokio::test]
async fn api_200_error_with_string_code_parsed_correctly() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/group"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"error": true,
"code": "32610",
"message": "For security reasons, you must use HTTP POST."
})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let err: crate::error::BzrError = client
.get_json_query::<serde_json::Value>("group", &[])
.await
.unwrap_err();
assert!(
matches!(&err, crate::error::BzrError::Api { code: 32610, .. }),
"expected Api error with code 32610, got: {err}"
);
}
}