Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<site_id>/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
Expand All @@ -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(...)`.
Expand Down
1 change: 1 addition & 0 deletions native/swift/Sources/wordpress-api/Exports.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions wp_api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 6 additions & 2 deletions wp_api/src/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand All @@ -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!(
Expand Down
98 changes: 92 additions & 6 deletions wp_api/src/request/endpoint.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<String>) -> Arc<ParsedUrl>;
/// 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<String>) -> Arc<ResolvedUrl>;

/// Returns the route key for an endpoint, matching the keys used in
/// `WpApiDetails.routes`. Implementations must produce the same path
Expand All @@ -167,11 +171,15 @@ impl WpOrgSiteApiUrlResolver {

#[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(),
fn resolve(&self, namespace: String, endpoint_segments: Vec<String>) -> Arc<ResolvedUrl> {
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,
)
}

Expand Down Expand Up @@ -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);
}
}
}
Loading