Skip to content

Commit 421a5ae

Browse files
committed
Probe direct host for editor capability detection on Atomic sites
Fixes #22879. EditorSettingsRepository.fetchRouteSupport queried the WP.com proxy via getWpApiClient, but GutenbergView fetches wp-block-editor/v1/settings from the direct host. On Atomic sites without an application password those hosts can disagree, so the proxy would advertise the route, capability detection would say "theme styles supported," and the editor would then 404 trying to load it.
1 parent f36d26f commit 421a5ae

4 files changed

Lines changed: 236 additions & 33 deletions

File tree

RELEASE-NOTES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
-----
55
* [**] Resolved an issue where the editor could become impossible to exit when it failed to load.
66
* [*] Atomic sites can now create application passwords without leaving the app.
7+
* [**] Fixed a case where the editor failed to load on WP.com Atomic sites whose host doesn't expose `wp-block-editor/v1/settings`.
78

89
26.7
910
-----

WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt

Lines changed: 76 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,19 @@ import org.wordpress.android.fluxc.persistence.EditorSettingsSqlUtils
1212
import org.wordpress.android.modules.IO_THREAD
1313
import org.wordpress.android.util.AppLog
1414
import org.wordpress.android.util.AppLog.T
15+
import rs.wordpress.api.kotlin.ApiDiscoveryResult
16+
import rs.wordpress.api.kotlin.WpLoginClient
1517
import rs.wordpress.api.kotlin.WpRequestResult
18+
import uniffi.wp_api.ApiUrlResolver
19+
import uniffi.wp_api.WpApiDetails
1620
import javax.inject.Inject
1721
import javax.inject.Named
1822
import javax.inject.Singleton
1923

