From 8955bfbbcae6d78cd3c1ab6de2f2fe7c73008633 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:22:31 -0600 Subject: [PATCH 1/2] fix: normalize namespace trailing slash in RESTAPIRepository on both platforms Ensures namespaces like "sites/123" (without trailing slash) produce the same URLs as "sites/123/" so callers don't need to worry about the convention. Adds tests on both platforms to verify. Co-Authored-By: Claude Opus 4.6 --- .../wordpress/gutenberg/RESTAPIRepository.kt | 4 ++- .../gutenberg/RESTAPIRepositoryTest.kt | 34 +++++++++++++++++-- .../Sources/RESTAPIRepository.swift | 4 ++- .../Services/RESTAPIRepositoryTests.swift | 28 +++++++++++++++ ios/Tests/GutenbergKitTests/TestHelpers.swift | 8 +++-- 5 files changed, 71 insertions(+), 7 deletions(-) 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 172e8a872..7a3eee0cc 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt @@ -24,7 +24,9 @@ class RESTAPIRepository( private val json = Json { ignoreUnknownKeys = true } private val apiRoot = configuration.siteApiRoot.trimEnd('/') - private val namespace = configuration.siteApiNamespace.firstOrNull() + private val namespace = configuration.siteApiNamespace.firstOrNull()?.let { + it.trimEnd('/') + "/" + } private val editorSettingsUrl = buildNamespacedUrl(EDITOR_SETTINGS_PATH) private val activeThemeUrl = buildNamespacedUrl(ACTIVE_THEME_PATH) private val siteSettingsUrl = buildNamespacedUrl(SITE_SETTINGS_PATH) 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 c15b19acf..fa9a742a9 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt @@ -38,12 +38,15 @@ class RESTAPIRepositoryTest { private fun makeConfiguration( shouldUsePlugins: Boolean = true, - shouldUseThemeStyles: Boolean = true + shouldUseThemeStyles: Boolean = true, + siteApiRoot: String = TEST_API_ROOT, + siteApiNamespace: Array = arrayOf() ): EditorConfiguration { - return EditorConfiguration.builder(TEST_SITE_URL, TEST_API_ROOT, "post") + return EditorConfiguration.builder(TEST_SITE_URL, siteApiRoot, "post") .setPlugins(shouldUsePlugins) .setThemeStyles(shouldUseThemeStyles) .setAuthHeader("Bearer test-token") + .setSiteApiNamespace(siteApiNamespace) .build() } @@ -337,6 +340,33 @@ class RESTAPIRepositoryTest { assertEquals(expectedURLs, capturedURLs.toSet()) } + @Test + fun `namespace is inserted into URLs`() = runBlocking { + val capturedURLs = mutableListOf() + val capturingClient = createCapturingClient { capturedURLs.add(it) } + val configuration = makeConfiguration(siteApiNamespace = arrayOf("sites/123/")) + val repository = makeRepository(configuration = configuration, httpClient = capturingClient) + + repository.fetchPost(id = 1) + repository.fetchEditorSettings() + repository.fetchSettingsOptions() + + assertTrue(capturedURLs.any { it.contains("sites/123/posts/1") }) + assertTrue(capturedURLs.any { it.contains("sites/123/settings") }) + } + + @Test + fun `namespace without trailing slash is normalized`() = runBlocking { + val capturedURLs = mutableListOf() + val capturingClient = createCapturingClient { capturedURLs.add(it) } + val configuration = makeConfiguration(siteApiNamespace = arrayOf("sites/123")) + val repository = makeRepository(configuration = configuration, httpClient = capturingClient) + + repository.fetchPost(id = 1) + + assertTrue(capturedURLs.any { it.contains("sites/123/posts/1") }) + } + private fun createCapturingClient(onRequest: (String) -> Unit): EditorHTTPClientProtocol { return object : EditorHTTPClientProtocol { override suspend fun download(url: String, destination: File): EditorHTTPClientDownloadResponse { diff --git a/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift b/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift index efe3d9ad8..91b53fd56 100644 --- a/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift +++ b/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift @@ -61,10 +61,12 @@ public struct RESTAPIRepository: Sendable { /// Builds a URL by inserting the namespace after the version segment of the path. /// For example: `/wp/v2/posts` with namespace `sites/123/` becomes `/wp/v2/sites/123/posts` private static func buildNamespacedURL(apiRoot: URL, path: String, namespace: String?) -> URL { - guard let namespace = namespace else { + guard let rawNamespace = namespace else { return apiRoot.appending(rawPath: path) } + let namespace = rawNamespace.hasSuffix("/") ? rawNamespace : rawNamespace + "/" + // Parse the path to find where to insert the namespace // Path format is typically: /prefix/version/endpoint (e.g., /wp/v2/posts or /wp-block-editor/v1/settings) let components = path.split(separator: "/", omittingEmptySubsequences: true) diff --git a/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift b/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift index 030a668a3..2da9ea91e 100644 --- a/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift +++ b/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift @@ -261,6 +261,33 @@ struct RESTAPIRepositoryTests: MakesTestFixtures { #expect(capturedURL?.absoluteString.contains("context=edit") == true) #expect(capturedURL?.absoluteString.contains("/posts/42") == true) } + + @Test("namespace is inserted into URLs") + func namespaceIsInsertedIntoURLs() async throws { + let mockClient = EditorAssetLibraryMockHTTPClient() + let configuration = makeConfiguration(siteApiNamespace: ["sites/123/"]) + let repository = makeRepository(configuration: configuration, httpClient: mockClient) + + _ = try await repository.fetchPost(id: 1) + _ = try await repository.fetchEditorSettings() + _ = try await repository.fetchSettingsOptions() + + let urls = mockClient.requestedURLs.map(\.absoluteString) + #expect(urls.contains { $0.contains("sites/123/posts/1") }) + #expect(urls.contains { $0.contains("sites/123/settings") }) + } + + @Test("namespace without trailing slash is normalized") + func namespaceWithoutTrailingSlashIsNormalized() async throws { + let mockClient = EditorAssetLibraryMockHTTPClient() + let configuration = makeConfiguration(siteApiNamespace: ["sites/123"]) + let repository = makeRepository(configuration: configuration, httpClient: mockClient) + + _ = try await repository.fetchPost(id: 1) + + let urls = mockClient.requestedURLs.map(\.absoluteString) + #expect(urls.contains { $0.contains("sites/123/posts/1") }) + } } // MARK: - URL Capturing Mock Client @@ -286,3 +313,4 @@ final class URLCapturingMockHTTPClient: EditorHTTPClientProtocol, @unchecked Sen ) } } + diff --git a/ios/Tests/GutenbergKitTests/TestHelpers.swift b/ios/Tests/GutenbergKitTests/TestHelpers.swift index 7ee0752fa..edb4240a3 100644 --- a/ios/Tests/GutenbergKitTests/TestHelpers.swift +++ b/ios/Tests/GutenbergKitTests/TestHelpers.swift @@ -17,7 +17,7 @@ protocol MakesTestFixtures { func makeConfiguration( postID: Int?, title: String?, content: String?, siteURL: URL, postType: PostTypeDetails, - shouldUsePlugins: Bool, shouldUseThemeStyles: Bool + shouldUsePlugins: Bool, shouldUseThemeStyles: Bool, siteApiNamespace: [String] ) -> EditorConfiguration func makeConfigurationBuilder(postType: PostTypeDetails) -> EditorConfigurationBuilder func makeService(for configuration: EditorConfiguration?) -> EditorService @@ -40,12 +40,14 @@ extension MakesTestFixtures { siteURL: URL = Self.testSiteURL, postType: PostTypeDetails = .post, shouldUsePlugins: Bool = true, - shouldUseThemeStyles: Bool = true + shouldUseThemeStyles: Bool = true, + siteApiNamespace: [String] = [] ) -> EditorConfiguration { var builder = EditorConfigurationBuilder( postType: postType, siteURL: siteURL, - siteApiRoot: Self.testApiRoot + siteApiRoot: Self.testApiRoot, + siteApiNamespace: siteApiNamespace ) .apply(title, { $0.setTitle($1) }) .apply(content, { $0.setContent($1) }) From 0222eb347d3cc204a569963e8dfcbeec9e7eb351 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 3 Apr 2026 09:57:49 -0400 Subject: [PATCH 2/2] fix: use try? in namespace URL tests to avoid mock decoding failures The mock HTTP client returns empty data, causing JSON decoding errors that prevented the namespace URL assertions from being reached. Since these tests only verify URL construction, try? is appropriate. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Services/RESTAPIRepositoryTests.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift b/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift index 2da9ea91e..c1f52a3f6 100644 --- a/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift +++ b/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift @@ -268,9 +268,11 @@ struct RESTAPIRepositoryTests: MakesTestFixtures { let configuration = makeConfiguration(siteApiNamespace: ["sites/123/"]) let repository = makeRepository(configuration: configuration, httpClient: mockClient) - _ = try await repository.fetchPost(id: 1) - _ = try await repository.fetchEditorSettings() - _ = try await repository.fetchSettingsOptions() + // 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() let urls = mockClient.requestedURLs.map(\.absoluteString) #expect(urls.contains { $0.contains("sites/123/posts/1") }) @@ -283,7 +285,9 @@ struct RESTAPIRepositoryTests: MakesTestFixtures { let configuration = makeConfiguration(siteApiNamespace: ["sites/123"]) let repository = makeRepository(configuration: configuration, httpClient: mockClient) - _ = try await repository.fetchPost(id: 1) + // Using try? because the mock returns empty data that fails decoding. + // We only care about the URL that was requested, not the response. + _ = try? await repository.fetchPost(id: 1) let urls = mockClient.requestedURLs.map(\.absoluteString) #expect(urls.contains { $0.contains("sites/123/posts/1") })