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 172e8a87..7a3eee0c 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 c15b19ac..fa9a742a 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 efe3d9ad..91b53fd5 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 030a668a..2da9ea91 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 7ee0752f..edb4240a 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 2da9ea91..c1f52a3f 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") })