2024
@Singleton
2125
class EditorSettingsRepository @Inject constructor(
2226
private val wpApiClientProvider: WpApiClientProvider,
27+
private val wpLoginClient: WpLoginClient,
2328
private val appPrefsWrapper: AppPrefsWrapper,
2429
private val themeRepository: ThemeRepository,
2530
@Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher
@@ -92,36 +97,14 @@ class EditorSettingsRepository @Inject constructor(
9297
private suspend fun fetchRouteSupport(
9398
site: SiteModel
9499
): Boolean = try {
95-
val client =
96-
wpApiClientProvider.getWpApiClient(site)
97-
val resolver =
98-
wpApiClientProvider.getApiUrlResolver(site)
99-
val response =
100-
client.request { it.apiRoot().get() }
101-
102-
if (response is WpRequestResult.Success) {
103-
val data = response.response.data
104-
appPrefsWrapper
105-
.setSiteSupportsEditorSettings(
106-
site,
107-
data.hasRouteForEndpoint(
108-
resolver,
109-
"/wp-block-editor/v1",
110-
"settings"
111-
)
112-
)
113-
appPrefsWrapper
114-
.setSiteSupportsEditorAssets(
115-
site,
116-
data.hasRouteForEndpoint(
117-
resolver,
118-
"/wpcom/v2",
119-
"editor-assets"
120-
)
121-
)
122-
true
100+
// For Atomic sites the editor fetches `wp-block-editor/v1/settings`
101+
// from the direct host — proxy and direct host can advertise
102+
// different route lists, so detection has to probe the direct host
103+
// too. See #22879.
104+
if (site.isWPComAtomic) {
105+
fetchRouteSupportViaDirectHostDiscovery(site)
123106
} else {
124-
false
107+
fetchRouteSupportViaConfiguredClient(site)
125108
}
126109
} catch (e: CancellationException) {
127110
throw e
@@ -135,6 +118,70 @@ class EditorSettingsRepository @Inject constructor(
135118
false
136119
}
137120

121+
private suspend fun fetchRouteSupportViaConfiguredClient(
122+
site: SiteModel
123+
): Boolean {
124+
val client = wpApiClientProvider.getWpApiClient(site)
125+
val resolver = wpApiClientProvider.getApiUrlResolver(site)
126+
val response = client.request { it.apiRoot().get() }
127+
return if (response is WpRequestResult.Success) {
128+
persistRouteSupport(site, response.response.data, resolver)
129+
true
130+
} else {
131+
false
132+
}
133+
}
134+
135+
/**
136+
* On WP.com Atomic sites the editor fetches `wp-block-editor/v1/settings`
137+
* from the direct host — not the WP.com proxy — so detection has to
138+
* match. Run REST API autodiscovery on the site URL so we don't have to
139+
* assume the API lives at `/wp-json` (custom permalink structures or
140+
* REST API paths would break that assumption), then use the routes list
141+
* returned by discovery directly — no second request needed.
142+
*/
143+
private suspend fun fetchRouteSupportViaDirectHostDiscovery(
144+
site: SiteModel
145+
): Boolean {
146+
val discovery = wpLoginClient.apiDiscovery(site.url)
147+
if (discovery !is ApiDiscoveryResult.Success) {
148+
AppLog.w(
149+
T.EDITOR,
150+
"Direct-host API discovery failed for" +
151+
" site=${site.name}: ${discovery::class.simpleName}"
152+
)
153+
return false
154+
}
155+
val resolver = wpApiClientProvider.urlResolverFor(
156+
discovery.success.apiRootUrl
157+
)
158+
persistRouteSupport(site, discovery.success.apiDetails, resolver)
159+
return true
160+
}
161+
162+
private fun persistRouteSupport(
163+
site: SiteModel,
164+
data: WpApiDetails,
165+
resolver: ApiUrlResolver,
166+
) {
167+
appPrefsWrapper.setSiteSupportsEditorSettings(
168+
site,
169+
data.hasRouteForEndpoint(
170+
resolver,
171+
"/wp-block-editor/v1",
172+
"settings"
173+
)
174+
)
175+
appPrefsWrapper.setSiteSupportsEditorAssets(
176+
site,
177+
data.hasRouteForEndpoint(
178+
resolver,
179+
"/wpcom/v2",
180+
"editor-assets"
181+
)
182+
)
183+
}
184+
138185
@Suppress("TooGenericExceptionCaught")
139186
private suspend fun fetchThemeBlockStyleSupport(
140187
site: SiteModel

WordPress/src/test/java/org/wordpress/android/repositories/EditorSettingsRepositoryTest.kt

Lines changed: 149 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,15 @@ import org.wordpress.android.BaseUnitTest
1515
import org.wordpress.android.fluxc.model.SiteModel
1616
import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider
1717
import org.wordpress.android.ui.prefs.AppPrefsWrapper
18+
import rs.wordpress.api.kotlin.ApiDiscoveryResult
1819
import rs.wordpress.api.kotlin.WpApiClient
20+
import rs.wordpress.api.kotlin.WpLoginClient
1921
import rs.wordpress.api.kotlin.WpRequestResult
2022
import uniffi.wp_api.ApiRootRequestGetResponse
2123
import uniffi.wp_api.ApiUrlResolver
24+
import uniffi.wp_api.AutoDiscoveryAttemptSuccess
25+
import uniffi.wp_api.DiscoveredAuthenticationMechanism
26+
import uniffi.wp_api.ParseUrlException
2227
import uniffi.wp_api.ThemeAuthor
2328
import uniffi.wp_api.ThemeAuthorUri
2429
import uniffi.wp_api.ThemeDescription
@@ -36,6 +41,9 @@ class EditorSettingsRepositoryTest : BaseUnitTest() {
3641
@Mock
3742
lateinit var wpApiClientProvider: WpApiClientProvider
3843

44+
@Mock
45+
lateinit var wpLoginClient: WpLoginClient
46+
3947
@Mock
4048
lateinit var appPrefsWrapper: AppPrefsWrapper
4149

@@ -48,6 +56,9 @@ class EditorSettingsRepositoryTest : BaseUnitTest() {
4856
@Mock
4957
lateinit var apiUrlResolver: ApiUrlResolver
5058

59+
@Mock
60+
lateinit var directHostResolver: ApiUrlResolver
61+
5162
private lateinit var repository: EditorSettingsRepository
5263

5364
private val testSite = SiteModel().apply {
@@ -64,6 +75,7 @@ class EditorSettingsRepositoryTest : BaseUnitTest() {
6475

6576
repository = EditorSettingsRepository(
6677
wpApiClientProvider = wpApiClientProvider,
78+
wpLoginClient = wpLoginClient,
6779
appPrefsWrapper = appPrefsWrapper,
6880
themeRepository = themeRepository,
6981
ioDispatcher = testDispatcher()
@@ -186,22 +198,155 @@ class EditorSettingsRepositoryTest : BaseUnitTest() {
186198
.setSiteThemeIsBlockTheme(any(), any())
187199
}
188200

189-
@Suppress("UNCHECKED_CAST")
201+
@Test
202+
fun `atomic site probes via api discovery`() =
203+
runTest {
204+
val atomicSite = SiteModel().apply {
205+
id = 2
206+
url = "https://atomic.example.com"
207+
setIsWPCom(true)
208+
setIsWPComAtomic(true)
209+
}
210+
mockDiscoverySuccess(
211+
siteUrl = atomicSite.url,
212+
hasEditorSettings = false,
213+
hasEditorAssets = false
214+
)
215+
whenever(themeRepository.fetchCurrentTheme(atomicSite))
216+
.thenReturn(buildTheme(isBlockTheme = false))
217+
218+
val result =
219+
repository.fetchEditorCapabilitiesForSite(atomicSite)
220+
221+
assertThat(result).isTrue()
222+
verify(appPrefsWrapper)
223+
.setSiteSupportsEditorSettings(atomicSite, false)
224+
verify(appPrefsWrapper)
225+
.setSiteSupportsEditorAssets(atomicSite, false)
226+
verify(wpApiClientProvider, never()).getWpApiClient(atomicSite)
227+
}
228+
229+
@Test
230+
fun `atomic site returns false when discovery fails`() =
231+
runTest {
232+
val atomicSite = SiteModel().apply {
233+
id = 4
234+
url = "https://atomic.example.com"
235+
setIsWPCom(true)
236+
setIsWPComAtomic(true)
237+
}
238+
whenever(wpLoginClient.apiDiscovery(atomicSite.url))
239+
.thenReturn(
240+
ApiDiscoveryResult.FailureParseSiteUrl(
241+
ParseUrlException.Generic("")
242+
)
243+
)
244+
whenever(themeRepository.fetchCurrentTheme(atomicSite))
245+
.thenReturn(buildTheme(isBlockTheme = false))
246+
247+
val result =
248+
repository.fetchEditorCapabilitiesForSite(atomicSite)
249+
250+
assertThat(result).isFalse()
251+
verify(appPrefsWrapper, never())
252+
.setSiteSupportsEditorSettings(any(), any())
253+
verify(appPrefsWrapper, never())
254+
.setSiteSupportsEditorAssets(any(), any())
255+
verify(wpApiClientProvider, never()).getWpApiClient(atomicSite)
256+
}
257+
258+
@Test
259+
fun `atomic site with app password also probes via api discovery`() =
260+
runTest {
261+
val atomicSite = SiteModel().apply {
262+
id = 3
263+
url = "https://atomic.example.com"
264+
setIsWPCom(true)
265+
setIsWPComAtomic(true)
266+
apiRestUsernamePlain = "user"
267+
apiRestPasswordPlain = "secret"
268+
}
269+
mockDiscoverySuccess(
270+
siteUrl = atomicSite.url,
271+
hasEditorSettings = true,
272+
hasEditorAssets = true
273+
)
274+
whenever(themeRepository.fetchCurrentTheme(atomicSite))
275+
.thenReturn(buildTheme(isBlockTheme = false))
276+
277+
val result =
278+
repository.fetchEditorCapabilitiesForSite(atomicSite)
279+
280+
assertThat(result).isTrue()
281+
verify(appPrefsWrapper)
282+
.setSiteSupportsEditorSettings(atomicSite, true)
283+
verify(appPrefsWrapper)
284+
.setSiteSupportsEditorAssets(atomicSite, true)
285+
verify(wpApiClientProvider, never()).getWpApiClient(atomicSite)
286+
}
287+
190288
private suspend fun mockApiRootResponse(
191289
hasEditorSettings: Boolean,
192290
hasEditorAssets: Boolean
291+
) = mockApiRootResponseFor(
292+
client = wpApiClient,
293+
resolver = apiUrlResolver,
294+
hasEditorSettings = hasEditorSettings,
295+
hasEditorAssets = hasEditorAssets,
296+
)
297+
298+
private suspend fun mockDiscoverySuccess(
299+
siteUrl: String,
300+
hasEditorSettings: Boolean,
301+
hasEditorAssets: Boolean,
193302
) {
194303
val apiDetails = mock<WpApiDetails>()
195304
whenever(
196305
apiDetails.hasRouteForEndpoint(
197-
apiUrlResolver,
306+
directHostResolver,
198307
"/wp-block-editor/v1",
199308
"settings"
200309
)
201310
).thenReturn(hasEditorSettings)
202311
whenever(
203312
apiDetails.hasRouteForEndpoint(
204-
apiUrlResolver,
313+
directHostResolver,
314+
"/wpcom/v2",
315+
"editor-assets"
316+
)
317+
).thenReturn(hasEditorAssets)
318+
val apiRootUrl = mock<uniffi.wp_api.ParsedUrl>()
319+
whenever(wpApiClientProvider.urlResolverFor(apiRootUrl))
320+
.thenReturn(directHostResolver)
321+
val success = AutoDiscoveryAttemptSuccess(
322+
parsedSiteUrl = mock(),
323+
apiRootUrl = apiRootUrl,
324+
apiDetails = apiDetails,
325+
authentication = DiscoveredAuthenticationMechanism
326+
.ApplicationPasswords(mock()),
327+
)
328+
whenever(wpLoginClient.apiDiscovery(siteUrl))
329+
.thenReturn(ApiDiscoveryResult.Success(success))
330+
}
331+
332+
@Suppress("UNCHECKED_CAST")
333+
private suspend fun mockApiRootResponseFor(
334+
client: WpApiClient,
335+
resolver: ApiUrlResolver,
336+
hasEditorSettings: Boolean,
337+
hasEditorAssets: Boolean
338+
) {
339+
val apiDetails = mock<WpApiDetails>()
340+
whenever(
341+
apiDetails.hasRouteForEndpoint(
342+
resolver,
343+
"/wp-block-editor/v1",
344+
"settings"
345+
)
346+
).thenReturn(hasEditorSettings)
347+
whenever(
348+
apiDetails.hasRouteForEndpoint(
349+
resolver,
205350
"/wpcom/v2",
206351
"editor-assets"
207352
)
@@ -211,7 +356,7 @@ class EditorSettingsRepositoryTest : BaseUnitTest() {
211356
data = apiDetails,
212357
headerMap = mock<WpNetworkHeaderMap>()
213358
)
214-
whenever(wpApiClient.request<Any>(any()))
359+
whenever(client.request<Any>(any()))
215360
.thenReturn(
216361
WpRequestResult.Success(response)
217362
as WpRequestResult<Any>

libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/rs/WpApiClientProvider.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,16 @@ class WpApiClientProvider @Inject constructor(
193193
)
194194
}
195195

196+
/**
197+
* Builds a [WpOrgSiteApiUrlResolver] for an already-parsed REST API root
198+
* URL (e.g. one returned by `WpLoginClient.apiDiscovery`). Exposed so
199+
* callers don't have to construct the uniffi resolver directly — useful
200+
* for testability.
201+
*/
202+
fun urlResolverFor(
203+
apiRootUrl: ParsedUrl
204+
): uniffi.wp_api.ApiUrlResolver = WpOrgSiteApiUrlResolver(apiRootUrl)
205+
196206
fun getApiRootUrlFrom(site: SiteModel): String = site.buildUrl()
197207

198208
private fun SiteModel.buildUrl(): String =

0 commit comments

Comments
 (0)