use alloc::{
borrow::ToOwned as _,
format,
string::{String, ToString as _},
vec::Vec,
};
use crate::{error::Result, models};
use serde::de::DeserializeOwned;
use tracing::{debug, instrument};
const API_BASE_URL: &str = "https://api.amber.com.au/v1/";
#[derive(Debug, Clone, bon::Builder)]
pub struct Amber {
client: reqwest::Client,
api_key: Option<String>,
base_url: String,
#[builder(default = 3)]
max_retries: u32,
#[builder(default = true)]
retry_on_rate_limit: bool,
}
impl Default for Amber {
#[inline]
#[expect(
clippy::expect_used,
reason = "reqwest::Client::builder() with basic config cannot fail"
)]
fn default() -> Self {
debug!("Creating default Amber API client");
let client = reqwest::Client::builder()
.user_agent(format!("amber-api/{}", env!("CARGO_PKG_VERSION")))
.timeout(core::time::Duration::from_secs(30))
.build()
.expect("Failed to build HTTP client");
Self {
client,
#[cfg(feature = "std")]
api_key: std::env::var("AMBER_API_KEY")
.ok()
.filter(|s| !s.is_empty()),
#[cfg(not(feature = "std"))]
api_key: None,
base_url: API_BASE_URL.to_owned(),
max_retries: 3,
retry_on_rate_limit: true,
}
}
}
#[bon::bon]
impl Amber {
#[instrument(skip(self, query), level = "debug")]
async fn get<T: DeserializeOwned, I, K, V>(&self, path: &str, query: I) -> Result<T>
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<str>,
V: AsRef<str>,
{
let endpoint = format!("{}{}", self.base_url, path);
let query_params: Vec<(String, String)> = query
.into_iter()
.map(|(k, v)| (k.as_ref().to_owned(), v.as_ref().to_owned()))
.collect();
let mut attempt: u32 = 0;
loop {
let current_attempt = attempt.saturating_add(1);
let max_attempts = self.max_retries.saturating_add(1);
debug!("GET {endpoint} (attempt {current_attempt}/{max_attempts})");
let mut request = self.client.get(&endpoint);
if let Some(api_key) = &self.api_key {
request = request.bearer_auth(api_key);
}
if !query_params.is_empty() {
for (key, value) in &query_params {
debug!("Query parameter: {}={}", key, value);
}
request = request.query(&query_params);
}
match request.send().await {
Ok(response) => {
let status = response.status();
debug!("Status code: {}", status);
if let Some(remaining) = response
.headers()
.get("RateLimit-Remaining")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
{
debug!("Rate limit remaining: {}", remaining);
}
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
let retry_after = response
.headers()
.get("RateLimit-Reset")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(60);
if !self.retry_on_rate_limit {
return Err(crate::error::AmberError::RateLimitExceeded(retry_after));
}
if attempt >= self.max_retries {
return Err(crate::error::AmberError::RateLimitExhausted {
attempts: attempt,
retry_after,
});
}
debug!(
"Rate limit hit. Waiting {} seconds before retry",
retry_after
);
tokio::time::sleep(tokio::time::Duration::from_secs(retry_after)).await;
attempt = attempt.saturating_add(1);
continue;
}
if status.is_success() {
return response.json::<T>().await.map_err(Into::into);
}
let body = response
.text()
.await
.unwrap_or_else(|_| String::from("<body not available>"));
return Err(crate::error::AmberError::UnexpectedStatus {
status: status.as_u16(),
body,
});
}
Err(e) => {
return Err(e.into());
}
}
}
}
#[inline]
#[builder]
pub async fn current_renewables(
&self,
state: models::State,
next: Option<u32>,
previous: Option<u32>,
resolution: Option<models::Resolution>,
) -> Result<Vec<models::Renewable>> {
self.get(
&format!("state/{state}/renewables/current"),
[
("next", next.map(|n| n.to_string())),
("previous", previous.map(|p| p.to_string())),
("resolution", resolution.map(|r| r.to_string())),
]
.into_iter()
.filter_map(|(k, v)| v.map(|val| (k, val))),
)
.await
}
#[inline]
pub async fn sites(&self) -> Result<Vec<crate::models::Site>> {
self.get("sites", core::iter::empty::<(&str, &str)>()).await
}
#[inline]
#[builder]
pub async fn prices(
&self,
site_id: &str,
start_date: Option<jiff::civil::Date>,
end_date: Option<jiff::civil::Date>,
resolution: Option<models::Resolution>,
) -> Result<Vec<models::Interval>> {
self.get(
&format!("sites/{site_id}/prices"),
[
("startDate", start_date.map(|d| d.to_string())),
("endDate", end_date.map(|d| d.to_string())),
("resolution", resolution.map(|r| r.to_string())),
]
.into_iter()
.filter_map(|(k, v)| v.map(|val| (k, val))),
)
.await
}
#[inline]
#[builder]
pub async fn current_prices(
&self,
site_id: &str,
next: Option<u32>,
previous: Option<u32>,
resolution: Option<models::Resolution>,
) -> Result<Vec<models::Interval>> {
self.get(
&format!("sites/{site_id}/prices/current"),
[
("next", next.map(|n| n.to_string())),
("previous", previous.map(|p| p.to_string())),
("resolution", resolution.map(|r| r.to_string())),
]
.into_iter()
.filter_map(|(k, v)| v.map(|val| (k, val))),
)
.await
}
#[inline]
#[builder]
pub async fn usage(
&self,
site_id: &str,
start_date: jiff::civil::Date,
end_date: jiff::civil::Date,
) -> Result<Vec<models::Usage>> {
let start_date_str = start_date.to_string();
let end_date_str = end_date.to_string();
let query_params = [
("startDate", start_date_str.as_str()),
("endDate", end_date_str.as_str()),
];
self.get(&format!("sites/{site_id}/usage"), query_params)
.await
}
}