Skip to content

Expose query-aware REST URL resolution (ParsedUrl.by_appending_query_pairs) #1543

Description

@jkmassel

Repo: Automattic/wordpress-rs (Rust + UniFFI → Swift & Kotlin bindings). Not GutenbergKit.
Tracking: GutenbergKit#579. Prior art: wordpress-rs#1366 (the rest_route discovery fix, shipped in 0.6.0).

Why

GutenbergKit#579 consolidates six hand-rolled rest_route URL joiners onto wprs's WpOrgSiteApiUrlResolver. The resolver already does the rest_route-aware path join and is exported. The one gap: consumers can't attach endpoint query parameters (context=edit, status=active, exclude=core,gutenberg) to a resolved URL without re-implementing the ?& merge — because resolve() takes no query and ParsedUrl's query methods aren't exported across FFI. Closing this gap unblocks GutenbergKit adoption and deletes the Swift + Kotlin copies of the merge.

Goal

Expose, across the UniFFI boundary (Swift + Kotlin), a rest_route-aware way to attach arbitrary query pairs to a resolved REST URL. Additive, non-breaking.

Where the code is

  • wp_api/src/parsed_url.rsParsedUrl { inner: Url }. The rest_route join by_extending_rest_api_path (non-exported impl, ~L46; note it uses query_pairs_mut().append_pair, which percent-encodes). Exported block #[uniffi::export] impl ParsedUrl (~L127: parse / url / pretty_url).
  • wp_api/src/request/endpoint.rsApiUrlResolver trait (#[uniffi::export(with_foreign)], ~L136: resolve(namespace, segments), route_path(namespace, path)); WpOrgSiteApiUrlResolver (~L147); resolve delegates to by_extending_rest_api_path.
  • wp_api/src/url_query.rs — existing pub(crate) AppendUrlQueryPairs trait + QueryPairs wrapper (the internal typed-request query mechanism). Reuse this machinery for consistency where practical.

Recommended approach

Add an exported method on ParsedUrl (a general, reusable URL primitive — composes with resolve()):

#[uniffi::export]
impl ParsedUrl {
    /// Appends query parameters to this URL, preserving any existing query.
    /// Works uniformly on path roots and query-based (`?rest_route=`) roots:
    /// `append_pair` adds `&k=v` after the existing query (the rest_route value)
    /// or `?k=v` if there is none.
    pub fn by_appending_query_pairs(&self, pairs: Vec<QueryPair>) -> Arc<ParsedUrl> {
        let mut url = self.inner.clone();
        for p in &pairs {
            url.query_pairs_mut().append_pair(&p.name, &p.value);
        }
        Arc::new(ParsedUrl::new(url))
    }
}

Consumer flow (host or GutenbergKit): resolver.resolve(ns, segments).byAppendingQueryPairs([...]). Correct on both root forms because append_pair handles the ?/& bookkeeping and encoding — the same mechanism by_extending_rest_api_path already uses, so behavior stays consistent.

Keep resolve(), route_path, and the ApiUrlResolver trait unchanged (the trait is with_foreign; adding a required method breaks Swift/Kotlin implementors).

Decisions to make (flagged, with evidence)

  • Param type across FFI. No #[uniffi::export] signature in the repo takes Vec<(String, String)> — tuples appear only in internal code — and the convention is #[derive(uniffi::Record)]. So introduce a small record:
    #[derive(Debug, Clone, uniffi::Record)]
    pub struct QueryPair { pub name: String, pub value: String }
    First check url_query.rs for an existing Record-friendly pair type to reuse. If the pinned UniFFI version supports exported tuples and the team prefers them, Vec<(String, String)> is acceptable — confirm before choosing.
  • Optional resolver convenience. Optionally add resolve_with_query(namespace, segments, pairs) so callers resolve-and-attach in one call. Not required; the ParsedUrl method is the must-have. If added, keep it additive (new method, not a changed signature).

Behavior spec — golden cases (must hold)

Appending [{context, edit}, {status, active}] to a resolved base:

Base (from resolve) Result
https://example.com/wp-json/wp/v2/themes (path root) …/wp/v2/themes?context=edit&status=active
https://example.com/index.php?rest_route=%2Fwp%2Fv2%2Fthemes (query root) …?rest_route=%2Fwp%2Fv2%2Fthemes&context=edit&status=active
query root already carrying &debug=1 new pairs appended after; &debug=1 preserved

Plus:

  • Empty pairs → URL returned unchanged.
  • Reserved chars in a value, e.g. exclude=core,gutenberg → serialized exclude=core%2Cgutenberg (form-urlencoded; WordPress decodes it). Assert the encoded form deliberately — it's byte-different from GutenbergKit's current literal-comma output but functionally equivalent, and it matches by_extending_rest_api_path's existing encoding.
  • Order preserved; duplicate keys allowed (append, do not dedupe).

Tests

  • Add an rstest table in parsed_url.rs's test module, mirroring the existing #[case::rest_route_query_form(...)] style, covering every golden case above.
  • If the repo has a Swift/Kotlin binding smoke-test harness, add one call through the generated bindings to prove the FFI export + the QueryPair record round-trip.

Bindings / build verification

  • This must cross the UniFFI boundary. Regenerate and verify the Swift and Kotlin bindings expose by_appending_query_pairs and QueryPair.
  • Per the repo's own CHANGELOG note, make xcframework-only-macos is the fast way to verify a UniFFI change (not the full 11-target make xcframework). Confirm the Kotlin bindings regenerate cleanly too.

CHANGELOG

Add an ### Added entry under ## [Unreleased] in CHANGELOG.md, e.g.:

REST URL resolution can now attach endpoint query parameters via ParsedUrl.by_appending_query_pairs (Swift/Kotlin), so consumers building ?rest_route= URLs no longer re-implement the ?& merge. Complements WpOrgSiteApiUrlResolver.resolve.

Out of scope

  • No GutenbergKit or host-app changes; no EditorConfiguration API change (that's Bump serde from 1.0.217 to 1.0.218 #579 downstream).
  • Don't change resolve() / route_path signatures or the ApiUrlResolver trait shape.
  • Don't touch preload-key formatting (a GutenbergKit concern).
  • Cutting a wprs release is a separate step after merge.

Acceptance criteria

  • ParsedUrl.by_appending_query_pairs (+ QueryPair if used) exported and callable from Swift and Kotlin.
  • Correct on path roots and ?rest_route= roots (trailing slash and none), preserves existing query params, order-stable, empty-safe, encoding matches by_extending_rest_api_path.
  • rstest coverage for all golden cases; binding smoke test if a harness exists.
  • resolve() / route_path / trait unchanged; change is purely additive.
  • ## [Unreleased] → Added CHANGELOG entry.
  • make xcframework-only-macos (or equivalent) confirms the Swift surface; Kotlin bindings regenerate cleanly.

References

  • GutenbergKit#579 (consolidation), wordpress-rs#1366 (rest_route fix, in 0.6.0)
  • parsed_url.rs:
    pub fn by_extending_rest_api_path<I>(&self, segments: I) -> Url
    where
    I: IntoIterator,
    I::Item: AsRef<str>,
    {
    let appended_path = segments
    .into_iter()
    .flat_map(|s| {
    s.as_ref()
    .split('/')
    .filter_map(|x| match x.trim() {
    "" => None,
    y => Some(y.to_string()),
    })
    .collect::<Vec<String>>()
    })
    .collect::<Vec<_>>()
    .join("/");
    if self.inner.query_pairs().any(|(k, _)| k == "rest_route") {
    let pairs: Vec<(String, String)> = self
    .inner
    .query_pairs()
    .map(|(k, v)| {
    if k == "rest_route" {
    let base = v.trim_end_matches('/');
    let new_value = if appended_path.is_empty() {
    v.into_owned()
    } else {
    format!("{base}/{appended_path}")
    };
    (k.into_owned(), new_value)
    } else {
    (k.into_owned(), v.into_owned())
    }
    })
    .collect();
    let mut url = self.inner.clone();
    url.query_pairs_mut().clear();
    for (k, v) in pairs {
    url.query_pairs_mut().append_pair(&k, &v);
    }
    return url;
    }
    self.by_extending_and_splitting_by_forward_slash([appended_path])
    }
    }
    impl Display for ParsedUrl {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    write!(f, "{}", self.inner)
    }
    }
  • endpoint.rs (resolver):
    #[uniffi::export(with_foreign)]
    pub trait ApiUrlResolver: Send + Sync {
    fn resolve(&self, namespace: String, endpoint_segments: Vec<String>) -> Arc<ParsedUrl>;
    /// Returns the route key for an endpoint, matching the keys used in
    /// `WpApiDetails.routes`. Implementations must produce the same path
    /// structure that `resolve` would produce after the base URL.
    fn route_path(&self, namespace: String, endpoint_path: String) -> String;
    }
    #[derive(Debug, uniffi::Object)]
    pub struct WpOrgSiteApiUrlResolver {
    pub api_root_url: Arc<ParsedUrl>,
    }
    #[uniffi::export]
    impl WpOrgSiteApiUrlResolver {
    #[uniffi::constructor]
    pub fn new(api_root_url: Arc<ParsedUrl>) -> Self {
    Self { api_root_url }
    }
    fn api_root_url(&self) -> Arc<ParsedUrl> {
    self.api_root_url.clone()
    }
    }
    #[uniffi::export]
    impl ApiUrlResolver for WpOrgSiteApiUrlResolver {
    fn resolve(&self, namespace: String, endpoint_segments: Vec<String>) -> Arc<ParsedUrl> {
    Arc::new(
    self.api_root_url
    .by_extending_rest_api_path([namespace].into_iter().chain(endpoint_segments))
    .into(),
    )
    }
  • api-fetch reference behavior: https://github.com/WordPress/gutenberg/blob/816fdbb14f353498aed164cde410274d741057ec/packages/api-fetch/src/middlewares/root-url.ts#L11-L40

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions