pub mod error;
pub mod pagination;
pub mod ratelimit;
pub mod retry;
use error::{ApiError, Result};
use ratelimit::RateLimiter;
use reqwest::{Client, Method, RequestBuilder, StatusCode};
use retry::{retry_with_backoff, RetryConfig};
use secrecy::{ExposeSecret, SecretString};
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::fmt;
use std::time::Duration;
use tracing::{debug, error, warn};
use url::Url;
#[derive(Clone)]
pub enum AuthMethod {
Basic {
username: String,
token: SecretString,
},
Bearer {
token: SecretString,
},
GenieKey {
api_key: SecretString,
},
}
impl fmt::Debug for AuthMethod {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AuthMethod::Basic { username, .. } => f
.debug_struct("Basic")
.field("username", username)
.field("token", &"[REDACTED]")
.finish(),
AuthMethod::Bearer { .. } => f
.debug_struct("Bearer")
.field("token", &"[REDACTED]")
.finish(),
AuthMethod::GenieKey { .. } => f
.debug_struct("GenieKey")
.field("api_key", &"[REDACTED]")
.finish(),
}
}
}
#[derive(Clone)]
pub struct ApiClient {
client: Client,
base_url: Url,
auth: Option<AuthMethod>,
retry_config: RetryConfig,
rate_limiter: RateLimiter,
}
impl ApiClient {
pub fn new(base_url: impl AsRef<str>) -> Result<Self> {
let url = Url::parse(base_url.as_ref()).map_err(ApiError::InvalidUrl)?;
if url.scheme() != "https" {
let is_localhost = url
.host_str()
.map(|h| h == "localhost" || h == "127.0.0.1" || h.starts_with("127."))
.unwrap_or(false);
if !is_localhost {
return Err(ApiError::InvalidUrl(
url::ParseError::InvalidDomainCharacter,
));
}
}
let client = Client::builder()
.user_agent(format!("atlassian-cli/{}", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(30))
.build()
.map_err(ApiError::RequestFailed)?;
Ok(Self {
client,
base_url: url,
auth: None,
retry_config: RetryConfig::default(),
rate_limiter: RateLimiter::new(),
})
}
fn safe_join(&self, path: &str) -> Result<Url> {
let joined = self
.base_url
.join(path.strip_prefix('/').unwrap_or(path))
.map_err(ApiError::InvalidUrl)?;
if joined.scheme() != self.base_url.scheme() || joined.host() != self.base_url.host() {
return Err(ApiError::InvalidUrl(
url::ParseError::InvalidDomainCharacter,
));
}
Ok(joined)
}
pub fn with_basic_auth(
mut self,
username: impl Into<String>,
token: impl Into<String>,
) -> Self {
self.auth = Some(AuthMethod::Basic {
username: username.into(),
token: SecretString::from(token.into()),
});
self
}
pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
self.auth = Some(AuthMethod::Bearer {
token: SecretString::from(token.into()),
});
self
}
pub fn with_genie_key(mut self, api_key: impl Into<String>) -> Self {
self.auth = Some(AuthMethod::GenieKey {
api_key: SecretString::from(api_key.into()),
});
self
}
pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
self.retry_config = config;
self
}
pub fn base_url(&self) -> &str {
self.base_url.as_str()
}
pub fn http_client(&self) -> &Client {
&self.client
}
pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
self.request(Method::GET, path, Option::<&()>::None).await
}
pub async fn post<T: DeserializeOwned, B: Serialize + ?Sized>(
&self,
path: &str,
body: &B,
) -> Result<T> {
self.request(Method::POST, path, Some(body)).await
}
pub async fn put<T: DeserializeOwned, B: Serialize + ?Sized>(
&self,
path: &str,
body: &B,
) -> Result<T> {
self.request(Method::PUT, path, Some(body)).await
}
pub async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
self.request(Method::DELETE, path, Option::<&()>::None)
.await
}
pub async fn delete_with_body<T: DeserializeOwned, B: Serialize + ?Sized>(
&self,
path: &str,
body: &B,
) -> Result<T> {
self.request(Method::DELETE, path, Some(body)).await
}
pub async fn delete_no_content(&self, path: &str) -> Result<()> {
if let Some(wait_secs) = self.rate_limiter.check_limit().await {
warn!(wait_secs, "Rate limit reached, waiting");
tokio::time::sleep(Duration::from_secs(wait_secs)).await;
}
let joined = self.safe_join(path)?;
debug!(method = "DELETE", url = %joined, "Sending delete (no content) request");
retry_with_backoff(&self.retry_config, || async {
let mut req = self.client.request(Method::DELETE, joined.clone());
req = self.apply_auth(req);
let response = req.send().await.map_err(ApiError::RequestFailed)?;
self.rate_limiter.update_from_response(&response).await;
let status = response.status();
match status {
StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationFailed {
message: "Invalid or expired credentials".to_string(),
}),
StatusCode::FORBIDDEN => {
let message = response
.text()
.await
.unwrap_or_else(|_| "Access forbidden".to_string());
Err(ApiError::Forbidden { message })
}
StatusCode::NOT_FOUND => {
let resource = joined.path().to_string();
Err(ApiError::NotFound { resource })
}
StatusCode::BAD_REQUEST => {
let message = response
.text()
.await
.unwrap_or_else(|_| "Bad request".to_string());
Err(ApiError::BadRequest { message })
}
StatusCode::GONE => {
let message = response
.text()
.await
.unwrap_or_else(|_| "API endpoint has been removed".to_string());
Err(ApiError::EndpointGone { message })
}
StatusCode::TOO_MANY_REQUESTS => {
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse().ok())
.unwrap_or(60);
Err(ApiError::RateLimitExceeded { retry_after })
}
status if status.is_server_error() => {
let message = response
.text()
.await
.unwrap_or_else(|_| "Server error".to_string());
Err(ApiError::ServerError {
status: status.as_u16(),
message,
})
}
status if status.is_success() => Ok(()),
_ => {
let message = response
.text()
.await
.unwrap_or_else(|_| format!("Unexpected status: {}", status));
Err(ApiError::ServerError {
status: status.as_u16(),
message,
})
}
}
})
.await
}
pub async fn get_text(&self, path: &str) -> Result<String> {
if let Some(wait_secs) = self.rate_limiter.check_limit().await {
warn!(wait_secs, "Rate limit reached, waiting");
tokio::time::sleep(Duration::from_secs(wait_secs)).await;
}
let joined = self.safe_join(path)?;
debug!(method = "GET", url = %joined, "Sending text request");
let result = retry_with_backoff(&self.retry_config, || async {
let mut req = self.client.request(Method::GET, joined.clone());
req = self.apply_auth(req);
req = req.header("Accept", "text/plain, */*;q=0.1");
let response = req.send().await.map_err(ApiError::RequestFailed)?;
self.rate_limiter.update_from_response(&response).await;
let status = response.status();
match status {
StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationFailed {
message: "Invalid or expired credentials".to_string(),
}),
StatusCode::FORBIDDEN => {
let message = response
.text()
.await
.unwrap_or_else(|_| "Access forbidden".to_string());
Err(ApiError::Forbidden { message })
}
StatusCode::NOT_FOUND => {
let resource = joined.path().to_string();
Err(ApiError::NotFound { resource })
}
StatusCode::BAD_REQUEST => {
let message = response
.text()
.await
.unwrap_or_else(|_| "Bad request".to_string());
Err(ApiError::BadRequest { message })
}
StatusCode::NOT_ACCEPTABLE => {
let message = response
.text()
.await
.unwrap_or_else(|_| "Content not acceptable".to_string());
Err(ApiError::ServerError {
status: 406,
message,
})
}
StatusCode::GONE => {
let message = response
.text()
.await
.unwrap_or_else(|_| "API endpoint has been removed".to_string());
Err(ApiError::EndpointGone { message })
}
StatusCode::TOO_MANY_REQUESTS => {
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse().ok())
.unwrap_or(60);
Err(ApiError::RateLimitExceeded { retry_after })
}
status if status.is_server_error() => {
let message = response
.text()
.await
.unwrap_or_else(|_| "Server error".to_string());
Err(ApiError::ServerError {
status: status.as_u16(),
message,
})
}
status if status.is_success() => response.text().await.map_err(|e| {
error!("Failed to read text response: {}", e);
ApiError::InvalidResponse(e.to_string())
}),
_ => {
let message = response
.text()
.await
.unwrap_or_else(|_| format!("Unexpected status: {}", status));
Err(ApiError::ServerError {
status: status.as_u16(),
message,
})
}
}
})
.await?;
Ok(result)
}
pub async fn get_bytes(&self, path: &str) -> Result<Vec<u8>> {
if let Some(wait_secs) = self.rate_limiter.check_limit().await {
warn!(wait_secs, "Rate limit reached, waiting");
tokio::time::sleep(Duration::from_secs(wait_secs)).await;
}
let joined = self.safe_join(path)?;
debug!(method = "GET", url = %joined, "Sending bytes request");
let result = retry_with_backoff(&self.retry_config, || async {
let mut req = self.client.request(Method::GET, joined.clone());
req = self.apply_auth(req);
let response = req.send().await.map_err(ApiError::RequestFailed)?;
self.rate_limiter.update_from_response(&response).await;
let status = response.status();
match status {
StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationFailed {
message: "Invalid or expired credentials".to_string(),
}),
StatusCode::FORBIDDEN => {
let message = response
.text()
.await
.unwrap_or_else(|_| "Access forbidden".to_string());
Err(ApiError::Forbidden { message })
}
StatusCode::NOT_FOUND => {
let resource = joined.path().to_string();
Err(ApiError::NotFound { resource })
}
StatusCode::GONE => {
let message = response
.text()
.await
.unwrap_or_else(|_| "API endpoint has been removed".to_string());
Err(ApiError::EndpointGone { message })
}
StatusCode::TOO_MANY_REQUESTS => {
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse().ok())
.unwrap_or(60);
Err(ApiError::RateLimitExceeded { retry_after })
}
status if status.is_success() => {
response.bytes().await.map(|b| b.to_vec()).map_err(|e| {
error!("Failed to read bytes response: {}", e);
ApiError::InvalidResponse(e.to_string())
})
}
_ => {
let message = response
.text()
.await
.unwrap_or_else(|_| format!("Unexpected status: {}", status));
Err(ApiError::ServerError {
status: status.as_u16(),
message,
})
}
}
})
.await?;
Ok(result)
}
pub async fn request<T: DeserializeOwned, B: Serialize + ?Sized>(
&self,
method: Method,
path: &str,
body: Option<&B>,
) -> Result<T> {
if let Some(wait_secs) = self.rate_limiter.check_limit().await {
warn!(wait_secs, "Rate limit reached, waiting");
tokio::time::sleep(Duration::from_secs(wait_secs)).await;
}
let joined = self.safe_join(path)?;
debug!(method = %method, url = %joined, "Sending request");
let result = retry_with_backoff(&self.retry_config, || async {
let mut req = self.client.request(method.clone(), joined.clone());
req = self.apply_auth(req);
if let Some(body) = body {
req = req.json(body);
}
let response = req.send().await.map_err(ApiError::RequestFailed)?;
self.rate_limiter.update_from_response(&response).await;
let status = response.status();
match status {
StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationFailed {
message: "Invalid or expired credentials".to_string(),
}),
StatusCode::FORBIDDEN => {
let message = response
.text()
.await
.unwrap_or_else(|_| "Access forbidden".to_string());
Err(ApiError::Forbidden { message })
}
StatusCode::NOT_FOUND => {
let resource = joined.path().to_string();
Err(ApiError::NotFound { resource })
}
StatusCode::BAD_REQUEST => {
let message = response
.text()
.await
.unwrap_or_else(|_| "Bad request".to_string());
Err(ApiError::BadRequest { message })
}
StatusCode::GONE => {
let message = response
.text()
.await
.unwrap_or_else(|_| "API endpoint has been removed".to_string());
Err(ApiError::EndpointGone { message })
}
StatusCode::TOO_MANY_REQUESTS => {
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse().ok())
.unwrap_or(60);
Err(ApiError::RateLimitExceeded { retry_after })
}
status if status.is_server_error() => {
let message = response
.text()
.await
.unwrap_or_else(|_| "Server error".to_string());
Err(ApiError::ServerError {
status: status.as_u16(),
message,
})
}
status if status.is_success() => {
let bytes = response
.bytes()
.await
.map_err(|e| ApiError::InvalidResponse(e.to_string()))?;
let slice: &[u8] = if bytes.iter().all(|b| b.is_ascii_whitespace()) {
b"null"
} else {
&bytes
};
serde_json::from_slice::<T>(slice).map_err(|e| {
error!("Failed to parse JSON response: {}", e);
ApiError::InvalidResponse(e.to_string())
})
}
_ => {
let message = response
.text()
.await
.unwrap_or_else(|_| format!("Unexpected status: {}", status));
Err(ApiError::ServerError {
status: status.as_u16(),
message,
})
}
}
})
.await?;
Ok(result)
}
pub fn apply_auth(&self, request: RequestBuilder) -> RequestBuilder {
match &self.auth {
Some(AuthMethod::Basic { username, token }) => {
request.basic_auth(username, Some(token.expose_secret()))
}
Some(AuthMethod::Bearer { token }) => request.bearer_auth(token.expose_secret()),
Some(AuthMethod::GenieKey { api_key }) => request.header(
"Authorization",
format!("GenieKey {}", api_key.expose_secret()),
),
None => request,
}
}
pub fn rate_limiter(&self) -> &RateLimiter {
&self.rate_limiter
}
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn test_403_returns_forbidden() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("test"))
.respond_with(ResponseTemplate::new(403).set_body_string("You do not have access"))
.mount(&server)
.await;
let client = ApiClient::new(server.uri()).unwrap();
let result: error::Result<serde_json::Value> = client.get("/test").await;
match result {
Err(ApiError::Forbidden { message }) => {
assert!(message.contains("You do not have access"));
}
other => panic!("Expected Forbidden, got: {:?}", other),
}
}
#[tokio::test]
async fn test_401_returns_authentication_failed() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("test"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let client = ApiClient::new(server.uri()).unwrap();
let result: error::Result<serde_json::Value> = client.get("/test").await;
match result {
Err(ApiError::AuthenticationFailed { .. }) => {}
other => panic!("Expected AuthenticationFailed, got: {:?}", other),
}
}
#[tokio::test]
async fn test_403_get_text_returns_forbidden() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("text-endpoint"))
.respond_with(ResponseTemplate::new(403).set_body_string("Forbidden resource"))
.mount(&server)
.await;
let client = ApiClient::new(server.uri()).unwrap();
let result = client.get_text("/text-endpoint").await;
match result {
Err(ApiError::Forbidden { message }) => {
assert!(message.contains("Forbidden resource"));
}
other => panic!("Expected Forbidden, got: {:?}", other),
}
}
#[tokio::test]
async fn test_403_get_bytes_returns_forbidden() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("bytes-endpoint"))
.respond_with(ResponseTemplate::new(403).set_body_string("Access denied"))
.mount(&server)
.await;
let client = ApiClient::new(server.uri()).unwrap();
let result = client.get_bytes("/bytes-endpoint").await;
match result {
Err(ApiError::Forbidden { message }) => {
assert!(message.contains("Access denied"));
}
other => panic!("Expected Forbidden, got: {:?}", other),
}
}
#[tokio::test]
async fn test_204_no_content_put_succeeds() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("issue/AEA-1"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let client = ApiClient::new(server.uri()).unwrap();
let result: error::Result<serde_json::Value> = client
.put("/issue/AEA-1", &serde_json::json!({"fields": {}}))
.await;
match result {
Ok(serde_json::Value::Null) => {}
other => panic!("Expected Ok(Null) for 204, got: {:?}", other),
}
}
#[tokio::test]
async fn test_200_empty_body_succeeds() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("transitions"))
.respond_with(ResponseTemplate::new(200).set_body_string(" \n"))
.mount(&server)
.await;
let client = ApiClient::new(server.uri()).unwrap();
let result: error::Result<serde_json::Value> =
client.post("/transitions", &serde_json::json!({})).await;
match result {
Ok(serde_json::Value::Null) => {}
other => panic!("Expected Ok(Null) for empty 200, got: {:?}", other),
}
}
#[tokio::test]
async fn test_200_json_body_still_parses() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("issue/AEA-1"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"key": "AEA-1"})),
)
.mount(&server)
.await;
let client = ApiClient::new(server.uri()).unwrap();
let result: serde_json::Value = client.get("/issue/AEA-1").await.unwrap();
assert_eq!(result["key"], "AEA-1");
}
}