amber_api/
client.rs

1//! # Amber Electric API Client
2//!
3//! This module provides a client for interacting with the [Amber Electric Public API](https://api.amber.com.au/v1).
4
5use crate::{error::Result, models};
6use serde::de::DeserializeOwned;
7use tracing::{debug, instrument};
8
9/// The base URL for the Amber Electric API
10const API_BASE_URL: &str = "https://api.amber.com.au/v1/";
11
12/// Main client for the Amber Electric API
13///
14/// This client provides a high-level interface to all Amber Electric API
15/// endpoints.
16#[derive(Debug, bon::Builder)]
17pub struct Amber {
18    /// HTTP client for making requests
19    agent: ureq::Agent,
20    /// Optional API key for authenticated requests
21    api_key: Option<String>,
22    /// Base URL for the Amber API
23    base_url: String,
24}
25
26impl Default for Amber {
27    /// Create a new default Amber API client.
28    ///
29    /// This create a default client that is authenticated if an API key is set
30    /// in the `AMBER_API_KEY` environment variable.
31    #[inline]
32    fn default() -> Self {
33        debug!("Creating default Amber API client");
34        Self {
35            agent: ureq::agent(),
36            api_key: std::env::var("AMBER_API_KEY")
37                .ok()
38                .filter(|s| !s.is_empty()),
39            base_url: API_BASE_URL.to_owned(),
40        }
41    }
42}
43
44#[bon::bon]
45impl Amber {
46    /// Perform a GET request to the Amber API.
47    #[instrument(skip(self, query), level = "debug")]
48    fn get<T: DeserializeOwned, I, K, V>(&self, path: &str, query: I) -> Result<T>
49    where
50        I: IntoIterator<Item = (K, V)>,
51        K: AsRef<str>,
52        V: AsRef<str>,
53    {
54        let endpoint = format!("{}{}", self.base_url, path);
55        debug!("GET {endpoint}");
56
57        let mut request = self.agent.get(&endpoint);
58        if let Some(api_key) = &self.api_key {
59            request = request.header("Authorization", &format!("Bearer {api_key}"));
60        }
61
62        for (key, value) in query {
63            debug!("Query parameter: {}={}", key.as_ref(), value.as_ref());
64            request = request.query(key.as_ref(), value.as_ref());
65        }
66
67        let mut response = request.call()?;
68        debug!("Status code: {}", response.status());
69        Ok(response.body_mut().read_json()?)
70    }
71
72    /// Returns the current percentage of renewables in the grid for a specific state.
73    ///
74    /// This method retrieves renewable energy data for the specified Australian state.
75    /// The data shows the current percentage of renewable energy in the grid and can
76    /// optionally include historical and forecast data.
77    ///
78    /// # Parameters
79    ///
80    /// - `state`: The Australian state (NSW, VIC, QLD, SA)
81    /// - `next`: Optional number of forecast intervals to return
82    /// - `previous`: Optional number of historical intervals to return
83    /// - `resolution`: Optional interval duration (5 or 30 minutes, default 30)
84    ///
85    /// # Authentication
86    ///
87    /// This endpoint does not require authentication and can be called without an API key.
88    ///
89    /// # Returns
90    ///
91    /// Returns a [`Result`] containing a [`Vec`] of [`Renewable`] objects on success.
92    ///
93    /// # Errors
94    ///
95    /// This method will return an error if:
96    ///
97    /// - There's a network error communicating with the API
98    /// - The API returns an internal server error (HTTP 500)
99    ///
100    /// # Example
101    ///
102    /// ```
103    /// use amber_api::Amber;
104    /// use amber_api::models::{State, Resolution};
105    ///
106    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
107    /// let client = Amber::default();
108    ///
109    /// // Get current renewables data for Victoria
110    /// let renewables = client.current_renewables()
111    ///     .state(State::Vic)
112    ///     .call()?;
113    ///
114    /// for renewable in renewables {
115    ///     println!("{}", renewable);
116    /// }
117    ///
118    /// // Get current data with 8 forecast intervals
119    /// let renewables_with_forecast = client.current_renewables()
120    ///     .state(State::Nsw)
121    ///     .next(8)
122    ///     .resolution(Resolution::FiveMinute)
123    ///     .call()?;
124    /// # Ok(())
125    /// # }
126    /// ```
127    ///
128    /// [`Renewable`]: crate::models::Renewable
129    #[inline]
130    #[builder]
131    pub fn current_renewables(
132        &self,
133        state: models::State,
134        next: Option<u32>,
135        previous: Option<u32>,
136        resolution: Option<models::Resolution>,
137    ) -> Result<Vec<models::Renewable>> {
138        self.get(
139            &format!("state/{state}/renewables/current"),
140            [
141                ("next", next.map(|n| n.to_string())),
142                ("previous", previous.map(|p| p.to_string())),
143                ("resolution", resolution.map(|r| r.to_string())),
144            ]
145            .into_iter()
146            .filter_map(|(k, v)| v.map(|val| (k, val))),
147        )
148    }
149
150    /// Return all sites linked to your account.
151    ///
152    /// This method returns information about all electricity sites associated with your
153    /// Amber account. Each site represents a location where you have electricity service.
154    ///
155    /// # Authentication
156    ///
157    /// This method requires authentication via API key. The API key can be provided
158    /// either through the `AMBER_API_KEY` environment variable (when using [`Amber::default()`])
159    /// or by explicitly setting it when building the client.
160    ///
161    /// # Returns
162    ///
163    /// Returns a [`Result`] containing a [`Vec`] of [`Site`] objects on success.
164    ///
165    /// # Errors
166    ///
167    /// This method will return an error if:
168    ///
169    /// - The API key is missing or invalid (HTTP 401)
170    /// - There's a network error communicating with the API
171    /// - The API returns an internal server error (HTTP 500)
172    ///
173    /// # Example
174    ///
175    /// ```
176    /// use amber_api::Amber;
177    ///
178    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
179    /// let client = Amber::default();
180    /// let sites = client.sites()?;
181    ///
182    /// for site in sites {
183    ///     println!("Site {}: {} ({})", site.id, site.network, site.status);
184    /// }
185    /// # Ok(())
186    /// # }
187    /// ```
188    ///
189    /// [`Site`]: crate::models::Site
190    #[inline]
191    pub fn sites(&self) -> Result<Vec<crate::models::Site>> {
192        self.get("sites", core::iter::empty::<(&str, &str)>())
193    }
194
195    /// Returns all the prices between the start and end dates for a specific site.
196    ///
197    /// This method retrieves historical pricing data for the specified site between
198    /// the given date range. The date range cannot exceed 7 days.
199    ///
200    /// # Parameters
201    ///
202    /// - `site_id`: ID of the site you are fetching prices for (obtained from [`sites()`])
203    /// - `start_date`: Optional start date for the price range (defaults to today)
204    /// - `end_date`: Optional end date for the price range (defaults to today)
205    /// - `resolution`: Optional interval duration (5 or 30 minutes, defaults to your billing interval)
206    ///
207    /// # Authentication
208    ///
209    /// This method requires authentication via API key. The API key can be provided
210    /// either through the `AMBER_API_KEY` environment variable (when using [`Amber::default()`])
211    /// or by explicitly setting it when building the client.
212    ///
213    /// # Returns
214    ///
215    /// Returns a [`Result`] containing a [`Vec`] of [`Interval`] objects on success.
216    /// Intervals are returned in order: General > Controlled Load > Feed In.
217    ///
218    /// # Errors
219    ///
220    /// This method will return an error if:
221    ///
222    /// - The API key is missing or invalid (HTTP 401)
223    /// - The site ID is invalid (HTTP 400)
224    /// - The site is not found (HTTP 404)
225    /// - The date range exceeds 7 days (HTTP 422)
226    /// - There's a network error communicating with the API
227    /// - The API returns an internal server error (HTTP 500)
228    ///
229    /// # Example
230    ///
231    /// ```
232    /// use std::str::FromStr;
233    ///
234    /// use amber_api::Amber;
235    /// use amber_api::models::Resolution;
236    /// use jiff::civil::Date;
237    ///
238    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
239    /// let client = Amber::default();
240    /// let sites = client.sites()?;
241    /// let site_id = &sites[0].id;
242    ///
243    /// // Get prices for today
244    /// let prices = client.prices()
245    ///     .site_id(site_id)
246    ///     .call()?;
247    ///
248    /// // Get prices for a specific date range
249    /// let start_date = Date::from_str("2021-05-01").expect("Invalid start date");
250    /// let end_date = Date::from_str("2021-05-03").expect("Invalid end date");
251    /// let prices = client.prices()
252    ///     .site_id(site_id)
253    ///     .start_date(start_date)
254    ///     .end_date(end_date)
255    ///     .resolution(Resolution::FiveMinute)
256    ///     .call()?;
257    ///
258    /// for interval in prices {
259    ///     match interval {
260    ///         amber_api::models::Interval::ActualInterval(actual) => {
261    ///             println!("Actual price: {:.2}c/kWh", actual.base.per_kwh);
262    ///         }
263    ///         _ => {} // Handle other interval types as needed
264    ///     }
265    /// }
266    /// # Ok(())
267    /// # }
268    /// ```
269    ///
270    /// [`sites()`]: Self::sites
271    /// [`Interval`]: crate::models::Interval
272    #[inline]
273    #[builder]
274    pub fn prices(
275        &self,
276        site_id: &str,
277        start_date: Option<jiff::civil::Date>,
278        end_date: Option<jiff::civil::Date>,
279        resolution: Option<models::Resolution>,
280    ) -> Result<Vec<models::Interval>> {
281        self.get(
282            &format!("sites/{site_id}/prices"),
283            [
284                ("startDate", start_date.map(|d| d.to_string())),
285                ("endDate", end_date.map(|d| d.to_string())),
286                ("resolution", resolution.map(|r| r.to_string())),
287            ]
288            .into_iter()
289            .filter_map(|(k, v)| v.map(|val| (k, val))),
290        )
291    }
292
293    /// Returns the current price for a specific site.
294    ///
295    /// This method retrieves the current pricing data for the specified site,
296    /// optionally including historical and forecast data.
297    ///
298    /// # Parameters
299    ///
300    /// - `site_id`: ID of the site you are fetching prices for (obtained from [`sites()`])
301    /// - `next`: Optional number of forecast intervals to return (max 2048 total)
302    /// - `previous`: Optional number of historical intervals to return (max 2048 total)
303    /// - `resolution`: Optional interval duration (5 or 30 minutes, defaults to your billing interval)
304    ///
305    /// # Authentication
306    ///
307    /// This method requires authentication via API key. The API key can be provided
308    /// either through the `AMBER_API_KEY` environment variable (when using [`Amber::default()`])
309    /// or by explicitly setting it when building the client.
310    ///
311    /// # Returns
312    ///
313    /// Returns a [`Result`] containing a [`Vec`] of [`Interval`] objects on success.
314    /// Intervals are returned in order: General > Controlled Load > Feed In.
315    ///
316    /// # Errors
317    ///
318    /// This method will return an error if:
319    ///
320    /// - The API key is missing or invalid (HTTP 401)
321    /// - The site ID is invalid (HTTP 400)
322    /// - The site is not found (HTTP 404)
323    /// - The total number of intervals exceeds 2048 (HTTP 422)
324    /// - There's a network error communicating with the API
325    /// - The API returns an internal server error (HTTP 500)
326    ///
327    /// # Example
328    ///
329    /// ```
330    /// use amber_api::Amber;
331    /// use amber_api::models::Resolution;
332    ///
333    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
334    /// let client = Amber::default();
335    /// let sites = client.sites()?;
336    /// let site_id = &sites[0].id;
337    ///
338    /// // Get current prices only
339    /// let current_prices = client.current_prices()
340    ///     .site_id(site_id)
341    ///     .call()?;
342    ///
343    /// // Get current prices with forecast
344    /// let prices_with_forecast = client.current_prices()
345    ///     .site_id(site_id)
346    ///     .next(8)
347    ///     .resolution(Resolution::ThirtyMinute)
348    ///     .call()?;
349    ///
350    /// // Get current prices with history and forecast
351    /// let full_prices = client.current_prices()
352    ///     .site_id(site_id)
353    ///     .previous(8)
354    ///     .next(8)
355    ///     .call()?;
356    ///
357    /// for interval in current_prices {
358    ///     match interval {
359    ///         amber_api::models::Interval::CurrentInterval(current) => {
360    ///             println!("Current price: {:.2}c/kWh (estimate: {})",
361    ///                      current.base.per_kwh, current.estimate);
362    ///         }
363    ///         _ => {} // Handle other interval types as needed
364    ///     }
365    /// }
366    /// # Ok(())
367    /// # }
368    /// ```
369    ///
370    /// [`sites()`]: Self::sites
371    /// [`Interval`]: crate::models::Interval
372    #[inline]
373    #[builder]
374    pub fn current_prices(
375        &self,
376        site_id: &str,
377        next: Option<u32>,
378        previous: Option<u32>,
379        resolution: Option<models::Resolution>,
380    ) -> Result<Vec<models::Interval>> {
381        self.get(
382            &format!("sites/{site_id}/prices/current"),
383            [
384                ("next", next.map(|n| n.to_string())),
385                ("previous", previous.map(|p| p.to_string())),
386                ("resolution", resolution.map(|r| r.to_string())),
387            ]
388            .into_iter()
389            .filter_map(|(k, v)| v.map(|val| (k, val))),
390        )
391    }
392
393    /// Returns all usage data between the start and end dates for a specific site.
394    ///
395    /// This method retrieves historical usage data for the specified site between
396    /// the given date range. The date range cannot exceed 7 days, and the API can
397    /// only return 90 days worth of data.
398    ///
399    /// # Parameters
400    ///
401    /// - `site_id`: ID of the site you are fetching usage for (obtained from [`sites()`])
402    /// - `start_date`: Start date for the usage data (required)
403    /// - `end_date`: End date for the usage data (required)
404    /// - `resolution`: Optional interval duration (deprecated, will be ignored)
405    ///
406    /// # Authentication
407    ///
408    /// This method requires authentication via API key. The API key can be provided
409    /// either through the `AMBER_API_KEY` environment variable (when using [`Amber::default()`])
410    /// or by explicitly setting it when building the client.
411    ///
412    /// # Returns
413    ///
414    /// Returns a [`Result`] containing a [`Vec`] of [`Usage`] objects on success.
415    /// Usage data is returned in order: General > Controlled Load > Feed In.
416    ///
417    /// # Errors
418    ///
419    /// This method will return an error if:
420    ///
421    /// - The API key is missing or invalid (HTTP 401)
422    /// - The site ID is invalid (HTTP 400)
423    /// - The site is not found (HTTP 404)
424    /// - The date range exceeds 7 days (HTTP 422)
425    /// - There's a network error communicating with the API
426    /// - The API returns an internal server error (HTTP 500)
427    ///
428    /// # Example
429    ///
430    /// ```
431    /// use std::str::FromStr;
432    ///
433    /// use amber_api::Amber;
434    /// use jiff::civil::Date;
435    ///
436    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
437    /// let client = Amber::default();
438    /// let sites = client.sites()?;
439    /// let site_id = &sites[0].id;
440    ///
441    /// // Get usage data for a specific date range
442    /// let start_date = Date::from_str("2021-05-01").expect("Invalid start date");
443    /// let end_date = Date::from_str("2021-05-03").expect("Invalid end date");
444    /// let usage_data = client.usage()
445    ///     .site_id(site_id)
446    ///     .start_date(start_date)
447    ///     .end_date(end_date)
448    ///     .call()?;
449    ///
450    /// for usage in usage_data {
451    ///     println!("Channel {}: {:.2} kWh, Cost: ${:.2}",
452    ///              usage.channel_identifier, usage.kwh, usage.cost);
453    /// }
454    /// # Ok(())
455    /// # }
456    /// ```
457    ///
458    /// [`sites()`]: Self::sites
459    /// [`Usage`]: crate::models::Usage
460    #[inline]
461    #[builder]
462    pub fn usage(
463        &self,
464        site_id: &str,
465        start_date: jiff::civil::Date,
466        end_date: jiff::civil::Date,
467    ) -> Result<Vec<models::Usage>> {
468        let start_date_str = start_date.to_string();
469        let end_date_str = end_date.to_string();
470        let query_params = [
471            ("startDate", start_date_str.as_str()),
472            ("endDate", end_date_str.as_str()),
473        ];
474
475        self.get(&format!("sites/{site_id}/usage"), query_params)
476    }
477}