You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The canonical, tested implementation already exists in wordpress-rs (WpOrgSiteApiUrlResolver) and is already exported to Swift (and generated for Kotlin via UniFFI). We're re-deriving — and re-bugging — something the layer everyone already depends on has solved.
Proposal: consumers adopt the wordpress-rs resolver instead of concatenating onto a flattened siteApiRoot: URL. Delete the copies.
The six implementations, and how they diverge
Copy
Layer
Behavior
Implementation
1
@wordpress/api-fetch (JS, upstream — the reference)
The punchline: the six don't even produce the same string — wordpress-rs emits ?rest_route=%2Fwp%2Fv2%2F… (percent-encoded), everyone else keeps literal slashes — and copies 5 and 6 are partial reimplementations that would break the moment they're handed a query-carrying endpoint.
The same bug, found and fixed three times
wordpress-rs #1366:"the client path-extended the discovered?rest_route=/API root, producing URLs like…/index.php/wp/v2/users/me?rest_route=/that WordPress collapsed to the API index."
And the two host copies reached for different primitives with opposite outcomes: WordPress-iOS used appendPathComponent (a structured path API → stranded the query → fatal "editor won't open"), WordPress-Android used string concat (→ accidentally correct on a trailing-slash root, fragile otherwise). Same operation, opposite results — the clearest possible sign it shouldn't be hand-rolled per layer.
Root cause
The seam between the layer that knows the site topology (host + wordpress-rs) and the layer that builds the URL is a flattened siteApiRoot: URL. By the time a consumer has it, wordpress-rs's structured knowledge — "this is therest_routeform" — is gone, so each consumer re-sniffs it from the string.
GutenbergKit already delegates auth (you hand it an auth header) and transport (you hand it an EditorHTTPClientProtocol) to the host. Resolution is the one site-specific concern that leaked into the library — and it leaked precisely because the seam is a flattened root instead of a resolver.
Proposal
1. Treat WpOrgSiteApiUrlResolver as the single source of truth
It already exists, is tested for the tricky cases (no-trailing-slash, extra query params, empty segments), and is already surfaced in the Swift bindings (Exports.swift#L29) and generated into the Kotlin bindings via UniFFI. No new primitive to build.
2. GutenbergKit stops building URLs
Replace siteApiRoot: URL-as-a-thing-to-build-from with host-supplied resolved endpoints, or an injected resolver the host backs with wordpress-rs — the same shape as the existing auth/transport injection, generalizing today's editorSettingsEndpoint / editorAssetsEndpoint overrides. Deletes copies 3 and 4.
3. Hosts stop building URLs
WordPress-iOS appendingRESTPath and WordPress-Android buildEditorAssetsEndpoint delegate to the resolver they already have (both apps construct a WpApiClient from the same root). Deletes copies 5 and 6.
Work required
Usable from the bindings today (no wordpress-rs change): WpOrgSiteApiUrlResolver::new(apiRoot), ApiUrlResolver.resolve(namespace, segments) (the rest_route-aware path join, endpoint.rs#L136-L144), and route_path(namespace, path) (canonical path keys for the preload map). The only gap is attaching endpoint query params: resolve takes namespace + path segments only, and ParsedUrl's query-carrying methods aren't exported across the FFI boundary (parsed_url.rs#L13-L94 is a plain impl; only parse/url/pretty_url at L127+ carry #[uniffi::export]).
Expose query-aware resolution — a resolve variant taking Vec<(String, String)>, or a UniFFI-exported rest_route-aware append_query_pairs on ParsedUrl. Additive, non-breaking; the algorithm already exists (the url crate handles the ?/& bookkeeping), so it's API surface + generated bindings + tests, not logic.
Small
GutenbergKit
Adopt the resolver at the EditorConfiguration seam — host-supplied resolved endpoints, or an injected resolver (parallel to the existing auth/transport injection) — and route every REST URL through it. Breaking change to EditorConfiguration consumers. Deletes copies 3 and 4.
Bulk of the work
WordPress-iOS
Replace appendingRESTPath (WordPress-iOS PR 25859) with a resolve() call; adopt GutenbergKit's new config contract. Deletes copy 5.
Small
WordPress-Android
Replace buildEditorAssetsEndpoint with a resolve() call; adopt GutenbergKit's new config contract. Deletes copy 6.
Small
Sequencing — wordpress-rs is not a prerequisite for everything:
Hosts adoptresolve()for editor-assets now. That endpoint carries no query string, so it needs nothing new from wordpress-rs — deletes copies 5 and 6 immediately.
wordpress-rs ships query-aware resolution (Automattic/wordpress-rs#1543) — the small additive PR above. Runs in parallel with step 1.
GutenbergKit adopts the resolver and deletes copies 3 and 4 — blocked only on step 2 (its endpoints carry queries); the hosts then pick up the new config contract.
What this removes vs. keeps
Removes: copies 3–6 — four hand-rolled joins in Swift + Kotlin, host and library — collapsing them onto copy 2 (wordpress-rs).
Keeps (deliberately):
siteApiRoot still flows through GutenbergKit — the JS layer needs it for createRootURLMiddleware, and native uses it for webview host-origin matching. The library stops concatenating onto it; it doesn't stop receiving it.
The preload keys in EditorPreloadList (canonical WP paths like /wp/v2/themes?context=edit&status=active) — a contract with api-fetch's preloading middleware, not host URL surgery. (route_path above can supply these.)
Native prefetch + disk-cache orchestration — GutenbergKit still owns what the editor needs at boot; the host owns how to reach this site.
Alternatives considered
Bag of finished endpoints vs. injected resolver — decide the GutenbergKit API shape in review; both eliminate the string surgery. Leaning resolver-injection (exact parallel to auth/transport; covers any runtime-parameterized fetch).
Build a resolver in wordpress-rs — moot, it already exists (WpOrgSiteApiUrlResolver).
Status quo — rejected: six copies, five behaviors, three separate fixes of the same root cause.
Summary
?rest_route=/form" is hand-rolled in six places across four repos.WpOrgSiteApiUrlResolver) and is already exported to Swift (and generated for Kotlin via UniFFI). We're re-deriving — and re-bugging — something the layer everyone already depends on has solved.siteApiRoot: URL. Delete the copies.The six implementations, and how they diverge
@wordpress/api-fetch(JS, upstream — the reference)?→&merge, strips leading slash, literal slashesroot-url.ts#L11-L40rest_routevalue; covers no-trailing-slash, extra query params, empty segmentsparsed_url.rs#L46-L101+ resolverendpoint.rs#L147-L172?→&merge + slash normalization; literal slashes; keeps leading slash (deliberately unlike api-fetch)Foundation+Extensions.swift#L69-L95StringExtensions.kt#L40-L56EditorConfiguration+Blog.swift#L90-L98"${root}wpcom/v2/…"concat — no separator, no merge; correct only when the root ends in/GutenbergKitSettingsBuilder.kt#L157-L163The punchline: the six don't even produce the same string — wordpress-rs emits
?rest_route=%2Fwp%2Fv2%2F…(percent-encoded), everyone else keeps literal slashes — and copies 5 and 6 are partial reimplementations that would break the moment they're handed a query-carrying endpoint.The same bug, found and fixed three times
?rest_route=/API root, producing URLs like…/index.php/wp/v2/users/me?rest_route=/that WordPress collapsed to the API index."And the two host copies reached for different primitives with opposite outcomes: WordPress-iOS used
appendPathComponent(a structured path API → stranded the query → fatal "editor won't open"), WordPress-Android used string concat (→ accidentally correct on a trailing-slash root, fragile otherwise). Same operation, opposite results — the clearest possible sign it shouldn't be hand-rolled per layer.Root cause
The seam between the layer that knows the site topology (host + wordpress-rs) and the layer that builds the URL is a flattened
siteApiRoot: URL. By the time a consumer has it, wordpress-rs's structured knowledge — "this is therest_routeform" — is gone, so each consumer re-sniffs it from the string.GutenbergKit already delegates auth (you hand it an auth header) and transport (you hand it an
EditorHTTPClientProtocol) to the host. Resolution is the one site-specific concern that leaked into the library — and it leaked precisely because the seam is a flattened root instead of a resolver.Proposal
1. Treat
WpOrgSiteApiUrlResolveras the single source of truthIt already exists, is tested for the tricky cases (no-trailing-slash, extra query params, empty segments), and is already surfaced in the Swift bindings (
Exports.swift#L29) and generated into the Kotlin bindings via UniFFI. No new primitive to build.2. GutenbergKit stops building URLs
Replace
siteApiRoot: URL-as-a-thing-to-build-from with host-supplied resolved endpoints, or an injected resolver the host backs with wordpress-rs — the same shape as the existing auth/transport injection, generalizing today'seditorSettingsEndpoint/editorAssetsEndpointoverrides. Deletes copies 3 and 4.3. Hosts stop building URLs
WordPress-iOS
appendingRESTPathand WordPress-AndroidbuildEditorAssetsEndpointdelegate to the resolver they already have (both apps construct aWpApiClientfrom the same root). Deletes copies 5 and 6.Work required
Usable from the bindings today (no wordpress-rs change):
WpOrgSiteApiUrlResolver::new(apiRoot),ApiUrlResolver.resolve(namespace, segments)(the rest_route-aware path join,endpoint.rs#L136-L144), androute_path(namespace, path)(canonical path keys for the preload map). The only gap is attaching endpoint query params:resolvetakes namespace + path segments only, andParsedUrl's query-carrying methods aren't exported across the FFI boundary (parsed_url.rs#L13-L94is a plainimpl; onlyparse/url/pretty_urlat L127+ carry#[uniffi::export]).resolvevariant takingVec<(String, String)>, or a UniFFI-exported rest_route-awareappend_query_pairsonParsedUrl. Additive, non-breaking; the algorithm already exists (theurlcrate handles the?/&bookkeeping), so it's API surface + generated bindings + tests, not logic.EditorConfigurationseam — host-supplied resolved endpoints, or an injected resolver (parallel to the existing auth/transport injection) — and route every REST URL through it. Breaking change toEditorConfigurationconsumers. Deletes copies 3 and 4.appendingRESTPath(WordPress-iOS PR 25859) with aresolve()call; adopt GutenbergKit's new config contract. Deletes copy 5.buildEditorAssetsEndpointwith aresolve()call; adopt GutenbergKit's new config contract. Deletes copy 6.Sequencing — wordpress-rs is not a prerequisite for everything:
resolve()for editor-assets now. That endpoint carries no query string, so it needs nothing new from wordpress-rs — deletes copies 5 and 6 immediately.What this removes vs. keeps
Removes: copies 3–6 — four hand-rolled joins in Swift + Kotlin, host and library — collapsing them onto copy 2 (wordpress-rs).
Keeps (deliberately):
siteApiRootstill flows through GutenbergKit — the JS layer needs it forcreateRootURLMiddleware, and native uses it for webview host-origin matching. The library stops concatenating onto it; it doesn't stop receiving it.EditorPreloadList(canonical WP paths like/wp/v2/themes?context=edit&status=active) — a contract with api-fetch's preloading middleware, not host URL surgery. (route_pathabove can supply these.)Alternatives considered
Build a resolver in wordpress-rs— moot, it already exists (WpOrgSiteApiUrlResolver).Scope / non-goals
Related
WpOrgSiteApiUrlResolver, joinparsed_url.rs, prior fix #1366@wordpress/api-fetchreference:root-url.ts