From fd291711bb555320c0ed51e62f3d3a90571154bc Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 30 Jul 2026 19:31:53 -0400 Subject: [PATCH 1/4] fix(ios): build REST URLs for sites using plain permalinks Sites with plain permalinks have no path-based REST root, so WordPress advertises the query form `https://site/?rest_route=/` instead. Appending an endpoint to that root landed the path before the query, malforming every native REST URL. Teach `URL.appending(rawPath:)` to append the endpoint to the query value when the root carries one, merging the path's own query string with `&`. This mirrors `@wordpress/api-fetch`'s root URL middleware, which the web layer already uses, so native and web requests resolve identically. Also route `editorAssetsUrl` through the same primitive instead of `URL.appending(path:)`, which had the same defect. Co-Authored-By: Claude Opus 5 (1M context) --- .../Extensions/Foundation+Extensions.swift | 37 ++++++++++++ .../Sources/Stores/EditorAssetLibrary.swift | 4 +- .../Extensions/FoundationTests.swift | 35 +++++++++-- .../Services/RESTAPIRepositoryTests.swift | 60 +++++++++++++++++++ 4 files changed, 129 insertions(+), 7 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/Extensions/Foundation+Extensions.swift b/ios/Sources/GutenbergKit/Sources/Extensions/Foundation+Extensions.swift index aa76bb7c4..9485fed12 100644 --- a/ios/Sources/GutenbergKit/Sources/Extensions/Foundation+Extensions.swift +++ b/ios/Sources/GutenbergKit/Sources/Extensions/Foundation+Extensions.swift @@ -47,11 +47,36 @@ 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, so native and web requests resolve to the same endpoints. + /// /// - 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 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 +124,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..809c9a654 100644 --- a/ios/Tests/GutenbergKitTests/Extensions/FoundationTests.swift +++ b/ios/Tests/GutenbergKitTests/Extensions/FoundationTests.swift @@ -245,11 +245,36 @@ 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("normalizes a query-based REST root without a trailing slash") + func normalizesQueryBasedRootWithoutTrailingSlash() { + 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") } // 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 From e64028965bbc44351352934ec05956cdb465a722 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 30 Jul 2026 19:32:02 -0400 Subject: [PATCH 2/4] fix(android): build REST URLs for sites using plain permalinks Sites with plain permalinks have no path-based REST root, so WordPress advertises the query form `https://site/?rest_route=/` instead. The root was normalized with `trimEnd('/')` and concatenated with the endpoint, which mangled the route value and emitted a second `?` for endpoints that carry their own query string. Add `String.appendingRestPath`, which appends the endpoint to the query value when the root carries one and merges the path's query string with `&`. This mirrors `@wordpress/api-fetch`'s root URL middleware, which the web layer already uses, so native and web requests resolve identically. Route the repository and both asset library URL builders through it, and align namespace insertion with iOS, which also namespaces two-segment paths. Co-Authored-By: Claude Opus 5 (1M context) --- .../gutenberg/EditorAssetsLibrary.kt | 2 +- .../wordpress/gutenberg/RESTAPIRepository.kt | 19 +++-- .../wordpress/gutenberg/StringExtensions.kt | 38 +++++++++ .../gutenberg/stores/EditorAssetsLibrary.kt | 14 ++-- .../gutenberg/RESTAPIRepositoryTest.kt | 78 +++++++++++++++++++ .../gutenberg/StringExtensionsTest.kt | 75 ++++++++++++++++++ 6 files changed, 214 insertions(+), 12 deletions(-) create mode 100644 android/Gutenberg/src/test/java/org/wordpress/gutenberg/StringExtensionsTest.kt 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..3a8d64c7f 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,41 @@ 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, so native and web requests resolve to the same endpoints. + * + * @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..975a393c9 --- /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 `normalizes a query-based API root without a 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") + ) + } +} From 4e031690cfa5adb292a6eca1f8e4715a5ffe04ec Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Sat, 1 Aug 2026 09:45:12 -0400 Subject: [PATCH 3/4] fix(ios): scope the api-fetch parity claim and guard file URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `appending(rawPath:)` doc comment claimed native and web requests resolve to the same endpoints, but that only holds for the canonical `?rest_route=/` root. For a root without a trailing slash the two deliberately diverge: api-fetch strips the leading slash, while this keeps it because WordPress's `rest_route` matching expects it. Scope the claim and name the deviation, and rename the test that pins it so it states the contract rather than implying generic normalization. Also guard the query-based branch with `!isFileURL`. It fired on any URL carrying a `?`, and `EditorAssetBundle` calls this on local file URLs, so the REST-root behavior and the path-joining behavior shared an invariant nothing enforced. No live bug — `siteId` is a host — but the two callers are now independent. Co-Authored-By: Claude Opus 5 (1M context) --- .../Sources/Extensions/Foundation+Extensions.swift | 10 ++++++++-- .../Extensions/FoundationTests.swift | 14 ++++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/Extensions/Foundation+Extensions.swift b/ios/Sources/GutenbergKit/Sources/Extensions/Foundation+Extensions.swift index 9485fed12..3c64660f8 100644 --- a/ios/Sources/GutenbergKit/Sources/Extensions/Foundation+Extensions.swift +++ b/ios/Sources/GutenbergKit/Sources/Extensions/Foundation+Extensions.swift @@ -56,7 +56,13 @@ extension URL { /// ``` /// /// This mirrors the behavior of `@wordpress/api-fetch`'s root URL middleware, which the web - /// layer uses, so native and web requests resolve to the same endpoints. + /// 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. @@ -65,7 +71,7 @@ extension URL { // 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 urlString.contains("?") { + if !isFileURL && urlString.contains("?") { let path = rawPath.replacingFirstOccurrence(of: "?", with: "&") // The route value must keep exactly one leading slash regardless of whether the root diff --git a/ios/Tests/GutenbergKitTests/Extensions/FoundationTests.swift b/ios/Tests/GutenbergKitTests/Extensions/FoundationTests.swift index 809c9a654..f7da0fac0 100644 --- a/ios/Tests/GutenbergKitTests/Extensions/FoundationTests.swift +++ b/ios/Tests/GutenbergKitTests/Extensions/FoundationTests.swift @@ -263,8 +263,8 @@ struct URLAppendingRawPathTests { == "https://example.com/?rest_route=/wp/v2/themes&context=edit&status=active") } - @Test("normalizes a query-based REST root without a trailing slash") - func normalizesQueryBasedRootWithoutTrailingSlash() { + @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") @@ -277,6 +277,16 @@ struct URLAppendingRawPathTests { #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 @Test("appends path with numeric ID") From 2c06e898ac76d2c68ea6f72a9825930a31e51ee9 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Sat, 1 Aug 2026 09:45:21 -0400 Subject: [PATCH 4/4] fix(android): scope the api-fetch parity claim in appendingRestPath The doc comment claimed native and web requests resolve to the same endpoints, but that only holds for the canonical `?rest_route=/` root. For a root without a trailing slash the two deliberately diverge: api-fetch strips the leading slash, while this keeps it because WordPress's `rest_route` matching expects it. Scope the claim and name the deviation, and rename the test that pins it so it states the contract rather than implying generic normalization. Mirrors the iOS change in the preceding commit. Co-Authored-By: Claude Opus 5 (1M context) --- .../main/java/org/wordpress/gutenberg/StringExtensions.kt | 5 ++++- .../java/org/wordpress/gutenberg/StringExtensionsTest.kt | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) 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 3a8d64c7f..d492b5d5c 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/StringExtensions.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/StringExtensions.kt @@ -29,7 +29,10 @@ fun String.encodeForEditor(): String { * ``` * * This mirrors the behavior of `@wordpress/api-fetch`'s root URL middleware, which the web - * layer uses, so native and web requests resolve to the same endpoints. + * 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. diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/StringExtensionsTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/StringExtensionsTest.kt index 975a393c9..8e7ed6c2f 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/StringExtensionsTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/StringExtensionsTest.kt @@ -58,7 +58,7 @@ class StringExtensionsTest { } @Test - fun `normalizes a query-based API root without a trailing slash`() { + 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")