From 9c0ab0e319d71d48eadb018c8c49d46a1eb00efd Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:33:18 -0600 Subject: [PATCH 1/7] Add a `ResolvedUrl` type carrying the request URL and its preload key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ResolvedUrl` is the two projections of a resolved REST endpoint: `url()` returns the request URL to fetch, and `canonical_route_key()` returns the origin-less `path[?query]` that `@wordpress/api-fetch`'s preload middleware matches on. The key uses a memoized canonical route path plus the request URL's query with `rest_route` dropped, so it is byte-identical on pretty (`…/wp-json/…`) and plain (`…?rest_route=…`) permalink sites and omits the `?` when there is no query. `by_appending_query_pairs` attaches endpoint query parameters while preserving that path. Not yet returned by `resolve`; that comes next. --- wp_api/src/lib.rs | 1 + wp_api/src/resolved_url.rs | 213 +++++++++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 wp_api/src/resolved_url.rs diff --git a/wp_api/src/lib.rs b/wp_api/src/lib.rs index de04634dd..9980459f1 100644 --- a/wp_api/src/lib.rs +++ b/wp_api/src/lib.rs @@ -44,6 +44,7 @@ pub mod post_types; pub mod posts; pub mod prelude; pub mod request; +pub mod resolved_url; pub mod search_results; pub mod sidebars; pub mod site_settings; diff --git a/wp_api/src/resolved_url.rs b/wp_api/src/resolved_url.rs new file mode 100644 index 000000000..bf2a5c851 --- /dev/null +++ b/wp_api/src/resolved_url.rs @@ -0,0 +1,213 @@ +use crate::parsed_url::{ParsedUrl, QueryPair}; +use std::sync::Arc; +use url::Url; + +/// A REST URL resolved against a specific site, retaining enough context to emit +/// both halves a preloading editor needs: the request URL to fetch, and the +/// canonical, origin-less route key that `@wordpress/api-fetch`'s preload +/// middleware matches on. +/// +/// `ApiUrlResolver::resolve` returns this instead of a bare [`ParsedUrl`]. The +/// request URL and the cache key are two projections of the same resolved +/// endpoint: [`ResolvedUrl::url`] for the request, and +/// [`ResolvedUrl::canonical_route_key`] for the preload key. +#[derive(Debug, Clone, uniffi::Object)] +pub struct ResolvedUrl { + /// Request URL in the site's advertised form (`…/wp-json/…` or + /// `…?rest_route=…`). This is the URL to actually fetch. + inner: Url, + /// The API root this was resolved against (`…/wp-json`, `…/index.php?rest_route=/`, + /// or a WordPress.com base). Retained as the resolution context so callers + /// never have to pass the root back in to derive the key. + api_root: Arc, + /// The canonical, origin-less REST route path (e.g. `/wp/v2/themes`), + /// memoized at resolve time so [`canonical_route_key`](Self::canonical_route_key) + /// is a pure concatenation and is guaranteed to match `route_path()`. + route_path: String, +} + +#[uniffi::export] +impl ResolvedUrl { + /// Assembles a `ResolvedUrl` from its parts. + /// + /// Exported so foreign [`ApiUrlResolver`](crate::request::endpoint::ApiUrlResolver) + /// implementations can build the value their `resolve` returns. `url` is the + /// request URL, `api_root` is the root it was resolved against, and + /// `route_path` is the canonical route path the same resolver's + /// `route_path()` would produce for these inputs (leading slash, real + /// slashes, no trailing slash). + #[uniffi::constructor] + pub fn new(url: Arc, api_root: Arc, route_path: String) -> Arc { + Arc::new(Self { + inner: url.inner.clone(), + api_root, + route_path, + }) + } + + /// The request URL to actually fetch. + pub fn url(&self) -> Arc { + Arc::new(ParsedUrl::new(self.inner.clone())) + } + + /// A copy with `pairs` appended to the request URL's query, preserving the + /// resolution context. The route path is unchanged by query parameters, so + /// the canonical route key gains the same pairs but keeps its path. + /// + /// Delegates to [`ParsedUrl::by_appending_query_pairs`] so the encoding + /// matches the request URL exactly. + pub fn by_appending_query_pairs(&self, pairs: Vec) -> Arc { + let appended = ParsedUrl::new(self.inner.clone()).by_appending_query_pairs(pairs); + Arc::new(Self { + inner: appended.inner.clone(), + api_root: self.api_root.clone(), + route_path: self.route_path.clone(), + }) + } + + /// The origin-less canonical `path[?query]` that `@wordpress/api-fetch`'s + /// preload middleware matches on. + /// + /// `api-fetch` normalizes both an outgoing request and each preload key + /// through the same origin-less function: it keys on path + query, unwraps + /// the `?rest_route=` form back to the canonical `/wp/v2/…` path, and early + /// returns when there is no query. So the key is the canonical route path + /// plus the endpoint query params (everything except `rest_route`), and a + /// query-less endpoint is just the bare path — no trailing `?`. + /// + /// INVARIANT: for the same inputs, this is byte-identical on pretty + /// (`…/wp-json/…`) and plain (`…?rest_route=…`) permalink sites. The path + /// comes from the memoized `route_path`; the query is re-serialized from the + /// request URL with `rest_route` dropped, which is the only per-form + /// difference. + pub fn canonical_route_key(&self) -> String { + // Rebuild the query from the request URL's own pairs, dropping the + // `rest_route` param that only plain-permalink roots carry. Re-encoding + // through `query_pairs_mut` reuses the exact form-encoding the request + // URL was built with, so the key's query is byte-for-byte what the + // request carries (minus `rest_route`). + let retained: Vec<(String, String)> = self + .inner + .query_pairs() + .filter_map(|(name, value)| { + (name != "rest_route").then(|| (name.into_owned(), value.into_owned())) + }) + .collect(); + + if retained.is_empty() { + return self.route_path.clone(); + } + + let mut scratch = self.inner.clone(); + scratch.set_query(None); + { + let mut query_pairs = scratch.query_pairs_mut(); + for (name, value) in &retained { + query_pairs.append_pair(name, value); + } + } + + match scratch.query() { + Some(query) => format!("{}?{}", self.route_path, query), + None => self.route_path.clone(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + /// Builds a `ResolvedUrl` the way a resolver would, for exercising the + /// projections in isolation from `ApiUrlResolver::resolve`. + fn resolved(request_url: &str, api_root: &str, route_path: &str) -> Arc { + ResolvedUrl::new( + ParsedUrl::parse(request_url) + .expect("valid request url") + .into(), + ParsedUrl::parse(api_root).expect("valid api root").into(), + route_path.to_string(), + ) + } + + fn query_pairs(pairs: &[(&str, &str)]) -> Vec { + pairs + .iter() + .map(|(name, value)| QueryPair { + name: name.to_string(), + value: value.to_string(), + }) + .collect() + } + + #[test] + fn url_returns_the_request_url() { + let resolved = resolved( + "https://example.com/wp-json/wp/v2/themes?context=edit", + "https://example.com/wp-json", + "/wp/v2/themes", + ); + assert_eq!( + resolved.url().url(), + "https://example.com/wp-json/wp/v2/themes?context=edit" + ); + } + + #[test] + fn appending_query_pairs_preserves_route_path_and_extends_the_request_url() { + let resolved = resolved( + "https://example.com/wp-json/wp/v2/themes", + "https://example.com/wp-json", + "/wp/v2/themes", + ); + let appended = resolved + .by_appending_query_pairs(query_pairs(&[("context", "edit"), ("status", "active")])); + assert_eq!( + appended.url().url(), + "https://example.com/wp-json/wp/v2/themes?context=edit&status=active" + ); + assert_eq!( + appended.canonical_route_key(), + "/wp/v2/themes?context=edit&status=active" + ); + } + + /// A query-less resolved URL keys on the bare path — no trailing `?`. + #[rstest] + #[case::pretty("https://example.com/wp-json/wp/v2/themes")] + #[case::plain("https://example.com/index.php?rest_route=%2Fwp%2Fv2%2Fthemes")] + fn canonical_route_key_without_query_is_the_bare_path(#[case] request_url: &str) { + let resolved = resolved(request_url, "https://example.com/wp-json", "/wp/v2/themes"); + assert_eq!(resolved.canonical_route_key(), "/wp/v2/themes"); + } + + /// The crux: pretty and plain request URLs for the same endpoint produce a + /// byte-identical key. `rest_route` is dropped; the remaining pairs re-encode + /// identically. + #[rstest] + #[case::two_pairs( + "https://example.com/wp-json/wp/v2/themes?context=edit&status=active", + "https://example.com/index.php?rest_route=%2Fwp%2Fv2%2Fthemes&context=edit&status=active", + "/wp/v2/themes?context=edit&status=active" + )] + #[case::encoded_value( + "https://example.com/wp-json/wp/v2/themes?exclude=core%2Cgutenberg", + "https://example.com/index.php?rest_route=%2Fwp%2Fv2%2Fthemes&exclude=core%2Cgutenberg", + "/wp/v2/themes?exclude=core%2Cgutenberg" + )] + fn canonical_route_key_is_identical_across_permalink_forms( + #[case] pretty_url: &str, + #[case] plain_url: &str, + #[case] expected_key: &str, + ) { + let pretty = resolved(pretty_url, "https://example.com/wp-json", "/wp/v2/themes"); + let plain = resolved( + plain_url, + "https://example.com/index.php?rest_route=/", + "/wp/v2/themes", + ); + assert_eq!(pretty.canonical_route_key(), expected_key); + assert_eq!(plain.canonical_route_key(), expected_key); + } +} From 2e2ea7a9dd480f3cd621bafbbc46af0dbbbf2107 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:33:26 -0600 Subject: [PATCH 2/7] Return `ResolvedUrl` from `ApiUrlResolver::resolve` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolve` now returns `Arc` instead of `Arc` across the trait and all three resolvers (`WpOrgSiteApiUrlResolver`, `WpComDotOrgApiUrlResolver`, `WpComApiClientInternalUrlResolver`), each stamping the value with the root it used and the canonical route path its own `route_path()` produces. Internal call sites — the endpoint-builder macro funnel and the resolver tests — unwrap to the request URL with `.url()`, so request-building is unchanged and the plain-permalinks and index-self-href golden URL tables still pass verbatim. This is a breaking change to the `with_foreign` trait: apps with custom `ApiUrlResolver` implementations must return a `ResolvedUrl`. --- wp_api/src/login.rs | 8 +- wp_api/src/request/endpoint.rs | 101 ++++++++++++++++-- wp_api/src/wp_com/endpoint.rs | 40 +++---- .../test_resolved_url_canonical_route_key.rs | 93 ++++++++++++++++ .../generate/helpers_to_generate_tokens.rs | 2 +- 5 files changed, 216 insertions(+), 28 deletions(-) create mode 100644 wp_api_integration_tests/tests/test_resolved_url_canonical_route_key.rs diff --git a/wp_api/src/login.rs b/wp_api/src/login.rs index d567464cd..c51947f5e 100644 --- a/wp_api/src/login.rs +++ b/wp_api/src/login.rs @@ -955,7 +955,9 @@ mod tests { #[case] endpoint_path: &str, ) { let resolver = wp_org_resolver(); - let resolved = resolver.resolve(namespace.to_string(), vec![endpoint_path.to_string()]); + let resolved = resolver + .resolve(namespace.to_string(), vec![endpoint_path.to_string()]) + .url(); let route_key = resolver.route_path(namespace.to_string(), endpoint_path.to_string()); assert!( @@ -976,7 +978,9 @@ mod tests { #[case] endpoint_path: &str, ) { let resolver = wp_com_resolver("mobile.blog"); - let resolved = resolver.resolve(namespace.to_string(), vec![endpoint_path.to_string()]); + let resolved = resolver + .resolve(namespace.to_string(), vec![endpoint_path.to_string()]) + .url(); let route_key = resolver.route_path(namespace.to_string(), endpoint_path.to_string()); assert!( diff --git a/wp_api/src/request/endpoint.rs b/wp_api/src/request/endpoint.rs index 49d4d339c..4d8230a9d 100644 --- a/wp_api/src/request/endpoint.rs +++ b/wp_api/src/request/endpoint.rs @@ -1,4 +1,5 @@ use crate::parsed_url::ParsedUrl; +use crate::resolved_url::ResolvedUrl; use std::sync::Arc; use strum_macros::EnumIter; use url::Url; @@ -140,7 +141,10 @@ impl AsNamespace for WpNamespace { #[uniffi::export(with_foreign)] pub trait ApiUrlResolver: Send + Sync { - fn resolve(&self, namespace: String, endpoint_segments: Vec) -> Arc; + /// Resolves an endpoint to a [`ResolvedUrl`], which carries both the request + /// URL to fetch (`url()`) and the canonical, origin-less route key that + /// preloading editors match on (`canonical_route_key()`). + fn resolve(&self, namespace: String, endpoint_segments: Vec) -> Arc; /// Returns the route key for an endpoint, matching the keys used in /// `WpApiDetails.routes`. Implementations must produce the same path @@ -167,11 +171,15 @@ impl WpOrgSiteApiUrlResolver { #[uniffi::export] impl ApiUrlResolver for WpOrgSiteApiUrlResolver { - fn resolve(&self, namespace: String, endpoint_segments: Vec) -> Arc { - Arc::new( - self.api_root_url - .by_extending_rest_api_path([namespace].into_iter().chain(endpoint_segments)) - .into(), + fn resolve(&self, namespace: String, endpoint_segments: Vec) -> Arc { + let route_path = self.route_path(namespace.clone(), endpoint_segments.join("/")); + let url = self + .api_root_url + .by_extending_rest_api_path([namespace].into_iter().chain(endpoint_segments)); + ResolvedUrl::new( + Arc::new(ParsedUrl::new(url)), + self.api_root_url.clone(), + route_path, ) } @@ -246,6 +254,7 @@ mod tests { assert_eq!( resolver .resolve(namespace.to_string(), endpoint_segments) + .url() .url(), expected ); @@ -286,6 +295,84 @@ mod tests { value: "core,gutenberg".to_string(), }, ]); - assert_eq!(result.url(), expected); + assert_eq!(result.url().url(), expected); + // The canonical route key is origin-less and identical on every API-root + // form — the request URLs above differ, but the key does not. + assert_eq!( + result.canonical_route_key(), + "/wp/v2/themes?context=edit&exclude=core%2Cgutenberg" + ); + } + + /// End-to-end through `resolve()`: for the same endpoint, a pretty + /// (`…/wp-json/…`) and a plain (`…?rest_route=…`) API root build different + /// request URLs but the **same** `canonical_route_key()` — the invariant the + /// preload middleware relies on. Covers a query-less key (no `?`), multiple + /// params, an encoded value, and a route with embedded slashes. + #[rstest] + #[case::two_pairs( + "themes", + vec![("context", "edit"), ("status", "active")], + "https://example.com/wp-json/wp/v2/themes?context=edit&status=active", + "https://example.com/index.php?rest_route=%2Fwp%2Fv2%2Fthemes&context=edit&status=active", + "/wp/v2/themes?context=edit&status=active" + )] + #[case::no_query( + "themes", + vec![], + "https://example.com/wp-json/wp/v2/themes", + "https://example.com/index.php?rest_route=%2Fwp%2Fv2%2Fthemes", + "/wp/v2/themes" + )] + #[case::encoded_value( + "themes", + vec![("exclude", "core,gutenberg")], + "https://example.com/wp-json/wp/v2/themes?exclude=core%2Cgutenberg", + "https://example.com/index.php?rest_route=%2Fwp%2Fv2%2Fthemes&exclude=core%2Cgutenberg", + "/wp/v2/themes?exclude=core%2Cgutenberg" + )] + #[case::embedded_slash_route( + "block-renderer/core/paragraph", + vec![("context", "edit")], + "https://example.com/wp-json/wp/v2/block-renderer/core/paragraph?context=edit", + "https://example.com/index.php?rest_route=%2Fwp%2Fv2%2Fblock-renderer%2Fcore%2Fparagraph&context=edit", + "/wp/v2/block-renderer/core/paragraph?context=edit" + )] + fn resolved_url_request_and_key( + #[case] segment: &str, + #[case] pairs: Vec<(&str, &str)>, + #[case] expected_pretty_url: &str, + #[case] expected_plain_url: &str, + #[case] expected_key: &str, + ) { + use crate::parsed_url::QueryPair; + + for (root, expected_url) in [ + ("https://example.com/wp-json", expected_pretty_url), + ( + "https://example.com/index.php?rest_route=/", + expected_plain_url, + ), + ] { + let resolver = + WpOrgSiteApiUrlResolver::new(ParsedUrl::parse(root).expect("valid url").into()); + let resolved = resolver + .resolve( + WpNamespace::WpV2.namespace_value().to_string(), + vec![segment.to_string()], + ) + .by_appending_query_pairs( + pairs + .iter() + .map(|(name, value)| QueryPair { + name: name.to_string(), + value: value.to_string(), + }) + .collect(), + ); + assert_eq!(resolved.url().url(), expected_url); + // Identical for BOTH roots. + assert_eq!(resolved.canonical_route_key(), expected_key); + } } } diff --git a/wp_api/src/wp_com/endpoint.rs b/wp_api/src/wp_com/endpoint.rs index 957303e72..5ca825582 100644 --- a/wp_api/src/wp_com/endpoint.rs +++ b/wp_api/src/wp_com/endpoint.rs @@ -1,6 +1,7 @@ use crate::{ parsed_url::ParsedUrl, request::endpoint::{ApiUrlResolver, AsNamespace, WpNamespace}, + resolved_url::ResolvedUrl, wp_com::WpComBaseUrl, }; use std::sync::Arc; @@ -67,7 +68,7 @@ impl WpComDotOrgApiUrlResolver { #[uniffi::export] impl ApiUrlResolver for WpComDotOrgApiUrlResolver { - fn resolve(&self, namespace: String, endpoint_segments: Vec) -> Arc { + fn resolve(&self, namespace: String, endpoint_segments: Vec) -> Arc { { if !WpNamespace::iter().any(|n| n.namespace_value() == namespace) { panic!( @@ -78,6 +79,9 @@ impl ApiUrlResolver for WpComDotOrgApiUrlResolver { } } + let route_path = self.route_path(namespace.clone(), endpoint_segments.join("/")); + let api_root: Arc = Arc::new(self.base_url.clone()); + // The API root endpoint needs special handling for WordPress.com if namespace == WpNamespace::None.namespace_value() && endpoint_segments.is_empty() { let url_string = format!( @@ -86,18 +90,15 @@ impl ApiUrlResolver for WpComDotOrgApiUrlResolver { ); let parsed_url = ParsedUrl::parse(&url_string).expect("WordPress.com API root URL is valid"); - return Arc::new(parsed_url); + return ResolvedUrl::new(Arc::new(parsed_url), api_root, route_path); } - Arc::new( - self.base_url - .by_extending_and_splitting_by_forward_slash( - vec![namespace, "sites".to_string(), self.site_id.to_string()] - .into_iter() - .chain(endpoint_segments), - ) - .into(), - ) + let url = self.base_url.by_extending_and_splitting_by_forward_slash( + vec![namespace, "sites".to_string(), self.site_id.to_string()] + .into_iter() + .chain(endpoint_segments), + ); + ResolvedUrl::new(Arc::new(ParsedUrl::new(url)), api_root, route_path) } fn route_path(&self, namespace: String, endpoint_path: String) -> String { @@ -130,7 +131,7 @@ impl Default for WpComApiClientInternalUrlResolver { } impl ApiUrlResolver for WpComApiClientInternalUrlResolver { - fn resolve(&self, namespace: String, endpoint_segments: Vec) -> Arc { + fn resolve(&self, namespace: String, endpoint_segments: Vec) -> Arc { { if WpNamespace::iter().any(|n| n.namespace_value() == namespace) { panic!( @@ -138,12 +139,14 @@ impl ApiUrlResolver for WpComApiClientInternalUrlResolver { ); } } - Arc::new( - self.base_url - .by_extending_and_splitting_by_forward_slash( - vec![namespace].into_iter().chain(endpoint_segments), - ) - .into(), + let route_path = self.route_path(namespace.clone(), endpoint_segments.join("/")); + let url = self.base_url.by_extending_and_splitting_by_forward_slash( + vec![namespace].into_iter().chain(endpoint_segments), + ); + ResolvedUrl::new( + Arc::new(ParsedUrl::new(url)), + Arc::new(self.base_url.clone()), + route_path, ) } @@ -207,6 +210,7 @@ pub(crate) mod tests { assert_eq!( resolver .resolve(namespace.to_string(), endpoint_segments) + .url() .url(), expected_url ); diff --git a/wp_api_integration_tests/tests/test_resolved_url_canonical_route_key.rs b/wp_api_integration_tests/tests/test_resolved_url_canonical_route_key.rs new file mode 100644 index 000000000..9ba22f2c0 --- /dev/null +++ b/wp_api_integration_tests/tests/test_resolved_url_canonical_route_key.rs @@ -0,0 +1,93 @@ +//! End-to-end invariant for [`ResolvedUrl::canonical_route_key`]: for a given +//! endpoint, the origin-less route key `@wordpress/api-fetch`'s preload +//! middleware matches on is **byte-identical** whether the site advertises +//! pretty permalinks (`…/wp-json/…`) or plain permalinks (`…/index.php?rest_route=…`). +//! +//! This is the property GutenbergKit relies on: wprs precomputes the preload +//! key natively via `resolve(...).canonical_route_key()`, and it must match the +//! request api-fetch issues regardless of the site's permalink setting. The two +//! `WpOrgSiteApiUrlResolver`s below are seeded with the exact api-root forms a +//! self-hosted site advertises for each permalink setting, so this exercises the +//! same resolver path the client uses after discovery — no live server needed, +//! because the key is permalink-config-independent by construction and that is +//! precisely what is under test. + +use wp_api::{ + parsed_url::{ParsedUrl, QueryPair}, + request::endpoint::{ApiUrlResolver, WpOrgSiteApiUrlResolver}, +}; +use wp_api_integration_tests::prelude::rstest; + +/// The two api-root forms the same self-hosted site advertises: pretty +/// permalinks path-extend `…/wp-json`, plain permalinks extend the `rest_route` +/// query of `…/index.php?rest_route=/`. +const PRETTY_API_ROOT: &str = "https://example.com/wp-json"; +const PLAIN_API_ROOT: &str = "https://example.com/index.php?rest_route=/"; + +fn resolver(api_root: &str) -> WpOrgSiteApiUrlResolver { + WpOrgSiteApiUrlResolver::new(ParsedUrl::parse(api_root).expect("valid api root").into()) +} + +fn query_pairs(pairs: &[(&str, &str)]) -> Vec { + pairs + .iter() + .map(|(name, value)| QueryPair { + name: name.to_string(), + value: value.to_string(), + }) + .collect() +} + +/// Real endpoint route shapes: a collection, a nested singleton, an id-bearing +/// resource, a route with embedded slashes, and non-`wp/v2` namespaces. +#[rstest] +#[case::themes_collection("/wp/v2", vec!["themes"], "/wp/v2/themes")] +#[case::users_me("/wp/v2", vec!["users", "me"], "/wp/v2/users/me")] +#[case::posts_by_id("/wp/v2", vec!["posts", "123"], "/wp/v2/posts/123")] +#[case::types_post("/wp/v2", vec!["types", "post"], "/wp/v2/types/post")] +#[case::block_renderer_embedded_slash( + "/wp-block-editor/v1", + vec!["block-renderer", "core/paragraph"], + "/wp-block-editor/v1/block-renderer/core/paragraph" +)] +#[case::site_health_nested( + "/wp-site-health/v1", + vec!["tests", "background"], + "/wp-site-health/v1/tests/background" +)] +fn canonical_route_key_matches_across_permalink_forms( + #[case] namespace: &str, + #[case] segments: Vec<&str>, + #[case] expected_key: &str, +) { + let pretty = resolver(PRETTY_API_ROOT); + let plain = resolver(PLAIN_API_ROOT); + let segments: Vec = segments.into_iter().map(str::to_string).collect(); + + // Without a query, the key is the bare canonical path — no trailing `?`. + let pretty_bare = pretty.resolve(namespace.to_string(), segments.clone()); + let plain_bare = plain.resolve(namespace.to_string(), segments.clone()); + assert_eq!(pretty_bare.canonical_route_key(), expected_key); + assert_eq!( + plain_bare.canonical_route_key(), + pretty_bare.canonical_route_key(), + "plain and pretty permalink keys diverged for {expected_key}" + ); + + // With endpoint query params, both forms gain the same query in the key even + // though only the plain request URL also carries `rest_route`. + let pairs = query_pairs(&[("context", "edit"), ("status", "active")]); + let pretty_keyed = pretty + .resolve(namespace.to_string(), segments.clone()) + .by_appending_query_pairs(pairs.clone()); + let plain_keyed = plain + .resolve(namespace.to_string(), segments) + .by_appending_query_pairs(pairs); + let expected_keyed = format!("{expected_key}?context=edit&status=active"); + assert_eq!(pretty_keyed.canonical_route_key(), expected_keyed); + assert_eq!( + plain_keyed.canonical_route_key(), + pretty_keyed.canonical_route_key(), + "plain and pretty permalink keys diverged for {expected_keyed}" + ); +} diff --git a/wp_derive_request_builder/src/generate/helpers_to_generate_tokens.rs b/wp_derive_request_builder/src/generate/helpers_to_generate_tokens.rs index 62f4a613f..73173787b 100644 --- a/wp_derive_request_builder/src/generate/helpers_to_generate_tokens.rs +++ b/wp_derive_request_builder/src/generate/helpers_to_generate_tokens.rs @@ -283,7 +283,7 @@ pub fn fn_body_get_url_from_api_url_resolver( }) .collect::>(); quote! { - let mut url = std::sync::Arc::unwrap_or_clone(self.api_url_resolver.resolve( #enum_ident::namespace(&#enum_ident::#variant_ident).namespace_value().to_string() , vec![#(#url_parts,)*])).inner; + let mut url = std::sync::Arc::unwrap_or_clone(self.api_url_resolver.resolve( #enum_ident::namespace(&#enum_ident::#variant_ident).namespace_value().to_string() , vec![#(#url_parts,)*]).url()).inner; } } From 6ed56b0c30625d6a2e74709a26cc4dcb91c1e268 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:33:41 -0600 Subject: [PATCH 3/7] Re-export `ResolvedUrl` to Swift consumers New UniFFI objects are invisible to the Swift package's consumers without a `public typealias` in `Exports.swift`; only `swift test` catches the omission, not static review. --- native/swift/Sources/wordpress-api/Exports.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/native/swift/Sources/wordpress-api/Exports.swift b/native/swift/Sources/wordpress-api/Exports.swift index 6431b7411..4020f8e88 100644 --- a/native/swift/Sources/wordpress-api/Exports.swift +++ b/native/swift/Sources/wordpress-api/Exports.swift @@ -11,6 +11,7 @@ public typealias RequestExecutionError = WordPressAPIInternal.RequestExecutionEr public typealias InvalidSslErrorReason = WordPressAPIInternal.InvalidSslErrorReason public typealias ParsedUrl = WordPressAPIInternal.ParsedUrl public typealias QueryPair = WordPressAPIInternal.QueryPair +public typealias ResolvedUrl = WordPressAPIInternal.ResolvedUrl public typealias WpUuid = WordPressAPIInternal.WpUuid public typealias WpNetworkRequest = WordPressAPIInternal.WpNetworkRequest public typealias WpNetworkResponse = WordPressAPIInternal.WpNetworkResponse From 798e747c87db4463899c6fd51094b2259deef85a Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:33:41 -0600 Subject: [PATCH 4/7] Changelog: `ResolvedUrl` and the `resolve` signature change --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44ebb6d73..3d199c156 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - 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. It preserves any existing query, is order-stable, keeps duplicate keys, and form-urlencodes names and values — the same encoding `WpOrgSiteApiUrlResolver.resolve` already produces. Pairs are passed as the new `QueryPair` record ([#1543](https://github.com/Automattic/wordpress-rs/issues/1543)). +- `ApiUrlResolver.resolve` now returns a `ResolvedUrl`, which exposes both the request URL to fetch (`url()`) and the canonical, origin-less route key `@wordpress/api-fetch`'s preload middleware matches on (`canonical_route_key()`). The key is byte-identical on pretty (`…/wp-json/…`) and plain (`…?rest_route=…`) permalink sites: it unwraps the `?rest_route=` form back to the canonical `/wp/v2/…` path and omits the `?` entirely when there is no query. `ResolvedUrl.by_appending_query_pairs` attaches endpoint query parameters to the request URL while preserving that path, so a preloading editor can precompute the preload key natively instead of re-implementing api-fetch's normalization ([#1543](https://github.com/Automattic/wordpress-rs/issues/1543)). - WordPress.com `POST /me/transactions` endpoint for redeeming a shopping cart with the account's WordPress.com credits, completing a domain purchase - WordPress.com `GET /sites//purchases` endpoint for listing a site's purchases (plans, domains, and other subscriptions) - Publish the Kotlin bindings' per-endpoint Markdown API reference as an `ai-docs` Maven classifier zip on `rs.wordpress.api:kotlin`, generated from the UniFFI bindings for agent/tooling consumption @@ -21,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** `ApiUrlResolver.resolve` now returns `ResolvedUrl` instead of `ParsedUrl`. Custom `ApiUrlResolver` implementations (Swift/Kotlin) must return a `ResolvedUrl` — build one with `ResolvedUrl(url:apiRoot:routePath:)` — and callers that consumed the resolved value as a URL now read `resolve(...).url()` ([#1543](https://github.com/Automattic/wordpress-rs/issues/1543)). - **BREAKING:** `product_type` fields on `Product` and `WPComProduct` changed from `String` to `ProductType`. Callers that match on or construct these values will need to wrap/unwrap with `ProductType(...)`. - **BREAKING:** The cache now enables SQLite foreign key enforcement on every connection it prepares, and fails with `SqliteDbError::ForeignKeysUnavailable` if the setting doesn't take effect. Removing a site relies on `ON DELETE CASCADE` to clear its cached rows, so on builds where enforcement defaulted to off those rows were silently left behind. - **BREAKING:** `ShoppingCart.coupon` changed from `String` to `CouponCode`, and `ShoppingCartCostOverride.override_code` from `String` to `CostOverrideCode`, so the shopping cart and site plans describe these values with the same types. Callers will need to wrap/unwrap with `CouponCode(...)` / `CostOverrideCode(...)`. From 9580d0ace81cc9ca858ca74554e51aac8c501320 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:58:22 -0600 Subject: [PATCH 5/7] Return the request URL as a string from `ResolvedUrl.url()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ResolvedUrl.url()` now mirrors `ParsedUrl.url()` — it returns the request URL as a string rather than a `ParsedUrl` — and the new `parsed_url()` is the escape hatch to the `ParsedUrl` object. This fixes the inconsistency where `.url()` meant different things on the two types, and keeps the `resolve(...).url()` call pattern returning the same string it did when `resolve` returned a `ParsedUrl`, so that call site is unaffected by the switch to `ResolvedUrl`. The endpoint-builder macro funnel and the resolver tests that need the `ParsedUrl` object use `.parsed_url()`. --- CHANGELOG.md | 4 ++-- wp_api/src/login.rs | 4 ++-- wp_api/src/request/endpoint.rs | 5 ++--- wp_api/src/resolved_url.rs | 22 ++++++++++++++----- wp_api/src/wp_com/endpoint.rs | 1 - .../generate/helpers_to_generate_tokens.rs | 2 +- 6 files changed, 24 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d199c156..ec15c24c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - 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. It preserves any existing query, is order-stable, keeps duplicate keys, and form-urlencodes names and values — the same encoding `WpOrgSiteApiUrlResolver.resolve` already produces. Pairs are passed as the new `QueryPair` record ([#1543](https://github.com/Automattic/wordpress-rs/issues/1543)). -- `ApiUrlResolver.resolve` now returns a `ResolvedUrl`, which exposes both the request URL to fetch (`url()`) and the canonical, origin-less route key `@wordpress/api-fetch`'s preload middleware matches on (`canonical_route_key()`). The key is byte-identical on pretty (`…/wp-json/…`) and plain (`…?rest_route=…`) permalink sites: it unwraps the `?rest_route=` form back to the canonical `/wp/v2/…` path and omits the `?` entirely when there is no query. `ResolvedUrl.by_appending_query_pairs` attaches endpoint query parameters to the request URL while preserving that path, so a preloading editor can precompute the preload key natively instead of re-implementing api-fetch's normalization ([#1543](https://github.com/Automattic/wordpress-rs/issues/1543)). +- `ApiUrlResolver.resolve` now returns a `ResolvedUrl`, which exposes the request URL to fetch — as a string from `url()` (mirroring `ParsedUrl.url()`), or as a `ParsedUrl` object from `parsed_url()` — and the canonical, origin-less route key `@wordpress/api-fetch`'s preload middleware matches on (`canonical_route_key()`). The key is byte-identical on pretty (`…/wp-json/…`) and plain (`…?rest_route=…`) permalink sites: it unwraps the `?rest_route=` form back to the canonical `/wp/v2/…` path and omits the `?` entirely when there is no query. `ResolvedUrl.by_appending_query_pairs` attaches endpoint query parameters to the request URL while preserving that path, so a preloading editor can precompute the preload key natively instead of re-implementing api-fetch's normalization ([#1543](https://github.com/Automattic/wordpress-rs/issues/1543)). - WordPress.com `POST /me/transactions` endpoint for redeeming a shopping cart with the account's WordPress.com credits, completing a domain purchase - WordPress.com `GET /sites//purchases` endpoint for listing a site's purchases (plans, domains, and other subscriptions) - Publish the Kotlin bindings' per-endpoint Markdown API reference as an `ai-docs` Maven classifier zip on `rs.wordpress.api:kotlin`, generated from the UniFFI bindings for agent/tooling consumption @@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **BREAKING:** `ApiUrlResolver.resolve` now returns `ResolvedUrl` instead of `ParsedUrl`. Custom `ApiUrlResolver` implementations (Swift/Kotlin) must return a `ResolvedUrl` — build one with `ResolvedUrl(url:apiRoot:routePath:)` — and callers that consumed the resolved value as a URL now read `resolve(...).url()` ([#1543](https://github.com/Automattic/wordpress-rs/issues/1543)). +- **BREAKING:** `ApiUrlResolver.resolve` now returns `ResolvedUrl` instead of `ParsedUrl`. `resolve(...).url()` still returns the request URL as a string, so that call pattern is unchanged, but the return type differs: custom `ApiUrlResolver` implementations (Swift/Kotlin) must now return a `ResolvedUrl` — build one with `ResolvedUrl(url:apiRoot:routePath:)` — and code that used the resolved value as a `ParsedUrl` object reads `resolve(...).parsed_url()` ([#1543](https://github.com/Automattic/wordpress-rs/issues/1543)). - **BREAKING:** `product_type` fields on `Product` and `WPComProduct` changed from `String` to `ProductType`. Callers that match on or construct these values will need to wrap/unwrap with `ProductType(...)`. - **BREAKING:** The cache now enables SQLite foreign key enforcement on every connection it prepares, and fails with `SqliteDbError::ForeignKeysUnavailable` if the setting doesn't take effect. Removing a site relies on `ON DELETE CASCADE` to clear its cached rows, so on builds where enforcement defaulted to off those rows were silently left behind. - **BREAKING:** `ShoppingCart.coupon` changed from `String` to `CouponCode`, and `ShoppingCartCostOverride.override_code` from `String` to `CostOverrideCode`, so the shopping cart and site plans describe these values with the same types. Callers will need to wrap/unwrap with `CouponCode(...)` / `CostOverrideCode(...)`. diff --git a/wp_api/src/login.rs b/wp_api/src/login.rs index c51947f5e..dd9cc8678 100644 --- a/wp_api/src/login.rs +++ b/wp_api/src/login.rs @@ -957,7 +957,7 @@ mod tests { let resolver = wp_org_resolver(); let resolved = resolver .resolve(namespace.to_string(), vec![endpoint_path.to_string()]) - .url(); + .parsed_url(); let route_key = resolver.route_path(namespace.to_string(), endpoint_path.to_string()); assert!( @@ -980,7 +980,7 @@ mod tests { let resolver = wp_com_resolver("mobile.blog"); let resolved = resolver .resolve(namespace.to_string(), vec![endpoint_path.to_string()]) - .url(); + .parsed_url(); let route_key = resolver.route_path(namespace.to_string(), endpoint_path.to_string()); assert!( diff --git a/wp_api/src/request/endpoint.rs b/wp_api/src/request/endpoint.rs index 4d8230a9d..95d32e86d 100644 --- a/wp_api/src/request/endpoint.rs +++ b/wp_api/src/request/endpoint.rs @@ -254,7 +254,6 @@ mod tests { assert_eq!( resolver .resolve(namespace.to_string(), endpoint_segments) - .url() .url(), expected ); @@ -295,7 +294,7 @@ mod tests { value: "core,gutenberg".to_string(), }, ]); - assert_eq!(result.url().url(), expected); + assert_eq!(result.url(), expected); // The canonical route key is origin-less and identical on every API-root // form — the request URLs above differ, but the key does not. assert_eq!( @@ -370,7 +369,7 @@ mod tests { }) .collect(), ); - assert_eq!(resolved.url().url(), expected_url); + assert_eq!(resolved.url(), expected_url); // Identical for BOTH roots. assert_eq!(resolved.canonical_route_key(), expected_key); } diff --git a/wp_api/src/resolved_url.rs b/wp_api/src/resolved_url.rs index bf2a5c851..6cd05dae8 100644 --- a/wp_api/src/resolved_url.rs +++ b/wp_api/src/resolved_url.rs @@ -45,8 +45,16 @@ impl ResolvedUrl { }) } - /// The request URL to actually fetch. - pub fn url(&self) -> Arc { + /// The request URL to actually fetch, as a string. Mirrors + /// [`ParsedUrl::url`], so a `resolve(...).url()` call site keeps returning + /// the same string it did when `resolve` returned a `ParsedUrl`. + pub fn url(&self) -> String { + self.inner.to_string() + } + + /// The request URL to actually fetch, as a [`ParsedUrl`] — the escape hatch + /// for anywhere a `ParsedUrl` is needed (further query edits, comparisons). + pub fn parsed_url(&self) -> Arc { Arc::new(ParsedUrl::new(self.inner.clone())) } @@ -142,16 +150,20 @@ mod tests { } #[test] - fn url_returns_the_request_url() { + fn url_and_parsed_url_return_the_request_url() { let resolved = resolved( "https://example.com/wp-json/wp/v2/themes?context=edit", "https://example.com/wp-json", "/wp/v2/themes", ); + // `url()` mirrors `ParsedUrl::url()` — the URL string. assert_eq!( - resolved.url().url(), + resolved.url(), "https://example.com/wp-json/wp/v2/themes?context=edit" ); + // `parsed_url()` is the escape hatch to the `ParsedUrl` object; its own + // `url()` returns the same string. + assert_eq!(resolved.parsed_url().url(), resolved.url()); } #[test] @@ -164,7 +176,7 @@ mod tests { let appended = resolved .by_appending_query_pairs(query_pairs(&[("context", "edit"), ("status", "active")])); assert_eq!( - appended.url().url(), + appended.url(), "https://example.com/wp-json/wp/v2/themes?context=edit&status=active" ); assert_eq!( diff --git a/wp_api/src/wp_com/endpoint.rs b/wp_api/src/wp_com/endpoint.rs index 5ca825582..256d82971 100644 --- a/wp_api/src/wp_com/endpoint.rs +++ b/wp_api/src/wp_com/endpoint.rs @@ -210,7 +210,6 @@ pub(crate) mod tests { assert_eq!( resolver .resolve(namespace.to_string(), endpoint_segments) - .url() .url(), expected_url ); diff --git a/wp_derive_request_builder/src/generate/helpers_to_generate_tokens.rs b/wp_derive_request_builder/src/generate/helpers_to_generate_tokens.rs index 73173787b..b8a5a235f 100644 --- a/wp_derive_request_builder/src/generate/helpers_to_generate_tokens.rs +++ b/wp_derive_request_builder/src/generate/helpers_to_generate_tokens.rs @@ -283,7 +283,7 @@ pub fn fn_body_get_url_from_api_url_resolver( }) .collect::>(); quote! { - let mut url = std::sync::Arc::unwrap_or_clone(self.api_url_resolver.resolve( #enum_ident::namespace(&#enum_ident::#variant_ident).namespace_value().to_string() , vec![#(#url_parts,)*]).url()).inner; + let mut url = std::sync::Arc::unwrap_or_clone(self.api_url_resolver.resolve( #enum_ident::namespace(&#enum_ident::#variant_ident).namespace_value().to_string() , vec![#(#url_parts,)*]).parsed_url()).inner; } } From acacafa853d2ba60f531574e524c1165f5e92eb1 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:10:43 -0600 Subject: [PATCH 6/7] Add `ResolvedUrl.as_str()` for consistency with `ParsedUrl` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `as_str()` returns the request URL as a borrowed `&str` — the Rust-side, zero-copy counterpart to `url()`, mirroring `ParsedUrl::as_str()`. Like `ParsedUrl`'s, it lives in a plain (non-`#[uniffi::export]`) impl, so it's Rust-only and leaves the FFI surface unchanged; Swift/Kotlin still use `url()`. --- wp_api/src/resolved_url.rs | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/wp_api/src/resolved_url.rs b/wp_api/src/resolved_url.rs index 6cd05dae8..4a49267cb 100644 --- a/wp_api/src/resolved_url.rs +++ b/wp_api/src/resolved_url.rs @@ -26,6 +26,16 @@ pub struct ResolvedUrl { route_path: String, } +impl ResolvedUrl { + /// The request URL as a borrowed string slice — the Rust-side, zero-copy + /// counterpart to [`url`](Self::url), mirroring [`ParsedUrl::as_str`]. Not + /// exported across the FFI (a borrow can't cross it), so Swift/Kotlin use + /// `url()` instead. + pub fn as_str(&self) -> &str { + self.inner.as_str() + } +} + #[uniffi::export] impl ResolvedUrl { /// Assembles a `ResolvedUrl` from its parts. @@ -150,20 +160,20 @@ mod tests { } #[test] - fn url_and_parsed_url_return_the_request_url() { + fn url_accessors_return_the_request_url() { let resolved = resolved( "https://example.com/wp-json/wp/v2/themes?context=edit", "https://example.com/wp-json", "/wp/v2/themes", ); - // `url()` mirrors `ParsedUrl::url()` — the URL string. - assert_eq!( - resolved.url(), - "https://example.com/wp-json/wp/v2/themes?context=edit" - ); + let expected = "https://example.com/wp-json/wp/v2/themes?context=edit"; + // `url()` mirrors `ParsedUrl::url()` — the owned URL string. + assert_eq!(resolved.url(), expected); + // `as_str()` mirrors `ParsedUrl::as_str()` — the same value, borrowed. + assert_eq!(resolved.as_str(), expected); // `parsed_url()` is the escape hatch to the `ParsedUrl` object; its own // `url()` returns the same string. - assert_eq!(resolved.parsed_url().url(), resolved.url()); + assert_eq!(resolved.parsed_url().url(), expected); } #[test] From 57213d5c4f1cf52c125ca947c695345d557da8cd Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:23:25 -0600 Subject: [PATCH 7/7] Update `WpDerivedRequest` funnel snapshots for the `.parsed_url()` unwrap The inline token-stream snapshots in `helpers_to_generate_tokens.rs` assert the exact code the endpoint-builder macro emits. The funnel now unwraps the resolved URL via `.parsed_url()` before taking `.inner`, so the six `test_fn_body_get_url_from_api_root_url` cases still expected the pre-change `resolve(...)).inner` form. Updates them to the `resolve(...).parsed_url()).inner` form. These run in the `test-rust-wp-derived-request-parser` and `test-rust-lib` CI jobs (`cargo test --package wp_derive_request_builder` / `cargo test --lib`), which `cargo test -p wp_api --lib` doesn't cover. --- .../src/generate/helpers_to_generate_tokens.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/wp_derive_request_builder/src/generate/helpers_to_generate_tokens.rs b/wp_derive_request_builder/src/generate/helpers_to_generate_tokens.rs index b8a5a235f..913fac4db 100644 --- a/wp_derive_request_builder/src/generate/helpers_to_generate_tokens.rs +++ b/wp_derive_request_builder/src/generate/helpers_to_generate_tokens.rs @@ -1003,27 +1003,27 @@ mod tests { #[rstest] #[case( url_static_users(), - "let mut url = std :: sync :: Arc :: unwrap_or_clone (self . api_url_resolver . resolve (Foo :: namespace (& Foo :: Bar) . namespace_value () . to_string () , vec ! [\"users\" . to_string () ,])) . inner ;" + "let mut url = std :: sync :: Arc :: unwrap_or_clone (self . api_url_resolver . resolve (Foo :: namespace (& Foo :: Bar) . namespace_value () . to_string () , vec ! [\"users\" . to_string () ,]) . parsed_url ()) . inner ;" )] #[case( url_users_with_user_id(), - "let mut url = std :: sync :: Arc :: unwrap_or_clone (self . api_url_resolver . resolve (Foo :: namespace (& Foo :: Bar) . namespace_value () . to_string () , vec ! [\"users\" . to_string () , user_id . to_string () ,])) . inner ;" + "let mut url = std :: sync :: Arc :: unwrap_or_clone (self . api_url_resolver . resolve (Foo :: namespace (& Foo :: Bar) . namespace_value () . to_string () , vec ! [\"users\" . to_string () , user_id . to_string () ,]) . parsed_url ()) . inner ;" )] #[case( url_users_with_user_id(), - "let mut url = std :: sync :: Arc :: unwrap_or_clone (self . api_url_resolver . resolve (Foo :: namespace (& Foo :: Bar) . namespace_value () . to_string () , vec ! [\"users\" . to_string () , user_id . to_string () ,])) . inner ;" + "let mut url = std :: sync :: Arc :: unwrap_or_clone (self . api_url_resolver . resolve (Foo :: namespace (& Foo :: Bar) . namespace_value () . to_string () , vec ! [\"users\" . to_string () , user_id . to_string () ,]) . parsed_url ()) . inner ;" )] #[case( vec![UrlPart::Dynamic("user_id".to_string()), UrlPart::Dynamic("user_type".to_string())], - "let mut url = std :: sync :: Arc :: unwrap_or_clone (self . api_url_resolver . resolve (Foo :: namespace (& Foo :: Bar) . namespace_value () . to_string () , vec ! [user_id . to_string () , user_type . to_string () ,])) . inner ;" + "let mut url = std :: sync :: Arc :: unwrap_or_clone (self . api_url_resolver . resolve (Foo :: namespace (& Foo :: Bar) . namespace_value () . to_string () , vec ! [user_id . to_string () , user_type . to_string () ,]) . parsed_url ()) . inner ;" )] #[case( vec![UrlPart::Static("users".to_string()), UrlPart::Dynamic("user_id".to_string()), UrlPart::Dynamic("user_type".to_string()), ], - "let mut url = std :: sync :: Arc :: unwrap_or_clone (self . api_url_resolver . resolve (Foo :: namespace (& Foo :: Bar) . namespace_value () . to_string () , vec ! [\"users\" . to_string () , user_id . to_string () , user_type . to_string () ,])) . inner ;" + "let mut url = std :: sync :: Arc :: unwrap_or_clone (self . api_url_resolver . resolve (Foo :: namespace (& Foo :: Bar) . namespace_value () . to_string () , vec ! [\"users\" . to_string () , user_id . to_string () , user_type . to_string () ,]) . parsed_url ()) . inner ;" )] #[case( vec![UrlPart::Static("users".to_string()), UrlPart::Static("me".to_string()), UrlPart::Dynamic("user_id".to_string()), UrlPart::Dynamic("user_type".to_string()), ], - "let mut url = std :: sync :: Arc :: unwrap_or_clone (self . api_url_resolver . resolve (Foo :: namespace (& Foo :: Bar) . namespace_value () . to_string () , vec ! [\"users\" . to_string () , \"me\" . to_string () , user_id . to_string () , user_type . to_string () ,])) . inner ;" + "let mut url = std :: sync :: Arc :: unwrap_or_clone (self . api_url_resolver . resolve (Foo :: namespace (& Foo :: Bar) . namespace_value () . to_string () , vec ! [\"users\" . to_string () , \"me\" . to_string () , user_id . to_string () , user_type . to_string () ,]) . parsed_url ()) . inner ;" )] fn test_fn_body_get_url_from_api_root_url( #[case] url_parts: Vec,