diff --git a/native/kotlin/api/android/src/androidTest/kotlin/rs/wordpress/api/android/UsersEndpointAndroidTest.kt b/native/kotlin/api/android/src/androidTest/kotlin/rs/wordpress/api/android/UsersEndpointAndroidTest.kt index d991ca147..e1702ee34 100644 --- a/native/kotlin/api/android/src/androidTest/kotlin/rs/wordpress/api/android/UsersEndpointAndroidTest.kt +++ b/native/kotlin/api/android/src/androidTest/kotlin/rs/wordpress/api/android/UsersEndpointAndroidTest.kt @@ -1,6 +1,7 @@ package rs.wordpress.api.android import kotlinx.coroutines.test.runTest +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.junit.Assert import org.junit.Test import rs.wordpress.api.kotlin.WpApiClient @@ -17,7 +18,7 @@ class UsersEndpointAndroidTest { username = BuildConfig.TEST_ADMIN_USERNAME, password = BuildConfig.TEST_ADMIN_PASSWORD ) - private val client = WpApiClient(siteUrl, authentication) + private val client = WpApiClient(siteUrl.toHttpUrlOrNull()!!, authentication) @Test fun testUserListRequest() = runTest { diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/UsersEndpointTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/UsersEndpointTest.kt index fad96155d..cefc4d790 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/UsersEndpointTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/UsersEndpointTest.kt @@ -1,6 +1,7 @@ package rs.wordpress.api.kotlin import kotlinx.coroutines.test.runTest +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.junit.jupiter.api.Test import uniffi.wp_api.SparseUserField import uniffi.wp_api.UserListParams @@ -17,7 +18,7 @@ class UsersEndpointTest { private val authentication = wpAuthenticationFromUsernameAndPassword( username = testCredentials.adminUsername, password = testCredentials.adminPassword ) - private val client = WpApiClient(siteUrl, authentication) + private val client = WpApiClient(siteUrl.toHttpUrlOrNull()!!, authentication) @Test fun testUserListRequest() = runTest { diff --git a/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/api/kotlin/WpApiClient.kt b/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/api/kotlin/WpApiClient.kt index 22d5b11af..7f9356e5d 100644 --- a/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/api/kotlin/WpApiClient.kt +++ b/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/api/kotlin/WpApiClient.kt @@ -3,23 +3,25 @@ package rs.wordpress.api.kotlin import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import okhttp3.HttpUrl +import uniffi.wp_api.ApiBaseUrl import uniffi.wp_api.RequestExecutor import uniffi.wp_api.WpApiException import uniffi.wp_api.WpAuthentication import uniffi.wp_api.WpRequestBuilder import uniffi.wp_api.WpRestErrorWrapper +import uniffi.wp_api.apiBaseUrlFromStr class WpApiClient -@Throws(WpApiException::class) constructor( - siteUrl: String, + siteUrl: HttpUrl, authentication: WpAuthentication, private val requestExecutor: RequestExecutor = WpRequestExecutor(), private val dispatcher: CoroutineDispatcher = Dispatchers.IO ) { // Don't expose `WpRequestBuilder` directly so we can control how it's used private val requestBuilder by lazy { - WpRequestBuilder(siteUrl, authentication, requestExecutor) + WpRequestBuilder(siteUrl.toApiBaseUrl(), authentication, requestExecutor) } // Provides the _only_ way to execute requests using our Kotlin wrapper. @@ -46,3 +48,10 @@ constructor( } } } + +fun HttpUrl.toApiBaseUrl(): ApiBaseUrl { + val absoluteUrl = this.toString() + // This call will never return a null value, because absoluteUrl is always + // a valid url string. + return apiBaseUrlFromStr(absoluteUrl)!! +} diff --git a/native/swift/Example/Example/LoginView.swift b/native/swift/Example/Example/LoginView.swift index eeb58e53b..6dd33004d 100644 --- a/native/swift/Example/Example/LoginView.swift +++ b/native/swift/Example/Example/LoginView.swift @@ -113,7 +113,7 @@ struct LoginView: View { return nil } - let client = try WordPressAPI( + let client = WordPressAPI( urlSession: .shared, baseUrl: apiRoot, authenticationStategy: .none diff --git a/native/swift/Example/Example/UserListViewModel.swift b/native/swift/Example/Example/UserListViewModel.swift index 021b141a2..0fc5a435b 100644 --- a/native/swift/Example/Example/UserListViewModel.swift +++ b/native/swift/Example/Example/UserListViewModel.swift @@ -15,7 +15,7 @@ extension UserWithViewContext: Identifiable {} // swiftlint:disable force_try var api: WordPressAPI { - try! WordPressAPI( + WordPressAPI( urlSession: .shared, baseUrl: URL(string: loginManager.getDefaultSiteUrl()!)!, authenticationStategy: try! loginManager.getLoginCredentials()! @@ -34,7 +34,7 @@ extension UserWithViewContext: Identifiable {} self.fetchUsersTask = Task { @MainActor in do { - users = try await api.users.listWithViewContext(params: nil) + users = try await api.users.listWithViewContext(params: .init()) } catch let error { shouldPresentAlert = true self.error = MyError(underlyingError: error) diff --git a/native/swift/Sources/wordpress-api/Login/API+Login.swift b/native/swift/Sources/wordpress-api/Login/API+Login.swift index cded264e3..c4054d9b2 100644 --- a/native/swift/Sources/wordpress-api/Login/API+Login.swift +++ b/native/swift/Sources/wordpress-api/Login/API+Login.swift @@ -10,7 +10,7 @@ import FoundationNetworking public extension WordPressAPI { static func findRestApiEndpointRoot(forSiteUrl url: URL, using session: URLSession) async throws -> URL? { let request = WpNetworkRequest(method: .head, url: url, headerMap: [:]) - let ephemeralClient = try WordPressAPI(urlSession: session, baseUrl: url, authenticationStategy: .none) + let ephemeralClient = WordPressAPI(urlSession: session, baseUrl: url, authenticationStategy: .none) let response = try await ephemeralClient.perform(request: request) return getLinkHeader(response: response, name: "https://api.w.org/")?.asUrl() diff --git a/native/swift/Sources/wordpress-api/WordPressAPI.swift b/native/swift/Sources/wordpress-api/WordPressAPI.swift index bf8d2b8d6..68e53af38 100644 --- a/native/swift/Sources/wordpress-api/WordPressAPI.swift +++ b/native/swift/Sources/wordpress-api/WordPressAPI.swift @@ -16,8 +16,8 @@ public struct WordPressAPI { private let urlSession: URLSession package let requestBuilder: WpRequestBuilderProtocol - public init(urlSession: URLSession, baseUrl: URL, authenticationStategy: WpAuthentication) throws { - try self.init( + public init(urlSession: URLSession, baseUrl: URL, authenticationStategy: WpAuthentication) { + self.init( urlSession: urlSession, baseUrl: baseUrl, authenticationStategy: authenticationStategy, @@ -30,10 +30,10 @@ public struct WordPressAPI { baseUrl: URL, authenticationStategy: WpAuthentication, executor: SafeRequestExecutor - ) throws { + ) { self.urlSession = urlSession - self.requestBuilder = try WpRequestBuilder( - siteUrl: baseUrl.absoluteString, + self.requestBuilder = WpRequestBuilder( + siteUrl: baseUrl.asApiBaseUrl(), authentication: authenticationStategy, requestExecutor: executor ) @@ -223,4 +223,10 @@ extension URL { func asRestUrl() -> WpRestApiUrl { WpRestApiUrl(stringValue: self.absoluteString) } + + func asApiBaseUrl() -> ApiBaseUrl { + // This call will never return a nil value, because `absoluteString` is + // always a valid url string. + apiBaseUrlFromStr(str: absoluteString)! + } } diff --git a/native/swift/Tests/wordpress-api/URLTests.swift b/native/swift/Tests/wordpress-api/URLTests.swift new file mode 100644 index 000000000..6d4f1efbe --- /dev/null +++ b/native/swift/Tests/wordpress-api/URLTests.swift @@ -0,0 +1,31 @@ +import Foundation +import XCTest + +@testable import WordPressAPI + +#if canImport(WordPressAPIInternal) +@testable import WordPressAPIInternal +#endif + +class URLTests: XCTestCase { + + func testParseApiBaseUrl() throws { + let urls = try [ + "http://example.com/path?query=value#fragment", + "http://example.com:8080/path", + "http://sub.sub2.example.com/path", + "http://192.168.1.1/path", + "http://example.com/a/very/long/path/that/goes/on/forever", + "http://example.com/path%20with%20spaces", + "http://example.com/~user!$&'()*+,;=:@/path", + "http://user:password@example.com/path", + "http://example.com", + "http://example.com./path" + ] + .map { try XCTUnwrap(URL(string: $0)) } + for url in urls { + XCTAssertTrue(apiBaseUrlFromStr(str: url.absoluteString) != nil, "Invalid URL: \(url)") + } + } + +} diff --git a/native/swift/Tests/wordpress-api/WordPressAPITests.swift b/native/swift/Tests/wordpress-api/WordPressAPITests.swift index 168e13d87..96777b155 100644 --- a/native/swift/Tests/wordpress-api/WordPressAPITests.swift +++ b/native/swift/Tests/wordpress-api/WordPressAPITests.swift @@ -40,7 +40,7 @@ final class WordPressAPITests: XCTestCase { let stubs = HTTPStubs() stubs.stub(path: "/wp-json/wp/v2/users/1", with: .json(response)) - let api = try WordPressAPI( + let api = WordPressAPI( urlSession: .shared, baseUrl: URL(string: "https://wordpress.org")!, authenticationStategy: .none, @@ -56,7 +56,7 @@ final class WordPressAPITests: XCTestCase { let stubs = HTTPStubs() stubs.missingStub = .failure(URLError(.timedOut)) - let api = try WordPressAPI( + let api = WordPressAPI( urlSession: .shared, baseUrl: URL(string: "https://wordpress.org")!, authenticationStategy: .none, diff --git a/wp_api/src/lib.rs b/wp_api/src/lib.rs index 0c2d279ef..f3cafba92 100644 --- a/wp_api/src/lib.rs +++ b/wp_api/src/lib.rs @@ -32,25 +32,21 @@ pub struct WpRequestBuilder { impl WpRequestBuilder { #[uniffi::constructor] pub fn new( - site_url: String, + site_url: ApiBaseUrl, authentication: WpAuthentication, request_executor: Arc, - ) -> Result { - let api_base_url: Arc = ApiBaseUrl::new(site_url.as_str()) - .map_err(|err| WpApiError::SiteUrlParsingError { - reason: err.to_string(), - })? - .into(); + ) -> Self { + let api_base_url: Arc = site_url.into(); let request_builder = Arc::new(request::RequestBuilder::new( request_executor, authentication.clone(), )); - Ok(Self { + Self { users: UsersRequestBuilder::new(api_base_url.clone(), request_builder.clone()).into(), plugins: PluginsRequestBuilder::new(api_base_url.clone(), request_builder.clone()) .into(), - }) + } } pub fn users(&self) -> Arc { diff --git a/wp_api/src/request/endpoint.rs b/wp_api/src/request/endpoint.rs index 0407cbeb5..e89e3ca13 100644 --- a/wp_api/src/request/endpoint.rs +++ b/wp_api/src/request/endpoint.rs @@ -42,23 +42,48 @@ impl From for WpEndpointUrl { } } -#[derive(Debug, Clone)] -pub(crate) struct ApiBaseUrl { - url: Url, +#[derive(Debug, Clone, uniffi::Record)] +pub struct ApiBaseUrl { + standard_absolute_url: String, } -impl ApiBaseUrl { - pub fn new(site_base_url: &str) -> Result { - Url::parse(site_base_url).map(|parsed_url| { +impl TryFrom<&str> for ApiBaseUrl { + type Error = url::ParseError; + + fn try_from(value: &str) -> Result { + Url::parse(value).map(|parsed_url| { let url = parsed_url .extend(WP_JSON_PATH_SEGMENTS) .expect("ApiBaseUrl is already parsed, so this can't result in an error"); - Self { url } + Self { + standard_absolute_url: url.into(), + } }) } +} + +impl From for ApiBaseUrl { + fn from(url: Url) -> Self { + Self { + standard_absolute_url: url.into(), + } + } +} + +impl ApiBaseUrl { + pub fn new(site_base_url: &str) -> Result { + site_base_url.try_into() + } + + fn url(&self) -> Url { + self.standard_absolute_url + .as_str() + .try_into() + .expect("standard_absolute_url was assigned from a Url instance, so this can't result in an error") + } fn by_appending(&self, segment: &str) -> Url { - self.url + self.url() .clone() .append(segment) .expect("ApiBaseUrl is already parsed, so this can't result in an error") @@ -69,17 +94,22 @@ impl ApiBaseUrl { I: IntoIterator, I::Item: AsRef, { - self.url + self.url() .clone() .extend(segments) .expect("ApiBaseUrl is already parsed, so this can't result in an error") } fn as_str(&self) -> &str { - self.url.as_str() + self.standard_absolute_url.as_str() } } +#[uniffi::export] +fn api_base_url_from_str(str: &str) -> Option { + ApiBaseUrl::try_from(str).ok() +} + trait UrlExtension { fn append(self, segment: &str) -> Result; fn extend(self, segments: I) -> Result @@ -154,7 +184,7 @@ mod tests { )] test_base_url: &str, ) { - let api_base_url = ApiBaseUrl::new(test_base_url).unwrap(); + let api_base_url: ApiBaseUrl = test_base_url.try_into().unwrap(); let expected_wp_json_url = wp_json_endpoint(test_base_url); assert_eq!(expected_wp_json_url, api_base_url.as_str()); assert_eq!( @@ -177,7 +207,7 @@ mod tests { #[fixture] pub fn fixture_api_base_url() -> Arc { - ApiBaseUrl::new("https://example.com").unwrap().into() + ApiBaseUrl::try_from("https://example.com").unwrap().into() } pub fn validate_endpoint(endpoint_url: ApiEndpointUrl, path: &str) { diff --git a/wp_api/tests/integration_test_common.rs b/wp_api/tests/integration_test_common.rs index c836d353c..33e2a4ac9 100644 --- a/wp_api/tests/integration_test_common.rs +++ b/wp_api/tests/integration_test_common.rs @@ -3,7 +3,9 @@ use futures::Future; use http::HeaderMap; use std::{fs::read_to_string, process::Command, sync::Arc}; use wp_api::{ - request::{RequestExecutor, RequestMethod, WpNetworkRequest, WpNetworkResponse}, + request::{ + endpoint::ApiBaseUrl, RequestExecutor, RequestMethod, WpNetworkRequest, WpNetworkResponse, + }, users::UserId, RequestExecutionError, WpApiError, WpAuthentication, WpRequestBuilder, WpRestError, WpRestErrorCode, WpRestErrorWrapper, @@ -29,7 +31,6 @@ pub fn request_builder() -> WpRequestBuilder { authentication, Arc::new(AsyncWpNetworking::default()), ) - .expect("Site url is generated by our tooling") } pub fn request_builder_as_subscriber() -> WpRequestBuilder { @@ -43,7 +44,6 @@ pub fn request_builder_as_subscriber() -> WpRequestBuilder { authentication, Arc::new(AsyncWpNetworking::default()), ) - .expect("Site url is generated by our tooling") } pub trait AssertWpError { @@ -93,7 +93,7 @@ impl AssertWpError for Result { #[derive(Debug)] pub struct TestCredentials { - pub site_url: String, + pub site_url: ApiBaseUrl, pub admin_username: String, pub admin_password: String, pub subscriber_username: String, @@ -104,7 +104,7 @@ pub fn read_test_credentials_from_file() -> TestCredentials { let file_contents = read_to_string("../test_credentials").unwrap(); let lines: Vec<&str> = file_contents.lines().collect(); TestCredentials { - site_url: lines[0].to_string(), + site_url: lines[0].try_into().unwrap(), admin_username: lines[1].to_string(), admin_password: lines[2].to_string(), subscriber_username: lines[3].to_string(), diff --git a/wp_api/tests/test_users_err.rs b/wp_api/tests/test_users_err.rs index 05c9b57a6..477e8017e 100644 --- a/wp_api/tests/test_users_err.rs +++ b/wp_api/tests/test_users_err.rs @@ -168,7 +168,6 @@ async fn retrieve_user_err_unauthorized() { WpAuthentication::None, Arc::new(AsyncWpNetworking::default()), ) - .expect("Site url is generated by our tooling") .users() .retrieve_me_with_edit_context() .await