From edd102ffe7371efa6aa44446ffa49ec326557fa5 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Thu, 5 Dec 2024 10:03:32 -0500 Subject: [PATCH 1/8] Adds auto discovery attempt type --- wp_api/src/login/login_client.rs | 8 ++-- wp_api/src/login/url_discovery.rs | 66 +++++++++++++++++++++++-------- 2 files changed, 55 insertions(+), 19 deletions(-) diff --git a/wp_api/src/login/login_client.rs b/wp_api/src/login/login_client.rs index abc4a804..58bd4966 100644 --- a/wp_api/src/login/login_client.rs +++ b/wp_api/src/login/login_client.rs @@ -8,8 +8,9 @@ use crate::request::{ use crate::ParsedUrl; use super::url_discovery::{ - self, FetchApiDetailsError, FetchApiRootUrlError, StateInitial, UrlDiscoveryAttemptError, - UrlDiscoveryAttemptSuccess, UrlDiscoveryError, UrlDiscoveryState, UrlDiscoverySuccess, + self, AutoDiscoveryAttempt, FetchApiDetailsError, FetchApiRootUrlError, StateInitial, + UrlDiscoveryAttemptError, UrlDiscoveryAttemptSuccess, UrlDiscoveryError, UrlDiscoveryState, + UrlDiscoverySuccess, }; const API_ROOT_LINK_HEADER: &str = "https://api.w.org/"; @@ -89,8 +90,9 @@ impl WpLoginClient { async fn attempt_api_discovery( &self, - site_url: &str, + attempt: &AutoDiscoveryAttempt, ) -> Result { + let site_url = attempt.site_url.as_str(); let initial_state = StateInitial::new(site_url); let parsed_url_state = initial_state diff --git a/wp_api/src/login/url_discovery.rs b/wp_api/src/login/url_discovery.rs index a14d84d7..4497caf3 100644 --- a/wp_api/src/login/url_discovery.rs +++ b/wp_api/src/login/url_discovery.rs @@ -9,18 +9,52 @@ use super::WpApiDetails; const API_ROOT_LINK_HEADER: &str = "https://api.w.org/"; -pub fn construct_attempts(input_site_url: String) -> Vec { - let mut attempts = vec![input_site_url.clone()]; +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct AutoDiscoveryAttempt { + pub(crate) site_url: String, + pub(crate) attempt_type: AutoDiscoveryAttemptType, +} + +impl AutoDiscoveryAttempt { + fn new(site_url: impl Into, attempt_type: AutoDiscoveryAttemptType) -> Self { + Self { + site_url: site_url.into(), + attempt_type, + } + } +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum AutoDiscoveryAttemptType { + Original, + AutoHttps, + AutoDotPhpExtensionForWpAdmin, +} + +pub fn construct_attempts(input_site_url: String) -> Vec { + let mut attempts = vec![AutoDiscoveryAttempt::new( + input_site_url.clone(), + AutoDiscoveryAttemptType::Original, + )]; if !input_site_url.starts_with("http") { - attempts.push(format!("https://{}", input_site_url)) + attempts.push(AutoDiscoveryAttempt::new( + format!("https://{}", input_site_url), + AutoDiscoveryAttemptType::AutoHttps, + )) } if input_site_url.ends_with("wp-admin") { - attempts.push(format!("{}.php", input_site_url)) + attempts.push(AutoDiscoveryAttempt::new( + format!("{}.php", input_site_url), + AutoDiscoveryAttemptType::AutoDotPhpExtensionForWpAdmin, + )) } else if input_site_url.ends_with("wp-admin/") { let mut s = input_site_url.clone(); s.pop() .expect("Already verified that there is at least one char"); - attempts.push(format!("{}.php", s)); + attempts.push(AutoDiscoveryAttempt::new( + format!("{}.php", s), + AutoDiscoveryAttemptType::AutoDotPhpExtensionForWpAdmin, + )) } attempts } @@ -249,25 +283,25 @@ mod tests { use rstest::*; #[rstest] - #[case("localhost", vec!["localhost", "https://localhost"])] - #[case("http://localhost", vec!["http://localhost"])] - #[case("http://localhost/wp-json", vec!["http://localhost/wp-json"])] - #[case("http://localhost/wp-admin.php", vec!["http://localhost/wp-admin.php"])] - #[case("http://localhost/wp-admin", vec!["http://localhost/wp-admin", "http://localhost/wp-admin.php"])] - #[case("http://localhost/wp-admin/", vec!["http://localhost/wp-admin/", "http://localhost/wp-admin.php"])] - #[case("orchestremetropolitain.com/wp-json", vec!["orchestremetropolitain.com/wp-json", "https://orchestremetropolitain.com/wp-json"])] - #[case("https://orchestremetropolitain.com", vec!["https://orchestremetropolitain.com"])] + #[case("localhost", vec![AutoDiscoveryAttempt::new("localhost", AutoDiscoveryAttemptType::Original), AutoDiscoveryAttempt::new("https://localhost", AutoDiscoveryAttemptType::AutoHttps)])] + #[case("http://localhost", vec![AutoDiscoveryAttempt::new("http://localhost", AutoDiscoveryAttemptType::Original)])] + #[case("http://localhost/wp-json", vec![AutoDiscoveryAttempt::new("http://localhost/wp-json", AutoDiscoveryAttemptType::Original)])] + #[case("http://localhost/wp-admin.php", vec![AutoDiscoveryAttempt::new("http://localhost/wp-admin.php", AutoDiscoveryAttemptType::Original)])] + #[case("http://localhost/wp-admin", vec![AutoDiscoveryAttempt::new("http://localhost/wp-admin", AutoDiscoveryAttemptType::Original), AutoDiscoveryAttempt::new("http://localhost/wp-admin.php", AutoDiscoveryAttemptType::AutoDotPhpExtensionForWpAdmin)])] + #[case("http://localhost/wp-admin/", vec![AutoDiscoveryAttempt::new("http://localhost/wp-admin/", AutoDiscoveryAttemptType::Original), AutoDiscoveryAttempt::new("http://localhost/wp-admin.php", AutoDiscoveryAttemptType::AutoDotPhpExtensionForWpAdmin)])] + #[case("orchestremetropolitain.com/wp-json", vec![AutoDiscoveryAttempt::new("orchestremetropolitain.com/wp-json", AutoDiscoveryAttemptType::Original), AutoDiscoveryAttempt::new("https://orchestremetropolitain.com/wp-json", AutoDiscoveryAttemptType::AutoHttps)])] + #[case("https://orchestremetropolitain.com", vec![AutoDiscoveryAttempt::new("https://orchestremetropolitain.com", AutoDiscoveryAttemptType::Original)])] #[case( "https://orchestremetropolitain.com/fr/", - vec!["https://orchestremetropolitain.com/fr/"] + vec![AutoDiscoveryAttempt::new("https://orchestremetropolitain.com/fr/", AutoDiscoveryAttemptType::Original)] )] #[case( "https://orchestremetropolitain.com/wp-json", - vec!["https://orchestremetropolitain.com/wp-json"] + vec![AutoDiscoveryAttempt::new("https://orchestremetropolitain.com/wp-json", AutoDiscoveryAttemptType::Original)] )] fn test_construct_attempts( #[case] input_site_url: &str, - #[case] mut expected_attempts: Vec<&str>, + #[case] mut expected_attempts: Vec, ) { let mut found_attempts = construct_attempts(input_site_url.to_string()); found_attempts.sort(); From 87225d101f86912080da92acc4c2c0ca4f0ac1fa Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Thu, 5 Dec 2024 10:57:19 -0500 Subject: [PATCH 2/8] Remove state machine from login auto discovery --- wp_api/src/login/login_client.rs | 81 +++++++++++++++++------- wp_api/src/login/url_discovery.rs | 102 +----------------------------- 2 files changed, 59 insertions(+), 124 deletions(-) diff --git a/wp_api/src/login/login_client.rs b/wp_api/src/login/login_client.rs index 58bd4966..a31f7642 100644 --- a/wp_api/src/login/login_client.rs +++ b/wp_api/src/login/login_client.rs @@ -8,10 +8,11 @@ use crate::request::{ use crate::ParsedUrl; use super::url_discovery::{ - self, AutoDiscoveryAttempt, FetchApiDetailsError, FetchApiRootUrlError, StateInitial, + self, AutoDiscoveryAttempt, FetchApiDetailsError, FetchApiRootUrlError, UrlDiscoveryAttemptError, UrlDiscoveryAttemptSuccess, UrlDiscoveryError, UrlDiscoveryState, UrlDiscoverySuccess, }; +use super::WpApiDetails; const API_ROOT_LINK_HEADER: &str = "https://api.w.org/"; @@ -93,36 +94,70 @@ impl WpLoginClient { attempt: &AutoDiscoveryAttempt, ) -> Result { let site_url = attempt.site_url.as_str(); - let initial_state = StateInitial::new(site_url); - let parsed_url_state = - initial_state - .parse() - .map_err(|e| UrlDiscoveryAttemptError::FailedToParseSiteUrl { - site_url: site_url.to_string(), - error: e, - })?; - let parsed_site_url = parsed_url_state.site_url.clone(); - let state_fetched_api_root_url = self - .fetch_api_root_url(&parsed_url_state.site_url) + let parsed_site_url: Arc = self.parse_attempt_url(site_url)?.into(); + let api_root_url: Arc = self + .fetch_api_root_url(&parsed_site_url) .await - .and_then(|r| parsed_url_state.parse_api_root_response(r)) + .and_then(|r| self.parse_api_root_response(&parsed_site_url, r)) .map_err(|e| UrlDiscoveryAttemptError::FetchApiRootUrlFailed { - site_url: Arc::new(parsed_site_url), + site_url: Arc::clone(&parsed_site_url), error: e, - })?; - match self - .fetch_wp_api_details(&state_fetched_api_root_url.api_root_url) + })? + .into(); + let api_details: Arc = self + .fetch_wp_api_details(Arc::clone(&api_root_url)) .await + .and_then(|api_details_response| self.parse_api_details_response(api_details_response)) + .map_err(|err| UrlDiscoveryAttemptError::FetchApiDetailsFailed { + site_url: Arc::clone(&parsed_site_url), + api_root_url: Arc::clone(&api_root_url), + error: err, + })? + .into(); + Ok(UrlDiscoveryAttemptSuccess { + site_url: Arc::clone(&parsed_site_url), + api_root_url: Arc::clone(&api_root_url), + api_details: Arc::clone(&api_details), + }) + } + + fn parse_attempt_url(&self, site_url: &str) -> Result { + ParsedUrl::parse(site_url).map_err(|e| UrlDiscoveryAttemptError::FailedToParseSiteUrl { + site_url: site_url.to_string(), + error: e, + }) + } + + fn parse_api_root_response( + &self, + site_url: &ParsedUrl, + response: WpNetworkResponse, + ) -> Result { + match response + .get_link_header(API_ROOT_LINK_HEADER) + .into_iter() + .nth(0) { - Ok(r) => state_fetched_api_root_url.parse_api_details_response(r), - Err(e) => Err(UrlDiscoveryAttemptError::FetchApiDetailsFailed { - site_url: Arc::new(state_fetched_api_root_url.site_url), - api_root_url: Arc::new(state_fetched_api_root_url.api_root_url), - error: e, + Some(url) => Ok(ParsedUrl::new(url)), + None => Err(FetchApiRootUrlError::ApiRootLinkHeaderNotFound { + header_map: response.header_map, + status_code: response.status_code, }), } } + fn parse_api_details_response( + &self, + response: WpNetworkResponse, + ) -> Result { + serde_json::from_slice::(&response.body).map_err(|err| { + FetchApiDetailsError::ApiDetailsCouldntBeParsed { + reason: err.to_string(), + response: response.body_as_string(), + } + }) + } + // Fetches the site's homepage with a HEAD request, then extracts the Link header pointing // to the WP.org API root async fn fetch_api_root_url( @@ -143,7 +178,7 @@ impl WpLoginClient { async fn fetch_wp_api_details( &self, - api_root_url: &ParsedUrl, + api_root_url: Arc, ) -> Result { self.request_executor .execute( diff --git a/wp_api/src/login/url_discovery.rs b/wp_api/src/login/url_discovery.rs index 4497caf3..95d94a2f 100644 --- a/wp_api/src/login/url_discovery.rs +++ b/wp_api/src/login/url_discovery.rs @@ -1,9 +1,6 @@ use std::{collections::HashMap, sync::Arc}; -use crate::{ - request::{WpNetworkHeaderMap, WpNetworkResponse}, - ParseUrlError, ParsedUrl, RequestExecutionError, -}; +use crate::{request::WpNetworkHeaderMap, ParseUrlError, ParsedUrl, RequestExecutionError}; use super::WpApiDetails; @@ -115,103 +112,6 @@ pub enum UrlDiscoveryError { }, } -#[derive(Debug)] -pub(super) struct StateInitial { - pub site_url: String, -} - -impl StateInitial { - pub fn new(site_url: &str) -> Self { - Self { - site_url: site_url.to_string(), - } - } - - pub fn parse(self) -> Result { - ParsedUrl::parse(self.site_url.as_str()).map(StateParsedUrl::new) - } -} - -#[derive(Debug)] -pub(super) struct StateParsedUrl { - pub site_url: ParsedUrl, -} - -impl StateParsedUrl { - fn new(site_url: ParsedUrl) -> Self { - Self { site_url } - } - - pub fn parse_api_root_response( - self, - response: WpNetworkResponse, - ) -> Result { - match response - .get_link_header(API_ROOT_LINK_HEADER) - .into_iter() - .nth(0) - { - Some(url) => Ok(StateFetchedApiRootUrl { - site_url: self.site_url, - api_root_url: ParsedUrl::new(url), - }), - None => Err(FetchApiRootUrlError::ApiRootLinkHeaderNotFound { - header_map: response.header_map, - status_code: response.status_code, - }), - } - } -} - -#[derive(Debug)] -pub(super) struct StateFetchedApiRootUrl { - pub site_url: ParsedUrl, - pub api_root_url: ParsedUrl, -} - -impl StateFetchedApiRootUrl { - pub fn parse_api_details_response( - self, - response: WpNetworkResponse, - ) -> Result { - match serde_json::from_slice::(&response.body) { - Ok(api_details) => Ok(UrlDiscoveryAttemptSuccess { - site_url: Arc::new(self.site_url), - api_details: Arc::new(api_details), - api_root_url: Arc::new(self.api_root_url), - }), - Err(err) => { - let e = FetchApiDetailsError::ApiDetailsCouldntBeParsed { - reason: err.to_string(), - response: response.body_as_string(), - }; - Err(UrlDiscoveryAttemptError::FetchApiDetailsFailed { - site_url: Arc::new(self.site_url), - api_root_url: Arc::new(self.api_root_url), - error: e, - }) - } - } - } -} - -impl From for UrlDiscoveryAttemptSuccess { - fn from(state: StateFetchedApiDetails) -> Self { - UrlDiscoveryAttemptSuccess { - site_url: Arc::new(state.site_url), - api_details: Arc::new(state.api_details), - api_root_url: Arc::new(state.api_root_url), - } - } -} - -#[derive(Debug)] -pub(super) struct StateFetchedApiDetails { - pub site_url: ParsedUrl, - pub api_details: WpApiDetails, - pub api_root_url: ParsedUrl, -} - #[derive(Debug, thiserror::Error, uniffi::Error)] pub enum FetchApiRootUrlError { #[error( From ac1c9e93b4f40f5240155e53b434a8f0f39a0133 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Fri, 6 Dec 2024 10:51:43 -0500 Subject: [PATCH 3/8] Rework auto discovery results --- wp_api/src/login.rs | 7 +- wp_api/src/login/login_client.rs | 183 +++++++--------- wp_api/src/login/url_discovery.rs | 203 +++++++++--------- .../tests/test_login_err.rs | 24 ++- .../tests/test_login_immut.rs | 17 +- 5 files changed, 211 insertions(+), 223 deletions(-) diff --git a/wp_api/src/login.rs b/wp_api/src/login.rs index 93a24731..251e6bad 100644 --- a/wp_api/src/login.rs +++ b/wp_api/src/login.rs @@ -4,16 +4,13 @@ use std::str; use std::sync::Arc; use wp_serde_helper::deserialize_i64_or_string; -pub use login_client::WpLoginClient; -pub use url_discovery::{UrlDiscoveryError, UrlDiscoveryState, UrlDiscoverySuccess}; - use crate::ParsedUrl; use crate::WpUuid; const KEY_APPLICATION_PASSWORDS: &str = "application-passwords"; -mod login_client; -mod url_discovery; +pub mod login_client; +pub mod url_discovery; #[derive(Debug, uniffi::Record)] pub struct WpRestApiUrls { diff --git a/wp_api/src/login/login_client.rs b/wp_api/src/login/login_client.rs index a31f7642..08979836 100644 --- a/wp_api/src/login/login_client.rs +++ b/wp_api/src/login/login_client.rs @@ -1,18 +1,17 @@ use std::str; use std::sync::Arc; +use super::url_discovery::{ + self, AutoDiscoveryAttempt, AutoDiscoveryAttemptFailure, AutoDiscoveryAttemptResult, + AutoDiscoveryAttemptSuccess, AutoDiscoveryResult, AutoDiscoveryUniffiResult, + ParseApiRootUrlError, +}; +use super::WpApiDetails; use crate::request::endpoint::WpEndpointUrl; use crate::request::{ RequestExecutor, RequestMethod, WpNetworkHeaderMap, WpNetworkRequest, WpNetworkResponse, }; -use crate::ParsedUrl; - -use super::url_discovery::{ - self, AutoDiscoveryAttempt, FetchApiDetailsError, FetchApiRootUrlError, - UrlDiscoveryAttemptError, UrlDiscoveryAttemptSuccess, UrlDiscoveryError, UrlDiscoveryState, - UrlDiscoverySuccess, -}; -use super::WpApiDetails; +use crate::{ParsedUrl, RequestExecutionError}; const API_ROOT_LINK_HEADER: &str = "https://api.w.org/"; @@ -30,11 +29,8 @@ impl UniffiWpLoginClient { } } - async fn api_discovery( - &self, - site_url: String, - ) -> Result { - self.inner.api_discovery(site_url).await + async fn api_discovery(&self, site_url: String) -> AutoDiscoveryUniffiResult { + self.inner.api_discovery(site_url).await.into() } } @@ -48,83 +44,84 @@ impl WpLoginClient { Self { request_executor } } - pub async fn api_discovery( - &self, - site_url: String, - ) -> Result { + pub async fn api_discovery(&self, site_url: String) -> AutoDiscoveryResult { let attempts = futures::future::join_all( url_discovery::construct_attempts(site_url) - .iter() - .map(|s| async { self.attempt_api_discovery(s).await }), + .into_iter() + .map(|attempt| async { self.attempt_api_discovery(attempt).await }), ) .await; - let successful_attempt = attempts.iter().find_map(|a| { - if let Ok(s) = a { - Some(( - Arc::clone(&s.site_url), - Arc::clone(&s.api_details), - Arc::clone(&s.api_root_url), - )) - } else { - None - } - }); - - let attempts = attempts - .into_iter() - .map(|a| match a { - Ok(s) => (s.site_url.url(), UrlDiscoveryState::Success(s)), - Err(e) => (e.site_url(), UrlDiscoveryState::Failure(e)), - }) - .collect(); - if let Some(s) = successful_attempt { - Ok(UrlDiscoverySuccess { - site_url: s.0, - api_details: s.1, - api_root_url: s.2, - attempts, - }) - } else { - Err(UrlDiscoveryError::UrlDiscoveryFailed { attempts }) + AutoDiscoveryResult { + attempts: attempts.into_iter().map(|r| (r.attempt_type, r)).collect(), } } async fn attempt_api_discovery( &self, - attempt: &AutoDiscoveryAttempt, - ) -> Result { - let site_url = attempt.site_url.as_str(); - let parsed_site_url: Arc = self.parse_attempt_url(site_url)?.into(); - let api_root_url: Arc = self - .fetch_api_root_url(&parsed_site_url) - .await - .and_then(|r| self.parse_api_root_response(&parsed_site_url, r)) - .map_err(|e| UrlDiscoveryAttemptError::FetchApiRootUrlFailed { - site_url: Arc::clone(&parsed_site_url), - error: e, - })? - .into(); - let api_details: Arc = self - .fetch_wp_api_details(Arc::clone(&api_root_url)) - .await - .and_then(|api_details_response| self.parse_api_details_response(api_details_response)) - .map_err(|err| UrlDiscoveryAttemptError::FetchApiDetailsFailed { - site_url: Arc::clone(&parsed_site_url), - api_root_url: Arc::clone(&api_root_url), - error: err, - })? - .into(); - Ok(UrlDiscoveryAttemptSuccess { - site_url: Arc::clone(&parsed_site_url), - api_root_url: Arc::clone(&api_root_url), - api_details: Arc::clone(&api_details), - }) + attempt: AutoDiscoveryAttempt, + ) -> AutoDiscoveryAttemptResult { + let result = self + .inner_attempt_api_discovery(attempt.attempt_site_url.as_str()) + .await; + AutoDiscoveryAttemptResult { + attempt_type: attempt.attempt_type, + attempt_site_url: attempt.attempt_site_url, + result, + } } - fn parse_attempt_url(&self, site_url: &str) -> Result { - ParsedUrl::parse(site_url).map_err(|e| UrlDiscoveryAttemptError::FailedToParseSiteUrl { - site_url: site_url.to_string(), - error: e, + async fn inner_attempt_api_discovery( + &self, + attempt_site_url: &str, + ) -> Result { + let parsed_site_url = ParsedUrl::parse(attempt_site_url) + .map_err(|error| AutoDiscoveryAttemptFailure::ParseSiteUrl { error })?; + let fetch_api_root_url_response = match self.fetch_api_root_url(&parsed_site_url).await { + Ok(r) => r, + Err(error) => { + return Err(AutoDiscoveryAttemptFailure::FetchApiRootUrl { + parsed_site_url, + error, + }) + } + }; + let api_root_url = + match self.parse_api_root_response(&parsed_site_url, fetch_api_root_url_response) { + Ok(api_root_url) => api_root_url, + Err(error) => { + return Err(AutoDiscoveryAttemptFailure::ParseApiRootUrl { + parsed_site_url, + error, + }) + } + }; + + let fetch_api_details_response = match self.fetch_wp_api_details(&api_root_url).await { + Ok(r) => r, + Err(error) => { + return Err(AutoDiscoveryAttemptFailure::FetchApiDetails { + parsed_site_url, + api_root_url, + error, + }) + } + }; + let api_details: WpApiDetails = + match serde_json::from_slice::(&fetch_api_details_response.body) { + Ok(api_details) => api_details, + Err(error) => { + return Err(AutoDiscoveryAttemptFailure::ParseApiDetails { + parsed_site_url, + api_root_url, + error, + }) + } + }; + + Ok(AutoDiscoveryAttemptSuccess { + parsed_site_url, + api_root_url, + api_details, }) } @@ -132,54 +129,39 @@ impl WpLoginClient { &self, site_url: &ParsedUrl, response: WpNetworkResponse, - ) -> Result { + ) -> Result { match response .get_link_header(API_ROOT_LINK_HEADER) .into_iter() .nth(0) { Some(url) => Ok(ParsedUrl::new(url)), - None => Err(FetchApiRootUrlError::ApiRootLinkHeaderNotFound { + None => Err(ParseApiRootUrlError::ApiRootLinkHeaderNotFound { header_map: response.header_map, status_code: response.status_code, }), } } - fn parse_api_details_response( - &self, - response: WpNetworkResponse, - ) -> Result { - serde_json::from_slice::(&response.body).map_err(|err| { - FetchApiDetailsError::ApiDetailsCouldntBeParsed { - reason: err.to_string(), - response: response.body_as_string(), - } - }) - } - // Fetches the site's homepage with a HEAD request, then extracts the Link header pointing // to the WP.org API root async fn fetch_api_root_url( &self, parsed_site_url: &ParsedUrl, - ) -> Result { + ) -> Result { let api_root_request = WpNetworkRequest { method: RequestMethod::HEAD, url: WpEndpointUrl(parsed_site_url.url()), header_map: WpNetworkHeaderMap::default().into(), body: None, }; - self.request_executor - .execute(api_root_request.into()) - .await - .map_err(FetchApiRootUrlError::from) + self.request_executor.execute(api_root_request.into()).await } async fn fetch_wp_api_details( &self, - api_root_url: Arc, - ) -> Result { + api_root_url: &ParsedUrl, + ) -> Result { self.request_executor .execute( WpNetworkRequest { @@ -191,6 +173,5 @@ impl WpLoginClient { .into(), ) .await - .map_err(FetchApiDetailsError::from) } } diff --git a/wp_api/src/login/url_discovery.rs b/wp_api/src/login/url_discovery.rs index 95d94a2f..2373aa4a 100644 --- a/wp_api/src/login/url_discovery.rs +++ b/wp_api/src/login/url_discovery.rs @@ -1,34 +1,122 @@ -use std::{collections::HashMap, sync::Arc}; - -use crate::{request::WpNetworkHeaderMap, ParseUrlError, ParsedUrl, RequestExecutionError}; - use super::WpApiDetails; +use crate::{request::WpNetworkHeaderMap, ParseUrlError, ParsedUrl, RequestExecutionError}; +use std::{collections::HashMap, sync::Arc}; const API_ROOT_LINK_HEADER: &str = "https://api.w.org/"; #[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] pub(crate) struct AutoDiscoveryAttempt { - pub(crate) site_url: String, + pub(crate) attempt_site_url: String, pub(crate) attempt_type: AutoDiscoveryAttemptType, } impl AutoDiscoveryAttempt { - fn new(site_url: impl Into, attempt_type: AutoDiscoveryAttemptType) -> Self { + fn new(attempt_site_url: impl Into, attempt_type: AutoDiscoveryAttemptType) -> Self { Self { - site_url: site_url.into(), + attempt_site_url: attempt_site_url.into(), attempt_type, } } } -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] -pub(crate) enum AutoDiscoveryAttemptType { +#[derive(Debug, uniffi::Record)] +pub struct AutoDiscoveryUniffiResult { + pub attempts: HashMap>, +} + +impl From for AutoDiscoveryUniffiResult { + fn from(value: AutoDiscoveryResult) -> Self { + Self { + attempts: value + .attempts + .into_iter() + .map(|(k, v)| (k, Arc::new(v))) + .collect(), + } + } +} + +#[derive(Debug)] +pub struct AutoDiscoveryResult { + pub attempts: HashMap, +} + +impl AutoDiscoveryResult { + pub fn find_successful(self) -> Option { + self.attempts + .into_iter() + .find(|(attempt_type, result)| result.result.is_ok()) + .map(|(attempt_type, result)| result) + } +} + +#[derive(Debug, uniffi::Object)] +pub struct AutoDiscoveryAttemptResult { + pub attempt_type: AutoDiscoveryAttemptType, + pub attempt_site_url: String, + pub result: Result, +} + +#[derive(Debug)] +pub struct AutoDiscoveryAttemptSuccess { + pub parsed_site_url: ParsedUrl, + pub api_root_url: ParsedUrl, + pub api_details: WpApiDetails, +} + +#[derive(Debug)] +pub enum AutoDiscoveryAttemptFailure { + ParseSiteUrl { + error: ParseUrlError, + }, + FetchApiRootUrl { + parsed_site_url: ParsedUrl, + error: RequestExecutionError, + }, + ParseApiRootUrl { + parsed_site_url: ParsedUrl, + error: ParseApiRootUrlError, + }, + FetchApiDetails { + parsed_site_url: ParsedUrl, + api_root_url: ParsedUrl, + error: RequestExecutionError, + }, + ParseApiDetails { + parsed_site_url: ParsedUrl, + api_root_url: ParsedUrl, + error: serde_json::Error, + }, +} + +impl AutoDiscoveryAttemptFailure { + pub fn into_attempt_result( + self, + attempt_type: AutoDiscoveryAttemptType, + attempt_site_url: String, + ) -> AutoDiscoveryAttemptResult { + AutoDiscoveryAttemptResult { + attempt_type, + attempt_site_url, + result: Err(self), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, uniffi::Enum)] +pub enum AutoDiscoveryAttemptType { Original, AutoHttps, AutoDotPhpExtensionForWpAdmin, } -pub fn construct_attempts(input_site_url: String) -> Vec { +impl AutoDiscoveryAttemptType { + fn is_the_site_url_same_as_the_user_input(&self) -> bool { + matches!(self, AutoDiscoveryAttemptType::Original) + } +} + +pub(crate) fn construct_attempts(input_site_url: String) -> Vec { let mut attempts = vec![AutoDiscoveryAttempt::new( input_site_url.clone(), AutoDiscoveryAttemptType::Original, @@ -56,73 +144,8 @@ pub fn construct_attempts(input_site_url: String) -> Vec { attempts } -#[derive(Debug, uniffi::Enum)] -pub enum UrlDiscoveryState { - Success(UrlDiscoveryAttemptSuccess), - Failure(UrlDiscoveryAttemptError), -} - -#[derive(Debug, uniffi::Record)] -pub struct UrlDiscoveryAttemptSuccess { - pub site_url: Arc, - pub api_details: Arc, - pub api_root_url: Arc, -} - -#[derive(Debug, uniffi::Enum)] -pub enum UrlDiscoveryAttemptError { - FailedToParseSiteUrl { - site_url: String, - error: ParseUrlError, - }, - FetchApiRootUrlFailed { - site_url: Arc, - error: FetchApiRootUrlError, - }, - FetchApiDetailsFailed { - site_url: Arc, - api_root_url: Arc, - error: FetchApiDetailsError, - }, -} - -impl UrlDiscoveryAttemptError { - pub fn site_url(&self) -> String { - match self { - UrlDiscoveryAttemptError::FailedToParseSiteUrl { site_url, .. } => site_url.clone(), - UrlDiscoveryAttemptError::FetchApiRootUrlFailed { site_url, .. } => site_url.url(), - UrlDiscoveryAttemptError::FetchApiDetailsFailed { site_url, .. } => site_url.url(), - } - } -} - -#[derive(Debug, uniffi::Record)] -pub struct UrlDiscoverySuccess { - pub site_url: Arc, - pub api_details: Arc, - pub api_root_url: Arc, - pub attempts: HashMap, -} - #[derive(Debug, thiserror::Error, uniffi::Error)] -pub enum UrlDiscoveryError { - #[error("Url discovery failed: {:?}", attempts)] - UrlDiscoveryFailed { - attempts: HashMap, - }, -} - -#[derive(Debug, thiserror::Error, uniffi::Error)] -pub enum FetchApiRootUrlError { - #[error( - "Request execution failed!\nStatus Code: '{:?}'\nResponse: '{}'", - status_code, - reason - )] - RequestExecutionFailed { - status_code: Option, - reason: String, - }, +pub enum ParseApiRootUrlError { #[error( "Api root link header not found!\nStatus Code: '{:#?}'\nHeader Map: '{:#?}'", status_code, @@ -134,20 +157,6 @@ pub enum FetchApiRootUrlError { }, } -impl From for FetchApiRootUrlError { - fn from(value: RequestExecutionError) -> Self { - match value { - RequestExecutionError::RequestExecutionFailed { - status_code, - reason, - } => Self::RequestExecutionFailed { - status_code, - reason, - }, - } - } -} - #[derive(Debug, thiserror::Error, uniffi::Error)] pub enum FetchApiDetailsError { #[error( @@ -163,20 +172,6 @@ pub enum FetchApiDetailsError { ApiDetailsCouldntBeParsed { reason: String, response: String }, } -impl From for FetchApiDetailsError { - fn from(value: RequestExecutionError) -> Self { - match value { - RequestExecutionError::RequestExecutionFailed { - status_code, - reason, - } => Self::RequestExecutionFailed { - status_code, - reason, - }, - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/wp_api_integration_tests/tests/test_login_err.rs b/wp_api_integration_tests/tests/test_login_err.rs index 5880e939..fb92bdde 100644 --- a/wp_api_integration_tests/tests/test_login_err.rs +++ b/wp_api_integration_tests/tests/test_login_err.rs @@ -1,18 +1,30 @@ use rstest::rstest; use serial_test::parallel; use std::sync::Arc; -use wp_api::login::{UrlDiscoveryError, WpLoginClient}; +use wp_api::login::login_client::WpLoginClient; +use wp_api::login::url_discovery::{AutoDiscoveryAttemptFailure, AutoDiscoveryAttemptType}; use wp_api_integration_tests::AsyncWpNetworking; #[rstest] #[case("http://optional-https.wpmt.co")] // Fails because it's `http` #[tokio::test] #[parallel] -async fn test_login_flow_err_url_discovery_failed(#[case] site_url: &str) { +async fn test_login_flow_err_parse_api_details(#[case] site_url: &str) { let client = WpLoginClient::new(Arc::new(AsyncWpNetworking::default())); - let err = client - .api_discovery(site_url.to_string()) - .await + let mut result = client.api_discovery(site_url.to_string()).await; + let original_attempt_error = result + .attempts + .remove(&AutoDiscoveryAttemptType::Original) + .unwrap() + .result .unwrap_err(); - assert!(matches!(err, UrlDiscoveryError::UrlDiscoveryFailed { .. })); + println!(); + assert!( + matches!( + original_attempt_error, + AutoDiscoveryAttemptFailure::ParseApiDetails { .. } + ), + "{:#?}", + original_attempt_error + ); } diff --git a/wp_api_integration_tests/tests/test_login_immut.rs b/wp_api_integration_tests/tests/test_login_immut.rs index dcdc248e..2f0f9ecc 100644 --- a/wp_api_integration_tests/tests/test_login_immut.rs +++ b/wp_api_integration_tests/tests/test_login_immut.rs @@ -1,8 +1,8 @@ use rstest::rstest; use serial_test::parallel; use std::sync::Arc; -use wp_api::login::WpLoginClient; -use wp_api_integration_tests::{AssertResponse, AsyncWpNetworking}; +use wp_api::login::login_client::WpLoginClient; +use wp_api_integration_tests::AsyncWpNetworking; const LOCALHOST_AUTH_URL: &str = "http://localhost/wp-admin/authorize-application.php"; const AUTOMATTIC_WIDGETS_AUTH_URL: &str = @@ -54,12 +54,15 @@ const VANILLA_WP_SITE_URL: &str = "https://vanilla.wpmt.co/wp-admin/authorize-ap #[parallel] async fn test_login_flow(#[case] site_url: &str, #[case] expected_auth_url: &str) { let client = WpLoginClient::new(Arc::new(AsyncWpNetworking::default())); - let url_discovery = client - .api_discovery(site_url.to_string()) - .await - .assert_response(); + let result = client.api_discovery(site_url.to_string()).await; + let failure_message = format!("Auto discovery failed: {:#?}", result); + let successful_attempt = result.find_successful(); + assert!(successful_attempt.is_some(), "{}", failure_message); assert_eq!( - url_discovery + successful_attempt + .unwrap() + .result + .unwrap() .api_details .find_application_passwords_authentication_url(), Some(expected_auth_url.to_string()) From 1a6a2a9133ad0822b77f761e5f7c5d3786cc3db0 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Fri, 6 Dec 2024 14:56:06 -0500 Subject: [PATCH 4/8] Implement helpers for AutoDiscoveryAttemptResult & AutoDiscoveryAttemptFailure --- wp_api/src/login.rs | 6 +- wp_api/src/login/url_discovery.rs | 153 ++++++++++++++++++ .../tests/test_login_err.rs | 11 +- 3 files changed, 160 insertions(+), 10 deletions(-) diff --git a/wp_api/src/login.rs b/wp_api/src/login.rs index 251e6bad..87cf8e68 100644 --- a/wp_api/src/login.rs +++ b/wp_api/src/login.rs @@ -44,7 +44,7 @@ pub fn extract_login_details_from_url( }) } -#[derive(Debug, Serialize, Deserialize, uniffi::Object)] +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Object)] pub struct WpApiDetails { pub name: String, pub description: String, @@ -67,12 +67,12 @@ impl WpApiDetails { } } -#[derive(Debug, Serialize, Deserialize, uniffi::Record)] +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct WpRestApiAuthenticationScheme { pub endpoints: WpRestApiAuthenticationEndpoint, } -#[derive(Debug, Serialize, Deserialize, uniffi::Record)] +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct WpRestApiAuthenticationEndpoint { pub authorization: String, } diff --git a/wp_api/src/login/url_discovery.rs b/wp_api/src/login/url_discovery.rs index 2373aa4a..b0659002 100644 --- a/wp_api/src/login/url_discovery.rs +++ b/wp_api/src/login/url_discovery.rs @@ -57,6 +57,77 @@ pub struct AutoDiscoveryAttemptResult { pub result: Result, } +#[uniffi::export] +impl AutoDiscoveryAttemptResult { + fn attempt_site_url(&self) -> String { + self.attempt_site_url.clone() + } + + fn error_message(&self) -> Option { + match &self.result { + Ok(_) => None, + Err(error) => Some(error.error_message()), + } + } + + fn is_successful(&self) -> bool { + self.result.is_ok() + } + + fn is_network_error(&self) -> bool { + match &self.result { + Ok(_) => false, + Err(error) => error.is_network_error(), + } + } + + fn is_original_attempt(&self) -> bool { + matches!(self.attempt_type, AutoDiscoveryAttemptType::Original) + } + + fn has_failed_to_parse_site_url(&self) -> bool { + match &self.result { + Ok(success) => false, + Err(error) => error.parsed_site_url().is_none(), + } + } + + fn has_failed_to_parse_api_root_url(&self) -> Option { + match &self.result { + Ok(success) => Some(false), + Err(error) => error.has_failed_to_parse_api_root_url(), + } + } + + fn has_failed_to_parse_api_details(&self) -> Option { + match &self.result { + Ok(success) => Some(false), + Err(error) => error.has_failed_to_parse_api_details(), + } + } + + fn parsed_site_url(&self) -> Option> { + match &self.result { + Ok(success) => Some(Arc::new(success.parsed_site_url.clone())), + Err(error) => error.parsed_site_url().map(|p| Arc::new(p.clone())), + } + } + + fn api_root_url(&self) -> Option> { + match &self.result { + Ok(success) => Some(Arc::new(success.api_root_url.clone())), + Err(error) => error.api_root_url().map(|p| Arc::new(p.clone())), + } + } + + fn api_details(&self) -> Option> { + match &self.result { + Ok(success) => Some(Arc::new(success.api_details.clone())), + Err(_) => None, + } + } +} + #[derive(Debug)] pub struct AutoDiscoveryAttemptSuccess { pub parsed_site_url: ParsedUrl, @@ -101,6 +172,88 @@ impl AutoDiscoveryAttemptFailure { result: Err(self), } } + + pub fn error_message(&self) -> String { + match self { + AutoDiscoveryAttemptFailure::ParseSiteUrl { error } => error.to_string(), + AutoDiscoveryAttemptFailure::FetchApiRootUrl { error, .. } => error.to_string(), + AutoDiscoveryAttemptFailure::ParseApiRootUrl { error, .. } => error.to_string(), + AutoDiscoveryAttemptFailure::FetchApiDetails { error, .. } => error.to_string(), + AutoDiscoveryAttemptFailure::ParseApiDetails { error, .. } => { + format!("Failed to parse api details: {:#?}", error) + } + } + } + + pub fn is_network_error(&self) -> bool { + match self { + AutoDiscoveryAttemptFailure::FetchApiRootUrl { .. } => true, + AutoDiscoveryAttemptFailure::FetchApiDetails { .. } => true, + AutoDiscoveryAttemptFailure::ParseSiteUrl { .. } => false, + AutoDiscoveryAttemptFailure::ParseApiRootUrl { .. } => false, + AutoDiscoveryAttemptFailure::ParseApiDetails { .. } => false, + } + } + + pub fn parsed_site_url(&self) -> Option<&ParsedUrl> { + match self { + AutoDiscoveryAttemptFailure::ParseSiteUrl { .. } => None, + AutoDiscoveryAttemptFailure::FetchApiRootUrl { + parsed_site_url, .. + } => Some(parsed_site_url), + AutoDiscoveryAttemptFailure::ParseApiRootUrl { + parsed_site_url, .. + } => Some(parsed_site_url), + AutoDiscoveryAttemptFailure::FetchApiDetails { + parsed_site_url, .. + } => Some(parsed_site_url), + AutoDiscoveryAttemptFailure::ParseApiDetails { + parsed_site_url, .. + } => Some(parsed_site_url), + } + } + + // If it failed while parsing the site url or fetching the api root url, we never tried to + // parse it, so we return `None` + // + // If we fail to parse with `AutoDiscoveryAttemptFailure::ParseApiRootUrl`, we return + // `Some(true)`, because that's exactly when the failure happened. + // + // If an error occurs after parsing the api root url, we return `Some(false)`. + pub fn has_failed_to_parse_api_root_url(&self) -> Option { + match self { + AutoDiscoveryAttemptFailure::ParseSiteUrl { .. } => None, + AutoDiscoveryAttemptFailure::FetchApiRootUrl { .. } => None, + AutoDiscoveryAttemptFailure::ParseApiRootUrl { .. } => Some(true), + AutoDiscoveryAttemptFailure::FetchApiDetails { api_root_url, .. } => Some(false), + AutoDiscoveryAttemptFailure::ParseApiDetails { api_root_url, .. } => Some(false), + } + } + + pub fn api_root_url(&self) -> Option<&ParsedUrl> { + match self { + AutoDiscoveryAttemptFailure::ParseSiteUrl { .. } => None, + AutoDiscoveryAttemptFailure::FetchApiRootUrl { .. } => None, + AutoDiscoveryAttemptFailure::ParseApiRootUrl { .. } => None, + AutoDiscoveryAttemptFailure::FetchApiDetails { api_root_url, .. } => Some(api_root_url), + AutoDiscoveryAttemptFailure::ParseApiDetails { api_root_url, .. } => Some(api_root_url), + } + } + + // If it failed while parsing the site url, fetching the api root url, parsing the api root url + // or fetching the api details, we never tried to parse it, so we return `None`. + // + // If we fail to parse with `AutoDiscoveryAttemptFailure::ParseApiDetails`, we return + // `Some(true)`, because that's exactly when the failure happened. + pub fn has_failed_to_parse_api_details(&self) -> Option { + match self { + AutoDiscoveryAttemptFailure::ParseSiteUrl { .. } => None, + AutoDiscoveryAttemptFailure::FetchApiRootUrl { .. } => None, + AutoDiscoveryAttemptFailure::ParseApiRootUrl { .. } => None, + AutoDiscoveryAttemptFailure::FetchApiDetails { api_root_url, .. } => None, + AutoDiscoveryAttemptFailure::ParseApiDetails { api_root_url, .. } => Some(true), + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, uniffi::Enum)] diff --git a/wp_api_integration_tests/tests/test_login_err.rs b/wp_api_integration_tests/tests/test_login_err.rs index fb92bdde..2bfad5cc 100644 --- a/wp_api_integration_tests/tests/test_login_err.rs +++ b/wp_api_integration_tests/tests/test_login_err.rs @@ -2,7 +2,7 @@ use rstest::rstest; use serial_test::parallel; use std::sync::Arc; use wp_api::login::login_client::WpLoginClient; -use wp_api::login::url_discovery::{AutoDiscoveryAttemptFailure, AutoDiscoveryAttemptType}; +use wp_api::login::url_discovery::AutoDiscoveryAttemptType; use wp_api_integration_tests::AsyncWpNetworking; #[rstest] @@ -18,12 +18,9 @@ async fn test_login_flow_err_parse_api_details(#[case] site_url: &str) { .unwrap() .result .unwrap_err(); - println!(); - assert!( - matches!( - original_attempt_error, - AutoDiscoveryAttemptFailure::ParseApiDetails { .. } - ), + assert_eq!( + original_attempt_error.has_failed_to_parse_api_details(), + Some(true), "{:#?}", original_attempt_error ); From b9766776f4a2111e26943e750a069d09b8e2658e Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Mon, 9 Dec 2024 12:26:12 -0500 Subject: [PATCH 5/8] Rename AutoDiscoveryAttemptType::Original as UserInput --- wp_api/src/login/url_discovery.rs | 30 +++++++++---------- .../tests/test_login_err.rs | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/wp_api/src/login/url_discovery.rs b/wp_api/src/login/url_discovery.rs index b0659002..77d73253 100644 --- a/wp_api/src/login/url_discovery.rs +++ b/wp_api/src/login/url_discovery.rs @@ -81,8 +81,8 @@ impl AutoDiscoveryAttemptResult { } } - fn is_original_attempt(&self) -> bool { - matches!(self.attempt_type, AutoDiscoveryAttemptType::Original) + fn is_user_input_attempt(&self) -> bool { + matches!(self.attempt_type, AutoDiscoveryAttemptType::UserInput) } fn has_failed_to_parse_site_url(&self) -> bool { @@ -258,21 +258,21 @@ impl AutoDiscoveryAttemptFailure { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, uniffi::Enum)] pub enum AutoDiscoveryAttemptType { - Original, + UserInput, AutoHttps, AutoDotPhpExtensionForWpAdmin, } impl AutoDiscoveryAttemptType { fn is_the_site_url_same_as_the_user_input(&self) -> bool { - matches!(self, AutoDiscoveryAttemptType::Original) + matches!(self, AutoDiscoveryAttemptType::UserInput) } } pub(crate) fn construct_attempts(input_site_url: String) -> Vec { let mut attempts = vec![AutoDiscoveryAttempt::new( input_site_url.clone(), - AutoDiscoveryAttemptType::Original, + AutoDiscoveryAttemptType::UserInput, )]; if !input_site_url.starts_with("http") { attempts.push(AutoDiscoveryAttempt::new( @@ -331,21 +331,21 @@ mod tests { use rstest::*; #[rstest] - #[case("localhost", vec![AutoDiscoveryAttempt::new("localhost", AutoDiscoveryAttemptType::Original), AutoDiscoveryAttempt::new("https://localhost", AutoDiscoveryAttemptType::AutoHttps)])] - #[case("http://localhost", vec![AutoDiscoveryAttempt::new("http://localhost", AutoDiscoveryAttemptType::Original)])] - #[case("http://localhost/wp-json", vec![AutoDiscoveryAttempt::new("http://localhost/wp-json", AutoDiscoveryAttemptType::Original)])] - #[case("http://localhost/wp-admin.php", vec![AutoDiscoveryAttempt::new("http://localhost/wp-admin.php", AutoDiscoveryAttemptType::Original)])] - #[case("http://localhost/wp-admin", vec![AutoDiscoveryAttempt::new("http://localhost/wp-admin", AutoDiscoveryAttemptType::Original), AutoDiscoveryAttempt::new("http://localhost/wp-admin.php", AutoDiscoveryAttemptType::AutoDotPhpExtensionForWpAdmin)])] - #[case("http://localhost/wp-admin/", vec![AutoDiscoveryAttempt::new("http://localhost/wp-admin/", AutoDiscoveryAttemptType::Original), AutoDiscoveryAttempt::new("http://localhost/wp-admin.php", AutoDiscoveryAttemptType::AutoDotPhpExtensionForWpAdmin)])] - #[case("orchestremetropolitain.com/wp-json", vec![AutoDiscoveryAttempt::new("orchestremetropolitain.com/wp-json", AutoDiscoveryAttemptType::Original), AutoDiscoveryAttempt::new("https://orchestremetropolitain.com/wp-json", AutoDiscoveryAttemptType::AutoHttps)])] - #[case("https://orchestremetropolitain.com", vec![AutoDiscoveryAttempt::new("https://orchestremetropolitain.com", AutoDiscoveryAttemptType::Original)])] + #[case("localhost", vec![AutoDiscoveryAttempt::new("localhost", AutoDiscoveryAttemptType::UserInput), AutoDiscoveryAttempt::new("https://localhost", AutoDiscoveryAttemptType::AutoHttps)])] + #[case("http://localhost", vec![AutoDiscoveryAttempt::new("http://localhost", AutoDiscoveryAttemptType::UserInput)])] + #[case("http://localhost/wp-json", vec![AutoDiscoveryAttempt::new("http://localhost/wp-json", AutoDiscoveryAttemptType::UserInput)])] + #[case("http://localhost/wp-admin.php", vec![AutoDiscoveryAttempt::new("http://localhost/wp-admin.php", AutoDiscoveryAttemptType::UserInput)])] + #[case("http://localhost/wp-admin", vec![AutoDiscoveryAttempt::new("http://localhost/wp-admin", AutoDiscoveryAttemptType::UserInput), AutoDiscoveryAttempt::new("http://localhost/wp-admin.php", AutoDiscoveryAttemptType::AutoDotPhpExtensionForWpAdmin)])] + #[case("http://localhost/wp-admin/", vec![AutoDiscoveryAttempt::new("http://localhost/wp-admin/", AutoDiscoveryAttemptType::UserInput), AutoDiscoveryAttempt::new("http://localhost/wp-admin.php", AutoDiscoveryAttemptType::AutoDotPhpExtensionForWpAdmin)])] + #[case("orchestremetropolitain.com/wp-json", vec![AutoDiscoveryAttempt::new("orchestremetropolitain.com/wp-json", AutoDiscoveryAttemptType::UserInput), AutoDiscoveryAttempt::new("https://orchestremetropolitain.com/wp-json", AutoDiscoveryAttemptType::AutoHttps)])] + #[case("https://orchestremetropolitain.com", vec![AutoDiscoveryAttempt::new("https://orchestremetropolitain.com", AutoDiscoveryAttemptType::UserInput)])] #[case( "https://orchestremetropolitain.com/fr/", - vec![AutoDiscoveryAttempt::new("https://orchestremetropolitain.com/fr/", AutoDiscoveryAttemptType::Original)] + vec![AutoDiscoveryAttempt::new("https://orchestremetropolitain.com/fr/", AutoDiscoveryAttemptType::UserInput)] )] #[case( "https://orchestremetropolitain.com/wp-json", - vec![AutoDiscoveryAttempt::new("https://orchestremetropolitain.com/wp-json", AutoDiscoveryAttemptType::Original)] + vec![AutoDiscoveryAttempt::new("https://orchestremetropolitain.com/wp-json", AutoDiscoveryAttemptType::UserInput)] )] fn test_construct_attempts( #[case] input_site_url: &str, diff --git a/wp_api_integration_tests/tests/test_login_err.rs b/wp_api_integration_tests/tests/test_login_err.rs index 2bfad5cc..514c427d 100644 --- a/wp_api_integration_tests/tests/test_login_err.rs +++ b/wp_api_integration_tests/tests/test_login_err.rs @@ -14,7 +14,7 @@ async fn test_login_flow_err_parse_api_details(#[case] site_url: &str) { let mut result = client.api_discovery(site_url.to_string()).await; let original_attempt_error = result .attempts - .remove(&AutoDiscoveryAttemptType::Original) + .remove(&AutoDiscoveryAttemptType::UserInput) .unwrap() .result .unwrap_err(); From d9cbfa645cb8b2ba5cf0ce4973b38521233a0f01 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Mon, 9 Dec 2024 15:09:41 -0500 Subject: [PATCH 6/8] Add attempt specific fields to AutoDiscoveryUniffiResult --- wp_api/src/api_error.rs | 4 +- wp_api/src/login/login_client.rs | 2 +- wp_api/src/login/url_discovery.rs | 74 ++++++++++++++----- wp_api/src/parsed_url.rs | 2 +- .../tests/test_login_immut.rs | 16 ++-- 5 files changed, 71 insertions(+), 27 deletions(-) diff --git a/wp_api/src/api_error.rs b/wp_api/src/api_error.rs index a8edd8a0..367bbb25 100644 --- a/wp_api/src/api_error.rs +++ b/wp_api/src/api_error.rs @@ -394,7 +394,7 @@ pub enum WpErrorCode { CustomError(String), } -#[derive(Debug, PartialEq, Eq, thiserror::Error, uniffi::Error)] +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, uniffi::Error)] pub enum RequestExecutionError { #[error( "Request execution failed!\nStatus Code: '{:?}'.\nResponse: '{}'", @@ -407,7 +407,7 @@ pub enum RequestExecutionError { }, } -#[derive(Debug, PartialEq, Eq, thiserror::Error, uniffi::Error)] +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, uniffi::Error)] pub enum MediaUploadRequestExecutionError { #[error( "Request execution failed!\nStatus Code: '{:?}'.\nResponse: '{}'", diff --git a/wp_api/src/login/login_client.rs b/wp_api/src/login/login_client.rs index 08979836..30049708 100644 --- a/wp_api/src/login/login_client.rs +++ b/wp_api/src/login/login_client.rs @@ -113,7 +113,7 @@ impl WpLoginClient { return Err(AutoDiscoveryAttemptFailure::ParseApiDetails { parsed_site_url, api_root_url, - error, + parsing_error_message: error.to_string(), }) } }; diff --git a/wp_api/src/login/url_discovery.rs b/wp_api/src/login/url_discovery.rs index 77d73253..f0116851 100644 --- a/wp_api/src/login/url_discovery.rs +++ b/wp_api/src/login/url_discovery.rs @@ -21,17 +21,28 @@ impl AutoDiscoveryAttempt { #[derive(Debug, uniffi::Record)] pub struct AutoDiscoveryUniffiResult { - pub attempts: HashMap>, + pub user_input_attempt: Arc, + pub successful_attempt: Option>, + pub auto_https_attempt: Option>, + pub auto_dot_php_extension_for_wp_admin_attempt: Option>, + pub is_successful: bool, } impl From for AutoDiscoveryUniffiResult { fn from(value: AutoDiscoveryResult) -> Self { + let get_attempt_result = |attempt_type| { + value + .get_attempt(&attempt_type) + .map(|a| Arc::new(a.clone())) + }; Self { - attempts: value - .attempts - .into_iter() - .map(|(k, v)| (k, Arc::new(v))) - .collect(), + user_input_attempt: Arc::new(value.user_input_attempt().clone()), + successful_attempt: value.find_successful().map(|a| Arc::new(a.clone())), + auto_https_attempt: get_attempt_result(AutoDiscoveryAttemptType::AutoHttps), + auto_dot_php_extension_for_wp_admin_attempt: get_attempt_result( + AutoDiscoveryAttemptType::AutoDotPhpExtensionForWpAdmin, + ), + is_successful: value.is_successful(), } } } @@ -42,15 +53,41 @@ pub struct AutoDiscoveryResult { } impl AutoDiscoveryResult { - pub fn find_successful(self) -> Option { + pub fn is_successful(&self) -> bool { self.attempts - .into_iter() - .find(|(attempt_type, result)| result.result.is_ok()) - .map(|(attempt_type, result)| result) + .iter() + .any(|(_, result)| result.is_successful()) + } + + pub fn find_successful(&self) -> Option<&AutoDiscoveryAttemptResult> { + // If the user attempt is successful, prefer it over other attempts + let user_input_attempt = self.user_input_attempt(); + if user_input_attempt.is_successful() { + return Some(user_input_attempt); + } + self.attempts.iter().find_map(|(_, result)| { + if result.is_successful() { + Some(result) + } else { + None + } + }) + } + + pub fn user_input_attempt(&self) -> &AutoDiscoveryAttemptResult { + self.get_attempt(&AutoDiscoveryAttemptType::UserInput) + .expect("User input url is always attempted") + } + + pub fn get_attempt( + &self, + attempt_type: &AutoDiscoveryAttemptType, + ) -> Option<&AutoDiscoveryAttemptResult> { + self.attempts.get(attempt_type) } } -#[derive(Debug, uniffi::Object)] +#[derive(Debug, Clone, uniffi::Object)] pub struct AutoDiscoveryAttemptResult { pub attempt_type: AutoDiscoveryAttemptType, pub attempt_site_url: String, @@ -128,14 +165,14 @@ impl AutoDiscoveryAttemptResult { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct AutoDiscoveryAttemptSuccess { pub parsed_site_url: ParsedUrl, pub api_root_url: ParsedUrl, pub api_details: WpApiDetails, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum AutoDiscoveryAttemptFailure { ParseSiteUrl { error: ParseUrlError, @@ -156,7 +193,7 @@ pub enum AutoDiscoveryAttemptFailure { ParseApiDetails { parsed_site_url: ParsedUrl, api_root_url: ParsedUrl, - error: serde_json::Error, + parsing_error_message: String, }, } @@ -179,8 +216,11 @@ impl AutoDiscoveryAttemptFailure { AutoDiscoveryAttemptFailure::FetchApiRootUrl { error, .. } => error.to_string(), AutoDiscoveryAttemptFailure::ParseApiRootUrl { error, .. } => error.to_string(), AutoDiscoveryAttemptFailure::FetchApiDetails { error, .. } => error.to_string(), - AutoDiscoveryAttemptFailure::ParseApiDetails { error, .. } => { - format!("Failed to parse api details: {:#?}", error) + AutoDiscoveryAttemptFailure::ParseApiDetails { + parsing_error_message, + .. + } => { + format!("Failed to parse api details: {:#?}", parsing_error_message) } } } @@ -297,7 +337,7 @@ pub(crate) fn construct_attempts(input_site_url: String) -> Vec Date: Mon, 9 Dec 2024 14:33:00 -0700 Subject: [PATCH 7/8] WIP --- .../Example/Example.xcodeproj/project.pbxproj | 4 + .../xcshareddata/swiftpm/Package.resolved | 10 +- .../Example/Example/UI/LoginReport.swift | 86 +++++++++ .../swift/Example/Example/UI/LoginView.swift | 56 ++++-- .../swift/Sources/wordpress-api/Exports.swift | 6 +- .../wordpress-api/Foundation+Extensions.swift | 10 + .../Sources/wordpress-api/LoginAPI.swift | 178 ++++++++---------- .../WordPressLoginClientError.swift | 61 +----- 8 files changed, 227 insertions(+), 184 deletions(-) create mode 100644 native/swift/Example/Example/UI/LoginReport.swift diff --git a/native/swift/Example/Example.xcodeproj/project.pbxproj b/native/swift/Example/Example.xcodeproj/project.pbxproj index 4fae5374..f861ddd6 100644 --- a/native/swift/Example/Example.xcodeproj/project.pbxproj +++ b/native/swift/Example/Example.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 242132C82CE69CE80021D8E8 /* WordPressAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 242132C72CE69CE80021D8E8 /* WordPressAPI */; }; + 242CA0C12D03A7E200C0DD68 /* LoginReport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242CA0C02D03A7DF00C0DD68 /* LoginReport.swift */; }; 242D648E2C3602C1007CA96C /* ListViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242D648D2C3602C1007CA96C /* ListViewData.swift */; }; 242D64922C360687007CA96C /* RootListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242D64912C360687007CA96C /* RootListView.swift */; }; 242D64942C3608C6007CA96C /* ListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242D64932C3608C6007CA96C /* ListView.swift */; }; @@ -23,6 +24,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 242CA0C02D03A7DF00C0DD68 /* LoginReport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginReport.swift; sourceTree = ""; }; 242D648D2C3602C1007CA96C /* ListViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViewData.swift; sourceTree = ""; }; 242D64912C360687007CA96C /* RootListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootListView.swift; sourceTree = ""; }; 242D64932C3608C6007CA96C /* ListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListView.swift; sourceTree = ""; }; @@ -55,6 +57,7 @@ 242D64972C363960007CA96C /* UI */ = { isa = PBXGroup; children = ( + 242CA0C02D03A7DF00C0DD68 /* LoginReport.swift */, 2479BF872B621CB70014A01D /* Preview Content */, 242D64932C3608C6007CA96C /* ListView.swift */, 242D64912C360687007CA96C /* RootListView.swift */, @@ -191,6 +194,7 @@ files = ( 242D64922C360687007CA96C /* RootListView.swift in Sources */, 2479BF812B621CB60014A01D /* ExampleApp.swift in Sources */, + 242CA0C12D03A7E200C0DD68 /* LoginReport.swift in Sources */, 24A3C32F2BA8F96F00162AD1 /* LoginView.swift in Sources */, 2479BF932B621E9B0014A01D /* ListViewModel.swift in Sources */, 24A3C3362BAA874C00162AD1 /* LoginManager.swift in Sources */, diff --git a/native/swift/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/native/swift/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e911cd24..c0e68523 100644 --- a/native/swift/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/native/swift/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -40,10 +40,10 @@ { "identity" : "swift-syntax", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-syntax.git", + "location" : "https://github.com/swiftlang/swift-syntax.git", "state" : { - "revision" : "303e5c5c36d6a558407d364878df131c3546fad8", - "version" : "510.0.2" + "revision" : "cb53fa1bd3219b0b23ded7dfdd3b2baff266fd25", + "version" : "600.0.0" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/realm/SwiftLint", "state" : { - "revision" : "b515723b16eba33f15c4677ee65f3fef2ce8c255", - "version" : "0.55.1" + "revision" : "25f2776977e663305bee71309ea1e34d435065f1", + "version" : "0.57.1" } }, { diff --git a/native/swift/Example/Example/UI/LoginReport.swift b/native/swift/Example/Example/UI/LoginReport.swift new file mode 100644 index 00000000..1ca7a88d --- /dev/null +++ b/native/swift/Example/Example/UI/LoginReport.swift @@ -0,0 +1,86 @@ +import Foundation +import SwiftUI +import WordPressAPI +import AuthenticationServices +import WordPressAPIInternal + +struct AutoDiscoveryStepView: View { + let label: String + + let successIcon = "checkmark.circle" + let failureIcon = "exclamationmark.circle" + + let isSuccess: Bool + + var body: some View { + VStack(alignment: .leading) { + HStack(alignment: .firstTextBaseline) { + Image(systemName: isSuccess ? successIcon : failureIcon) + .font(.title) + Text(label).font(.title) + Spacer() + } + }.padding(.horizontal) + } +} + +struct AutoDiscoveryErrorView: View { + let errorMessage: String + + var body: some View { + Text(errorMessage) + } +} + +struct AutodiscoveryReportView: View { + let report: AutoDiscoveryResult? + + var body: some View { + if let report { + if let success = report.successfulAttempt { + AutodiscoveryResultView(attempt: success) + } else if let success = report.autoHttpsAttempt { + AutodiscoveryResultView(attempt: success) + } else { + AutodiscoveryResultView(attempt: report.userInputAttempt) + } + } else { + ProgressView() + } + } +} + +struct AutodiscoveryResultView: View { + let attempt: AutoDiscoveryAttemptResult + + var body: some View { + if let url = attempt.domainWithSubdomain { + Text(url) + } + + AutoDiscoveryStepView(label: "Site Connection", isSuccess: attempt.couldConnectToUrl) + AutoDiscoveryStepView(label: "Can connect using HTTPS", isSuccess: attempt.couldUseHttps) + AutoDiscoveryStepView(label: "Supports JSON ", isSuccess: attempt.foundApiRoot) + AutoDiscoveryStepView(label: "Found authentication URL ", isSuccess: attempt.foundAuthenticationUrl) + } +} + +#Preview("Live data") { + + struct AsyncTestView: View { + + @State var report: AutoDiscoveryResult? + + private let loginApi = WordPressLoginClient(requestExecutor: URLSession.shared) + + var body: some View { + AutodiscoveryReportView(report: report) + .task { + let result = await loginApi.autodiscoveryResult(forSite: "http://optional-https.wpmt.co") + self.report = result + } + } + } + + return AsyncTestView() +} diff --git a/native/swift/Example/Example/UI/LoginView.swift b/native/swift/Example/Example/UI/LoginView.swift index 721e5582..98f416b9 100644 --- a/native/swift/Example/Example/UI/LoginView.swift +++ b/native/swift/Example/Example/UI/LoginView.swift @@ -9,13 +9,13 @@ struct LoginView: View { private var url: String = "" @State - private var isLoggingIn: Bool = false + private var isLoading: Bool = false @State private var loginError: String? @State - private var loginTask: Task? + private var currentTask: Task? @Environment(\.webAuthenticationSession) private var webAuthenticationSession @@ -43,14 +43,14 @@ struct LoginView: View { #endif HStack { - if isLoggingIn { + if isLoading { ProgressView() .progressViewStyle(.circular) .controlSize(.small) .padding() } else { - Button(action: self.startLogin, label: { - Text("Sign In") + Button(action: self.startAutodiscovery, label: { + Text("Next") }) } } @@ -58,28 +58,44 @@ struct LoginView: View { .padding() } - func startLogin() { - self.loginError = nil - self.isLoggingIn = true + func startAutodiscovery() { + self.currentTask = Task { + self.isLoading = true - self.loginTask = Task { do { - let loginClient = WordPressLoginClient(urlSession: .shared) - let loginDetails = try await loginClient.login( - site: url, - appName: "WordPress SDK Example App", - appId: nil - ) + let loginClient = WordPressLoginClient(requestExecutor: URLSession.shared) + let loginDetails = await loginClient.autodiscoveryResult(forSite: url) + debugPrint(loginDetails) - try await loginManager.setLoginCredentials(to: loginDetails) - } catch let err { - handleLoginError(err) } + + self.isLoading = false } } + func startLogin() { + self.loginError = nil + self.isLoading = true + +// self.currentTask = Task { +// do { +//// let loginClient = WordPressLoginClient(requestExecutor: URLSession.shared) +//// let loginDetails = try await loginClient.login( +//// site: url, +//// appName: "WordPress SDK Example App", +//// appId: nil, +//// contextProvider: AuthenticationHelper() +//// ).get() +//// debugPrint(loginDetails) +//// try await loginManager.setLoginCredentials(to: loginDetails) +// } catch let err { +// handleLoginError(err) +// } +// } + } + private func handleLoginError(_ error: Error) { - self.isLoggingIn = false + self.isLoading = false self.loginError = error.localizedDescription } } @@ -88,4 +104,6 @@ class AuthenticationHelper: NSObject, ASWebAuthenticationPresentationContextProv func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { ASPresentationAnchor() } + +// LoginView().environmentObject(LoginManager()) } diff --git a/native/swift/Sources/wordpress-api/Exports.swift b/native/swift/Sources/wordpress-api/Exports.swift index c08b2c74..c4a7627a 100644 --- a/native/swift/Sources/wordpress-api/Exports.swift +++ b/native/swift/Sources/wordpress-api/Exports.swift @@ -20,9 +20,7 @@ public typealias WpNetworkHeaderMap = WordPressAPIInternal.WpNetworkHeaderMap public typealias WpApiApplicationPasswordDetails = WordPressAPIInternal.WpApiApplicationPasswordDetails public typealias WpAuthentication = WordPressAPIInternal.WpAuthentication -public typealias UrlDiscoveryError = WordPressAPIInternal.UrlDiscoveryError -public typealias UrlDiscoverySuccess = WordPressAPIInternal.UrlDiscoverySuccess -public typealias UrlDiscoveryAttemptError = WordPressAPIInternal.UrlDiscoveryAttemptError +public typealias AutoDiscoveryResult = WordPressAPIInternal.AutoDiscoveryUniffiResult // MARK: - Users @@ -124,4 +122,6 @@ public typealias WpSiteHealthTestsRequestExecutor = WordPressAPIInternal.WpSiteH extension WpSiteHealthTestsRequestExecutor: @unchecked Sendable {} // swiftlint:enable line_length +extension AutoDiscoveryResult: @unchecked Sendable {} + #endif diff --git a/native/swift/Sources/wordpress-api/Foundation+Extensions.swift b/native/swift/Sources/wordpress-api/Foundation+Extensions.swift index 760feba2..f4ddc391 100644 --- a/native/swift/Sources/wordpress-api/Foundation+Extensions.swift +++ b/native/swift/Sources/wordpress-api/Foundation+Extensions.swift @@ -17,3 +17,13 @@ public extension Date { wordpressDateFormatter.date(from: string) } } + +public extension URL { + var schemeAndHost: String? { + guard let scheme = self.scheme, let host = self.host else { + return nil + } + + return scheme.uppercased() + "" + "://" + host + } +} diff --git a/native/swift/Sources/wordpress-api/LoginAPI.swift b/native/swift/Sources/wordpress-api/LoginAPI.swift index ac34d4e3..d998f1b6 100644 --- a/native/swift/Sources/wordpress-api/LoginAPI.swift +++ b/native/swift/Sources/wordpress-api/LoginAPI.swift @@ -8,72 +8,80 @@ import WordPressAPIInternal import FoundationNetworking #endif -public final class WordPressLoginClient { - - public protocol AuthenticatorProtocol { - func authenticate(url: URL, callbackURL: URL) async throws(WordPressLoginClientError) -> URL - } - - private static let callbackURL = URL(string: "x-wordpress-app://login-callback")! +public actor WordPressLoginClient { private let requestExecutor: SafeRequestExecutor - private let client: UniffiWpLoginClient - public convenience init(urlSession: URLSession) { - self.init(requestExecutor: urlSession) + public enum Error: Swift.Error { + case invalidSiteAddress + case missingLoginUrl + case authenticationError(OAuthResponseUrlError) + case invalidApplicationPasswordCallback + case cancelled + case unknown(Swift.Error) + + /// We don't have anything useful to tell the user – this is basically "Something went wrong, please try again" + case generic } - init(requestExecutor: SafeRequestExecutor) { + public init(requestExecutor: SafeRequestExecutor) { self.requestExecutor = requestExecutor - self.client = UniffiWpLoginClient(requestExecutor: requestExecutor) } - public func login( - site: String, + /// Perform login autodiscovery and build a login URL + /// + public func authenticationUrl( + forSite proposedSiteUrl: String, appName: String, appId: WpUuid?, - authenticator: AuthenticatorProtocol - ) async throws -> WpApiApplicationPasswordDetails { - let loginURL = try await self.loginURL(forSite: site) - let authURL = createApplicationPasswordAuthenticationUrl( - loginUrl: loginURL, + callbackUrl: URL + ) async throws -> ParsedUrl { + guard let urlString = await UniffiWpLoginClient(requestExecutor: self.requestExecutor) + .apiDiscovery(siteUrl: proposedSiteUrl) + .successfulAttempt? + .apiDetails()? + .findApplicationPasswordsAuthenticationUrl() + else { + throw Error.invalidSiteAddress + } + + return createApplicationPasswordAuthenticationUrl( + loginUrl: try ParsedUrl.parse(input: urlString), appName: appName, appId: appId, - successUrl: Self.callbackURL.absoluteString, - rejectUrl: Self.callbackURL.absoluteString + successUrl: callbackUrl.absoluteString, + rejectUrl: callbackUrl.absoluteString ) - .asURL() - - let urlWithToken = try await authenticator.authenticate(url: authURL, callbackURL: Self.callbackURL) - return try handleAuthenticationCallback(urlWithToken) } - private func loginURL(forSite proposedSiteUrl: String) async throws(WordPressLoginClientError) -> ParsedUrl { + private func handleAuthenticationCallback( + _ urlWithToken: URL + ) throws(WordPressLoginClientError) -> WpApiApplicationPasswordDetails { + guard let parsed = try? ParsedUrl.from(url: urlWithToken) else { + throw .invalidApplicationPasswordCallback + } do { - let client = UniffiWpLoginClient(requestExecutor: self.requestExecutor) - let discoveryResult = try await client.apiDiscovery(siteUrl: proposedSiteUrl) - - // All sites should have some form of authentication we can use - guard - let passwordAuthenticationUrl = discoveryResult.apiDetails.findApplicationPasswordsAuthenticationUrl(), - let parsedLoginUrl = try? ParsedUrl.parse(input: passwordAuthenticationUrl) - else { - throw WordPressLoginClientError.missingLoginUrl - } - - return parsedLoginUrl - - } catch let error as UrlDiscoveryError { - throw WordPressLoginClientError.invalidSiteAddress(error) + return try extractLoginDetailsFromUrl(url: parsed) + } catch let error as OAuthResponseUrlError { + throw .authenticationError(error) } catch { - throw WordPressLoginClientError.unknown(error) + throw .unknown(error) } } - private func handleAuthenticationCallback( + /// Perform login autodiscovery and get the raw data about the process + /// + public func autodiscoveryResult(forSite proposedSiteUrl: String) async -> AutoDiscoveryResult { + await UniffiWpLoginClient(requestExecutor: self.requestExecutor) + .apiDiscovery(siteUrl: proposedSiteUrl) + } + + /// Parse the URL we get back from the WordPress website, turning it into login details + /// + public func parseAuthenticationCallback( _ urlWithToken: URL - ) throws(WordPressLoginClientError) -> WpApiApplicationPasswordDetails { + ) throws(Error) -> WpApiApplicationPasswordDetails { guard let parsed = try? ParsedUrl.from(url: urlWithToken) else { throw .invalidApplicationPasswordCallback } @@ -88,65 +96,41 @@ public final class WordPressLoginClient { } } -#if os(iOS) || os(macOS) +public extension AutoDiscoveryAttemptResult { + + public var couldConnectToUrl: Bool { + // no good way to find this in isolation + true + } -import AuthenticationServices + public var couldUseHttps: Bool { + self.apiRootUrl()?.asURL().scheme == "https" + } -extension WordPressLoginClient { + public var foundApiRoot: Bool { + self.apiRootUrl() != nil + } - class AuthenticationServiceAuthenticator: NSObject, AuthenticatorProtocol, - ASWebAuthenticationPresentationContextProviding { - func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - ASPresentationAnchor() - } + public var foundAuthenticationUrl: Bool { + self.apiDetails()?.findApplicationPasswordsAuthenticationUrl() != nil + } - @MainActor - func authenticate(url: URL, callbackURL: URL) async throws(WordPressLoginClientError) -> URL { - do { - return try await withCheckedThrowingContinuation { continuation in - let session = ASWebAuthenticationSession( - url: url, - callbackURLScheme: callbackURL.scheme! - ) { url, error in - if let url { - continuation.resume(returning: url) - } else if let error = error as? ASWebAuthenticationSessionError { - switch error.code { - case .canceledLogin: - continuation.resume(throwing: WordPressLoginClientError.cancelled) - case .presentationContextInvalid, .presentationContextNotProvided: - assertionFailure("An unexpected error received: \(error)") - continuation.resume(throwing: WordPressLoginClientError.cancelled) - @unknown default: - continuation.resume(throwing: WordPressLoginClientError.cancelled) - } - } else { - continuation.resume(throwing: WordPressLoginClientError.invalidApplicationPasswordCallback) - } - } - session.presentationContextProvider = self - session.start() - } - } catch { - // swiftlint:disable:next force_cast - throw error as! WordPressLoginClientError - } + public var authenticationUrl: URL? { + guard + let string = apiDetails()?.findApplicationPasswordsAuthenticationUrl(), + let url = URL(string: string) + else { + return nil } + + return url } - public func login( - site: String, - appName: String, - appId: WpUuid? - ) async throws -> WpApiApplicationPasswordDetails { - let provider = await AuthenticationServiceAuthenticator() - return try await login( - site: site, - appName: appName, - appId: appId, - authenticator: provider - ) + public var domainWithSubdomain: String? { + guard let scheme = apiRootUrl()?.asURL().scheme, let host = apiRootUrl()?.asURL().host else { + return nil + } + + return scheme + "://" + host } } - -#endif diff --git a/native/swift/Sources/wordpress-api/WordPressLoginClientError.swift b/native/swift/Sources/wordpress-api/WordPressLoginClientError.swift index 11301c2e..34b8e47d 100644 --- a/native/swift/Sources/wordpress-api/WordPressLoginClientError.swift +++ b/native/swift/Sources/wordpress-api/WordPressLoginClientError.swift @@ -9,69 +9,10 @@ import FoundationNetworking #endif public enum WordPressLoginClientError: Swift.Error { - case invalidSiteAddress(UrlDiscoveryError) + case invalidSiteAddress case missingLoginUrl case authenticationError(OAuthResponseUrlError) case invalidApplicationPasswordCallback case cancelled case unknown(Swift.Error) - - func isAutodiscoveryError() -> Bool { - guard case let .invalidSiteAddress(urlDiscoveryError) = self else { - return false - } - - guard case .UrlDiscoveryFailed = urlDiscoveryError else { - return false - } - - return true - } - - var isFailedToFetchApiDetails: Bool { - guard - case let .invalidSiteAddress(urlDiscoveryError) = self, - case .UrlDiscoveryFailed(let attempts) = urlDiscoveryError - else { - return false - } - - return attempts.values.contains { state in - return switch state { - case .failure(let error): urlDiscoverErrorIsFetchApiDetailsFailed(error) - default: false - } - } - } - - var isFailedToFetchApiRoot: Bool { - guard - case let .invalidSiteAddress(urlDiscoveryError) = self, - case .UrlDiscoveryFailed(let attempts) = urlDiscoveryError - else { - return false - } - - return attempts.values.contains { state in - if case let .failure(error) = state { - return isFetchRootUrlFailedError(error) - } - - return false - } - } - - private func urlDiscoverErrorIsFetchApiDetailsFailed(_ error: UrlDiscoveryAttemptError) -> Bool { - return switch error { - case .fetchApiDetailsFailed: true - default: false - } - } - - private func isFetchRootUrlFailedError(_ error: UrlDiscoveryAttemptError) -> Bool { - return switch error { - case .fetchApiRootUrlFailed: true - default: false - } - } } From 8d5d3d8b1410ee65f7707a4dfb9f1f536f8a219b Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 24 Jan 2025 11:36:35 +1300 Subject: [PATCH 8/8] Localized auto discovery errors (#488) * Localized auto discovery errors * Localize `ParseApiRootUrlError` * Implement reporting --------- Co-authored-by: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> --- Cargo.lock | 275 ++++++++++++++++++ Cargo.toml | 5 + .../Example/Example/UI/LoginReport.swift | 44 ++- .../swift/Example/Example/UI/LoginView.swift | 12 +- .../swift/Sources/wordpress-api/Exports.swift | 1 + .../wordpress-api/Foundation+Extensions.swift | 4 + .../Sources/wordpress-api/LoginAPI.swift | 28 +- wp_api/Cargo.toml | 5 + wp_api/localization/en-US/main.ftl | 14 + wp_api/localization/zh-CN/main.ftl | 7 + wp_api/src/lib.rs | 7 + wp_api/src/login/login_client.rs | 1 + wp_api/src/login/url_discovery.rs | 192 +++++++++++- 13 files changed, 564 insertions(+), 31 deletions(-) create mode 100644 wp_api/localization/en-US/main.ftl create mode 100644 wp_api/localization/zh-CN/main.ftl diff --git a/Cargo.lock b/Cargo.lock index d1d6a1b9..bd85f857 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -306,6 +306,16 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bstr" +version = "1.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531a9155a481e2ee699d4f98f43c0ca4ff8ee1bfd55c31e9e98fb29d2b176fe0" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.16.0" @@ -477,6 +487,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "deranged" version = "0.3.11" @@ -597,6 +632,92 @@ dependencies = [ "miniz_oxide 0.8.0", ] +[[package]] +name = "fluent-bundle" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe0a21ee80050c678013f82edf4b705fe2f26f1f9877593d13198612503f493" +dependencies = [ + "fluent-langneg 0.13.0", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash", + "self_cell 0.10.3", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ad0989667548f06ccd0e306ed56b61bd4d35458d54df5ec7587c0e8ed5e94" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7b2da3cb6583f7e5f98d3e0e1f9ff70451398037445c8e89a0dc51594cf1736" +dependencies = [ + "icu_locid", +] + +[[package]] +name = "fluent-syntax" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a530c4694a6a8d528794ee9bbd8ba0122e779629ac908d15ad5a7ae7763a33d" +dependencies = [ + "thiserror", +] + +[[package]] +name = "fluent-template-macros" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b86ebacac709a063f57ee83d37c451d60dc873554f9bd828531fd1ec43209c80" +dependencies = [ + "flume", + "ignore", + "once_cell", + "proc-macro2", + "quote", + "syn", + "unic-langid", +] + +[[package]] +name = "fluent-templates" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f339cc149c01ba2f7b0feba2acde6dd6e4a48472daffe2ddfdd6073e564c60b3" +dependencies = [ + "fluent-bundle", + "fluent-langneg 0.13.0", + "fluent-syntax", + "fluent-template-macros", + "flume", + "ignore", + "intl-memoizer", + "log", + "once_cell", + "thiserror", + "unic-langid", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -767,6 +888,19 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +[[package]] +name = "globset" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15f1ce686646e7f1e19bf7d5533fe443a45dbfb990e00629110797578b42fb19" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata 0.4.8", + "regex-syntax 0.8.5", +] + [[package]] name = "goblin" version = "0.8.2" @@ -1161,6 +1295,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata 0.4.8", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "2.2.6" @@ -1184,6 +1334,25 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" +[[package]] +name = "intl-memoizer" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe22e020fce238ae18a6d5d8c502ee76a52a6e880d99477657e6acc30ec57bda" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + [[package]] name = "ipnet" version = "2.9.0" @@ -1633,6 +1802,12 @@ dependencies = [ "toml_edit 0.21.1", ] +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + [[package]] name = "proc-macro2" version = "1.0.92" @@ -1964,6 +2139,12 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc_version" version = "0.4.0" @@ -2038,6 +2219,15 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scc" version = "2.1.4" @@ -2117,6 +2307,21 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14e4d63b804dc0c7ec4a1e52bcb63f02c7ac94476755aa579edac21e01f915d" +dependencies = [ + "self_cell 1.1.0", +] + +[[package]] +name = "self_cell" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fdfc24bc566f839a2da4c4295b82db7d25a24253867d5c64355abb5799bdbe" + [[package]] name = "semver" version = "1.0.23" @@ -2264,6 +2469,9 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] [[package]] name = "stable-pattern" @@ -2723,6 +2931,15 @@ dependencies = [ "toml 0.8.15", ] +[[package]] +name = "type-map" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb68604048ff8fa93347f02441e4487594adc20bb8a084f9e564d2b827a0a9f" +dependencies = [ + "rustc-hash", +] + [[package]] name = "ubyte" version = "0.10.4" @@ -2742,6 +2959,49 @@ dependencies = [ "version_check", ] +[[package]] +name = "unic-langid" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23dd9d1e72a73b25e07123a80776aae3e7b0ec461ef94f9151eed6ec88005a44" +dependencies = [ + "unic-langid-impl", + "unic-langid-macros", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5422c1f65949306c99240b81de9f3f15929f5a8bfe05bb44b034cc8bf593e5" +dependencies = [ + "tinystr", +] + +[[package]] +name = "unic-langid-macros" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da1cd2c042d3c7569a1008806b02039e7a4a2bdf8f8e96bd3c792434a0e275e" +dependencies = [ + "proc-macro-hack", + "tinystr", + "unic-langid-impl", + "unic-langid-macros-impl", +] + +[[package]] +name = "unic-langid-macros-impl" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ed7f4237ba393424195053097c1516bd4590dc82b84f2f97c5c69e12704555b" +dependencies = [ + "proc-macro-hack", + "quote", + "syn", + "unic-langid-impl", +] + [[package]] name = "unicase" version = "2.7.0" @@ -2961,6 +3221,16 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3307,8 +3577,12 @@ version = "0.1.0" dependencies = [ "async-trait", "base64", + "fluent-bundle", + "fluent-langneg 0.14.1", + "fluent-templates", "futures", "http 1.2.0", + "icu_locid", "indoc", "parse_link_header", "paste", @@ -3319,6 +3593,7 @@ dependencies = [ "serde_json", "strum_macros", "thiserror", + "unic-langid", "uniffi", "url", "uuid", diff --git a/Cargo.toml b/Cargo.toml index 77201bb9..d04a2338 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,9 +19,13 @@ base64 = "0.22" chrono = "0.4" clap = "4.5" convert_case = "0.6" +fluent-bundle = "0.15" +fluent-langneg = "0.14" +fluent-templates = "0.10" futures = "0.3" http = "1.2" indoc = "2.0" +icu_locid = "1.5" parse_link_header = "0.3" paste = "1.0" proc-macro-crate = "3.1.0" @@ -42,6 +46,7 @@ thiserror = "1.0" tokio = "1.42" toml = "0.8" trybuild = "1.0" +unic-langid = "0.9" uniffi = "0.28" url = "2.5" uuid = "1.11" diff --git a/native/swift/Example/Example/UI/LoginReport.swift b/native/swift/Example/Example/UI/LoginReport.swift index 1ca7a88d..a970976c 100644 --- a/native/swift/Example/Example/UI/LoginReport.swift +++ b/native/swift/Example/Example/UI/LoginReport.swift @@ -5,21 +5,38 @@ import AuthenticationServices import WordPressAPIInternal struct AutoDiscoveryStepView: View { - let label: String + let step: AutoDiscoveryStep let successIcon = "checkmark.circle" - let failureIcon = "exclamationmark.circle" - - let isSuccess: Bool + let warningIcon = "exclamationmark.circle" + let errorIcon = "xmark.circle" var body: some View { VStack(alignment: .leading) { HStack(alignment: .firstTextBaseline) { - Image(systemName: isSuccess ? successIcon : failureIcon) - .font(.title) - Text(label).font(.title) + if step.wasSuccessful { + Image(systemName: successIcon) + .font(.title) + .foregroundStyle(.green) + } else if step.isRequired { + Image(systemName: errorIcon) + .font(.title) + .foregroundStyle(.red) + } else { + Image(systemName: warningIcon) + .font(.title) + .foregroundStyle(.yellow) + } + + Text(step.name).font(.title) + Spacer() } + + if let errorMessage = step.errorMessage { + Text(errorMessage) + } + }.padding(.horizontal) } } @@ -53,15 +70,18 @@ struct AutodiscoveryReportView: View { struct AutodiscoveryResultView: View { let attempt: AutoDiscoveryAttemptResult + let locale = Locale.autoupdatingCurrent + var body: some View { if let url = attempt.domainWithSubdomain { Text(url) } - AutoDiscoveryStepView(label: "Site Connection", isSuccess: attempt.couldConnectToUrl) - AutoDiscoveryStepView(label: "Can connect using HTTPS", isSuccess: attempt.couldUseHttps) - AutoDiscoveryStepView(label: "Supports JSON ", isSuccess: attempt.foundApiRoot) - AutoDiscoveryStepView(label: "Found authentication URL ", isSuccess: attempt.foundAuthenticationUrl) + let steps = attempt.constructSteps(langId: locale.identifier) + + ForEach(steps) { step in + AutoDiscoveryStepView(step: step) + } } } @@ -76,7 +96,7 @@ struct AutodiscoveryResultView: View { var body: some View { AutodiscoveryReportView(report: report) .task { - let result = await loginApi.autodiscoveryResult(forSite: "http://optional-https.wpmt.co") + let result = await loginApi.autodiscoveryResult(forSite: "http://jalib923knblakis9ba92q3nbaslkes.nope") self.report = result } } diff --git a/native/swift/Example/Example/UI/LoginView.swift b/native/swift/Example/Example/UI/LoginView.swift index 98f416b9..510abe42 100644 --- a/native/swift/Example/Example/UI/LoginView.swift +++ b/native/swift/Example/Example/UI/LoginView.swift @@ -65,8 +65,18 @@ struct LoginView: View { do { let loginClient = WordPressLoginClient(requestExecutor: URLSession.shared) let loginDetails = await loginClient.autodiscoveryResult(forSite: url) - + debugPrint(loginDetails) + + let localeId = Locale.preferredLanguages.first ?? "en-US" + self.loginError = [ + loginDetails.userInputAttempt.errorMessage(localeId: localeId), + loginDetails.autoHttpsAttempt?.errorMessage(localeId: localeId), + loginDetails.autoDotPhpExtensionForWpAdminAttempt?.errorMessage(localeId: localeId) + ] + .compactMap { $0 } + .map { "- \($0)"} + .joined(separator: "\n") } self.isLoading = false diff --git a/native/swift/Sources/wordpress-api/Exports.swift b/native/swift/Sources/wordpress-api/Exports.swift index c4a7627a..b86d907a 100644 --- a/native/swift/Sources/wordpress-api/Exports.swift +++ b/native/swift/Sources/wordpress-api/Exports.swift @@ -21,6 +21,7 @@ public typealias WpNetworkHeaderMap = WordPressAPIInternal.WpNetworkHeaderMap public typealias WpApiApplicationPasswordDetails = WordPressAPIInternal.WpApiApplicationPasswordDetails public typealias WpAuthentication = WordPressAPIInternal.WpAuthentication public typealias AutoDiscoveryResult = WordPressAPIInternal.AutoDiscoveryUniffiResult +public typealias AutoDiscoveryStep = WordPressAPIInternal.AutoDiscoveryStep // MARK: - Users diff --git a/native/swift/Sources/wordpress-api/Foundation+Extensions.swift b/native/swift/Sources/wordpress-api/Foundation+Extensions.swift index f4ddc391..ec23f3ee 100644 --- a/native/swift/Sources/wordpress-api/Foundation+Extensions.swift +++ b/native/swift/Sources/wordpress-api/Foundation+Extensions.swift @@ -27,3 +27,7 @@ public extension URL { return scheme.uppercased() + "" + "://" + host } } + +extension AutoDiscoveryStep: Identifiable { + public var id: String { self.name } +} diff --git a/native/swift/Sources/wordpress-api/LoginAPI.swift b/native/swift/Sources/wordpress-api/LoginAPI.swift index d998f1b6..da39e56d 100644 --- a/native/swift/Sources/wordpress-api/LoginAPI.swift +++ b/native/swift/Sources/wordpress-api/LoginAPI.swift @@ -98,24 +98,40 @@ public actor WordPressLoginClient { public extension AutoDiscoveryAttemptResult { - public var couldConnectToUrl: Bool { + var couldConnectToUrl: Bool { // no good way to find this in isolation true } - public var couldUseHttps: Bool { + func getConnectionErrorMessage(for locale: Locale) -> String? { + self.errorMessage(localeId: locale.identifier) + } + + var couldUseHttps: Bool { self.apiRootUrl()?.asURL().scheme == "https" } - public var foundApiRoot: Bool { + func getHttpsErrorMessage(for locale: Locale) -> String? { + self.errorMessage(localeId: locale.identifier) + } + + var foundApiRoot: Bool { self.apiRootUrl() != nil } - public var foundAuthenticationUrl: Bool { + func getApiRootErrorMessage(for locale: Locale) -> String? { + self.errorMessage(localeId: locale.identifier) + } + + var foundAuthenticationUrl: Bool { self.apiDetails()?.findApplicationPasswordsAuthenticationUrl() != nil } - public var authenticationUrl: URL? { + func getAuthenticationUrlErrorMessage(for locale: Locale) -> String? { + self.errorMessage(localeId: locale.identifier) + } + + var authenticationUrl: URL? { guard let string = apiDetails()?.findApplicationPasswordsAuthenticationUrl(), let url = URL(string: string) @@ -126,7 +142,7 @@ public extension AutoDiscoveryAttemptResult { return url } - public var domainWithSubdomain: String? { + var domainWithSubdomain: String? { guard let scheme = apiRootUrl()?.asURL().scheme, let host = apiRootUrl()?.asURL().host else { return nil } diff --git a/wp_api/Cargo.toml b/wp_api/Cargo.toml index 070e1f12..3b614473 100644 --- a/wp_api/Cargo.toml +++ b/wp_api/Cargo.toml @@ -13,8 +13,12 @@ name = "wp_api" [dependencies] async-trait = { workspace = true } base64 = { workspace = true } +fluent-bundle = { workspace = true } +fluent-langneg = { workspace = true } +fluent-templates = { workspace = true } futures = { workspace = true } http = { workspace = true } +icu_locid = { workspace = true } indoc = { workspace = true } url = { workspace = true } parse_link_header = { workspace = true } @@ -24,6 +28,7 @@ serde = { workspace = true, features = [ "derive" ] } serde_json = { workspace = true } strum_macros = { workspace = true } thiserror = { workspace = true } +unic-langid = { workspace = true } uniffi = { workspace = true } uuid = { workspace = true, features = [ "v4" ] } wp_contextual = { path = "../wp_contextual" } diff --git a/wp_api/localization/en-US/main.ftl b/wp_api/localization/en-US/main.ftl new file mode 100644 index 00000000..668dee8f --- /dev/null +++ b/wp_api/localization/en-US/main.ftl @@ -0,0 +1,14 @@ +auto_discovery_attempt_failure_parse_site_url = Failed to parse the site URL +auto_discovery_attempt_failure_fetch_api_root_url = Failed to fetch the REST API endpoint of the site at {$site_url} +auto_discovery_attempt_failure_parse_api_root_url = Failed to parse the REST API endpoint of the site at {$site_url} +auto_discovery_attempt_failure_fetch_api_details = Failed to fetch the REST API details from {$api_url} +auto_discovery_attempt_failure_parse_api_details = Failed to parse the REST API details from {$api_url} + +parse_api_root_url_error_api_root_link_header_not_found = {$site_url} is not a WordPress site because we could not find REST API endpoint URL in it. + + +auto_discovery_step_connect = Site Connection +auto_discovery_step_https = Connect using HTTPS +auto_discovery_is_wordpress = Is WordPress Site +auto_discovery_step_json = JSON API Supported +auto_discovery_step_find_auth_url = Found Authentication URL diff --git a/wp_api/localization/zh-CN/main.ftl b/wp_api/localization/zh-CN/main.ftl new file mode 100644 index 00000000..33c38927 --- /dev/null +++ b/wp_api/localization/zh-CN/main.ftl @@ -0,0 +1,7 @@ +auto_discovery_attempt_failure_parse_site_url = 站点 URL 解析失败 +auto_discovery_attempt_failure_fetch_api_root_url = 站点 {$site_url} 的 REST API 地址获取失败 +auto_discovery_attempt_failure_parse_api_root_url = 站点 {$site_url} 的 REST API 地址解析失败 +auto_discovery_attempt_failure_fetch_api_details = 无法从 {$api_url} 获取 REST API 详细信息 +auto_discovery_attempt_failure_parse_api_details = 无法从 {$api_url} 解析 REST API 详细信息 + +parse_api_root_url_error_api_root_link_header_not_found = {$site_url} 不是 WordPress 站点,因为我们无法在其中找到 REST API 地址。 diff --git a/wp_api/src/lib.rs b/wp_api/src/lib.rs index c59a0b1a..c65981b0 100644 --- a/wp_api/src/lib.rs +++ b/wp_api/src/lib.rs @@ -199,3 +199,10 @@ macro_rules! generate { } uniffi::setup_scaffolding!(); + +fluent_templates::static_loader! { + static LOCALES = { + locales: "./localization", + fallback_language: "en-US" + }; +} diff --git a/wp_api/src/login/login_client.rs b/wp_api/src/login/login_client.rs index 30049708..cc4cb7f6 100644 --- a/wp_api/src/login/login_client.rs +++ b/wp_api/src/login/login_client.rs @@ -137,6 +137,7 @@ impl WpLoginClient { { Some(url) => Ok(ParsedUrl::new(url)), None => Err(ParseApiRootUrlError::ApiRootLinkHeaderNotFound { + site_url: site_url.clone().into(), header_map: response.header_map, status_code: response.status_code, }), diff --git a/wp_api/src/login/url_discovery.rs b/wp_api/src/login/url_discovery.rs index f0116851..45c2df37 100644 --- a/wp_api/src/login/url_discovery.rs +++ b/wp_api/src/login/url_discovery.rs @@ -2,6 +2,14 @@ use super::WpApiDetails; use crate::{request::WpNetworkHeaderMap, ParseUrlError, ParsedUrl, RequestExecutionError}; use std::{collections::HashMap, sync::Arc}; +use crate::LOCALES; + +use fluent_bundle::FluentValue; +use fluent_langneg::convert_vec_str_to_langids_lossy; +use fluent_langneg::negotiate_languages; +use fluent_langneg::NegotiationStrategy; +use fluent_templates::Loader; + const API_ROOT_LINK_HEADER: &str = "https://api.w.org/"; #[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] @@ -19,6 +27,14 @@ impl AutoDiscoveryAttempt { } } +#[derive(Debug, uniffi::Record)] +pub struct AutoDiscoveryStep { + pub name: String, + pub is_required: bool, + pub was_successful: bool, + pub error_message: Option +} + #[derive(Debug, uniffi::Record)] pub struct AutoDiscoveryUniffiResult { pub user_input_attempt: Arc, @@ -96,14 +112,60 @@ pub struct AutoDiscoveryAttemptResult { #[uniffi::export] impl AutoDiscoveryAttemptResult { + + fn construct_steps(&self, lang_id: String) -> Vec { + vec![ + AutoDiscoveryStep { + name: localized_message(&lang_id, "auto_discovery_step_connect"), + was_successful: true, + is_required: true, + error_message: None // TODO: We should be able to populate this + }, + AutoDiscoveryStep { + name: localized_message(&lang_id, "auto_discovery_step_https"), + was_successful: !self.has_failed_to_connect_with_https(), + is_required: false, + error_message: None // TODO: We should be able to populate this + }, + AutoDiscoveryStep { + name: localized_message(&lang_id, "auto_discovery_is_wordpress"), + is_required: true, + was_successful: true, // TODO: Write some evaluation for this + error_message: None // TODO: We should be able to populate this + }, + AutoDiscoveryStep { + name: localized_message(&lang_id, "auto_discovery_step_json"), + is_required: true, + was_successful: !self.has_failed_to_parse_api_details(), + error_message: Some(localized_message_with_args( + &lang_id, + "parse_api_root_url_error_api_root_link_header_not_found", + &HashMap::from([("site_url", self.attempt_site_url().into())]), + )) + }, + AutoDiscoveryStep { + name: localized_message(&lang_id, "auto_discovery_step_find_auth_url"), + is_required: true, + was_successful: !self.has_failed_to_parse_authentication_url(), + error_message: Some(localized_message_with_args( + &lang_id, + "auto_discovery_attempt_failure_parse_api_details", + &HashMap::from([("api_url", self.attempt_site_url().into())]), + )) + } + ] + } + fn attempt_site_url(&self) -> String { self.attempt_site_url.clone() } - fn error_message(&self) -> Option { + fn error_message(&self, locale_id: String) -> Option { match &self.result { Ok(_) => None, - Err(error) => Some(error.error_message()), + Err(error) => { + error.localized_error_message(locale_id) + }, } } @@ -129,18 +191,16 @@ impl AutoDiscoveryAttemptResult { } } - fn has_failed_to_parse_api_root_url(&self) -> Option { - match &self.result { - Ok(success) => Some(false), - Err(error) => error.has_failed_to_parse_api_root_url(), - } + fn has_failed_to_connect_with_https(&self) -> bool { + !self.attempt_site_url().starts_with("https://") // TODO: Can we do better here? } - fn has_failed_to_parse_api_details(&self) -> Option { - match &self.result { - Ok(success) => Some(false), - Err(error) => error.has_failed_to_parse_api_details(), - } + fn has_failed_to_parse_api_root_url(&self) -> bool { + false // TODO + } + + fn has_failed_to_parse_api_details(&self) -> bool { + false // TODO } fn parsed_site_url(&self) -> Option> { @@ -163,6 +223,14 @@ impl AutoDiscoveryAttemptResult { Err(_) => None, } } + + fn has_failed_to_parse_authentication_url(&self) -> bool { + if let Some(details) = self.api_details() { + return details.find_application_passwords_authentication_url().is_some(); + } + + false + } } #[derive(Debug, Clone)] @@ -345,11 +413,29 @@ pub enum ParseApiRootUrlError { header_map )] ApiRootLinkHeaderNotFound { + site_url: Arc, header_map: Arc, status_code: u16, }, } +impl WpLocalizedError for ParseApiRootUrlError { + fn localized_error_message(&self, lang_id: String) -> Option { + let message = match self { + ParseApiRootUrlError::ApiRootLinkHeaderNotFound { + site_url, + header_map, + status_code, + } => localized_message_with_args( + &lang_id, + "parse_api_root_url_error_api_root_link_header_not_found", + &HashMap::from([("site_url", site_url.url().into())]), + ), + }; + Some(message) + } +} + #[derive(Debug, thiserror::Error, uniffi::Error)] pub enum FetchApiDetailsError { #[error( @@ -365,6 +451,88 @@ pub enum FetchApiDetailsError { ApiDetailsCouldntBeParsed { reason: String, response: String }, } +#[uniffi::export(with_foreign)] +pub trait WpLocalizedError: Send + Sync { + fn localized_error_message(&self, locale_id: String) -> Option; +} + +impl WpLocalizedError for AutoDiscoveryAttemptFailure { + fn localized_error_message(&self, lang_id: String) -> Option { + match self { + AutoDiscoveryAttemptFailure::ParseSiteUrl { error } => Some(localized_message( + &lang_id, + "auto_discovery_attempt_failure_parse_site_url", + )), + AutoDiscoveryAttemptFailure::FetchApiRootUrl { + parsed_site_url, + error, + } => Some(localized_message_with_args( + &lang_id, + "auto_discovery_attempt_failure_fetch_api_root_url", + &HashMap::from([("site_url", parsed_site_url.url().into())]), + )), + AutoDiscoveryAttemptFailure::ParseApiRootUrl { + parsed_site_url, + error, + } => error.localized_error_message(lang_id), + AutoDiscoveryAttemptFailure::FetchApiDetails { + parsed_site_url, + api_root_url, + error, + } => Some(localized_message_with_args( + &lang_id, + "auto_discovery_attempt_failure_fetch_api_details", + &HashMap::from([("api_url", api_root_url.url().into())]), + )), + AutoDiscoveryAttemptFailure::ParseApiDetails { + parsed_site_url, + api_root_url, + parsing_error_message, + } => Some(localized_message_with_args( + &lang_id, + "auto_discovery_attempt_failure_parse_api_details", + &HashMap::from([("api_url", api_root_url.url().into())]), + )), + } + } +} + +fn locale_language_id(lang_id: &str) -> unic_langid::LanguageIdentifier { + // Look up the translated message for `message_key` in `lang_id`. + let requested = convert_vec_str_to_langids_lossy([lang_id]); + let default: icu_locid::LanguageIdentifier = icu_locid::langid!("en-US"); + let available: Vec = LOCALES + .locales() + .filter_map(|f| f.to_string().parse().ok()) + .collect(); + + let supported = negotiate_languages( + &requested, + &available, + Some(&default), + NegotiationStrategy::Filtering, + ); + + supported + .first() + .unwrap_or(&&default) + .to_string() + .parse() + .unwrap_or(unic_langid::langid!("en-US")) +} + +pub fn localized_message(lang_id: &str, message_key: &str) -> String { + LOCALES.lookup(&locale_language_id(lang_id), message_key) +} + +pub fn localized_message_with_args>( + lang_id: &str, + message_key: &str, + args: &HashMap, +) -> String { + LOCALES.lookup_with_args(&locale_language_id(lang_id), message_key, args) +} + #[cfg(test)] mod tests { use super::*;