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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions