use std::sync::{Arc, OnceLock};
use parking_lot::RwLock;
use crate::Error;
use crate::api::{
ActionsApi, ActivityPubApi, AdminApi, HooksApi, IssuesApi, MiscApi, NotificationsApi,
Oauth2Api, OrgsApi, PackagesApi, PullsApi, ReleasesApi, ReposApi, SettingsApi, StatusApi,
UsersApi,
};
#[derive(Clone)]
pub(crate) struct ClientConfig {
pub(crate) base_url: Arc<str>,
pub(crate) access_token: Arc<str>,
pub(crate) username: Arc<str>,
pub(crate) password: Arc<str>,
pub(crate) otp: Arc<str>,
pub(crate) sudo: Arc<str>,
pub(crate) user_agent: Arc<str>,
pub(crate) debug: bool,
pub(crate) ignore_version: bool,
}
impl std::fmt::Debug for ClientConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClientConfig")
.field("base_url", &self.base_url)
.field(
"access_token",
&if self.access_token.is_empty() {
&"" as &dyn std::fmt::Debug
} else {
&"***" as &dyn std::fmt::Debug
},
)
.field("username", &self.username)
.field("password", &"***")
.field("otp", &"***")
.field("sudo", &self.sudo)
.field("user_agent", &self.user_agent)
.field("debug", &self.debug)
.field("ignore_version", &self.ignore_version)
.finish()
}
}
struct ClientInner {
http: RwLock<reqwest::Client>,
config: RwLock<ClientConfig>,
server_version: OnceLock<semver::Version>,
preset_version: Option<semver::Version>,
version_loading: tokio::sync::Mutex<()>,
ssh_signer: RwLock<Option<crate::auth::ssh_sign::SshSigner>>,
}
#[derive(Clone)]
pub struct Client {
inner: Arc<ClientInner>,
}
impl std::fmt::Debug for Client {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Client")
.field("base_url", &self.inner.config.read().base_url)
.finish()
}
}
impl Client {
pub fn builder(base_url: &str) -> ClientBuilder<'_> {
ClientBuilder::new(base_url)
}
}
impl Client {
pub fn set_token(&self, token: impl Into<String>) {
let mut cfg = self.inner.config.write();
cfg.access_token = Arc::from(token.into());
cfg.username = Arc::from("");
cfg.password = Arc::from("");
}
pub fn set_basic_auth(&self, username: impl Into<String>, password: impl Into<String>) {
let mut cfg = self.inner.config.write();
cfg.username = Arc::from(username.into());
cfg.password = Arc::from(password.into());
cfg.access_token = Arc::from("");
}
pub fn set_otp(&self, otp: impl Into<String>) {
self.inner.config.write().otp = Arc::from(otp.into());
}
pub fn set_sudo(&self, sudo: impl Into<String>) {
self.inner.config.write().sudo = Arc::from(sudo.into());
}
pub fn set_user_agent(&self, agent: impl Into<String>) {
self.inner.config.write().user_agent = Arc::from(agent.into());
}
pub fn set_http_client(&self, client: reqwest::Client) {
*self.inner.http.write() = client;
}
pub fn base_url(&self) -> String {
self.inner.config.read().base_url.to_string()
}
}
impl Client {
#[allow(private_interfaces)]
pub(crate) fn read_config(&self) -> parking_lot::RwLockReadGuard<'_, ClientConfig> {
self.inner.config.read()
}
#[allow(dead_code)]
fn write_config(&self) -> parking_lot::RwLockWriteGuard<'_, ClientConfig> {
self.inner.config.write()
}
pub(crate) fn http_client(&self) -> reqwest::Client {
self.inner.http.read().clone()
}
pub(crate) fn server_version_lock(&self) -> &OnceLock<semver::Version> {
&self.inner.server_version
}
pub(crate) fn preset_version(&self) -> &Option<semver::Version> {
&self.inner.preset_version
}
pub(crate) fn ignore_version(&self) -> bool {
self.inner.config.read().ignore_version
}
pub(crate) async fn version_loading_lock(&self) -> tokio::sync::MutexGuard<'_, ()> {
self.inner.version_loading.lock().await
}
pub(crate) fn ssh_signer(
&self,
) -> parking_lot::RwLockReadGuard<'_, Option<crate::auth::ssh_sign::SshSigner>> {
self.inner.ssh_signer.read()
}
pub(crate) fn should_use_legacy_ssh(&self) -> bool {
if self.ignore_version() {
return true;
}
if let Some(v) = self.preset_version() {
return v < &*crate::version::VERSION_1_23_0;
}
if let Some(v) = self.server_version_lock().get() {
return v < &*crate::version::VERSION_1_23_0;
}
false
}
}
impl Client {
pub fn repos(&self) -> ReposApi<'_> {
ReposApi::new(self)
}
pub fn issues(&self) -> IssuesApi<'_> {
IssuesApi::new(self)
}
pub fn pulls(&self) -> PullsApi<'_> {
PullsApi::new(self)
}
pub fn orgs(&self) -> OrgsApi<'_> {
OrgsApi::new(self)
}
pub fn users(&self) -> UsersApi<'_> {
UsersApi::new(self)
}
pub fn admin(&self) -> AdminApi<'_> {
AdminApi::new(self)
}
pub fn hooks(&self) -> HooksApi<'_> {
HooksApi::new(self)
}
pub fn notifications(&self) -> NotificationsApi<'_> {
NotificationsApi::new(self)
}
pub fn actions(&self) -> ActionsApi<'_> {
ActionsApi::new(self)
}
pub fn releases(&self) -> ReleasesApi<'_> {
ReleasesApi::new(self)
}
pub fn settings(&self) -> SettingsApi<'_> {
SettingsApi::new(self)
}
pub fn oauth2(&self) -> Oauth2Api<'_> {
Oauth2Api::new(self)
}
pub fn packages(&self) -> PackagesApi<'_> {
PackagesApi::new(self)
}
pub fn miscellaneous(&self) -> MiscApi<'_> {
MiscApi::new(self)
}
pub fn activitypub(&self) -> ActivityPubApi<'_> {
ActivityPubApi::new(self)
}
pub fn status(&self) -> StatusApi<'_> {
StatusApi::new(self)
}
}
pub struct ClientBuilder<'a> {
base_url: &'a str,
access_token: Option<String>,
username: Option<String>,
password: Option<String>,
otp: Option<String>,
sudo: Option<String>,
user_agent: Option<String>,
debug: bool,
ignore_version: bool,
raw_preset_version: Option<String>,
preset_version: Option<semver::Version>,
http_client: Option<reqwest::Client>,
ssh_signer: Option<crate::auth::ssh_sign::SshSigner>,
timeout: Option<std::time::Duration>,
connect_timeout: Option<std::time::Duration>,
tcp_keepalive: Option<std::time::Duration>,
pool_max_idle_per_host: Option<usize>,
}
impl std::fmt::Debug for ClientBuilder<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClientBuilder")
.field("base_url", &self.base_url)
.field("access_token", &self.access_token.as_ref().map(|_| "***"))
.field("username", &self.username)
.field("password", &"***")
.field("otp", &"***")
.field("sudo", &self.sudo)
.field("user_agent", &self.user_agent)
.field("debug", &self.debug)
.field("ignore_version", &self.ignore_version)
.field("raw_preset_version", &self.raw_preset_version)
.field("preset_version", &self.preset_version)
.field("http_client", &self.http_client)
.finish()
}
}
impl<'a> ClientBuilder<'a> {
pub fn new(base_url: &'a str) -> Self {
Self {
base_url,
access_token: None,
username: None,
password: None,
otp: None,
sudo: None,
user_agent: None,
debug: false,
ignore_version: false,
raw_preset_version: None,
preset_version: None,
http_client: None,
ssh_signer: None,
timeout: None,
connect_timeout: None,
tcp_keepalive: None,
pool_max_idle_per_host: None,
}
}
pub fn token(mut self, token: impl Into<String>) -> Self {
self.access_token = Some(token.into());
self
}
pub fn basic_auth(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
self.username = Some(username.into());
self.password = Some(password.into());
self
}
pub fn otp(mut self, otp: impl Into<String>) -> Self {
self.otp = Some(otp.into());
self
}
pub fn sudo(mut self, sudo: impl Into<String>) -> Self {
self.sudo = Some(sudo.into());
self
}
pub fn user_agent(mut self, agent: impl Into<String>) -> Self {
self.user_agent = Some(agent.into());
self
}
pub fn gitea_version(mut self, version: &str) -> Self {
if version.is_empty() {
self.ignore_version = true;
self.raw_preset_version = None;
self.preset_version = None;
} else {
self.ignore_version = false;
self.raw_preset_version = Some(version.to_string());
self.preset_version = version.parse::<semver::Version>().ok();
}
self
}
pub fn debug(mut self, enabled: bool) -> Self {
self.debug = enabled;
self
}
pub fn http_client(mut self, client: reqwest::Client) -> Self {
self.http_client = Some(client);
self
}
pub fn timeout(mut self, timeout: std::time::Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn connect_timeout(mut self, timeout: std::time::Duration) -> Self {
self.connect_timeout = Some(timeout);
self
}
pub fn tcp_keepalive(mut self, timeout: std::time::Duration) -> Self {
self.tcp_keepalive = Some(timeout);
self
}
pub fn pool_max_idle_per_host(mut self, max: usize) -> Self {
self.pool_max_idle_per_host = Some(max);
self
}
pub fn ssh_cert<P: AsRef<std::path::Path>>(
mut self,
principal: impl Into<String>,
key_path: P,
passphrase: Option<&str>,
) -> crate::Result<Self> {
let key = crate::auth::ssh_sign::load_private_key(key_path.as_ref(), passphrase)?;
self.ssh_signer = Some(crate::auth::ssh_sign::SshSigner::Cert {
principal: principal.into(),
key,
certificate_bytes: None,
});
Ok(self)
}
pub fn ssh_cert_with_certificate<P1, P2>(
mut self,
principal: impl Into<String>,
key_path: P1,
cert_path: P2,
passphrase: Option<&str>,
) -> crate::Result<Self>
where
P1: AsRef<std::path::Path>,
P2: AsRef<std::path::Path>,
{
let key = crate::auth::ssh_sign::load_private_key(key_path.as_ref(), passphrase)?;
let cert_bytes = std::fs::read(cert_path.as_ref()).map_err(|e| {
crate::Error::SshSign(format!(
"failed to read {}: {e}",
cert_path.as_ref().display()
))
})?;
self.ssh_signer = Some(crate::auth::ssh_sign::SshSigner::Cert {
principal: principal.into(),
key,
certificate_bytes: Some(cert_bytes),
});
Ok(self)
}
pub fn ssh_pubkey<P: AsRef<std::path::Path>>(
mut self,
fingerprint: impl Into<String>,
key_path: P,
passphrase: Option<&str>,
) -> crate::Result<Self> {
let key = crate::auth::ssh_sign::load_private_key(key_path.as_ref(), passphrase)?;
self.ssh_signer = Some(crate::auth::ssh_sign::SshSigner::Pubkey {
fingerprint: fingerprint.into(),
key,
});
Ok(self)
}
pub fn build(self) -> crate::Result<Client> {
let parsed = url::Url::parse(self.base_url)?;
match parsed.scheme() {
"http" | "https" => {}
other => {
return Err(Error::Validation(format!(
"base_url must use http or https, got: {other}"
)));
}
}
if let Some(raw_version) = self.raw_preset_version.as_deref()
&& self.preset_version.is_none()
{
return Err(Error::Version(format!(
"invalid Gitea version '{raw_version}'"
)));
}
let base_url = parsed.as_str().trim_end_matches('/').to_string();
let default_timeout = std::time::Duration::from_secs(30);
let timeout = self.timeout.unwrap_or(default_timeout);
let connect_timeout = self
.connect_timeout
.unwrap_or(std::time::Duration::from_secs(10));
let tcp_keepalive = self
.tcp_keepalive
.unwrap_or(std::time::Duration::from_secs(60));
let pool_max_idle_per_host = self.pool_max_idle_per_host.unwrap_or(10);
let http = match self.http_client {
Some(client) => client,
None => reqwest::Client::builder()
.timeout(timeout)
.connect_timeout(connect_timeout)
.tcp_keepalive(tcp_keepalive)
.pool_max_idle_per_host(pool_max_idle_per_host)
.build()?,
};
let config = ClientConfig {
base_url: Arc::from(base_url),
access_token: Arc::from(self.access_token.unwrap_or_default()),
username: Arc::from(self.username.unwrap_or_default()),
password: Arc::from(self.password.unwrap_or_default()),
otp: Arc::from(self.otp.unwrap_or_default()),
sudo: Arc::from(self.sudo.unwrap_or_default()),
user_agent: Arc::from(self.user_agent.unwrap_or_default()),
debug: self.debug,
ignore_version: self.ignore_version,
};
Ok(Client {
inner: Arc::new(ClientInner {
http: RwLock::new(http),
config: RwLock::new(config),
server_version: OnceLock::new(),
preset_version: self.preset_version,
version_loading: tokio::sync::Mutex::new(()),
ssh_signer: RwLock::new(self.ssh_signer),
}),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Client>();
}
#[test]
fn test_client_build_token() {
let client = Client::builder("https://example.com")
.token("abc123")
.build()
.unwrap();
assert_eq!(client.base_url(), "https://example.com");
let cfg = client.read_config();
assert_eq!(&*cfg.access_token, "abc123");
assert!(cfg.username.is_empty());
assert!(cfg.password.is_empty());
}
#[test]
fn test_client_build_basic_auth() {
let client = Client::builder("https://example.com")
.basic_auth("user", "pass")
.build()
.unwrap();
let cfg = client.read_config();
assert_eq!(&*cfg.username, "user");
assert_eq!(&*cfg.password, "pass");
assert!(cfg.access_token.is_empty());
}
#[test]
fn test_client_build_invalid_url() {
let result = Client::builder("not-a-url").build();
assert!(result.is_err());
}
#[test]
fn test_client_build_invalid_scheme() {
let result = Client::builder("ftp://example.com").build();
assert!(result.is_err());
}
#[test]
fn test_client_setters() {
let client = Client::builder("https://example.com")
.token("initial")
.build()
.unwrap();
client.set_token("new-token");
assert_eq!(&*client.read_config().access_token, "new-token");
client.set_basic_auth("admin", "secret");
{
let cfg = client.read_config();
assert_eq!(&*cfg.username, "admin");
assert_eq!(&*cfg.password, "secret");
assert!(cfg.access_token.is_empty());
}
client.set_otp("123456");
assert_eq!(&*client.read_config().otp, "123456");
client.set_sudo("target-user");
assert_eq!(&*client.read_config().sudo, "target-user");
client.set_user_agent("my-sdk/0.1");
assert_eq!(&*client.read_config().user_agent, "my-sdk/0.1");
}
#[test]
fn test_client_gitea_version_ignore() {
let client = Client::builder("https://example.com")
.gitea_version("")
.build()
.unwrap();
assert!(client.ignore_version());
assert!(client.preset_version().is_none());
}
#[test]
fn test_client_gitea_version_preset() {
let client = Client::builder("https://example.com")
.gitea_version("1.22.0")
.build()
.unwrap();
assert!(!client.ignore_version());
assert_eq!(
client.preset_version().as_ref().map(|v| v.to_string()),
Some("1.22.0".to_string()),
);
}
#[test]
fn test_client_builder_url_trailing_slash() {
let client = Client::builder("https://example.com/").build().unwrap();
assert_eq!(client.base_url(), "https://example.com");
}
#[test]
fn test_client_builder_url_multiple_trailing_slashes() {
let client = Client::builder("https://example.com///").build().unwrap();
assert_eq!(client.base_url(), "https://example.com");
}
#[test]
fn test_client_builder_url_path_preserved() {
let client = Client::builder("https://example.com/gitea/")
.build()
.unwrap();
assert_eq!(client.base_url(), "https://example.com/gitea");
}
#[test]
fn test_client_debug_flag() {
let client = Client::builder("https://example.com")
.debug(true)
.build()
.unwrap();
assert!(client.read_config().debug);
let client = Client::builder("https://example.com")
.debug(false)
.build()
.unwrap();
assert!(!client.read_config().debug);
}
#[test]
fn test_client_builder_default() {
let client = Client::builder("https://example.com").build().unwrap();
let cfg = client.read_config();
assert!(cfg.access_token.is_empty());
assert!(cfg.username.is_empty());
assert!(cfg.password.is_empty());
assert!(cfg.otp.is_empty());
assert!(cfg.sudo.is_empty());
assert!(cfg.user_agent.is_empty());
assert!(!cfg.debug);
assert!(!cfg.ignore_version);
}
#[test]
fn test_client_gitea_version_invalid_string() {
let err = Client::builder("https://example.com")
.gitea_version("not-a-version")
.build()
.unwrap_err();
match err {
Error::Version(message) => assert!(message.contains("not-a-version")),
other => panic!("expected Error::Version, got: {other}"),
}
}
#[test]
fn test_client_clone_shares_state() {
let client = Client::builder("https://example.com")
.token("shared-token")
.build()
.unwrap();
let cloned = client.clone();
client.set_token("updated-token");
assert_eq!(&*cloned.read_config().access_token, "updated-token");
}
#[test]
fn test_client_builder_ssh_cert() {
let tmp = std::env::temp_dir().join("gitea_sdk_test_ssh_cert_builder");
std::fs::write(
&tmp,
include_bytes!("../tests/ssh_fixtures/id_ed25519_test"),
)
.expect("write temp key");
let client = Client::builder("https://example.com")
.ssh_cert("test-principal", &tmp, None::<&str>)
.expect("ssh_cert should succeed with valid key")
.build()
.expect("build with ssh_cert should succeed");
let signer = client.ssh_signer();
assert!(
signer.is_some(),
"ssh_signer should be present after ssh_cert()"
);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn test_client_builder_ssh_pubkey() {
let tmp = std::env::temp_dir().join("gitea_sdk_test_ssh_pubkey_builder");
std::fs::write(
&tmp,
include_bytes!("../tests/ssh_fixtures/id_ed25519_test"),
)
.expect("write temp key");
let fp = crate::auth::ssh_sign::fingerprint(
ssh_key::PrivateKey::from_openssh(include_bytes!(
"../tests/ssh_fixtures/id_ed25519_test"
))
.expect("parse test key")
.public_key(),
);
let client = Client::builder("https://example.com")
.ssh_pubkey(&fp, &tmp, None::<&str>)
.expect("ssh_pubkey should succeed with valid key")
.build()
.expect("build with ssh_pubkey should succeed");
let signer = client.ssh_signer();
assert!(
signer.is_some(),
"ssh_signer should be present after ssh_pubkey()"
);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn test_client_builder_no_ssh() {
let client = Client::builder("https://example.com").build().unwrap();
let signer = client.ssh_signer();
assert!(
signer.is_none(),
"ssh_signer should be None when no SSH configured"
);
}
#[test]
fn test_client_builder_ssh_cert_invalid_path() {
let result = Client::builder("https://example.com").ssh_cert(
"principal",
"/nonexistent/key",
None::<&str>,
);
assert!(
result.is_err(),
"ssh_cert with nonexistent path should return Err"
);
}
#[test]
fn test_client_builder_ssh_pubkey_invalid_path() {
let result = Client::builder("https://example.com").ssh_pubkey(
"SHA256:abc",
"/nonexistent/key",
None::<&str>,
);
assert!(
result.is_err(),
"ssh_pubkey with nonexistent path should return Err"
);
}
}