diff --git a/CHANGELOG.md b/CHANGELOG.md index 44ebb6d73..ec15c24c9 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 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 @@ -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`. `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/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 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/login.rs b/wp_api/src/login.rs index d567464cd..dd9cc8678 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()]) + .parsed_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()]) + .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 49d4d339c..95d32e86d 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, ) } @@ -287,5 +295,83 @@ mod tests { }, ]); 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!( + 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(), 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 new file mode 100644 index 000000000..4a49267cb --- /dev/null +++ b/wp_api/src/resolved_url.rs @@ -0,0 +1,235 @@ +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, +} + +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. + /// + /// 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, 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())) + } + + /// 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_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", + ); + 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(), expected); + } + + #[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(), + "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); + } +} diff --git a/wp_api/src/wp_com/endpoint.rs b/wp_api/src/wp_com/endpoint.rs index 957303e72..256d82971 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, ) } 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..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 @@ -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,)*]).parsed_url()).inner; } } @@ -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,