From 17123f09588693a286d06b6b283a0bd58b0d4a85 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Sat, 30 May 2026 17:45:39 -0400 Subject: [PATCH 1/2] Prototype: replace `WpErrorCode` fallback with `WpErrorCodeValue` record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the `CustomError(String)` fallback variant in `WpErrorCode` with a new `WpErrorCodeValue` record that separates the parsed enum from the raw API string: - `value: Option` — `Some` for known variants, `None` for unknown - `raw: String` — always the original string from the API This makes adding new `WpErrorCode` variants forward-compatible: client code checking the `raw` string continues to work after a variant is promoted, and the enum stays a real enum with full match support. Also changes `WpErrorCode` from `uniffi::Error` to `uniffi::Enum` since it was never returned as an error directly — always embedded as a field. Helper methods on `WpErrorCodeValue`: `is_code()`, `is_raw()`, `is_any_code()`. --- .../kotlin/AuthProviderTest.kt | 4 +- .../kotlin/BlockAutosavesEndpointTest.kt | 2 +- .../kotlin/BlockRendererEndpointTest.kt | 7 +- .../kotlin/BlockRevisionsEndpointTest.kt | 2 +- .../kotlin/BlockTypesEndpointTest.kt | 2 +- .../kotlin/BlocksEndpointTest.kt | 2 +- .../kotlin/CategoriesEndpointTest.kt | 4 +- .../kotlin/ErrorCodeForwardCompatTest.kt | 52 +++++++++++++++ .../kotlin/IntegrationTestHelpers.kt | 8 ++- .../kotlin/MenuLocationsEndpointTest.kt | 5 +- .../kotlin/NavMenuItemsEndpointTest.kt | 5 +- .../kotlin/PagesEndpointTest.kt | 2 +- .../kotlin/PluginsEndpointTest.kt | 4 +- .../kotlin/PostStatusesEndpointTest.kt | 4 +- .../kotlin/PostTypesEndpointTest.kt | 2 +- .../kotlin/PostsEndpointTest.kt | 2 +- .../kotlin/SidebarsEndpointTest.kt | 7 +- .../kotlin/TagsEndpointTest.kt | 2 +- .../kotlin/TemplatePartsEndpointTest.kt | 2 +- .../kotlin/TemplatesEndpointTest.kt | 2 +- .../kotlin/ThemesEndpointTest.kt | 5 +- .../kotlin/UsersEndpointTest.kt | 2 +- .../wordpress/api/kotlin/WpRequestResult.kt | 4 +- .../shared/ui/components/ErrorMessage.kt | 8 +-- wp_api/src/api_error.rs | 64 ++++++++++++++----- wp_api/src/jetpack/connection.rs | 9 +-- wp_api/src/login/login_client.rs | 10 ++- wp_api/src/login/url_discovery.rs | 10 ++- wp_api/src/request.rs | 8 +-- wp_api_integration_tests/src/lib.rs | 3 +- 30 files changed, 172 insertions(+), 71 deletions(-) create mode 100644 native/kotlin/api/kotlin/src/integrationTest/kotlin/ErrorCodeForwardCompatTest.kt diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/AuthProviderTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/AuthProviderTest.kt index 6573b7fa6..2b5901c5a 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/AuthProviderTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/AuthProviderTest.kt @@ -57,7 +57,7 @@ class AuthProviderTest { // Assert that initial unauthorized request fails assert(client.request { requestBuilder -> requestBuilder.users().retrieveMeWithEditContext() - }.wpErrorCode() is WpErrorCode.Unauthorized) + }.wpErrorCode() == WpErrorCode.UNAUTHORIZED) // Assert that request succeeds after setting `is_authorized = true` dynamicAuthProvider.isAuthorized = true @@ -80,7 +80,7 @@ class AuthProviderTest { // Assert that request fails without authentication assert(client.request { requestBuilder -> requestBuilder.users().retrieveMeWithEditContext() - }.wpErrorCode() is WpErrorCode.Unauthorized) + }.wpErrorCode() == WpErrorCode.UNAUTHORIZED) // Assert that request succeeds after authentication is modified modifiableAuthenticationProvider.setAuthentication( diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/BlockAutosavesEndpointTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/BlockAutosavesEndpointTest.kt index 3582a4bac..410aeacbd 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/BlockAutosavesEndpointTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/BlockAutosavesEndpointTest.kt @@ -56,6 +56,6 @@ class BlockAutosavesEndpointTest { val result = client.request { requestBuilder -> requestBuilder.blockAutosaves().listWithEditContext(99999999L) } - assert(result.wpErrorCode() is WpErrorCode.PostInvalidParent) + assertEquals(WpErrorCode.POST_INVALID_PARENT, result.wpErrorCode()) } } diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/BlockRendererEndpointTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/BlockRendererEndpointTest.kt index 730a229ad..6b3629dae 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/BlockRendererEndpointTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/BlockRendererEndpointTest.kt @@ -1,11 +1,12 @@ package rs.wordpress.api.kotlin +import kotlin.test.assertEquals + import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import uniffi.wp_api.BlockRendererPostParams import uniffi.wp_api.WpErrorCode import kotlin.test.assertTrue - class BlockRendererEndpointTest { private val client = defaultApiClient() @@ -41,7 +42,7 @@ class BlockRendererEndpointTest { params = BlockRendererPostParams() ) } - assert(result.wpErrorCode() is WpErrorCode.BlockInvalid) + assertEquals(WpErrorCode.BLOCK_INVALID, result.wpErrorCode()) } @Test @@ -52,6 +53,6 @@ class BlockRendererEndpointTest { params = BlockRendererPostParams() ) } - assert(result.wpErrorCode() is WpErrorCode.BlockInvalid) + assertEquals(WpErrorCode.BLOCK_INVALID, result.wpErrorCode()) } } diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/BlockRevisionsEndpointTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/BlockRevisionsEndpointTest.kt index 8999b68ad..fb9f3e02d 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/BlockRevisionsEndpointTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/BlockRevisionsEndpointTest.kt @@ -77,6 +77,6 @@ class BlockRevisionsEndpointTest { BlockRevisionListParams() ) } - assert(result.wpErrorCode() is WpErrorCode.PostInvalidParent) + assertEquals(WpErrorCode.POST_INVALID_PARENT, result.wpErrorCode()) } } diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/BlockTypesEndpointTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/BlockTypesEndpointTest.kt index fc57b4ab2..2407c778e 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/BlockTypesEndpointTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/BlockTypesEndpointTest.kt @@ -37,6 +37,6 @@ class BlockTypesEndpointTest { val result = client.request { requestBuilder -> requestBuilder.blockTypes().retrieveWithEditContext("nonexistent", "nonexistent") } - assert(result.wpErrorCode() is WpErrorCode.BlockTypeInvalid) + assertEquals(WpErrorCode.BLOCK_TYPE_INVALID, result.wpErrorCode()) } } diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/BlocksEndpointTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/BlocksEndpointTest.kt index 778453add..f917d36b8 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/BlocksEndpointTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/BlocksEndpointTest.kt @@ -73,6 +73,6 @@ class BlocksEndpointTest { BlockListParams(page = 99999999u) ) } - assert(result.wpErrorCode() is WpErrorCode.PostInvalidPageNumber) + assertEquals(WpErrorCode.POST_INVALID_PAGE_NUMBER, result.wpErrorCode()) } } diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/CategoriesEndpointTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/CategoriesEndpointTest.kt index 71828adcc..54e681423 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/CategoriesEndpointTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/CategoriesEndpointTest.kt @@ -123,7 +123,7 @@ class CategoriesEndpointTest { termId = 9999999, ) } - assert(result.wpErrorCode() is WpErrorCode.TermInvalid) + assertEquals(WpErrorCode.TERM_INVALID, result.wpErrorCode()) } @Test @@ -136,6 +136,6 @@ class CategoriesEndpointTest { TermCreateParams(name = "foo", parent = 9999999) ) } - assert(result.wpErrorCode() is WpErrorCode.TermInvalid) + assertEquals(WpErrorCode.TERM_INVALID, result.wpErrorCode()) } } diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/ErrorCodeForwardCompatTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/ErrorCodeForwardCompatTest.kt new file mode 100644 index 000000000..79e5cd4a9 --- /dev/null +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/ErrorCodeForwardCompatTest.kt @@ -0,0 +1,52 @@ +package rs.wordpress.api.kotlin + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import uniffi.wp_api.WpErrorCode +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class ErrorCodeForwardCompatTest { + private val client = defaultApiClient() + + @Test + fun testKnownErrorCodeHasValueAndRaw() = runTest { + // Trigger a known error: retrieving a non-existent post type + val result = client.request { requestBuilder -> + requestBuilder.postTypes().retrieveWithEditContext("nonexistent_type") + } + + val errorCodeValue = result.wpErrorCodeValue() + // `value` should be the known enum variant + assertEquals(WpErrorCode.TYPE_INVALID, errorCodeValue.value) + // `raw` should always contain the original API string + assertEquals("rest_type_invalid", errorCodeValue.raw) + } + + @Test + fun testWpErrorCodeHelperReturnsKnownVariant() = runTest { + // The wpErrorCode() helper extracts .value for convenience + val result = client.request { requestBuilder -> + requestBuilder.postTypes().retrieveWithEditContext("nonexistent_type") + } + + // This is the migration path: use == instead of `is` for enum comparison + assertEquals(WpErrorCode.TYPE_INVALID, result.wpErrorCode()) + } + + @Test + fun testRawStringIsAlwaysAvailable() = runTest { + // Even for known error codes, the raw string is preserved. + // This is the forward-compat guarantee: if a client was written before + // a variant existed, they could check the raw string. After the variant + // is added, their raw string check still works. + val result = client.request { requestBuilder -> + requestBuilder.postTypes().retrieveWithEditContext("nonexistent_type") + } + + val errorCodeValue = result.wpErrorCodeValue() + assertNotNull(errorCodeValue.value, "Known error codes should have a value") + assertEquals("rest_type_invalid", errorCodeValue.raw) + } +} diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/IntegrationTestHelpers.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/IntegrationTestHelpers.kt index 12f9f3ef3..3bafbbccf 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/IntegrationTestHelpers.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/IntegrationTestHelpers.kt @@ -7,6 +7,7 @@ import uniffi.wp_api.WpApiClientDelegate import uniffi.wp_api.WpApiMiddlewarePipeline import uniffi.wp_api.WpAuthenticationProvider import uniffi.wp_api.WpErrorCode +import uniffi.wp_api.WpErrorCodeValue import uniffi.wp_api.ParsedUrl import uniffi.wp_mobile.MockPostService import uniffi.wp_mobile.SiteInfo @@ -94,7 +95,12 @@ fun WpRequestResult.assertSuccessAndRetrieveData(): T { return (this as WpRequestResult.Success).response } -fun WpRequestResult.wpErrorCode(): WpErrorCode { +fun WpRequestResult.wpErrorCode(): WpErrorCode? { + assert(this is WpRequestResult.WpError) + return (this as WpRequestResult.WpError).errorCode.value +} + +fun WpRequestResult.wpErrorCodeValue(): WpErrorCodeValue { assert(this is WpRequestResult.WpError) return (this as WpRequestResult.WpError).errorCode } diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/MenuLocationsEndpointTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/MenuLocationsEndpointTest.kt index cb2e672d3..37a491ce2 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/MenuLocationsEndpointTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/MenuLocationsEndpointTest.kt @@ -1,11 +1,12 @@ package rs.wordpress.api.kotlin +import kotlin.test.assertEquals + import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import uniffi.wp_api.WpErrorCode import kotlin.test.assertNotNull import kotlin.test.assertTrue - class MenuLocationsEndpointTest { private val client = defaultApiClient() @@ -33,6 +34,6 @@ class MenuLocationsEndpointTest { requestBuilder.menuLocations() .retrieveWithViewContext("invalid_location_that_does_not_exist") } - assert(result.wpErrorCode() is WpErrorCode.MenuLocationInvalid) + assertEquals(WpErrorCode.MENU_LOCATION_INVALID, result.wpErrorCode()) } } diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/NavMenuItemsEndpointTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/NavMenuItemsEndpointTest.kt index dc17be4a4..27388c5c2 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/NavMenuItemsEndpointTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/NavMenuItemsEndpointTest.kt @@ -1,5 +1,7 @@ package rs.wordpress.api.kotlin +import kotlin.test.assertEquals + import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test @@ -7,7 +9,6 @@ import uniffi.wp_api.NavMenuItemListParams import uniffi.wp_api.SparseNavMenuItemFieldWithEditContext import uniffi.wp_api.WpErrorCode import kotlin.test.assertNotNull - class NavMenuItemsEndpointTest { private val testCredentials = TestCredentials.INSTANCE private val client = defaultApiClient() @@ -66,6 +67,6 @@ class NavMenuItemsEndpointTest { client.request { requestBuilder -> requestBuilder.navMenuItems().listWithEditContext(params) } - assert(result.wpErrorCode() is WpErrorCode.PostInvalidPageNumber) + assertEquals(WpErrorCode.POST_INVALID_PAGE_NUMBER, result.wpErrorCode()) } } diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/PagesEndpointTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/PagesEndpointTest.kt index 0ffbb7e3b..37427cc80 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/PagesEndpointTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/PagesEndpointTest.kt @@ -72,7 +72,7 @@ class PagesEndpointTest { client.request { requestBuilder -> requestBuilder.posts().listWithEditContext(PostEndpointType.Pages, params) } - assert(result.wpErrorCode() is WpErrorCode.PostInvalidPageNumber) + assertEquals(WpErrorCode.POST_INVALID_PAGE_NUMBER, result.wpErrorCode()) } @Test diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/PluginsEndpointTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/PluginsEndpointTest.kt index b4a7a0f54..b484efe6d 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/PluginsEndpointTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/PluginsEndpointTest.kt @@ -92,7 +92,7 @@ class PluginsEndpointTest { ) ) } - assert(result.wpErrorCode() is WpErrorCode.CannotInstallPlugin) + assertEquals(WpErrorCode.CANNOT_INSTALL_PLUGIN, result.wpErrorCode()) } @Test @@ -100,6 +100,6 @@ class PluginsEndpointTest { val result = client.request { requestBuilder -> requestBuilder.plugins().delete(PluginSlug(HELLO_DOLLY_PLUGIN_SLUG)) } - assert(result.wpErrorCode() is WpErrorCode.CannotDeleteActivePlugin) + assertEquals(WpErrorCode.CANNOT_DELETE_ACTIVE_PLUGIN, result.wpErrorCode()) } } diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/PostStatusesEndpointTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/PostStatusesEndpointTest.kt index ecd94ee15..9145a0166 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/PostStatusesEndpointTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/PostStatusesEndpointTest.kt @@ -63,7 +63,7 @@ class PostStatusesEndpointTest { requestBuilder.postStatuses() .retrieveWithViewContext(PostStatusSlug("non_existent_status")) } - assert(result.wpErrorCode() is WpErrorCode.StatusInvalid) + assertEquals(WpErrorCode.STATUS_INVALID, result.wpErrorCode()) } @Test @@ -71,7 +71,7 @@ class PostStatusesEndpointTest { val result = client.request { requestBuilder -> requestBuilder.postStatuses().retrieveWithViewContext(PostStatusSlug("auto-draft")) } - assert(result.wpErrorCode() is WpErrorCode.CannotReadStatus) + assertEquals(WpErrorCode.CANNOT_READ_STATUS, result.wpErrorCode()) } @Test diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/PostTypesEndpointTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/PostTypesEndpointTest.kt index e5d93dadd..218ac2cd4 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/PostTypesEndpointTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/PostTypesEndpointTest.kt @@ -43,6 +43,6 @@ class PostTypesEndpointTest { val result = client.request { requestBuilder -> requestBuilder.postTypes().retrieveWithEditContext(PostType.Custom("does_not_exist")) } - assert(result.wpErrorCode() is WpErrorCode.TypeInvalid) + assertEquals(WpErrorCode.TYPE_INVALID, result.wpErrorCode()) } } diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/PostsEndpointTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/PostsEndpointTest.kt index c803cbec1..e4b4266c5 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/PostsEndpointTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/PostsEndpointTest.kt @@ -72,7 +72,7 @@ class PostsEndpointTest { client.request { requestBuilder -> requestBuilder.posts().listWithEditContext(PostEndpointType.Posts, params) } - assert(result.wpErrorCode() is WpErrorCode.PostInvalidPageNumber) + assertEquals(WpErrorCode.POST_INVALID_PAGE_NUMBER, result.wpErrorCode()) } @Test diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/SidebarsEndpointTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/SidebarsEndpointTest.kt index ed7de439d..673cf4ebb 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/SidebarsEndpointTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/SidebarsEndpointTest.kt @@ -1,12 +1,13 @@ package rs.wordpress.api.kotlin +import kotlin.test.assertEquals + import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import uniffi.wp_api.SidebarUpdateParams import uniffi.wp_api.WpAuthenticationProvider import uniffi.wp_api.WpErrorCode import kotlin.test.assertTrue - class SidebarsEndpointTest { private val testCredentials = TestCredentials.INSTANCE private val client = defaultApiClient() @@ -54,7 +55,7 @@ class SidebarsEndpointTest { requestBuilder.sidebars() .retrieveWithViewContext("nonexistent_sidebar_that_does_not_exist") } - assert(result.wpErrorCode() is WpErrorCode.SidebarNotFound) + assertEquals(WpErrorCode.SIDEBAR_NOT_FOUND, result.wpErrorCode()) } @Test @@ -62,6 +63,6 @@ class SidebarsEndpointTest { val result = clientAsSubscriber.request { requestBuilder -> requestBuilder.sidebars().listWithEditContext() } - assert(result.wpErrorCode() is WpErrorCode.CannotManageWidgets) + assertEquals(WpErrorCode.CANNOT_MANAGE_WIDGETS, result.wpErrorCode()) } } diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/TagsEndpointTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/TagsEndpointTest.kt index 9c23aef29..612362bcb 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/TagsEndpointTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/TagsEndpointTest.kt @@ -115,6 +115,6 @@ class TagsEndpointTest { requestBuilder.terms() .retrieveWithEditContext(TermEndpointType.Tags, 9999999) } - assert(result.wpErrorCode() is WpErrorCode.TermInvalid) + assertEquals(WpErrorCode.TERM_INVALID, result.wpErrorCode()) } } diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/TemplatePartsEndpointTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/TemplatePartsEndpointTest.kt index e13c2ef68..321f0530d 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/TemplatePartsEndpointTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/TemplatePartsEndpointTest.kt @@ -116,6 +116,6 @@ class TemplatePartsEndpointTest { requestBuilder.templateParts() .delete(TEMPLATE_PART_TWENTY_TWENTY_FOUR_HEADER) } - assert(result.wpErrorCode() is WpErrorCode.InvalidTemplate) + assertEquals(WpErrorCode.INVALID_TEMPLATE, result.wpErrorCode()) } } diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/TemplatesEndpointTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/TemplatesEndpointTest.kt index e95c8b6a5..89c919358 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/TemplatesEndpointTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/TemplatesEndpointTest.kt @@ -115,6 +115,6 @@ class TemplatesEndpointTest { client.request { requestBuilder -> requestBuilder.templates().delete(TEMPLATE_TWENTY_TWENTY_FOUR_SINGLE) } - assert(result.wpErrorCode() is WpErrorCode.InvalidTemplate) + assertEquals(WpErrorCode.INVALID_TEMPLATE, result.wpErrorCode()) } } \ No newline at end of file diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/ThemesEndpointTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/ThemesEndpointTest.kt index 955fd3040..a07dd91d2 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/ThemesEndpointTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/ThemesEndpointTest.kt @@ -1,5 +1,7 @@ package rs.wordpress.api.kotlin +import kotlin.test.assertEquals + import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import uniffi.wp_api.SparseThemeFieldWithEditContext @@ -8,7 +10,6 @@ import uniffi.wp_api.ThemeStylesheet import uniffi.wp_api.WpErrorCode import kotlin.test.assertNotNull import kotlin.test.assertNull - private const val THEME_TWENTY_TWENTY_FIVE: String = "twentytwentyfive" class ThemesEndpointTest { @@ -67,6 +68,6 @@ class ThemesEndpointTest { requestBuilder.themes() .retrieveWithEditContext(ThemeStylesheet("invalid_stylesheet")) } - assert(result.wpErrorCode() is WpErrorCode.ThemeNotFound) + assertEquals(WpErrorCode.THEME_NOT_FOUND, result.wpErrorCode()) } } diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/UsersEndpointTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/UsersEndpointTest.kt index b7ddc9e83..80cab526c 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/UsersEndpointTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/UsersEndpointTest.kt @@ -82,7 +82,7 @@ class UsersEndpointTest { ) val result = client.request { requestBuilder -> requestBuilder.users().listWithEditContext(params) } - assert(result.wpErrorCode() is WpErrorCode.InvalidParam) + assertEquals(WpErrorCode.INVALID_PARAM, result.wpErrorCode()) } @Test diff --git a/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/api/kotlin/WpRequestResult.kt b/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/api/kotlin/WpRequestResult.kt index c5b59a0cb..b73542733 100644 --- a/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/api/kotlin/WpRequestResult.kt +++ b/native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/api/kotlin/WpRequestResult.kt @@ -2,13 +2,13 @@ package rs.wordpress.api.kotlin import uniffi.wp_api.RequestExecutionErrorReason import uniffi.wp_api.RequestMethod -import uniffi.wp_api.WpErrorCode +import uniffi.wp_api.WpErrorCodeValue import uniffi.wp_api.WpRedirect sealed class WpRequestResult { data class Success(val response: T) : WpRequestResult() data class WpError( - val errorCode: WpErrorCode, + val errorCode: WpErrorCodeValue, val errorMessage: String, val statusCode: UInt, val response: String, diff --git a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/components/ErrorMessage.kt b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/components/ErrorMessage.kt index 6c867c8dd..813ada6b7 100644 --- a/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/components/ErrorMessage.kt +++ b/native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/components/ErrorMessage.kt @@ -11,7 +11,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import rs.wordpress.api.kotlin.WpRequestResult import uniffi.wp_api.RequestExecutionErrorReason -import uniffi.wp_api.WpErrorCode +import uniffi.wp_api.WpErrorCodeValue fun WpRequestResult.errorDescription(): String = when (this) { is WpRequestResult.Success -> "" @@ -29,10 +29,8 @@ fun WpRequestResult.errorDescription(): String = when (this) { "Unknown error (HTTP $statusCode)" } -private fun WpErrorCode.displayName(): String = when (this) { - is WpErrorCode.CustomException -> v1 - else -> this::class.simpleName ?: "Unknown" -} +private fun WpErrorCodeValue.displayName(): String = + value?.name ?: raw private fun RequestExecutionErrorReason.description(): String = when (this) { is RequestExecutionErrorReason.DeviceIsOfflineError -> "Device is offline: $errorMessage" diff --git a/wp_api/src/api_error.rs b/wp_api/src/api_error.rs index d43ceb461..64bbb4236 100644 --- a/wp_api/src/api_error.rs +++ b/wp_api/src/api_error.rs @@ -2,7 +2,7 @@ use crate::request::WpRedirect; use crate::request::{ HttpAuthMethod, HttpAuthMethodParsingError, RequestMethod, WpNetworkResponse, }; -use serde::Deserialize; +use serde::{Deserialize, Deserializer}; use wp_localization::{MessageBundle, WpMessages, WpSupportsLocalization}; use wp_localization_macro::WpDeriveLocalizable; @@ -20,7 +20,7 @@ where } pub trait MaybeWpError { - fn wp_error_code(&self) -> Option<&WpErrorCode>; + fn wp_error_code(&self) -> Option<&WpErrorCodeValue>; fn is_unauthorized_error(&self) -> Option; } @@ -58,7 +58,7 @@ pub enum WpApiError { request_method: RequestMethod, }, WpError { - error_code: WpErrorCode, + error_code: WpErrorCodeValue, error_message: String, status_code: u32, response: String, @@ -68,7 +68,7 @@ pub enum WpApiError { } impl MaybeWpError for WpApiError { - fn wp_error_code(&self) -> Option<&WpErrorCode> { + fn wp_error_code(&self) -> Option<&WpErrorCodeValue> { match self { WpApiError::WpError { error_code, .. } => Some(error_code), _ => None, @@ -84,7 +84,7 @@ impl MaybeWpError for Result where E: MaybeWpError, { - fn wp_error_code(&self) -> Option<&WpErrorCode> { + fn wp_error_code(&self) -> Option<&WpErrorCodeValue> { if let Err(e) = self { e.wp_error_code() } else { @@ -183,7 +183,7 @@ impl ParsedRequestError for WpApiError { // This type is used to parse the API errors. It then gets converted to `WpApiError::WpError`. #[derive(Debug, Deserialize, PartialEq, Eq)] pub struct WpError { - pub code: WpErrorCode, + pub code: WpErrorCodeValue, pub message: String, } @@ -197,7 +197,7 @@ impl WpError { } } -#[derive(Debug, Clone, Deserialize, PartialEq, Eq, uniffi::Error)] +#[derive(Debug, Clone, Deserialize, PartialEq, Eq, uniffi::Enum)] pub enum WpErrorCode { #[serde(rename = "rest_already_trashed")] AlreadyTrashed, @@ -571,16 +571,50 @@ pub enum WpErrorCode { WpCoreUnableToDetermineInstalledPlugin, #[serde(rename = "unexpected_output")] WpCoreUnexpectedOutput, - // ------------------------------------------------------------------------------------ - // Fallback to a `String` error code - // ------------------------------------------------------------------------------------ - #[serde(untagged)] - CustomError(String), } -impl WpErrorCode { - fn is_unauthorized(&self) -> bool { - self == &Self::Unauthorized +/// A parsed error code value that always preserves the raw string from the API. +/// +/// `value` is `Some` when the raw string matches a known `WpErrorCode` variant, +/// and `None` for unknown error codes. The `raw` string is always available, +/// making this type forward-compatible: adding new `WpErrorCode` variants does +/// not break client code that checks the `raw` string. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct WpErrorCodeValue { + pub value: Option, + pub raw: String, +} + +impl WpErrorCodeValue { + pub fn is_unauthorized(&self) -> bool { + self.value.as_ref() == Some(&WpErrorCode::Unauthorized) + } +} + +#[uniffi::export] +impl WpErrorCodeValue { + /// Check if this error code matches a known enum variant. + fn is_code(&self, code: WpErrorCode) -> bool { + self.value.as_ref() == Some(&code) + } + + /// Check if the raw API string matches the given string. + fn is_raw(&self, raw: String) -> bool { + self.raw == raw + } + + /// Check if this error code matches any of the given known variants. + fn is_any_code(&self, codes: Vec) -> bool { + self.value.as_ref().is_some_and(|v| codes.contains(v)) + } +} + +impl<'de> Deserialize<'de> for WpErrorCodeValue { + fn deserialize>(deserializer: D) -> Result { + let raw = String::deserialize(deserializer)?; + let value = + serde_json::from_value::(serde_json::Value::String(raw.clone())).ok(); + Ok(Self { value, raw }) } } diff --git a/wp_api/src/jetpack/connection.rs b/wp_api/src/jetpack/connection.rs index a1d60ead7..e41dc9e22 100644 --- a/wp_api/src/jetpack/connection.rs +++ b/wp_api/src/jetpack/connection.rs @@ -1,6 +1,6 @@ use crate::{ api_client::WpApiClientDelegate, - api_error::{WpApiError, WpErrorCode}, + api_error::WpApiError, auth::{WpAuthentication, WpAuthenticationProvider}, jetpack::client::JetpackApiClient, parsed_url::ParsedUrl, @@ -217,11 +217,8 @@ impl JetpackConnectionClient { .await; // At the time of writing, `"code": "success"` is parsed as an error case. - if let Err(WpApiError::WpError { - error_code: WpErrorCode::CustomError(code), - .. - }) = &result - && (code == "success" || code == "already_connected") + if let Err(WpApiError::WpError { error_code, .. }) = &result + && (error_code.raw == "success" || error_code.raw == "already_connected") { return Ok(blog_id); } diff --git a/wp_api/src/login/login_client.rs b/wp_api/src/login/login_client.rs index 601ff9eed..fdbe6f27f 100644 --- a/wp_api/src/login/login_client.rs +++ b/wp_api/src/login/login_client.rs @@ -539,7 +539,10 @@ impl PerformsRequests for WpLoginClient { #[cfg(test)] mod tests { use super::*; - use crate::{api_error::WpErrorCode, unit_test_common::wp_network_response_from_json}; + use crate::{ + api_error::{WpErrorCode, WpErrorCodeValue}, + unit_test_common::wp_network_response_from_json, + }; #[test] fn test_parse_api_details_wp_error_rest_forbidden() { @@ -553,7 +556,10 @@ mod tests { matches!( result, Err(FetchAndParseApiRootFailure::WpError { - error_code: WpErrorCode::Forbidden, + error_code: WpErrorCodeValue { + value: Some(WpErrorCode::Forbidden), + .. + }, status_code: 403, .. }) diff --git a/wp_api/src/login/url_discovery.rs b/wp_api/src/login/url_discovery.rs index 8b5a4d3a2..1f514cde7 100644 --- a/wp_api/src/login/url_discovery.rs +++ b/wp_api/src/login/url_discovery.rs @@ -1,6 +1,6 @@ use super::{OAuth2Endpoints, WpApiDetails}; use crate::{ - api_error::{RequestExecutionError, RequestExecutionErrorReason, WpErrorCode}, + api_error::{RequestExecutionError, RequestExecutionErrorReason, WpErrorCodeValue}, login::KnownAuthenticationBlockingPlugin, parsed_url::{ParseUrlError, ParsedUrl}, request::{ResponseBodyType, WpRedirect}, @@ -501,7 +501,7 @@ pub enum FetchAndParseApiRootFailure { reason: Option, }, WpError { - error_code: WpErrorCode, + error_code: WpErrorCodeValue, error_message: String, status_code: u32, }, @@ -834,6 +834,7 @@ impl ParseApiRootFailureReason { mod tests { use super::AutoDiscoveryAttempt as A; use super::*; + use crate::api_error::{WpErrorCode, WpErrorCodeValue}; use crate::request::RequestMethod; use rstest::*; @@ -1122,7 +1123,10 @@ mod tests { parsed_site_url: example_parsed_url(), api_root_url: example_parsed_url(), fetch_and_parse_api_root_failure: FetchAndParseApiRootFailure::WpError { - error_code: WpErrorCode::Forbidden, + error_code: WpErrorCodeValue { + value: Some(WpErrorCode::Forbidden), + raw: "rest_forbidden".to_string(), + }, error_message: "".to_string(), status_code: 403, }, diff --git a/wp_api/src/request.rs b/wp_api/src/request.rs index 99875bc10..533e2d124 100644 --- a/wp_api/src/request.rs +++ b/wp_api/src/request.rs @@ -1,6 +1,6 @@ use self::endpoint::WpEndpointUrl; use crate::{ - api_error::{ParsedRequestError, RequestExecutionError, WpApiError, WpErrorCode}, + api_error::{ParsedRequestError, RequestExecutionError, WpApiError}, auth::WpAuthenticationProvider, url_query::{FromUrlQueryPairs, UrlQueryPairsMap}, }; @@ -957,10 +957,8 @@ pub async fn fetch_authentication_state( match parsed_res { Ok(_) => Ok(AuthenticationState::Authenticated), Err(wp_api_error) => { - if let WpApiError::WpError { - error_code: WpErrorCode::Unauthorized, - .. - } = wp_api_error + if let WpApiError::WpError { error_code, .. } = &wp_api_error + && error_code.is_unauthorized() { Ok(AuthenticationState::Unauthorized) } else { diff --git a/wp_api_integration_tests/src/lib.rs b/wp_api_integration_tests/src/lib.rs index 0f5d65369..f3dc317be 100644 --- a/wp_api_integration_tests/src/lib.rs +++ b/wp_api_integration_tests/src/lib.rs @@ -200,7 +200,8 @@ impl AssertWpError for Result { } = err { assert_eq!( - expected_error_code, error_code, + Some(&expected_error_code), + error_code.value.as_ref(), "Incorrect error code. Expected '{expected_error_code:?}', found '{error_code:?}'. Response was: '{response:?}'" ); } else { From c88cca7e6b44fb9d8936c7ce34d6fd192d65e3fa Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Sat, 30 May 2026 19:27:55 -0400 Subject: [PATCH 2/2] Fix `ErrorCodeForwardCompatTest` to pass `PostType` instead of `String` `retrieveWithEditContext` expects a `PostType`, not a raw string. --- .../integrationTest/kotlin/ErrorCodeForwardCompatTest.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/ErrorCodeForwardCompatTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/ErrorCodeForwardCompatTest.kt index 79e5cd4a9..ab4f485c0 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/ErrorCodeForwardCompatTest.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/ErrorCodeForwardCompatTest.kt @@ -2,10 +2,10 @@ package rs.wordpress.api.kotlin import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test +import uniffi.wp_api.PostType import uniffi.wp_api.WpErrorCode import kotlin.test.assertEquals import kotlin.test.assertNotNull -import kotlin.test.assertNull class ErrorCodeForwardCompatTest { private val client = defaultApiClient() @@ -14,7 +14,7 @@ class ErrorCodeForwardCompatTest { fun testKnownErrorCodeHasValueAndRaw() = runTest { // Trigger a known error: retrieving a non-existent post type val result = client.request { requestBuilder -> - requestBuilder.postTypes().retrieveWithEditContext("nonexistent_type") + requestBuilder.postTypes().retrieveWithEditContext(PostType.Custom("nonexistent_type")) } val errorCodeValue = result.wpErrorCodeValue() @@ -28,7 +28,7 @@ class ErrorCodeForwardCompatTest { fun testWpErrorCodeHelperReturnsKnownVariant() = runTest { // The wpErrorCode() helper extracts .value for convenience val result = client.request { requestBuilder -> - requestBuilder.postTypes().retrieveWithEditContext("nonexistent_type") + requestBuilder.postTypes().retrieveWithEditContext(PostType.Custom("nonexistent_type")) } // This is the migration path: use == instead of `is` for enum comparison @@ -42,7 +42,7 @@ class ErrorCodeForwardCompatTest { // a variant existed, they could check the raw string. After the variant // is added, their raw string check still works. val result = client.request { requestBuilder -> - requestBuilder.postTypes().retrieveWithEditContext("nonexistent_type") + requestBuilder.postTypes().retrieveWithEditContext(PostType.Custom("nonexistent_type")) } val errorCodeValue = result.wpErrorCodeValue()