use bon::Builder;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::load::Spec;
#[derive(Debug, Clone, Builder, Deserialize, Serialize)]
#[builder(derive(Clone, Debug), on(String, into))]
pub struct BearerAuth {
pub allow: Vec<String>,
#[serde(default)]
pub header: Option<String>,
#[serde(default)]
pub realm: Option<String>,
#[serde(default)]
pub on_unauthorized_status: Option<u16>,
#[serde(default)]
pub strip_prefix: Option<String>,
}
impl BearerAuth {
pub fn allow_tokens<I, S>(tokens: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self::builder()
.allow(tokens.into_iter().map(Into::into).collect())
.build()
}
}
impl From<BearerAuth> for Spec {
fn from(value: BearerAuth) -> Self {
let mut map = Map::new();
map.insert("type".into(), Value::String("auth".into()));
map.insert(
"allow".into(),
Value::Array(value.allow.into_iter().map(Value::String).collect()),
);
if let Some(header) = value.header {
map.insert("header".into(), Value::String(header));
}
if let Some(realm) = value.realm {
map.insert("realm".into(), Value::String(realm));
}
if let Some(status) = value.on_unauthorized_status {
map.insert("on_unauthorized_status".into(), Value::from(status));
}
if let Some(strip) = value.strip_prefix {
map.insert("strip_prefix".into(), Value::String(strip));
}
Spec::Inline(Value::Object(map))
}
}
#[derive(Debug, Clone, Builder, Deserialize, Serialize)]
#[builder(derive(Clone, Debug), on(String, into))]
pub struct RateLimit {
pub capacity: u64,
pub refill_per_sec: u64,
#[serde(default)]
pub key: Option<String>,
#[serde(default)]
pub constant_value: Option<String>,
#[serde(default)]
pub header_name: Option<String>,
#[serde(default)]
pub retry_after_ms: Option<u64>,
}
impl RateLimit {
#[must_use]
pub fn token_bucket(capacity: u64, refill_per_sec: u64) -> Self {
Self::builder()
.capacity(capacity)
.refill_per_sec(refill_per_sec)
.build()
}
}
impl From<RateLimit> for Spec {
fn from(value: RateLimit) -> Self {
let mut map = Map::new();
map.insert("type".into(), Value::String("rate_limit".into()));
map.insert("capacity".into(), Value::from(value.capacity));
map.insert("refill_per_sec".into(), Value::from(value.refill_per_sec));
let key = value.key.unwrap_or_else(|| "path_and_method".to_string());
map.insert("key".into(), Value::String(key));
if let Some(constant) = value.constant_value {
map.insert("constant_value".into(), Value::String(constant));
}
if let Some(header) = value.header_name {
map.insert("header_name".into(), Value::String(header));
}
if let Some(ms) = value.retry_after_ms {
map.insert("retry_after_ms".into(), Value::from(ms));
}
Spec::Inline(Value::Object(map))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Builder, Deserialize, Serialize)]
#[builder(derive(Clone, Debug), on(String, into))]
pub struct OauthAuth {
pub token_url: String,
pub client_id: String,
pub client_secret: String,
#[serde(default)]
#[builder(default)]
pub refresh_ahead_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Builder, Deserialize, Serialize)]
#[builder(derive(Clone, Debug), on(String, into))]
pub struct ClientAuth {
pub scheme: String,
#[serde(default)]
pub token: Option<String>,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub password: Option<String>,
#[serde(default)]
pub token_url: Option<String>,
#[serde(default)]
pub client_id: Option<String>,
#[serde(default)]
pub client_secret: Option<String>,
#[serde(default)]
pub refresh_ahead_ms: Option<u64>,
#[serde(default)]
pub access_key_id: Option<String>,
#[serde(default)]
pub secret_access_key: Option<String>,
#[serde(default)]
pub region: Option<String>,
#[serde(default)]
pub service: Option<String>,
#[serde(default)]
pub cnonce: Option<String>,
}
impl ClientAuth {
pub fn bearer(token: impl Into<String>) -> Self {
Self::builder().scheme("bearer").token(token).build()
}
pub fn basic(username: impl Into<String>, password: impl Into<String>) -> Self {
Self::builder()
.scheme("basic")
.username(username)
.password(password)
.build()
}
}
impl From<OauthAuth> for ClientAuth {
fn from(value: OauthAuth) -> Self {
Self::builder()
.scheme("oauth")
.token_url(value.token_url)
.client_id(value.client_id)
.client_secret(value.client_secret)
.refresh_ahead_ms(value.refresh_ahead_ms)
.build()
}
}
impl From<SigV4Auth> for ClientAuth {
fn from(value: SigV4Auth) -> Self {
Self::builder()
.scheme("sigv4")
.access_key_id(value.access_key_id)
.secret_access_key(value.secret_access_key)
.region(value.region)
.service(value.service)
.build()
}
}
impl From<DigestAuth> for ClientAuth {
fn from(value: DigestAuth) -> Self {
Self::builder()
.scheme("digest")
.username(value.username)
.password(value.password)
.maybe_cnonce(value.cnonce)
.build()
}
}
impl From<ClientAuth> for Spec {
fn from(value: ClientAuth) -> Self {
let mut map = Map::new();
map.insert("type".into(), Value::String("client-auth".into()));
map.insert("scheme".into(), Value::String(value.scheme));
for (key, field) in [
("token", value.token),
("username", value.username),
("password", value.password),
("token_url", value.token_url),
("client_id", value.client_id),
("client_secret", value.client_secret),
("access_key_id", value.access_key_id),
("secret_access_key", value.secret_access_key),
("region", value.region),
("service", value.service),
("cnonce", value.cnonce),
] {
if let Some(text) = field {
map.insert(key.into(), Value::String(text));
}
}
if let Some(ms) = value.refresh_ahead_ms {
map.insert("refresh_ahead_ms".into(), Value::from(ms));
}
Spec::Inline(Value::Object(map))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Builder, Deserialize, Serialize)]
#[builder(derive(Clone, Debug), on(String, into))]
pub struct SigV4Auth {
pub access_key_id: String,
pub secret_access_key: String,
pub region: String,
pub service: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Builder, Deserialize, Serialize)]
#[builder(derive(Clone, Debug), on(String, into))]
pub struct DigestAuth {
pub username: String,
pub password: String,
#[serde(default)]
pub cnonce: Option<String>,
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn bearer_auth_serializes_with_type_tag() {
let bearer = BearerAuth::allow_tokens(["t-1", "t-2"]);
let spec: Spec = bearer.into();
let Spec::Inline(value) = spec else {
panic!("expected inline")
};
assert_eq!(value.get("type").and_then(|v| v.as_str()), Some("auth"));
let allow = value
.get("allow")
.and_then(|v| v.as_array())
.expect("allow array");
assert_eq!(allow.len(), 2);
}
#[test]
fn bearer_auth_round_trips_through_toml() {
let original = BearerAuth::builder()
.allow(vec!["a".into(), "b".into()])
.header("x-token")
.realm("acme")
.on_unauthorized_status(403)
.strip_prefix("Token ")
.build();
let toml_text = toml::to_string(&original).expect("encode");
let restored: BearerAuth = toml::from_str(&toml_text).expect("decode");
assert_eq!(restored.allow, original.allow);
assert_eq!(restored.header, original.header);
assert_eq!(restored.realm, original.realm);
assert_eq!(
restored.on_unauthorized_status,
original.on_unauthorized_status
);
assert_eq!(restored.strip_prefix, original.strip_prefix);
}
#[test]
fn rate_limit_serializes_with_type_tag() {
let rl = RateLimit::token_bucket(100, 50);
let spec: Spec = rl.into();
let Spec::Inline(value) = spec else {
panic!("expected inline")
};
assert_eq!(
value.get("type").and_then(|v| v.as_str()),
Some("rate_limit"),
);
assert_eq!(value.get("capacity").and_then(|v| v.as_u64()), Some(100));
assert_eq!(
value.get("refill_per_sec").and_then(|v| v.as_u64()),
Some(50),
);
}
#[test]
fn rate_limit_round_trips_through_toml() {
let original = RateLimit::builder()
.capacity(500)
.refill_per_sec(100)
.key("header")
.header_name("x-client-id")
.retry_after_ms(2_000)
.build();
let toml_text = toml::to_string(&original).expect("encode");
let restored: RateLimit = toml::from_str(&toml_text).expect("decode");
assert_eq!(restored.capacity, original.capacity);
assert_eq!(restored.refill_per_sec, original.refill_per_sec);
assert_eq!(restored.key, original.key);
assert_eq!(restored.header_name, original.header_name);
assert_eq!(restored.retry_after_ms, original.retry_after_ms);
}
#[test]
fn oauth_builder_sugar_lowers_into_flat_client_auth() {
let auth: ClientAuth = OauthAuth::builder()
.token_url("https://idp/token")
.client_id("id")
.client_secret("secret")
.refresh_ahead_ms(30_000)
.build()
.into();
assert_eq!(auth.scheme, "oauth");
assert_eq!(auth.token_url.as_deref(), Some("https://idp/token"));
assert_eq!(auth.client_id.as_deref(), Some("id"));
assert_eq!(auth.refresh_ahead_ms, Some(30_000));
}
#[test]
fn client_auth_serializes_with_type_and_scheme_tags() {
let spec: Spec = ClientAuth::bearer("tok").into();
let Spec::Inline(value) = spec else {
panic!("expected inline")
};
assert_eq!(
value.get("type").and_then(|v| v.as_str()),
Some("client-auth")
);
assert_eq!(value.get("scheme").and_then(|v| v.as_str()), Some("bearer"));
assert_eq!(value.get("token").and_then(|v| v.as_str()), Some("tok"));
}
#[test]
fn client_auth_round_trips_through_toml_every_scheme() {
let cases = [
ClientAuth::bearer("tok"),
ClientAuth::basic("alice", "s3cr3t"),
OauthAuth::builder()
.token_url("https://idp/token")
.client_id("id")
.client_secret("secret")
.refresh_ahead_ms(5_000)
.build()
.into(),
SigV4Auth::builder()
.access_key_id("AKIDEXAMPLE")
.secret_access_key("wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY")
.region("us-east-1")
.service("s3")
.build()
.into(),
DigestAuth::builder()
.username("Mufasa")
.password("Circle of Life")
.build()
.into(),
];
for original in cases {
let toml_text = toml::to_string(&original).expect("encode");
let restored: ClientAuth = toml::from_str(&toml_text).expect("decode");
assert_eq!(restored, original, "config round-trip identity");
}
}
#[test]
fn sigv4_builder_sugar_lowers_into_flat_client_auth() {
let auth: ClientAuth = SigV4Auth::builder()
.access_key_id("AKIDEXAMPLE")
.secret_access_key("secret")
.region("us-east-1")
.service("s3")
.build()
.into();
assert_eq!(auth.scheme, "sigv4");
assert_eq!(auth.access_key_id.as_deref(), Some("AKIDEXAMPLE"));
assert_eq!(auth.region.as_deref(), Some("us-east-1"));
assert_eq!(auth.service.as_deref(), Some("s3"));
}
#[test]
fn digest_builder_sugar_lowers_into_flat_client_auth() {
let auth: ClientAuth = DigestAuth::builder()
.username("Mufasa")
.password("Circle of Life")
.build()
.into();
assert_eq!(auth.scheme, "digest");
assert_eq!(auth.username.as_deref(), Some("Mufasa"));
assert_eq!(auth.password.as_deref(), Some("Circle of Life"));
}
#[test]
fn sigv4_fluent_and_config_surfaces_agree() {
let fluent: Spec = ClientAuth::from(
SigV4Auth::builder()
.access_key_id("AKID")
.secret_access_key("sk")
.region("us-east-1")
.service("s3")
.build(),
)
.into();
let Spec::Inline(fluent_value) = fluent else {
panic!("expected inline")
};
assert_eq!(
fluent_value.get("type").and_then(Value::as_str),
Some("client-auth")
);
assert_eq!(
fluent_value.get("scheme").and_then(Value::as_str),
Some("sigv4")
);
assert_eq!(
fluent_value.get("region").and_then(Value::as_str),
Some("us-east-1")
);
assert_eq!(
fluent_value.get("service").and_then(Value::as_str),
Some("s3")
);
}
}