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..c1f52a3f6 100644 --- a/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift +++ b/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift @@ -261,6 +261,37 @@ 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) + + // 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") }) + #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) + + // 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") }) + } } // MARK: - URL Capturing Mock Client @@ -286,3 +317,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) })