use std::fmt;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use url::Url;
use crate::Error;
use crate::credentials::{self, Credentials};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
const ENV_REGION: &str = "ORIGINCLOUD_REGION";
const DEFAULT_MAX_ATTEMPTS: u32 = 5;
const DEFAULT_OPERATION_WAIT: Duration = Duration::from_secs(60);
#[derive(Clone)]
pub struct Client {
pub(crate) inner: Arc<Inner>,
}
pub(crate) struct Inner {
pub(crate) http: reqwest::Client,
pub(crate) credentials: Arc<dyn Credentials>,
region: String,
endpoint: Option<Url>,
pub(crate) user_agent: String,
pub(crate) max_attempts: u32,
pub(crate) timeout: Option<Duration>,
pub(crate) operation_wait: Duration,
token: Mutex<String>,
}
impl Client {
pub fn new() -> Result<Self, Error> {
Self::builder().build()
}
pub fn builder() -> ClientBuilder {
ClientBuilder::default()
}
pub fn service(&self, shortcode: &str, regional: bool) -> Service {
let base = match &self.inner.endpoint {
Some(endpoint) => Ok(endpoint.clone()),
None if !is_label(shortcode) => Err(format!(
"the service shortcode must be [a-z0-9-]+, got {shortcode:?}"
)),
None => {
let region = &self.inner.region;
let host = if regional && !region.is_empty() {
format!("{shortcode}.{region}.origincloudapis.eu")
} else {
format!("{shortcode}.origincloudapis.eu")
};
Url::parse(&format!("https://{host}"))
.map_err(|e| format!("the service host {host:?} isn't valid: {e}"))
}
};
Service {
client: self.clone(),
base,
}
}
pub fn consistency_token(&self) -> String {
self.inner.token.lock().expect("the token lock").clone()
}
pub fn set_consistency_token(&self, token: impl Into<String>) {
*self.inner.token.lock().expect("the token lock") = token.into();
}
}
impl fmt::Debug for Client {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Client")
.field("credentials", &self.inner.credentials)
.field("region", &self.inner.region)
.field("endpoint", &self.inner.endpoint.as_ref().map(Url::as_str))
.field("max_attempts", &self.inner.max_attempts)
.finish_non_exhaustive()
}
}
#[derive(Default)]
pub struct ClientBuilder {
credentials: Option<Arc<dyn Credentials>>,
region: Option<String>,
endpoint: Option<String>,
http: Option<reqwest::Client>,
user_agent: Option<String>,
max_attempts: Option<u32>,
timeout: Option<Duration>,
operation_wait: Option<Duration>,
}
impl ClientBuilder {
pub fn credentials(mut self, credentials: impl Credentials + 'static) -> Self {
self.credentials = Some(Arc::new(credentials));
self
}
pub fn region(mut self, region: impl Into<String>) -> Self {
self.region = Some(region.into());
self
}
pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = Some(endpoint.into());
self
}
pub fn http_client(mut self, http: reqwest::Client) -> Self {
self.http = Some(http);
self
}
pub fn user_agent(mut self, product: impl Into<String>) -> Self {
self.user_agent = Some(product.into());
self
}
pub fn max_attempts(mut self, attempts: u32) -> Self {
self.max_attempts = Some(attempts);
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn operation_wait(mut self, wait: Duration) -> Self {
self.operation_wait = Some(wait);
self
}
pub fn build(self) -> Result<Client, Error> {
self.build_with(&|name| std::env::var(name).ok())
}
pub(crate) fn build_with(self, env: &dyn Fn(&str) -> Option<String>) -> Result<Client, Error> {
if self.timeout == Some(Duration::ZERO) {
return Err(Error::Config(
"the timeout must be longer than zero".to_owned(),
));
}
let max_attempts = self.max_attempts.unwrap_or(DEFAULT_MAX_ATTEMPTS);
if max_attempts < 1 {
return Err(Error::Config(format!(
"the most attempts must be at least 1, got {max_attempts}"
)));
}
let mut user_agent = format!("origincloud-sdk-rust/{VERSION}");
if let Some(product) = self.user_agent.filter(|p| !p.is_empty()) {
user_agent = format!("{product} {user_agent}");
}
let region = match self.region {
Some(region) => region,
None => env(ENV_REGION).unwrap_or_default(),
};
if !region.is_empty() && !is_label(®ion) {
return Err(Error::Config(format!(
"the region must be [a-z0-9-]+, such as eu-central1, got {region:?}"
)));
}
let endpoint = match self.endpoint {
Some(endpoint) => {
let parsed = Url::parse(endpoint.trim_end_matches('/'))
.ok()
.filter(|u| u.has_host() && !u.cannot_be_a_base());
Some(parsed.ok_or_else(|| {
Error::Config(format!(
"the endpoint must be an absolute URL, got {endpoint:?}"
))
})?)
}
None => None,
};
let http = match self.http {
Some(http) => http,
None => reqwest::Client::builder()
.build()
.map_err(|e| Error::Config(format!("creating the HTTP client: {e}")))?,
};
let credentials = match self.credentials {
Some(credentials) => credentials,
None => credentials::from_environment(env)?,
};
Ok(Client {
inner: Arc::new(Inner {
http,
credentials,
region,
endpoint,
user_agent,
max_attempts,
timeout: self.timeout,
operation_wait: self.operation_wait.unwrap_or(DEFAULT_OPERATION_WAIT),
token: Mutex::default(),
}),
})
}
}
impl fmt::Debug for ClientBuilder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ClientBuilder")
.field("credentials", &self.credentials)
.field("region", &self.region)
.field("endpoint", &self.endpoint)
.field("max_attempts", &self.max_attempts)
.field("timeout", &self.timeout)
.field("operation_wait", &self.operation_wait)
.finish_non_exhaustive()
}
}
fn is_label(s: &str) -> bool {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
#[derive(Clone, Debug)]
pub struct Service {
pub(crate) client: Client,
pub(crate) base: Result<Url, String>,
}
impl Service {
pub fn client(&self) -> &Client {
&self.client
}
pub fn base_url(&self) -> Option<&Url> {
self.base.as_ref().ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::AccessToken;
fn no_env(_: &str) -> Option<String> {
None
}
fn base(client: &Client, regional: bool) -> String {
client
.service("pvs", regional)
.base_url()
.unwrap()
.as_str()
.trim_end_matches('/')
.to_owned()
}
#[test]
fn endpoints() {
for (region, regional, want) in [
("", true, "https://pvs.origincloudapis.eu"),
(
"eu-central1",
true,
"https://pvs.eu-central1.origincloudapis.eu",
),
("eu-central1", false, "https://pvs.origincloudapis.eu"),
] {
let client = Client::builder()
.credentials(AccessToken::new("t"))
.region(region)
.build_with(&no_env)
.unwrap();
assert_eq!(
base(&client, regional),
want,
"region {region:?}, regional {regional}"
);
}
let env = |name: &str| (name == ENV_REGION).then(|| "eu-west1".to_owned());
let client = Client::builder()
.credentials(AccessToken::new("t"))
.build_with(&env)
.unwrap();
assert_eq!(
base(&client, true),
"https://pvs.eu-west1.origincloudapis.eu"
);
let client = Client::builder()
.credentials(AccessToken::new("t"))
.region("")
.build_with(&env)
.unwrap();
assert_eq!(
base(&client, true),
"https://pvs.origincloudapis.eu",
"an empty region overrides the environment"
);
}
#[test]
fn a_region_that_isnt_a_host_label_is_refused() {
for region in [
"eu/central1",
"x@evil.com/",
"eu central1",
"eu-central1:443",
"EU-CENTRAL1",
] {
let built = Client::builder()
.credentials(AccessToken::new("t"))
.region(region)
.build_with(&no_env);
let Err(Error::Config(message)) = built else {
panic!("region {region:?}: {built:?}");
};
assert!(message.contains(&format!("{region:?}")), "{message}");
let env = |name: &str| (name == ENV_REGION).then(|| region.to_owned());
let built = Client::builder()
.credentials(AccessToken::new("t"))
.build_with(&env);
assert!(
matches!(built, Err(Error::Config(_))),
"ORIGINCLOUD_REGION={region:?}"
);
}
}
#[test]
fn a_shortcode_that_isnt_a_host_label_fails_its_calls_instead_of_panicking() {
let client = Client::builder()
.credentials(AccessToken::new("t"))
.build_with(&no_env)
.unwrap();
assert!(client.service("x@evil.com/", true).base_url().is_none());
assert!(client.service("pvs", true).base_url().is_some());
}
#[test]
fn invalid_options_are_refused() {
let built = Client::builder()
.credentials(AccessToken::new("t"))
.max_attempts(0)
.build_with(&no_env);
assert!(matches!(built, Err(Error::Config(_))));
for endpoint in [
"localhost",
"",
"http://",
"not a url",
"http://exa mple.com",
"mailto:x@y",
] {
let built = Client::builder()
.credentials(AccessToken::new("t"))
.endpoint(endpoint)
.build_with(&no_env);
assert!(
matches!(built, Err(Error::Config(_))),
"endpoint {endpoint:?}"
);
}
}
#[test]
fn no_credentials_at_all() {
let env = |name: &str| {
(name == "XDG_DATA_HOME").then(|| "/nonexistent/origincloud-sdk-test".to_owned())
};
assert!(matches!(
Client::builder().build_with(&env),
Err(Error::NoCredentials)
));
}
}