#[cfg(feature = "cache")]
use std::sync::Arc;
#[cfg(feature = "metrics")]
use std::time::Instant;
#[cfg(feature = "uuid")]
use uuid::Uuid;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use tracing::{debug, info_span};
use url::Url;
use crate::Error;
#[cfg(feature = "cache")]
use crate::cache::{Cache, MemoryCache, RuntimeCacheConfig, generate_cache_key};
#[cfg(feature = "metrics")]
use crate::metrics::{METRICS, MetricsCollector};
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Serialize, Deserialize, Debug)]
pub struct EmptyResponse;
#[derive(Clone, Debug, PartialEq)]
pub enum JiraDeploymentType {
Cloud,
DataCenter,
Server,
Unknown,
}
#[derive(Clone, Debug, PartialEq, Default)]
pub enum SearchApiVersion {
#[default]
Auto,
V2,
V3,
}
#[derive(Clone, Debug)]
pub enum Credentials {
Anonymous,
Basic(String, String),
Bearer(String),
Cookie(String),
#[cfg(feature = "oauth")]
OAuth1a {
consumer_key: String,
private_key_pem: String,
access_token: String,
access_token_secret: String,
},
}
#[derive(Clone)]
pub struct ClientCore {
pub host: Url,
pub credentials: Credentials,
pub search_api_version: SearchApiVersion,
#[cfg(feature = "cache")]
pub cache: Arc<dyn Cache>,
#[cfg(feature = "cache")]
pub cache_config: RuntimeCacheConfig,
}
impl std::fmt::Debug for ClientCore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClientCore")
.field("host", &self.host)
.field("credentials", &self.credentials)
.field("search_api_version", &self.search_api_version)
.field("cache_enabled", &cfg!(feature = "cache"))
.finish()
}
}
#[derive(Debug, Clone)]
pub struct RequestContext {
pub correlation_id: String,
pub method: String,
pub endpoint: String,
#[cfg(feature = "metrics")]
pub start_time: Instant,
}
impl RequestContext {
pub fn new(method: &str, endpoint: &str) -> Self {
#[cfg(feature = "uuid")]
let correlation_id = Uuid::new_v4().to_string();
#[cfg(not(feature = "uuid"))]
let correlation_id = format!(
"req_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
Self {
correlation_id,
method: method.to_string(),
endpoint: endpoint.to_string(),
#[cfg(feature = "metrics")]
start_time: Instant::now(),
}
}
pub fn finish(&self, success: bool) {
#[cfg(feature = "metrics")]
{
let duration = self.start_time.elapsed();
METRICS.record_request(&self.method, &self.endpoint, duration, success);
if !success {
METRICS.record_error("request_failed");
}
}
debug!(
correlation_id = %self.correlation_id,
method = %self.method,
endpoint = %self.endpoint,
success = success,
"Request completed"
);
}
pub fn create_span(&self) -> tracing::Span {
info_span!(
"jira_request",
correlation_id = %self.correlation_id,
method = %self.method,
endpoint = %self.endpoint
)
}
}
impl ClientCore {
pub fn new<H>(host: H, credentials: Credentials) -> Result<Self>
where
H: Into<String>,
{
Self::with_search_api_version(host, credentials, SearchApiVersion::default())
}
pub fn with_search_api_version<H>(
host: H,
credentials: Credentials,
search_api_version: SearchApiVersion,
) -> Result<Self>
where
H: Into<String>,
{
match Url::parse(&host.into()) {
Ok(host) => Ok(ClientCore {
host,
credentials,
search_api_version,
#[cfg(feature = "cache")]
cache: Arc::new(MemoryCache::new(std::time::Duration::from_secs(300))),
#[cfg(feature = "cache")]
cache_config: RuntimeCacheConfig::default(),
}),
Err(error) => Err(Error::from(error)),
}
}
#[cfg(feature = "cache")]
pub fn with_cache<H>(
host: H,
credentials: Credentials,
cache: Arc<dyn Cache>,
cache_config: RuntimeCacheConfig,
) -> Result<Self>
where
H: Into<String>,
{
match Url::parse(&host.into()) {
Ok(host) => Ok(ClientCore {
host,
credentials,
search_api_version: SearchApiVersion::default(),
cache,
cache_config,
}),
Err(error) => Err(Error::from(error)),
}
}
pub fn build_url(&self, api_name: &str, endpoint: &str) -> Result<Url> {
self.host
.join(&format!("rest/{api_name}/latest{endpoint}"))
.map_err(Error::from)
}
pub fn build_versioned_url(
&self,
api_name: &str,
version: Option<&str>,
endpoint: &str,
) -> Result<Url> {
let version_part = version.unwrap_or("latest");
self.host
.join(&format!("rest/{api_name}/{version_part}{endpoint}"))
.map_err(Error::from)
}
pub fn prepare_json_body<S>(&self, body: S) -> Result<Vec<u8>>
where
S: Serialize,
{
let data = serde_json::to_string::<S>(&body)?;
debug!("Json request: {}", data);
Ok(data.into_bytes())
}
pub fn process_response<D>(&self, status: reqwest::StatusCode, body: &str) -> Result<D>
where
D: DeserializeOwned,
{
match status {
reqwest::StatusCode::UNAUTHORIZED => Err(Error::Unauthorized),
reqwest::StatusCode::METHOD_NOT_ALLOWED => Err(Error::MethodNotAllowed),
reqwest::StatusCode::NOT_FOUND => Err(Error::NotFound),
client_err if client_err.is_client_error() => Err(Error::Fault {
code: status,
errors: serde_json::from_str(body)?,
}),
_ => {
let data = if body.is_empty() { "null" } else { body };
Ok(serde_json::from_str::<D>(data)?)
}
}
}
#[cfg(feature = "cache")]
pub fn check_cache<D>(&self, method: &str, endpoint: &str) -> Option<D>
where
D: DeserializeOwned,
{
if method != "GET" || !self.cache_config.should_cache_endpoint(endpoint) {
return None;
}
let cache_key = generate_cache_key(endpoint, "");
if let Some(cached_data) = self.cache.get(&cache_key) {
match std::str::from_utf8(&cached_data) {
Ok(json_str) => {
let processed_json = if json_str.is_empty() {
"null"
} else {
json_str
};
match serde_json::from_str::<D>(processed_json) {
Ok(result) => {
debug!(cache_key = %cache_key, "Cache hit");
Some(result)
}
Err(e) => {
debug!(cache_key = %cache_key, error = %e, "Failed to deserialize cached JSON");
None
}
}
}
Err(e) => {
debug!(cache_key = %cache_key, error = %e, "Cached data is not valid UTF-8");
None
}
}
} else {
None
}
}
#[cfg(feature = "cache")]
pub fn store_raw_response(&self, method: &str, endpoint: &str, raw_json: &str) {
if method != "GET" || !self.cache_config.should_cache_endpoint(endpoint) {
return;
}
let cache_key = generate_cache_key(endpoint, "");
let strategy = self.cache_config.strategy_for_endpoint(endpoint);
self.cache
.set(&cache_key, raw_json.as_bytes().to_vec(), strategy.ttl);
debug!(
cache_key = %cache_key,
ttl_secs = strategy.ttl.as_secs(),
size_bytes = raw_json.len(),
"Raw JSON response cached"
);
}
#[cfg(feature = "cache")]
pub fn clear_cache(&self) {
self.cache.clear();
debug!("Cache cleared");
}
#[cfg(feature = "cache")]
pub fn cache_stats(&self) -> crate::cache::CacheStats {
self.cache.stats()
}
#[cfg(feature = "oauth")]
pub fn get_oauth_header(&self, method: &str, url: &str) -> Result<Option<String>> {
match &self.credentials {
Credentials::OAuth1a {
consumer_key,
private_key_pem,
access_token,
access_token_secret,
} => {
let header = crate::oauth::generate_oauth_header(
method,
url,
consumer_key,
private_key_pem,
access_token,
access_token_secret,
)?;
Ok(Some(header))
}
_ => Ok(None),
}
}
pub fn apply_credentials_sync(
&self,
builder: reqwest::blocking::RequestBuilder,
) -> reqwest::blocking::RequestBuilder {
match &self.credentials {
Credentials::Anonymous => builder,
Credentials::Basic(user, pass) => {
builder.basic_auth(user.to_owned(), Some(pass.to_owned()))
}
Credentials::Bearer(token) => builder.bearer_auth(token.to_owned()),
Credentials::Cookie(jsessionid) => builder.header(
reqwest::header::COOKIE,
format!("JSESSIONID={}", jsessionid),
),
#[cfg(feature = "oauth")]
Credentials::OAuth1a { .. } => {
builder
}
}
}
pub fn apply_credentials_async(
&self,
builder: reqwest::RequestBuilder,
) -> reqwest::RequestBuilder {
match &self.credentials {
Credentials::Anonymous => builder,
Credentials::Basic(user, pass) => {
builder.basic_auth(user.to_owned(), Some(pass.to_owned()))
}
Credentials::Bearer(token) => builder.bearer_auth(token.to_owned()),
Credentials::Cookie(jsessionid) => builder.header(
reqwest::header::COOKIE,
format!("JSESSIONID={}", jsessionid),
),
#[cfg(feature = "oauth")]
Credentials::OAuth1a { .. } => {
builder
}
}
}
pub fn detect_deployment_type(&self) -> JiraDeploymentType {
let host_str = self.host.host_str().unwrap_or("");
if host_str.contains(".atlassian.net") {
JiraDeploymentType::Cloud
} else {
JiraDeploymentType::Unknown
}
}
pub fn get_search_api_version(&self) -> SearchApiVersion {
match &self.search_api_version {
SearchApiVersion::Auto => {
match self.detect_deployment_type() {
JiraDeploymentType::Cloud => SearchApiVersion::V3,
JiraDeploymentType::DataCenter
| JiraDeploymentType::Server
| JiraDeploymentType::Unknown => SearchApiVersion::V2, }
}
version => version.clone(),
}
}
}
pub trait PaginationInfo {
fn more_pages(count: u64, start_at: u64, total: u64) -> bool {
(start_at + count) < total
}
}