From ddcb375345adc67462da32f912950d1c6d06f8d2 Mon Sep 17 00:00:00 2001 From: Paul van Tilburg Date: Sun, 15 Jan 2023 16:31:37 +0100 Subject: [PATCH] Introduce an error type for services As a result, services don't always have to provide a `reqwest::Error` but also return other errors. The error variant `Error::NotAuthorized` in particular specifies that requests are not or no longer allowed and a login should be (re)attempted. This way, services can indicate that it is in this state and not have to provided a 403 status code `reqwest::Error` to show this. Add a depend on the `thiserror` crate for this. --- Cargo.lock | 21 +++++++++++++++++++++ Cargo.toml | 1 + src/services.rs | 20 ++++++++++++++++++-- src/services/hoymiles.rs | 12 ++++++------ src/services/my_autarco.rs | 20 +++++++++++++------- src/update.rs | 5 ++--- 6 files changed, 61 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4ba1e7f..7a7f755 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1670,6 +1670,7 @@ dependencies = [ "reqwest", "rocket", "serde", + "thiserror", "toml", "url", ] @@ -1738,6 +1739,26 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "thiserror" +version = "1.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a9cd18aa97d5c45c6603caea1da6628790b37f7a34b6ca89522331c5180fed0" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fb327af4685e4d03fa8cbcf1716380da910eeb2bb8be417e7f9fd3fb164f36f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thread_local" version = "1.1.4" diff --git a/Cargo.toml b/Cargo.toml index e8914b1..1e69372 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ once_cell = "1.9.0" reqwest = { version = "0.11.6", features = ["cookies", "json"] } rocket = { version = "0.5.0-rc.2", features = ["json"] } serde = "1.0.116" +thiserror = "1.0.38" toml = "0.5.6" url = "2.2.2" diff --git a/src/services.rs b/src/services.rs index 5369f37..b776d58 100644 --- a/src/services.rs +++ b/src/services.rs @@ -27,6 +27,22 @@ pub(crate) fn get(config: Config) -> color_eyre::Result { } } +/// The errors that can occur during service API transactions. +#[derive(Debug, thiserror::Error)] +pub(crate) enum Error { + /// The service is not or no longer authorized to perform requests. + /// + /// This usually indicates that the service needs to login again. + #[error("not/no longer authorized")] + NotAuthorized, + /// The services encountered some other API request error. + #[error("API request error")] + Request(#[from] reqwest::Error) +} + +/// Type alias for service results. +pub(crate) type Result = std::result::Result; + /// The supported cloud services. #[enum_dispatch(Service)] pub(crate) enum Services { @@ -44,8 +60,8 @@ pub(crate) trait Service { fn poll_interval(&self) -> u64; /// Perfoms a login on the cloud service (if necessary). - async fn login(&mut self) -> Result<(), reqwest::Error>; + async fn login(&mut self) -> Result<()>; /// Retrieves a status update using the API of the cloud service. - async fn update(&mut self, timestamp: u64) -> Result; + async fn update(&mut self, timestamp: u64) -> Result; } diff --git a/src/services/hoymiles.rs b/src/services/hoymiles.rs index 8ab4e82..f43e5b1 100644 --- a/src/services/hoymiles.rs +++ b/src/services/hoymiles.rs @@ -13,7 +13,7 @@ use rocket::async_trait; use serde::{Deserialize, Deserializer, Serialize}; use url::ParseError; -use crate::Status; +use crate::{services::Result, Status}; /// The base URL of Hoymiles API gateway. const BASE_URL: &str = "https://global.hoymiles.com/platform/api/gateway"; @@ -36,7 +36,7 @@ pub(crate) struct Config { } /// Instantiates the Hoymiles service. -pub(crate) fn service(config: Config) -> Result { +pub(crate) fn service(config: Config) -> Result { let cookie_jar = Arc::new(CookieJar::default()); let client = ClientBuilder::new() .cookie_provider(Arc::clone(&cookie_jar)) @@ -276,7 +276,7 @@ impl super::Service for Service { /// It mainly stores the acquired cookies in the client's cookie jar and adds the token cookie /// provided by the logins response. The login credentials come from the loaded configuration /// (see [`Config`]). - async fn login(&mut self) -> Result<(), reqwest::Error> { + async fn login(&mut self) -> Result<()> { let base_url = Url::parse(BASE_URL).expect("valid base URL"); let login_url = login_url().expect("valid login URL"); let login_request = ApiLoginRequest::new(&self.config.username, &self.config.password); @@ -292,7 +292,7 @@ impl super::Service for Service { eprintln!("api_response = {:#?}", &api_response); api_response.data.expect("No API response data found") } - Err(err) => return Err(err), + Err(err) => return Err(err.into()), }; // Insert the token in the reponse data as the cookie `hm_token` into the cookie jar. let cookie = format!("hm_token={}", login_response_data.token); @@ -306,7 +306,7 @@ impl super::Service for Service { /// It needs the cookies from the login to be able to perform the action. /// It uses a endpoint to construct the [`Status`] struct, but it needs to summarize the today /// value with the total value because Hoymiles only includes it after the day has finished. - async fn update(&mut self, _last_updated: u64) -> Result { + async fn update(&mut self, _last_updated: u64) -> Result { let api_url = api_url().expect("valid API power URL"); let api_data_request = ApiDataRequest::new(self.config.sid); let api_response = self @@ -321,7 +321,7 @@ impl super::Service for Service { eprintln!("api_response = {:#?}", &api_response); api_response.data.expect("No API response data found") } - Err(err) => return Err(err), + Err(err) => return Err(err.into()), }; let current_w = api_data.real_power; let mut total_kwh = (api_data.total_eq + api_data.today_eq) / 1000.0; diff --git a/src/services/my_autarco.rs b/src/services/my_autarco.rs index 35c0a98..3da8bdf 100644 --- a/src/services/my_autarco.rs +++ b/src/services/my_autarco.rs @@ -4,12 +4,15 @@ //! to retrieve the energy data (using the session cookies). //! See also: . -use reqwest::{Client, ClientBuilder, Url}; +use reqwest::{Client, ClientBuilder, StatusCode, Url}; use rocket::async_trait; use serde::Deserialize; use url::ParseError; -use crate::Status; +use crate::{ + services::{Error, Result}, + Status, +}; /// The base URL of My Autarco site. const BASE_URL: &str = "https://my.autarco.com"; @@ -29,7 +32,7 @@ pub(crate) struct Config { } /// Instantiates the My Autarco service. -pub(crate) fn service(config: Config) -> Result { +pub(crate) fn service(config: Config) -> Result { let client = ClientBuilder::new().cookie_store(true).build()?; let service = Service { client, config }; @@ -86,7 +89,7 @@ impl super::Service for Service { /// /// It mainly stores the acquired cookie in the client's cookie jar. The login credentials come /// from the loaded configuration (see [`Config`]). - async fn login(&mut self) -> Result<(), reqwest::Error> { + async fn login(&mut self) -> Result<()> { let params = [ ("username", &self.config.username), ("password", &self.config.password), @@ -101,20 +104,23 @@ impl super::Service for Service { /// /// It needs the cookie from the login to be able to perform the action. It uses both the /// `energy` and `power` endpoint to construct the [`Status`] struct. - async fn update(&mut self, last_updated: u64) -> Result { + async fn update(&mut self, last_updated: u64) -> Result { // Retrieve the data from the API endpoints. let api_energy_url = api_url(&self.config.site_id, "energy").expect("valid API energy URL"); let api_response = self.client.get(api_energy_url).send().await?; let api_energy = match api_response.error_for_status() { Ok(res) => res.json::().await?, - Err(err) => return Err(err), + Err(err) if err.status() == Some(StatusCode::UNAUTHORIZED) => { + return Err(Error::NotAuthorized) + } + Err(err) => return Err(err.into()), }; let api_power_url = api_url(&self.config.site_id, "power").expect("valid API power URL"); let api_response = self.client.get(api_power_url).send().await?; let api_power = match api_response.error_for_status() { Ok(res) => res.json::().await?, - Err(err) => return Err(err), + Err(err) => return Err(err.into()), }; Ok(Status { diff --git a/src/update.rs b/src/update.rs index 795dc32..0a229b3 100644 --- a/src/update.rs +++ b/src/update.rs @@ -2,11 +2,10 @@ use std::time::{Duration, SystemTime}; -use reqwest::StatusCode; use rocket::tokio::time::sleep; use crate::{ - services::{Service, Services}, + services::{Error, Service, Services}, STATUS, }; @@ -38,7 +37,7 @@ pub(super) async fn update_loop(service: Services) -> color_eyre::Result<()> { let status = match service.update(timestamp).await { Ok(status) => status, - Err(e) if e.status() == Some(StatusCode::UNAUTHORIZED) => { + Err(Error::NotAuthorized) => { println!("✨ Update unauthorized, trying to log in again..."); service.login().await?; println!("⚡ Logged in successfully!");