Automattic / Automattic/wordpress-rs
Expose query-aware REST URL resolution (ParsedUrl.by_appending_query_pairs)
- Dominant language
- Rust
- Stars
- 36
- Forks
- 5
- Avg merge
- 17h 30m
- Merged PRs (30d)
- 43
Description
**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.rs` — `ParsedUrl { 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.rs` — `ApiUrlResolver` 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()`):
```rust
#[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) -> Arc {
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:
```rust
#[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 #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`: https://github.com/Automattic/wordpress-rs/blob/835be4335b13e01094b0096d0395882f37fac24f/wp_api/src/parsed_url.rs#L46-L101
- `endpoint.rs` (resolver): https://github.com/Automattic/wordpress-rs/blob/835be4335b13e01094b0096d0395882f37fac24f/wp_api/src/request/endpoint.rs#L136-L172
- api-fetch reference behavior: https://github.com/WordPress/gutenberg/blob/816fdbb14f353498aed164cde410274d741057ec/packages/api-fetch/src/middlewares/root-url.ts#L11-L40
Contributor guide
No contributing guide indexed for this repository
Research direction
Start in wp_api/src/parsed_url.rs and inspect the existing by_extending_rest_api_path implementation and rstest cases, then check wp_api/src/url_query.rs for a reusable FFI-friendly query-pair type. Additive behavior is complete when path and query roots preserve existing parameters, encode and order appended pairs correctly, and empty input is unchanged. Verify the generated Swift and Kotlin surfaces, the relevant tests, CHANGELOG.md, and make xcframework-only-macos.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- kotlin, rust, swift
- Domain
- api, mobile-dev
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100