Skip to content

Stop re-implementing REST URL resolution in every client — adopt wordpress-rs's WpOrgSiteApiUrlResolver #579

Description

@jkmassel

Summary

  • "Join a REST endpoint onto a site's API root, handling the plain-permalink ?rest_route=/ form" is hand-rolled in six places across four repos.
  • They don't agree — five different behaviors, from full-and-tested down to raw-concat-that-works-by-luck. Two are partial impls that break if reused.
  • The same root-cause bug has been independently discovered and fixed three times: wordpress-rs#1366, GBK Plain Permalink Support #563/fix: build REST URLs for sites using plain permalinks #573, WordPress-iOS#25859.
  • 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) ?& merge, strips leading slash, literal slashes root-url.ts#L11-L40
2 wordpress-rs (Rust) — canonical, tested, already fixed #1366 full; percent-encodes into the rest_route value; covers no-trailing-slash, extra query params, empty segments parsed_url.rs#L46-L101 + resolver endpoint.rs#L147-L172
3 GutenbergKit iOS (#573) ?& merge + slash normalization; literal slashes; keeps leading slash (deliberately unlike api-fetch) Foundation+Extensions.swift#L69-L95
4 GutenbergKit Android (#573) same intent, separate Kotlin impl StringExtensions.kt#L40-L56
5 WordPress-iOS (#25859) slash-only — no query merge (works only because its one input has no query) EditorConfiguration+Blog.swift#L90-L98
6 WordPress-Android (trunk) raw "${root}wpcom/v2/…" concat — no separator, no merge; correct only when the root ends in / GutenbergKitSettingsBuilder.kt#L157-L163

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

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 the rest_route form" — 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]).

Repo Change Size
wordpress-rs (Automattic/wordpress-rs#1543) 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:

  1. Hosts adopt 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.
  2. wordpress-rs ships query-aware resolution (Automattic/wordpress-rs#1543) — the small additive PR above. Runs in parallel with step 1.
  3. 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.

Scope / non-goals

Related

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions