Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class EditorAssetsLibrary(
suspend fun loadManifestContent(headers: Map<String, String> = 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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('/') + "/"
}
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
}
)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>()
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<String>()
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<String>()
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<String>()
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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("/"))!
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")])
}
Expand Down
Loading
Loading