use std::fmt;
#[derive(Clone)]
pub enum AuthMethod {
Bearer(String),
OAuth(String),
Basic(BasicAuth),
ApiKey {
prefix: Option<String>,
key: String,
},
}
#[derive(Clone)]
pub struct BasicAuth {
pub username: String,
pub password: Option<String>,
}
impl fmt::Debug for BasicAuth {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("BasicAuth")
.field("username", &self.username)
.field("password", &"[REDACTED]")
.finish()
}
}
impl fmt::Debug for AuthMethod {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AuthMethod::Bearer(_) => f.debug_tuple("Bearer").field(&"[REDACTED]").finish(),
AuthMethod::OAuth(_) => f.debug_tuple("OAuth").field(&"[REDACTED]").finish(),
AuthMethod::Basic(_) => f.debug_tuple("Basic").field(&"[REDACTED]").finish(),
AuthMethod::ApiKey { prefix, .. } => f
.debug_struct("ApiKey")
.field("prefix", prefix)
.field("key", &"[REDACTED]")
.finish(),
}
}
}
#[derive(Debug, Clone)]
pub struct Configuration {
pub(crate) base_path: String,
pub(crate) user_agent: Option<String>,
pub(crate) client: reqwest::Client,
pub(crate) auth: Option<AuthMethod>,
}
impl Default for Configuration {
fn default() -> Self {
Configuration {
base_path: "http://localhost".to_owned(),
user_agent: Some("OpenFGA-Rust-SDK/1.x".to_owned()),
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("failed to build reqwest client"),
auth: None,
}
}
}
impl Configuration {
pub fn builder() -> ConfigurationBuilder {
ConfigurationBuilder::default()
}
pub(crate) fn apply_to_request(
&self,
req_builder: reqwest::RequestBuilder,
) -> reqwest::RequestBuilder {
let req_builder = if let Some(ua) = &self.user_agent {
req_builder.header(reqwest::header::USER_AGENT, ua.as_str())
} else {
req_builder
};
match &self.auth {
Some(AuthMethod::Bearer(token)) | Some(AuthMethod::OAuth(token)) => {
req_builder.bearer_auth(token)
}
Some(AuthMethod::Basic(creds)) => {
req_builder.basic_auth(&creds.username, creds.password.as_deref())
}
Some(AuthMethod::ApiKey { prefix, key }) => {
let header_val = match prefix {
Some(p) => format!("{} {}", p, key),
None => key.clone(),
};
req_builder.header(reqwest::header::AUTHORIZATION, header_val)
}
None => req_builder,
}
}
}
#[derive(Debug)]
pub struct ConfigurationBuilder {
base_path: String,
user_agent: Option<String>,
client: Option<reqwest::Client>,
auth: Option<AuthMethod>,
timeout: std::time::Duration,
}
impl Default for ConfigurationBuilder {
fn default() -> Self {
ConfigurationBuilder {
base_path: "http://localhost".to_owned(),
user_agent: Some("OpenFGA-Rust-SDK/1.x".to_owned()),
client: None,
auth: None,
timeout: std::time::Duration::from_secs(30),
}
}
}
impl ConfigurationBuilder {
pub fn base_path(mut self, base_path: impl Into<String>) -> Self {
self.base_path = base_path.into();
self
}
pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
self.user_agent = Some(ua.into());
self
}
pub fn client(mut self, client: reqwest::Client) -> Self {
self.client = Some(client);
self
}
pub fn timeout(mut self, timeout: std::time::Duration) -> Self {
self.timeout = timeout;
self
}
pub fn bearer_token(mut self, token: impl Into<String>) -> Self {
self.auth = Some(AuthMethod::Bearer(token.into()));
self
}
pub fn oauth_token(mut self, token: impl Into<String>) -> Self {
self.auth = Some(AuthMethod::OAuth(token.into()));
self
}
pub fn basic_auth(
mut self,
username: impl Into<String>,
password: Option<impl Into<String>>,
) -> Self {
self.auth = Some(AuthMethod::Basic(BasicAuth {
username: username.into(),
password: password.map(Into::into),
}));
self
}
pub fn api_key(mut self, key: impl Into<String>, prefix: Option<impl Into<String>>) -> Self {
self.auth = Some(AuthMethod::ApiKey {
key: key.into(),
prefix: prefix.map(Into::into),
});
self
}
pub fn build(self) -> Configuration {
Configuration {
base_path: self.base_path,
user_agent: self.user_agent,
client: self.client.unwrap_or_else(|| {
reqwest::Client::builder()
.timeout(self.timeout)
.build()
.expect("failed to build reqwest client")
}),
auth: self.auth,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::apis::{Error, ResponseContent, urlencode};
#[test]
fn builder_default_base_path_is_localhost() {
let config = Configuration::builder().build();
assert_eq!(config.base_path, "http://localhost");
}
#[test]
fn builder_bearer_token_sets_bearer_auth() {
let config = Configuration::builder().bearer_token("my-token").build();
assert!(
matches!(&config.auth, Some(AuthMethod::Bearer(t)) if t == "my-token"),
"expected Bearer(\"my-token\"), got {:?}",
config.auth
);
}
#[test]
fn builder_oauth_token_sets_oauth_auth() {
let config = Configuration::builder().oauth_token("oauth-tok").build();
assert!(
matches!(&config.auth, Some(AuthMethod::OAuth(t)) if t == "oauth-tok"),
"expected OAuth(\"oauth-tok\"), got {:?}",
config.auth
);
}
#[test]
fn builder_basic_auth_has_named_fields() {
let config = Configuration::builder()
.basic_auth("user", Some("pass"))
.build();
match &config.auth {
Some(AuthMethod::Basic(creds)) => {
assert_eq!(creds.username, "user");
assert_eq!(creds.password.as_deref(), Some("pass"));
}
other => panic!("expected Basic auth, got {:?}", other),
}
}
#[test]
fn builder_api_key_with_prefix() {
let config = Configuration::builder()
.api_key("my-key", Some("Token"))
.build();
match &config.auth {
Some(AuthMethod::ApiKey { prefix, key }) => {
assert_eq!(key, "my-key");
assert_eq!(prefix.as_deref(), Some("Token"));
}
other => panic!("expected ApiKey auth, got {:?}", other),
}
}
#[test]
fn builder_last_auth_setter_wins() {
let config = Configuration::builder()
.bearer_token("first")
.oauth_token("second")
.build();
assert!(
matches!(&config.auth, Some(AuthMethod::OAuth(t)) if t == "second"),
"expected OAuth(\"second\") to win, got {:?}",
config.auth
);
}
#[test]
fn builder_custom_base_path_is_preserved() {
let config = Configuration::builder()
.base_path("https://prod.example.com")
.build();
assert_eq!(config.base_path, "https://prod.example.com");
}
#[test]
fn error_display_response_error_with_entity_includes_entity_details() {
let rc: ResponseContent<String> = ResponseContent {
status: reqwest::StatusCode::BAD_REQUEST,
content: String::from("raw body"),
entity: Some(String::from("detail string")),
};
let err: Error<String> = Error::ResponseError(rc);
let display = format!("{err}");
assert!(
display.contains("status code"),
"display should contain 'status code', got: {display}"
);
assert!(
display.contains("detail string"),
"display should contain entity details, got: {display}"
);
}
#[test]
fn error_display_response_error_without_entity_shows_status_only() {
let rc: ResponseContent<String> = ResponseContent {
status: reqwest::StatusCode::BAD_REQUEST,
content: String::from("raw body"),
entity: None,
};
let err: Error<String> = Error::ResponseError(rc);
let display = format!("{err}");
assert!(
display.contains("status code 400"),
"display should contain 'status code 400', got: {display}"
);
}
#[test]
fn error_display_serde_error_shows_serde_module() {
let serde_err = serde_json::from_str::<i32>("not-a-number").unwrap_err();
let err: Error<String> = Error::Serde(serde_err);
let display = format!("{err}");
assert!(
display.starts_with("error in serde:"),
"display should start with 'error in serde:', got: {display}"
);
}
#[test]
fn urlencode_encodes_slashes_and_spaces() {
let encoded = urlencode("store/id with space");
assert!(
encoded.contains("%2F"),
"slash should be percent-encoded, got: {encoded}"
);
assert!(
encoded.contains('+') || encoded.contains("%20"),
"space should be encoded, got: {encoded}"
);
}
#[test]
fn urlencode_empty_string_returns_empty() {
let encoded = urlencode("");
assert_eq!(encoded, "", "empty string should encode to empty string");
}
#[test]
fn basic_auth_debug_redacts_password() {
let creds = BasicAuth {
username: "alice".to_string(),
password: Some("super-secret".to_string()),
};
let debug = format!("{creds:?}");
assert!(!debug.contains("super-secret"), "password must not appear in debug output");
assert!(debug.contains("[REDACTED]"));
assert!(debug.contains("alice"), "username should be visible");
}
#[test]
fn auth_method_bearer_debug_redacts_token() {
let auth = AuthMethod::Bearer("my-secret-token".to_string());
let debug = format!("{auth:?}");
assert!(!debug.contains("my-secret-token"));
assert!(debug.contains("[REDACTED]"));
}
#[test]
fn auth_method_oauth_debug_redacts_token() {
let auth = AuthMethod::OAuth("oauth-secret".to_string());
let debug = format!("{auth:?}");
assert!(!debug.contains("oauth-secret"));
assert!(debug.contains("[REDACTED]"));
}
#[test]
fn auth_method_basic_debug_redacts_credentials() {
let auth = AuthMethod::Basic(BasicAuth {
username: "bob".to_string(),
password: Some("hunter2".to_string()),
});
let debug = format!("{auth:?}");
assert!(!debug.contains("hunter2"));
assert!(debug.contains("[REDACTED]"));
}
#[test]
fn auth_method_apikey_debug_redacts_key_keeps_prefix() {
let auth = AuthMethod::ApiKey {
prefix: Some("Token".to_string()),
key: "api-secret-key".to_string(),
};
let debug = format!("{auth:?}");
assert!(!debug.contains("api-secret-key"));
assert!(debug.contains("[REDACTED]"));
assert!(debug.contains("Token"), "prefix should remain visible");
}
#[test]
fn configuration_default_has_localhost_base_path() {
let config = Configuration::default();
assert_eq!(config.base_path, "http://localhost");
assert!(config.auth.is_none());
}
#[test]
fn apply_to_request_skips_ua_header_when_user_agent_is_none() {
let mut config = Configuration::default();
config.user_agent = None;
let req = reqwest::Client::new().get("http://localhost");
let _req = config.apply_to_request(req);
}
#[test]
fn apply_to_request_with_basic_auth_does_not_panic() {
let config = Configuration::builder()
.basic_auth("user", Some("pass"))
.build();
let req = reqwest::Client::new().get("http://localhost");
let _req = config.apply_to_request(req);
}
#[test]
fn apply_to_request_with_api_key_no_prefix_does_not_panic() {
let config = Configuration::builder()
.api_key("raw-key", None::<String>)
.build();
let req = reqwest::Client::new().get("http://localhost");
let _req = config.apply_to_request(req);
}
#[test]
fn apply_to_request_with_api_key_with_prefix_does_not_panic() {
let config = Configuration::builder()
.api_key("raw-key", Some("Token"))
.build();
let req = reqwest::Client::new().get("http://localhost");
let _req = config.apply_to_request(req);
}
#[test]
fn apply_to_request_with_no_auth_does_not_panic() {
let config = Configuration::default(); let req = reqwest::Client::new().get("http://localhost");
let _req = config.apply_to_request(req);
}
#[test]
fn builder_user_agent_override_is_preserved() {
let config = Configuration::builder()
.user_agent("my-app/1.0")
.build();
assert_eq!(config.user_agent.as_deref(), Some("my-app/1.0"));
}
#[test]
fn builder_custom_client_is_stored() {
let custom = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.unwrap();
let config = Configuration::builder()
.bearer_token("t")
.client(custom)
.build();
assert!(matches!(&config.auth, Some(AuthMethod::Bearer(_))));
}
#[test]
fn builder_timeout_setter_does_not_panic() {
let _config = Configuration::builder()
.bearer_token("t")
.timeout(std::time::Duration::from_secs(5))
.build();
}
#[test]
fn error_display_io_error_shows_io_module() {
let io_err = std::io::Error::new(std::io::ErrorKind::Other, "disk full");
let err: Error<String> = Error::from(io_err);
let display = format!("{err}");
assert!(
display.starts_with("error in IO:"),
"display should start with 'error in IO:', got: {display}"
);
assert!(display.contains("disk full"));
}
#[test]
fn error_from_io_error_creates_io_variant() {
let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe");
let err: Error<String> = Error::from(io_err);
assert!(matches!(err, Error::Io(_)));
}
#[test]
fn error_source_returns_some_for_io() {
use std::error::Error as StdError;
let io_err = std::io::Error::new(std::io::ErrorKind::Other, "source-test");
let err: Error<String> = Error::from(io_err);
assert!(err.source().is_some());
}
#[test]
fn error_source_returns_some_for_serde() {
use std::error::Error as StdError;
let serde_err = serde_json::from_str::<i32>("not-a-number").unwrap_err();
let err: Error<String> = Error::Serde(serde_err);
assert!(err.source().is_some());
}
#[test]
fn error_source_returns_none_for_response_error() {
use std::error::Error as StdError;
let rc: ResponseContent<String> = ResponseContent {
status: reqwest::StatusCode::BAD_REQUEST,
content: String::new(),
entity: None,
};
let err: Error<String> = Error::ResponseError(rc);
assert!(err.source().is_none());
}
#[test]
fn error_source_returns_some_for_reqwest() {
use std::error::Error as StdError;
let build_result = reqwest::Client::new().get("").build();
let reqwest_err = build_result.unwrap_err();
let err: Error<String> = Error::from(reqwest_err);
assert!(err.source().is_some());
}
#[test]
fn error_from_reqwest_error_creates_reqwest_variant() {
let build_result = reqwest::Client::new().get("").build();
let reqwest_err = build_result.unwrap_err();
let err: Error<String> = Error::from(reqwest_err);
assert!(matches!(err, Error::Reqwest(_)));
let display = format!("{err}");
assert!(
display.starts_with("error in reqwest:"),
"display should start with 'error in reqwest:', got: {display}"
);
}
}