use crate::auth::{Auth, LEASH_AUTH_COOKIE};
use crate::env::Env;
use crate::errors::{LeashError, Result};
use crate::integrations::Integrations;
use crate::request::LeashRequest;
use crate::transport::Transport;
pub const DEFAULT_PLATFORM_URL: &str = "https://leash.build";
const AUTHORIZATION_HEADER: &str = "Authorization";
#[derive(Debug, Clone)]
pub struct Leash {
platform_url: String,
api_key: Option<String>,
bearer_token: Option<String>,
cookie_value: Option<String>,
http: reqwest::Client,
}
impl Leash {
pub fn new<R: LeashRequest>(req: R) -> Result<Self> {
let cookie_value = req.cookie(LEASH_AUTH_COOKIE);
let bearer_token = req
.header(AUTHORIZATION_HEADER)
.and_then(extract_bearer);
Ok(Self {
platform_url: resolve_platform_url(None),
api_key: std::env::var("LEASH_API_KEY").ok().filter(|s| !s.is_empty()),
bearer_token,
cookie_value,
http: default_http_client(),
})
}
pub fn from_api_key(api_key: impl Into<String>) -> Result<Self> {
let key = api_key.into();
if key.is_empty() {
return Err(LeashError::Unauthorized {
message: "LEASH_API_KEY is empty.".to_string(),
});
}
Ok(Self {
platform_url: resolve_platform_url(None),
api_key: Some(key),
bearer_token: None,
cookie_value: None,
http: default_http_client(),
})
}
pub fn from_token(token: impl Into<String>) -> Result<Self> {
let tok = token.into();
if tok.is_empty() {
return Err(LeashError::Unauthorized {
message: "JWT is empty.".to_string(),
});
}
Ok(Self {
platform_url: resolve_platform_url(None),
api_key: std::env::var("LEASH_API_KEY").ok().filter(|s| !s.is_empty()),
bearer_token: Some(tok.clone()),
cookie_value: Some(tok),
http: default_http_client(),
})
}
#[must_use]
pub fn with_platform_url(mut self, url: impl Into<String>) -> Self {
self.platform_url = url.into().trim_end_matches('/').to_string();
self
}
#[must_use]
pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
self.api_key = Some(api_key.into());
self
}
#[must_use]
pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
self.http = http;
self
}
pub fn auth(&self) -> Auth {
Auth {
cookie: self.cookie_value.clone(),
}
}
pub fn env(&self) -> Env {
let key = self
.api_key
.clone()
.or_else(|| self.bearer_token.clone());
Env::new(self.platform_url.clone(), key, self.http.clone())
}
pub fn integrations(&self) -> Integrations {
Integrations::new(self.transport())
}
pub fn platform_url(&self) -> &str {
&self.platform_url
}
pub fn has_api_key(&self) -> bool {
self.api_key.is_some()
}
pub fn has_cookie(&self) -> bool {
self.cookie_value.is_some()
}
pub fn has_bearer(&self) -> bool {
self.bearer_token.is_some()
}
fn transport(&self) -> Transport {
Transport::new(
self.platform_url.clone(),
self.api_key.clone(),
self.cookie_value.clone(),
self.http.clone(),
)
}
}
fn resolve_platform_url(override_url: Option<String>) -> String {
let raw = override_url
.or_else(|| std::env::var("LEASH_PLATFORM_URL").ok())
.unwrap_or_else(|| DEFAULT_PLATFORM_URL.to_string());
let trimmed = raw.trim_end_matches('/').to_string();
if trimmed.is_empty() {
DEFAULT_PLATFORM_URL.to_string()
} else {
trimmed
}
}
fn default_http_client() -> reqwest::Client {
reqwest::Client::builder()
.build()
.unwrap_or_else(|_| reqwest::Client::new())
}
fn extract_bearer(value: String) -> Option<String> {
let mut parts = value.splitn(2, ' ');
let scheme = parts.next()?;
if !scheme.eq_ignore_ascii_case("Bearer") {
return None;
}
let tok = parts.next()?.trim();
if tok.is_empty() {
None
} else {
Some(tok.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn req_with(cookie: Option<&str>, auth: Option<&str>) -> http::Request<()> {
let mut builder = http::Request::builder().uri("/");
if let Some(c) = cookie {
builder = builder.header("cookie", c);
}
if let Some(a) = auth {
builder = builder.header("authorization", a);
}
builder.body(()).unwrap()
}
#[test]
fn new_captures_cookie() {
std::env::remove_var("LEASH_API_KEY");
let req = req_with(Some("leash-auth=tok"), None);
let leash = Leash::new(&req).unwrap();
assert!(leash.has_cookie());
assert!(!leash.has_api_key());
assert!(!leash.has_bearer());
}
#[test]
fn new_captures_bearer() {
std::env::remove_var("LEASH_API_KEY");
let req = req_with(None, Some("Bearer abc"));
let leash = Leash::new(&req).unwrap();
assert!(leash.has_bearer());
assert!(!leash.has_api_key());
}
#[test]
fn from_api_key_rejects_empty() {
let err = Leash::from_api_key("").unwrap_err();
assert!(err.is_unauthorized());
}
#[test]
fn from_token_rejects_empty() {
let err = Leash::from_token("").unwrap_err();
assert!(err.is_unauthorized());
}
#[test]
fn with_api_key_overrides_env() {
let req = req_with(None, None);
let leash = Leash::new(&req).unwrap().with_api_key("override");
assert!(leash.has_api_key());
}
#[test]
fn with_platform_url_trims_trailing_slash() {
let req = req_with(None, None);
let leash = Leash::new(&req)
.unwrap()
.with_platform_url("https://staging.leash.build/");
assert_eq!(leash.platform_url(), "https://staging.leash.build");
}
#[test]
fn extract_bearer_handles_missing_and_malformed() {
assert_eq!(extract_bearer("Bearer abc".to_string()), Some("abc".to_string()));
assert_eq!(extract_bearer("bearer abc".to_string()), Some("abc".to_string()));
assert_eq!(extract_bearer("Token abc".to_string()), None);
assert_eq!(extract_bearer("Bearer ".to_string()), None);
assert_eq!(extract_bearer("".to_string()), None);
}
#[test]
fn env_fallback_uses_bearer_when_no_api_key() {
std::env::remove_var("LEASH_API_KEY");
let req = req_with(None, Some("Bearer fallback_jwt"));
let leash = Leash::new(&req).unwrap();
assert!(leash.has_bearer());
assert!(!leash.has_api_key());
let _env = leash.env();
}
}