use std::net::IpAddr;
use std::sync::Arc;
use std::time::Duration;
use url::{Host, Url};
use super::{ApiVersion, Client, Credentials, Inner, RetryPolicy, transport::Request};
use crate::error::{Error, Result};
#[derive(Debug)]
#[must_use]
pub struct ClientBuilder {
url: String,
credentials: Credentials,
http: Option<reqwest::Client>,
timeout: Duration,
connect_timeout: Duration,
user_agent: Option<String>,
app: String,
api: ApiVersion,
retry: RetryPolicy,
allow_insecure_http: bool,
}
impl ClientBuilder {
pub(crate) fn new(url: &str) -> Self {
Self {
url: url.trim().to_owned(),
credentials: Credentials::none(),
http: None,
timeout: Duration::from_secs(30),
connect_timeout: Duration::from_secs(10),
user_agent: None,
app: "cinnamon".to_owned(),
api: ApiVersion::Auto,
retry: RetryPolicy::default(),
allow_insecure_http: false,
}
}
pub fn credentials(mut self, credentials: Credentials) -> Self {
self.credentials = credentials;
self
}
pub fn access_token(self, token: impl Into<String>) -> Self {
self.credentials(Credentials::access_token(token))
}
pub fn api_secret(self, secret: impl Into<String>) -> Self {
self.credentials(Credentials::api_secret(secret))
}
pub fn app_name(mut self, app: impl Into<String>) -> Self {
self.app = app.into();
self
}
pub const fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub const fn connect_timeout(mut self, timeout: Duration) -> Self {
self.connect_timeout = timeout;
self
}
pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = Some(user_agent.into());
self
}
pub fn http_client(mut self, client: reqwest::Client) -> Self {
self.http = Some(client);
self
}
pub const fn api(mut self, api: ApiVersion) -> Self {
self.api = api;
self
}
pub const fn retry(mut self, retry: RetryPolicy) -> Self {
self.retry = retry;
self
}
pub const fn allow_insecure_http(mut self) -> Self {
self.allow_insecure_http = true;
self
}
pub fn build(self) -> Result<Client> {
let base = normalize_base_url(&self.url)?;
if base.scheme() == "http" && !self.allow_insecure_http && !is_local(&base) {
return Err(Error::InsecureTransport {
host: base.host_str().unwrap_or_default().to_owned(),
});
}
let http = match self.http {
Some(http) => http,
None => reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(self.timeout)
.connect_timeout(self.connect_timeout)
.user_agent(
self.user_agent
.unwrap_or_else(|| format!("cinnamon/{}", env!("CARGO_PKG_VERSION"))),
)
.build()
.map_err(|e| Error::Transport(Box::new(e)))?,
};
Ok(Client {
inner: Arc::new(Inner {
base,
http,
credentials: self.credentials,
jwt: tokio::sync::Mutex::new(None),
app: self.app,
api: self.api,
retry: self.retry,
server: tokio::sync::OnceCell::new(),
}),
})
}
pub async fn connect(self) -> Result<Client> {
let client = self.build()?;
let inner = &client.inner;
if inner.credentials.is_access_token() {
inner.bearer().await?;
} else if inner.credentials.is_api_secret() {
let resp = inner
.execute(Request::get("api/v1/verifyauth", "api/v1/verifyauth"))
.await?;
let body: serde_json::Value = resp.json()?;
let message = &body["message"];
if message["message"] != "OK" || message["isAdmin"] != true {
return Err(Error::Unauthorized {
message: "Nightscout rejected the API secret".into(),
});
}
}
inner.server_info().await?;
Ok(client)
}
}
fn normalize_base_url(raw: &str) -> Result<Url> {
let mut url = Url::parse(raw).map_err(|e| Error::InvalidUrl(format!("{raw}: {e}")))?;
if !matches!(url.scheme(), "http" | "https") {
return Err(Error::InvalidUrl(format!(
"{raw}: only http and https URLs are supported"
)));
}
if url.host().is_none() {
return Err(Error::InvalidUrl(format!("{raw}: missing host")));
}
url.set_query(None);
url.set_fragment(None);
if !url.path().ends_with('/') {
let path = format!("{}/", url.path());
url.set_path(&path);
}
Ok(url)
}
fn is_local(url: &Url) -> bool {
match url.host() {
Some(Host::Domain(d)) => d == "localhost" || d.ends_with(".localhost"),
Some(Host::Ipv4(ip)) => IpAddr::V4(ip).is_loopback(),
Some(Host::Ipv6(ip)) => IpAddr::V6(ip).is_loopback(),
None => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base_url_is_normalized() {
let url = normalize_base_url("https://ns.example.com/sub?x=1#frag")
.unwrap_or_else(|e| panic!("{e}"));
assert_eq!(url.as_str(), "https://ns.example.com/sub/");
assert!(normalize_base_url("ftp://ns.example.com").is_err());
assert!(normalize_base_url("not a url").is_err());
}
#[test]
fn plain_http_is_refused_for_remote_hosts() {
let err = Client::builder("http://ns.example.com").build().map(|_| ());
assert!(matches!(err, Err(Error::InsecureTransport { .. })));
assert!(Client::builder("http://localhost:1337").build().is_ok());
assert!(Client::builder("http://127.0.0.1:1337").build().is_ok());
assert!(
Client::builder("http://ns.example.com")
.allow_insecure_http()
.build()
.is_ok()
);
}
}