diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorAssetsLibrary.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorAssetsLibrary.kt index 4901c6da4..90f96a370 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorAssetsLibrary.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorAssetsLibrary.kt @@ -39,7 +39,7 @@ class EditorAssetsLibrary( suspend fun loadManifestContent(headers: Map = emptyMap()): String = withContext(Dispatchers.IO) { val endpoint = configuration.editorAssetsEndpoint - ?: "${configuration.siteApiRoot}wpcom/v2/editor-assets" + ?: configuration.siteApiRoot.appendingRestPath("/wpcom/v2/editor-assets") val connection = URL(endpoint).openConnection() as HttpURLConnection try { diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt index 692ca79ca..4d1c87916 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt @@ -23,7 +23,7 @@ class RESTAPIRepository( ) { private val json = Json { ignoreUnknownKeys = true } - private val apiRoot = configuration.siteApiRoot.trimEnd('/') + private val apiRoot = configuration.siteApiRoot private val namespace = configuration.siteApiNamespace.firstOrNull()?.let { it.trimEnd('/') + "/" } @@ -225,16 +225,25 @@ class RESTAPIRepository( * the result is `$apiRoot/wp/v2/sites/123/types`. */ private fun buildNamespacedUrl(path: String): String { + return apiRoot.appendingRestPath(namespacedPath(path)) + } + + /** + * Inserts the site API namespace after the version segment of [path], returning [path] + * unchanged when no namespace is configured. + */ + private fun namespacedPath(path: String): String { if (namespace == null) { - return "$apiRoot$path" + return path } val parts = path.removePrefix("/").split("/", limit = 3) - if (parts.size < 3) { - return "$apiRoot$path" + if (parts.size < 2) { + return path } - return "$apiRoot/${parts[0]}/${parts[1]}/$namespace${parts[2]}" + val remainder = parts.getOrNull(2).orEmpty() + return "/${parts[0]}/${parts[1]}/$namespace$remainder" } companion object { diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/StringExtensions.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/StringExtensions.kt index e042e9110..d492b5d5c 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/StringExtensions.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/StringExtensions.kt @@ -13,3 +13,44 @@ import java.net.URLEncoder fun String.encodeForEditor(): String { return URLEncoder.encode(this, "UTF-8").replace("+", "%20") } + +/** + * Appends a REST API endpoint path to this API root. + * + * Handles slash normalization between the root and the path, ensuring exactly one slash + * separates them. + * + * When the root is a query-based REST root — as used by sites with plain permalinks, + * e.g. `https://example.com/?rest_route=/` — the path is appended to the query value rather + * than the URL path, and any query string on [path] is merged with `&`: + * + * ``` + * https://example.com/?rest_route=/ + /wp/v2/media -> https://example.com/?rest_route=/wp/v2/media + * ``` + * + * This mirrors the behavior of `@wordpress/api-fetch`'s root URL middleware, which the web + * layer uses, for the canonical `?rest_route=/` root, so native and web requests resolve to the + * same endpoints. For a root supplied without a trailing slash the two intentionally diverge: + * this keeps the leading slash on the route value, which WordPress's `rest_route` matching + * expects, whereas the middleware strips it. + * + * @param path The endpoint path to append. May or may not start with a slash. + * @return The full endpoint URL. + */ +fun String.appendingRestPath(path: String): String { + // A query-based root already carries the REST route in its query string, so the path is + // concatenated onto that value and its own query separator becomes `&`. + if (contains("?")) { + val merged = path.replaceFirst("?", "&") + + // The route value must keep exactly one leading slash regardless of whether the root + // was supplied as `?rest_route=/` or `?rest_route=`. + return if (endsWith("/")) { + this + merged.removePrefix("/") + } else { + this + if (merged.startsWith("/")) merged else "/$merged" + } + } + + return trimEnd('/') + if (path.startsWith("/")) path else "/$path" +} diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/stores/EditorAssetsLibrary.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/stores/EditorAssetsLibrary.kt index e0596ee2a..57475e0ee 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/stores/EditorAssetsLibrary.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/stores/EditorAssetsLibrary.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.wordpress.gutenberg.EditorHTTPClient import org.wordpress.gutenberg.EditorHTTPClientProtocol +import org.wordpress.gutenberg.appendingRestPath import org.wordpress.gutenberg.model.EditorAssetBundle import org.wordpress.gutenberg.model.EditorCachePolicy import org.wordpress.gutenberg.model.EditorConfiguration @@ -265,14 +266,15 @@ class EditorAssetsLibrary( // MARK: - Helpers private fun editorAssetsUrl(configuration: EditorConfiguration): String { - val baseUrl = configuration.siteApiRoot.trimEnd('/') val namespace = configuration.siteApiNamespace.firstOrNull() - return if (namespace != null) { - "$baseUrl/wpcom/v2/${namespace}editor-assets?exclude=core,gutenberg" - } else { - "$baseUrl/wpcom/v2/editor-assets?exclude=core,gutenberg" - } + return configuration.siteApiRoot.appendingRestPath( + if (namespace != null) { + "/wpcom/v2/${namespace}editor-assets?exclude=core,gutenberg" + } else { + "/wpcom/v2/editor-assets?exclude=core,gutenberg" + } + ) } /** diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt index 40ee19ff6..d9de0bf82 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt @@ -341,6 +341,84 @@ class RESTAPIRepositoryTest { assertEquals(expectedURLs, capturedURLs.toSet()) } + /** + * Sites using plain permalinks have no path-based REST root, so WordPress advertises the + * query form `https://example.com/?rest_route=/` instead. + */ + @Test + fun `endpoints are appended to the route of a query-based API root`() = runBlocking { + val capturedURLs = mutableListOf() + val capturingClient = createCapturingClient { capturedURLs.add(it) } + + val configuration = EditorConfiguration.builder( + TEST_SITE_URL, + "https://example.com/?rest_route=/", + PostTypeDetails.post + ).setPlugins(true).setThemeStyles(true).setAuthHeader("Bearer test").build() + + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) + val repository = RESTAPIRepository(configuration, capturingClient, cache) + + repository.fetchPost(id = 1) + repository.fetchPostType("post") + repository.fetchActiveTheme() + repository.fetchPostTypes() + + val expectedURLs = setOf( + "https://example.com/?rest_route=/wp/v2/posts/1&context=edit", + "https://example.com/?rest_route=/wp/v2/types/post&context=edit", + "https://example.com/?rest_route=/wp/v2/themes&context=edit&status=active", + "https://example.com/?rest_route=/wp/v2/types&context=view" + ) + + assertEquals(expectedURLs, capturedURLs.toSet()) + } + + @Test + fun `URLs are normalized when query-based API root has no trailing slash`() = runBlocking { + val capturedURLs = mutableListOf() + val capturingClient = createCapturingClient { capturedURLs.add(it) } + + val configuration = EditorConfiguration.builder( + TEST_SITE_URL, + "https://example.com/?rest_route=", // No trailing slash + PostTypeDetails.post + ).setPlugins(true).setThemeStyles(true).setAuthHeader("Bearer test").build() + + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) + val repository = RESTAPIRepository(configuration, capturingClient, cache) + + repository.fetchPost(id = 1) + + assertEquals( + setOf("https://example.com/?rest_route=/wp/v2/posts/1&context=edit"), + capturedURLs.toSet() + ) + } + + @Test + fun `namespace is inserted into a query-based API root`() = runBlocking { + val capturedURLs = mutableListOf() + val capturingClient = createCapturingClient { capturedURLs.add(it) } + + val configuration = EditorConfiguration.builder( + TEST_SITE_URL, + "https://example.com/?rest_route=/", + PostTypeDetails.post + ).setPlugins(true).setThemeStyles(true).setAuthHeader("Bearer test") + .setSiteApiNamespace(arrayOf("sites/123/")).build() + + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) + val repository = RESTAPIRepository(configuration, capturingClient, cache) + + repository.fetchPost(id = 1) + + assertEquals( + setOf("https://example.com/?rest_route=/wp/v2/sites/123/posts/1&context=edit"), + capturedURLs.toSet() + ) + } + @Test fun `namespace is inserted into URLs`() = runBlocking { val capturedURLs = mutableListOf() diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/StringExtensionsTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/StringExtensionsTest.kt new file mode 100644 index 000000000..8e7ed6c2f --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/StringExtensionsTest.kt @@ -0,0 +1,75 @@ +package org.wordpress.gutenberg + +import org.junit.Assert.assertEquals +import org.junit.Test + +class StringExtensionsTest { + + // MARK: - Path-based API Roots + + @Test + fun `appends path when API root has no trailing slash`() { + assertEquals( + "https://example.com/wp-json/wp/v2/media", + "https://example.com/wp-json".appendingRestPath("/wp/v2/media") + ) + } + + @Test + fun `appends path when API root has trailing slash`() { + assertEquals( + "https://example.com/wp-json/wp/v2/media", + "https://example.com/wp-json/".appendingRestPath("/wp/v2/media") + ) + } + + @Test + fun `appends path without a leading slash`() { + assertEquals( + "https://example.com/wp-json/wp/v2/media", + "https://example.com/wp-json".appendingRestPath("wp/v2/media") + ) + } + + @Test + fun `preserves the query string of an appended path`() { + assertEquals( + "https://example.com/wp-json/wp/v2/themes?context=edit&status=active", + "https://example.com/wp-json".appendingRestPath("/wp/v2/themes?context=edit&status=active") + ) + } + + // MARK: - Query-based API Roots (plain permalinks) + + @Test + fun `appends path to the route of a query-based API root`() { + assertEquals( + "https://example.com/?rest_route=/wp/v2/media", + "https://example.com/?rest_route=/".appendingRestPath("/wp/v2/media") + ) + } + + @Test + fun `merges the path query string into a query-based API root`() { + assertEquals( + "https://example.com/?rest_route=/wp/v2/themes&context=edit&status=active", + "https://example.com/?rest_route=/".appendingRestPath("/wp/v2/themes?context=edit&status=active") + ) + } + + @Test + fun `keeps exactly one leading slash when the API root omits its trailing slash`() { + assertEquals( + "https://example.com/?rest_route=/wp/v2/media", + "https://example.com/?rest_route=".appendingRestPath("/wp/v2/media") + ) + } + + @Test + fun `appends path without a leading slash to a query-based API root`() { + assertEquals( + "https://example.com/?rest_route=/wp/v2/media", + "https://example.com/?rest_route=/".appendingRestPath("wp/v2/media") + ) + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Extensions/Foundation+Extensions.swift b/ios/Sources/GutenbergKit/Sources/Extensions/Foundation+Extensions.swift index aa76bb7c4..3c64660f8 100644 --- a/ios/Sources/GutenbergKit/Sources/Extensions/Foundation+Extensions.swift +++ b/ios/Sources/GutenbergKit/Sources/Extensions/Foundation+Extensions.swift @@ -47,11 +47,42 @@ extension URL { /// This method handles slash normalization between the base URL and the path being appended, /// ensuring exactly one slash separates them. /// + /// When the base URL is a query-based REST root — as used by sites with plain permalinks, + /// e.g. `https://example.com/?rest_route=/` — the path is appended to the query value rather + /// than the URL path, and any query string on `rawPath` is merged with `&`: + /// + /// ``` + /// https://example.com/?rest_route=/ + /wp/v2/media -> https://example.com/?rest_route=/wp/v2/media + /// ``` + /// + /// This mirrors the behavior of `@wordpress/api-fetch`'s root URL middleware, which the web + /// layer uses, for the canonical `?rest_route=/` root, so native and web requests resolve to + /// the same endpoints. For a root supplied without a trailing slash the two intentionally + /// diverge: this keeps the leading slash on the route value, which WordPress's `rest_route` + /// matching expects, whereas the middleware strips it. + /// + /// File URLs are always treated as plain paths, so a query string in a local path is never + /// mistaken for a query-based REST root. + /// /// - Parameter rawPath: The path to append. May or may not start with a slash. /// - Returns: A new URL with the path appended. func appending(rawPath: String) -> URL { let urlString = self.absoluteString + // A query-based root already carries the REST route in its query string, so the path is + // concatenated onto that value and its own query separator becomes `&`. + if !isFileURL && urlString.contains("?") { + let path = rawPath.replacingFirstOccurrence(of: "?", with: "&") + + // The route value must keep exactly one leading slash regardless of whether the root + // was supplied as `?rest_route=/` or `?rest_route=`. + if urlString.hasSuffix("/") { + return URL(string: urlString + path.trimmingPrefix("/"))! + } + + return URL(string: urlString + (path.hasPrefix("/") ? path : "/" + path))! + } + if urlString.hasSuffix("/") && rawPath.hasPrefix("/") { return URL(string: urlString + rawPath.trimmingPrefix("/"))! } @@ -99,6 +130,18 @@ extension Data { // MARK: - String Extensions extension String { + /// Replaces only the first occurrence of `target` with `replacement`. + /// + /// Used when merging a path's query string into a query-based REST root, where only the + /// leading `?` becomes `&` and any subsequent `&` separators are left untouched. + func replacingFirstOccurrence(of target: String, with replacement: String) -> String { + guard let range = self.range(of: target) else { + return self + } + + return self.replacingCharacters(in: range, with: replacement) + } + /// Calculates SHA1 from the given string and returns its hex representation. /// /// ```swift diff --git a/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift b/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift index de682bd4a..de2a71695 100644 --- a/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift +++ b/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift @@ -199,10 +199,10 @@ public actor EditorAssetLibrary { } else if let namespace = configuration.siteApiNamespace.first { // Insert namespace: /wpcom/v2/editor-assets -> /wpcom/v2/sites/123/editor-assets baseUrl = configuration.siteApiRoot - .appending(path: "/wpcom/v2/\(namespace)editor-assets") + .appending(rawPath: "/wpcom/v2/\(namespace)editor-assets") } else { baseUrl = configuration.siteApiRoot - .appending(path: "/wpcom/v2/editor-assets") + .appending(rawPath: "/wpcom/v2/editor-assets") } return baseUrl.appending(queryItems: [URLQueryItem(name: "exclude", value: "core,gutenberg")]) } diff --git a/ios/Tests/GutenbergKitTests/Extensions/FoundationTests.swift b/ios/Tests/GutenbergKitTests/Extensions/FoundationTests.swift index 80748fcae..f7da0fac0 100644 --- a/ios/Tests/GutenbergKitTests/Extensions/FoundationTests.swift +++ b/ios/Tests/GutenbergKitTests/Extensions/FoundationTests.swift @@ -245,11 +245,46 @@ struct URLAppendingRawPathTests { #expect(result.absoluteString == "https://example.com/api/posts?context=edit&per_page=10") } - @Test("preserves query parameters in base URL when appending path") - func preservesBaseQueryParameters() { - let base = URL(string: "https://example.com/api?token=abc")! - let result = base.appending(rawPath: "posts") - #expect(result.absoluteString == "https://example.com/api?token=abc/posts") + // MARK: - Query-based REST Roots (plain permalinks) + + @Test("appends path to the query value of a query-based REST root") + func appendsPathToQueryBasedRoot() { + let base = URL(string: "https://example.com/?rest_route=/")! + let result = base.appending(rawPath: "/wp/v2/media") + #expect(result.absoluteString == "https://example.com/?rest_route=/wp/v2/media") + } + + @Test("merges the path's query string into a query-based REST root") + func mergesQueryIntoQueryBasedRoot() { + let base = URL(string: "https://example.com/?rest_route=/")! + let result = base.appending(rawPath: "/wp/v2/themes?context=edit&status=active") + #expect( + result.absoluteString + == "https://example.com/?rest_route=/wp/v2/themes&context=edit&status=active") + } + + @Test("keeps exactly one leading slash when the REST root omits its trailing slash") + func keepsOneLeadingSlashWhenRootOmitsTrailingSlash() { + let base = URL(string: "https://example.com/?rest_route=")! + let result = base.appending(rawPath: "/wp/v2/media") + #expect(result.absoluteString == "https://example.com/?rest_route=/wp/v2/media") + } + + @Test("appends path without a leading slash to a query-based REST root") + func appendsPathWithoutLeadingSlashToQueryBasedRoot() { + let base = URL(string: "https://example.com/?rest_route=/")! + let result = base.appending(rawPath: "wp/v2/media") + #expect(result.absoluteString == "https://example.com/?rest_route=/wp/v2/media") + } + + @Test("treats a file URL containing a query character as a plain path") + func treatsFileURLWithQueryCharacterAsPlainPath() { + // `URL(fileURLWithPath:)` would percent-encode the `?`, so build the URL from a string to + // keep the literal character that the query-based REST root branch looks for. The appended + // path carries its own `?`, which that branch would rewrite to `&`. + let base = URL(string: "file:///tmp/assets/site?name")! + let result = base.appending(rawPath: "styles?v=2") + #expect(result.absoluteString == "file:///tmp/assets/site?name/styles?v=2") } // MARK: - Special Characters diff --git a/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift b/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift index 6f3ce61f1..b8923b01e 100644 --- a/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift +++ b/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift @@ -292,6 +292,66 @@ struct RESTAPIRepositoryTests: MakesTestFixtures { let urls = mockClient.requestedURLs.map(\.absoluteString) #expect(urls.contains { $0.contains("sites/123/posts/1") }) } + + // MARK: - Query-based API Root Tests (plain permalinks) + + /// Sites using plain permalinks have no path-based REST root, so WordPress advertises the + /// query form `https://example.com/?rest_route=/` instead. + @Test("endpoints are appended to the route of a query-based API root") + func endpointsAreAppendedToQueryBasedApiRoot() async throws { + let mockClient = EditorAssetLibraryMockHTTPClient() + let configuration = makeQueryRootConfiguration() + let repository = makeRepository(configuration: configuration, httpClient: mockClient) + + // Using try? because the mock returns empty data that fails decoding. + // We only care about the URLs that were requested, not the responses. + _ = try? await repository.fetchPost(id: 1) + _ = try? await repository.fetchEditorSettings() + _ = try? await repository.fetchSettingsOptions() + _ = try? await repository.fetchActiveTheme() + _ = try? await repository.fetchPostTypes() + + let urls = Set(mockClient.requestedURLs.map(\.absoluteString)) + #expect(urls.contains("https://example.com/?rest_route=/wp/v2/posts/1&context=edit")) + #expect( + urls.contains("https://example.com/?rest_route=/wp-block-editor/v1/settings")) + #expect(urls.contains("https://example.com/?rest_route=/wp/v2/settings")) + #expect( + urls.contains( + "https://example.com/?rest_route=/wp/v2/themes&context=edit&status=active")) + #expect(urls.contains("https://example.com/?rest_route=/wp/v2/types&context=view")) + } + + @Test("namespace is inserted into a query-based API root") + func namespaceIsInsertedIntoQueryBasedApiRoot() async throws { + let mockClient = EditorAssetLibraryMockHTTPClient() + let configuration = makeQueryRootConfiguration(siteApiNamespace: ["sites/123/"]) + let repository = makeRepository(configuration: configuration, httpClient: mockClient) + + // Using try? because the mock returns empty data that fails decoding. + // We only care about the URLs that were requested, not the responses. + _ = try? await repository.fetchPost(id: 1) + _ = try? await repository.fetchSettingsOptions() + + let urls = Set(mockClient.requestedURLs.map(\.absoluteString)) + #expect( + urls.contains("https://example.com/?rest_route=/wp/v2/sites/123/posts/1&context=edit")) + #expect(urls.contains("https://example.com/?rest_route=/wp/v2/sites/123/settings")) + } + + private func makeQueryRootConfiguration( + siteApiNamespace: [String] = [] + ) -> EditorConfiguration { + EditorConfigurationBuilder( + postType: .post, + siteURL: Self.testSiteURL, + siteApiRoot: URL(string: "https://example.com/?rest_route=/")!, + siteApiNamespace: siteApiNamespace + ) + .setShouldUseThemeStyles(true) + .setAuthHeader("Bearer test-token") + .build() + } } // MARK: - URL Capturing Mock Client