From 764928d353cc74f5e5a918982fa12dc144066415 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Thu, 6 Aug 2026 08:57:17 -0400 Subject: [PATCH 1/8] Add wp.com `GET /sites//stats/post/` endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-post view stats, for the "Latest Post Summary" card in the new Jetpack app stats. The response carries the post's metadata, like count, and comment count alongside the view history, so the card needs no separate post fetch. Notes from capturing real responses: - The endpoint accepts no query params. `num`, `date`, and `period` are silently ignored, so there is no params struct. - A week's `change` is `null`, a number, or `{"isInfinity": true}` when the previous week had no views. Modelled as `StatsPostViewsChange`, which round-trips all three. - `months` inside `years`/`averages` is `[]` rather than `{}` when empty, and `post_author` arrives as a string. `data` holds the post's entire history — thousands of rows for an old post — so `recent_daily_views(days)` returns just the trailing window callers actually render. Verified against 60 real responses across 15 sites. --- CHANGELOG.md | 1 + WPCOM_REST_API_CHECKLIST.md | 2 +- wp_api/src/wp_com/client.rs | 6 + wp_api/src/wp_com/endpoint.rs | 1 + .../endpoint/stats_post_views_endpoint.rs | 57 +++ wp_api/src/wp_com/mod.rs | 1 + wp_api/src/wp_com/stats_post_views.rs | 436 ++++++++++++++++++ .../wpcom/stats_post_views/post-no-views.json | 93 ++++ .../stats_post_views/post-with-views.json | 138 ++++++ wp_com_e2e/src/main.rs | 2 + wp_com_e2e/src/stats_post_views_tests.rs | 81 ++++ 11 files changed, 817 insertions(+), 1 deletion(-) create mode 100644 wp_api/src/wp_com/endpoint/stats_post_views_endpoint.rs create mode 100644 wp_api/src/wp_com/stats_post_views.rs create mode 100644 wp_api/tests/wpcom/stats_post_views/post-no-views.json create mode 100644 wp_api/tests/wpcom/stats_post_views/post-with-views.json create mode 100644 wp_com_e2e/src/stats_post_views_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a65b8d68..456e21e95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- WordPress.com `GET /sites//stats/post/` endpoint for per-post stats. Returns the post's view history, like and comment counts, and post metadata — everything the "Latest Post Summary" card needs. Use `recent_daily_views(days)` for the trailing window rather than the full history, which can run to thousands of entries. - 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 diff --git a/WPCOM_REST_API_CHECKLIST.md b/WPCOM_REST_API_CHECKLIST.md index df6e90d88..8c24a9112 100644 --- a/WPCOM_REST_API_CHECKLIST.md +++ b/WPCOM_REST_API_CHECKLIST.md @@ -329,7 +329,7 @@ investigate the relevant code before making decisions based on this document. - [ ] `GET /rest/v1.1/sites/$site/stats/comments` — top commenters and most-commented posts - [ ] `GET /rest/v1.1/sites/$site/stats/followers` — site followers (filterable by type) - [x] `GET /rest/v1.1/sites/$site/stats/insights` — most popular day/hour, yearly aggregates -- [ ] `GET /rest/v1.1/sites/$site/stats/post/$post_id` — per-post view stats +- [x] `GET /rest/v1.1/sites/$site/stats/post/$post_id` — per-post view stats - [ ] `GET /rest/v1.1/sites/$site/stats/publicize` — social media follower counts - [ ] `GET /rest/v1.1/sites/$site/stats/streak` — posting activity/streak data - [ ] `GET /rest/v1.1/sites/$site/stats/summary` — total likes, comments, followers diff --git a/wp_api/src/wp_com/client.rs b/wp_api/src/wp_com/client.rs index e67e2b225..263c8f64b 100644 --- a/wp_api/src/wp_com/client.rs +++ b/wp_api/src/wp_com/client.rs @@ -32,6 +32,7 @@ use super::endpoint::{ StatsFileDownloadsRequestBuilder, StatsFileDownloadsRequestExecutor, }, stats_insights_endpoint::{StatsInsightsRequestBuilder, StatsInsightsRequestExecutor}, + stats_post_views_endpoint::{StatsPostViewsRequestBuilder, StatsPostViewsRequestExecutor}, stats_referrers_endpoint::{StatsReferrersRequestBuilder, StatsReferrersRequestExecutor}, stats_region_views_endpoint::{ StatsRegionViewsRequestBuilder, StatsRegionViewsRequestExecutor, @@ -94,6 +95,7 @@ pub struct WpComApiRequestBuilder { stats_emails_summary: Arc, stats_devices_platform: Arc, stats_devices_screensize: Arc, + stats_post_views: Arc, stats_referrers: Arc, stats_subscribers: Arc, stats_region_views: Arc, @@ -141,6 +143,7 @@ impl WpComApiRequestBuilder { stats_emails_summary, stats_devices_platform, stats_devices_screensize, + stats_post_views, stats_referrers, stats_subscribers, stats_region_views, @@ -199,6 +202,7 @@ pub struct WpComApiClient { stats_emails_summary: Arc, stats_devices_platform: Arc, stats_devices_screensize: Arc, + stats_post_views: Arc, stats_referrers: Arc, stats_subscribers: Arc, stats_region_views: Arc, @@ -247,6 +251,7 @@ impl WpComApiClient { stats_emails_summary, stats_devices_platform, stats_devices_screensize, + stats_post_views, stats_referrers, stats_subscribers, stats_region_views, @@ -288,6 +293,7 @@ api_client_generate_endpoint_impl!(WpComApi, stats_devices_browser); api_client_generate_endpoint_impl!(WpComApi, stats_emails_summary); api_client_generate_endpoint_impl!(WpComApi, stats_devices_platform); api_client_generate_endpoint_impl!(WpComApi, stats_devices_screensize); +api_client_generate_endpoint_impl!(WpComApi, stats_post_views); api_client_generate_endpoint_impl!(WpComApi, stats_referrers); api_client_generate_endpoint_impl!(WpComApi, stats_subscribers); api_client_generate_endpoint_impl!(WpComApi, stats_region_views); diff --git a/wp_api/src/wp_com/endpoint.rs b/wp_api/src/wp_com/endpoint.rs index fb3e7ff6c..0b8682665 100644 --- a/wp_api/src/wp_com/endpoint.rs +++ b/wp_api/src/wp_com/endpoint.rs @@ -29,6 +29,7 @@ pub mod stats_devices_screensize_endpoint; pub mod stats_emails_summary_endpoint; pub mod stats_file_downloads_endpoint; pub mod stats_insights_endpoint; +pub mod stats_post_views_endpoint; pub mod stats_referrers_endpoint; pub mod stats_region_views_endpoint; pub mod stats_search_terms_endpoint; diff --git a/wp_api/src/wp_com/endpoint/stats_post_views_endpoint.rs b/wp_api/src/wp_com/endpoint/stats_post_views_endpoint.rs new file mode 100644 index 000000000..d6e47045c --- /dev/null +++ b/wp_api/src/wp_com/endpoint/stats_post_views_endpoint.rs @@ -0,0 +1,57 @@ +use crate::{ + posts::PostId, + request::endpoint::{AsNamespace, DerivedRequest}, + wp_com::{WpComNamespace, WpComSiteId, stats_post_views::StatsPostViewsResponse}, +}; +use wp_derive_request_builder::WpDerivedRequest; + +#[derive(WpDerivedRequest)] +enum StatsPostViewsRequest { + #[get(url = "/sites//stats/post/", output = StatsPostViewsResponse)] + GetStatsPostViews, +} + +impl DerivedRequest for StatsPostViewsRequest { + fn namespace(&self) -> impl AsNamespace { + WpComNamespace::RestV1_1 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + request::endpoint::ApiUrlResolver, + wp_com::endpoint::tests::{ + fixture_wp_com_api_url_resolver, validate_wp_com_rest_v1_1_endpoint, + }, + }; + use rstest::*; + use std::sync::Arc; + + #[rstest] + #[case::numeric_id(WpComSiteId(12345), PostId(2729), "/sites/12345/stats/post/2729")] + #[case::large_ids( + WpComSiteId(229889220), + PostId(9007199254740991), + "/sites/229889220/stats/post/9007199254740991" + )] + fn get_stats_post_views( + endpoint: StatsPostViewsRequestEndpoint, + #[case] site_id: WpComSiteId, + #[case] post_id: PostId, + #[case] expected_path: &str, + ) { + validate_wp_com_rest_v1_1_endpoint( + endpoint.get_stats_post_views(&site_id, &post_id), + expected_path, + ); + } + + #[fixture] + fn endpoint( + fixture_wp_com_api_url_resolver: Arc, + ) -> StatsPostViewsRequestEndpoint { + StatsPostViewsRequestEndpoint::new(fixture_wp_com_api_url_resolver) + } +} diff --git a/wp_api/src/wp_com/mod.rs b/wp_api/src/wp_com/mod.rs index df52bfce5..80c727a8f 100644 --- a/wp_api/src/wp_com/mod.rs +++ b/wp_api/src/wp_com/mod.rs @@ -25,6 +25,7 @@ pub mod stats_devices; pub mod stats_emails_summary; pub mod stats_file_downloads; pub mod stats_insights; +pub mod stats_post_views; pub mod stats_referrers; pub mod stats_region_views; pub mod stats_search_terms; diff --git a/wp_api/src/wp_com/stats_post_views.rs b/wp_api/src/wp_com/stats_post_views.rs new file mode 100644 index 000000000..db19b5731 --- /dev/null +++ b/wp_api/src/wp_com/stats_post_views.rs @@ -0,0 +1,436 @@ +use crate::posts::PostId; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use wp_serde_helper::{deserialize_empty_array_or_hashmap, deserialize_u64_or_string}; + +/// Response from the per-post stats endpoint. +/// +/// The endpoint returns the post's complete view history, so [`Self::data`] can +/// contain thousands of rows for a long-lived post. Callers that only need a +/// short trailing window (such as the "Latest Post Summary" card) should use +/// [`Self::recent_daily_views`] rather than materializing the whole series. +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct StatsPostViewsResponse { + /// The date the stats were generated for (format: YYYY-MM-DD). + pub date: String, + /// The post's all-time view count. + pub views: u64, + /// Yearly view totals, keyed by year (e.g. `"2026"`). + #[serde(deserialize_with = "deserialize_empty_array_or_hashmap")] + pub years: HashMap, + /// Yearly view averages, keyed by year (e.g. `"2026"`). + #[serde(deserialize_with = "deserialize_empty_array_or_hashmap")] + pub averages: HashMap, + /// The most recent weeks of daily views, oldest first. + pub weeks: Vec, + /// Field names for the [`Self::data`] rows, e.g. `["period", "views"]`. + pub fields: Vec, + /// The full daily view history as rows of values corresponding to [`Self::fields`]. + pub data: Vec>, + /// The highest view count the post reached in a single month. + pub highest_month: u64, + /// The highest daily view average the post reached. + pub highest_day_average: u64, + /// The highest weekly view average the post reached. + pub highest_week_average: u64, + /// The post's like count. + pub like_count: u64, + /// The post's comment counts. + pub discussion: StatsPostViewsDiscussion, + /// The post the stats belong to. + pub post: StatsPostViewsPost, +} + +#[uniffi::export] +impl StatsPostViewsResponse { + /// The post's complete daily view history, oldest first. + /// + /// Returns an empty list if the response is missing the `period` or `views` + /// column. + pub fn daily_views(&self) -> Vec { + self.daily_views_iter().collect() + } + + /// The most recent `days` entries of the daily view history, oldest first. + /// + /// Returns fewer entries if the post has a shorter history. + pub fn recent_daily_views(&self, days: u32) -> Vec { + let days = days as usize; + let all = self.daily_views(); + all[all.len().saturating_sub(days)..].to_vec() + } +} + +impl StatsPostViewsResponse { + fn daily_views_iter(&self) -> impl Iterator + '_ { + let period_index = self.fields.iter().position(|f| f == "period"); + let views_index = self.fields.iter().position(|f| f == "views"); + + self.data.iter().filter_map(move |row| { + if let (Some(period_index), Some(views_index)) = (period_index, views_index) + && let Some(period) = row.get(period_index).and_then(|v| v.as_string()) + && let Some(views) = row.get(views_index).and_then(|v| v.as_number()) + { + return Some(StatsPostViewsDataPoint { + period: period.clone(), + views, + }); + } + None + }) + } +} + +/// A single day's view count from the daily view history. +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, uniffi::Record)] +pub struct StatsPostViewsDataPoint { + /// The day the views were recorded on (format: YYYY-MM-DD). + pub period: String, + /// The number of views on that day. + pub views: u64, +} + +/// A value in the daily view history rows (can be a string, a number, or null). +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, uniffi::Enum)] +#[serde(untagged)] +pub enum StatsPostViewsDataValue { + String(String), + Number(u64), + Null, +} + +impl StatsPostViewsDataValue { + pub fn as_string(&self) -> Option<&String> { + match self { + StatsPostViewsDataValue::String(s) => Some(s), + _ => None, + } + } + + pub fn as_number(&self) -> Option { + match self { + StatsPostViewsDataValue::Number(n) => Some(*n), + _ => None, + } + } +} + +/// A year's view totals. +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct StatsPostViewsYear { + /// View totals keyed by month number (`"1"` through `"12"`). + #[serde(deserialize_with = "deserialize_empty_array_or_hashmap")] + pub months: HashMap, + /// The total views for the year. + pub total: u64, +} + +/// A year's view averages. +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct StatsPostViewsAverage { + /// View averages keyed by month number (`"1"` through `"12"`). + #[serde(deserialize_with = "deserialize_empty_array_or_hashmap")] + pub months: HashMap, + /// The average views across the whole year. + pub overall: f64, +} + +/// A week of daily views. +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct StatsPostViewsWeek { + /// The days in the week, oldest first. The final week may be partial. + pub days: Vec, + /// The total views for the week. + pub total: u64, + /// The average daily views for the week. + pub average: f64, + /// The change from the previous week, or `None` for the first week. + pub change: Option, +} + +/// The change in views from one week to the next. +#[derive(Debug, Clone, Copy, PartialEq, uniffi::Enum)] +pub enum StatsPostViewsChange { + /// The percentage change from the previous week. + Percentage { value: f64 }, + /// The previous week had no views, so the change is unbounded. The API + /// sends this as `{"isInfinity": true}` because the underlying value is + /// infinite and cannot be represented in JSON. + Infinite, +} + +/// The wire representations the API uses for a week's `change`. +#[derive(Deserialize)] +#[serde(untagged)] +enum RawStatsPostViewsChange { + Percentage(f64), + Infinite { + #[allow(dead_code)] + #[serde(rename = "isInfinity")] + is_infinity: bool, + }, +} + +impl<'de> Deserialize<'de> for StatsPostViewsChange { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Ok(match RawStatsPostViewsChange::deserialize(deserializer)? { + RawStatsPostViewsChange::Percentage(value) => Self::Percentage { value }, + RawStatsPostViewsChange::Infinite { .. } => Self::Infinite, + }) + } +} + +impl Serialize for StatsPostViewsChange { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeMap; + + match self { + Self::Percentage { value } => serializer.serialize_f64(*value), + Self::Infinite => { + let mut map = serializer.serialize_map(Some(1))?; + map.serialize_entry("isInfinity", &true)?; + map.end() + } + } + } +} + +/// A single day within a [`StatsPostViewsWeek`]. +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct StatsPostViewsDay { + /// The day (format: YYYY-MM-DD). + pub day: String, + /// The number of views on that day. + pub count: u64, +} + +/// A post's comment counts. +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct StatsPostViewsDiscussion { + /// The number of comments on the post. + pub comment_count: u64, +} + +/// The post the stats belong to. +/// +/// This mirrors WordPress' raw post row, so it carries the post's editorial +/// metadata but not a permalink. Fields the API sends that aren't modelled here +/// (post content, ping status, and similar) are ignored. +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct StatsPostViewsPost { + /// The post's ID. + #[serde(rename = "ID")] + pub id: PostId, + /// The post's title. + #[serde(rename = "post_title")] + pub title: String, + /// The post's publication date in the site's timezone (format: YYYY-MM-DD HH:MM:SS). + #[serde(rename = "post_date")] + pub date: String, + /// The post's publication date in GMT (format: YYYY-MM-DD HH:MM:SS). + #[serde(rename = "post_date_gmt")] + pub date_gmt: String, + /// The date the post was last modified (format: YYYY-MM-DD HH:MM:SS). + #[serde(rename = "post_modified")] + pub modified: String, + /// The post's slug. + #[serde(rename = "post_name")] + pub slug: String, + /// The post's status, e.g. `"publish"` or `"draft"`. + #[serde(rename = "post_status")] + pub status: String, + /// The post's type, e.g. `"post"` or `"page"`. + pub post_type: String, + /// The ID of the post's author. + #[serde(rename = "post_author", deserialize_with = "deserialize_u64_or_string")] + pub author_id: u64, + /// The post's globally unique identifier. Not a permalink. + pub guid: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::*; + + const WITH_VIEWS: &str = "tests/wpcom/stats_post_views/post-with-views.json"; + const NO_VIEWS: &str = "tests/wpcom/stats_post_views/post-no-views.json"; + + fn parse(json_file_path: &str) -> StatsPostViewsResponse { + let file = std::fs::File::open(json_file_path).expect("Failed to open file"); + serde_json::from_reader(file).expect("Unable to parse JSON") + } + + #[rstest] + #[case(WITH_VIEWS)] + #[case(NO_VIEWS)] + fn test_stats_post_views_response_deserialization(#[case] json_file_path: &str) { + let response = parse(json_file_path); + + assert!(!response.date.is_empty()); + assert_eq!(response.fields, vec!["period", "views"]); + assert!(!response.weeks.is_empty()); + } + + #[test] + fn test_stats_post_views_response_details() { + let response = parse(WITH_VIEWS); + + assert_eq!(response.date, "2026-08-06"); + assert_eq!(response.views, 19096); + assert_eq!(response.highest_month, 3224); + assert_eq!(response.highest_day_average, 293); + assert_eq!(response.highest_week_average, 3); + assert_eq!(response.like_count, 9); + assert_eq!(response.discussion.comment_count, 48); + + let year = response.years.get("2013").expect("2013 should exist"); + assert_eq!(year.total, 6146); + assert_eq!(year.months.get("6"), Some(&3224)); + + let average = response.averages.get("2013").expect("2013 should exist"); + assert_eq!(average.overall, 31.0); + assert_eq!(average.months.get("6"), Some(&293.0)); + } + + #[test] + fn test_stats_post_views_post() { + let post = parse(WITH_VIEWS).post; + + assert_eq!(post.id, PostId(2729)); + assert_eq!( + post.title, + "The Last Version of FeedDemon is Here, and it's Free" + ); + assert_eq!(post.date, "2013-06-20 09:15:49"); + assert_eq!(post.date_gmt, "2013-06-20 13:15:49"); + assert_eq!(post.modified, "2013-06-23 21:57:23"); + assert_eq!( + post.slug, + "the-last-version-of-feeddemon-is-here-and-its-free" + ); + assert_eq!(post.status, "publish"); + assert_eq!(post.post_type, "post"); + assert_eq!(post.guid, "https://example.com/?p=2729"); + // The API sends `post_author` as a string. + assert_eq!(post.author_id, 5399133); + } + + #[test] + fn test_stats_post_views_weeks() { + let weeks = parse(WITH_VIEWS).weeks; + + assert_eq!(weeks.len(), 3); + + let first = &weeks[0]; + assert_eq!(first.days.len(), 7); + assert_eq!(first.days[0].day, "2026-06-29"); + assert_eq!(first.days[0].count, 2); + assert_eq!(first.total, 7); + assert_eq!(first.average, 1.0); + assert!(first.change.is_none(), "the first week has no prior week"); + + let second = &weeks[1]; + assert_eq!(second.days.len(), 4, "a week may be partial"); + assert_eq!(second.total, 6); + assert_eq!( + second.change, + Some(StatsPostViewsChange::Percentage { + value: 133.33333333333334 + }) + ); + + // The API sends `{"isInfinity": true}` when the previous week had no views. + let last = &weeks[2]; + assert_eq!(last.change, Some(StatsPostViewsChange::Infinite)); + } + + #[test] + fn test_stats_post_views_change_round_trips() { + let weeks = parse(WITH_VIEWS).weeks; + + assert_eq!(serde_json::to_string(&weeks[0].change).unwrap(), "null"); + assert_eq!( + serde_json::to_string(&weeks[1].change).unwrap(), + "133.33333333333334" + ); + assert_eq!( + serde_json::to_string(&weeks[2].change).unwrap(), + r#"{"isInfinity":true}"# + ); + } + + #[test] + fn test_stats_post_views_daily_views() { + let response = parse(WITH_VIEWS); + + let daily_views = response.daily_views(); + assert_eq!(daily_views.len(), 5); + assert_eq!( + daily_views[0], + StatsPostViewsDataPoint { + period: "2013-06-20".to_string(), + views: 1194, + } + ); + assert_eq!( + daily_views[4], + StatsPostViewsDataPoint { + period: "2026-08-06".to_string(), + views: 0, + } + ); + } + + #[test] + fn test_stats_post_views_recent_daily_views() { + let response = parse(WITH_VIEWS); + + let recent = response.recent_daily_views(3); + assert_eq!(recent.len(), 3); + assert_eq!(recent[0].period, "2013-06-22"); + assert_eq!(recent[2].period, "2026-08-06"); + + // Asking for more days than the post has returns the whole history. + assert_eq!(response.recent_daily_views(100).len(), 5); + assert!(response.recent_daily_views(0).is_empty()); + } + + #[test] + fn test_stats_post_views_daily_views_without_known_fields() { + let mut response = parse(WITH_VIEWS); + response.fields = vec!["period".to_string()]; + + assert!(response.daily_views().is_empty()); + assert!(response.recent_daily_views(7).is_empty()); + } + + #[test] + fn test_stats_post_views_post_without_views() { + let response = parse(NO_VIEWS); + + assert_eq!(response.views, 0); + assert_eq!(response.highest_month, 0); + assert_eq!(response.like_count, 0); + assert_eq!(response.discussion.comment_count, 0); + + // The API sends `months` as an empty array rather than an empty object. + let year = response.years.get("2026").expect("2026 should exist"); + assert_eq!(year.total, 0); + assert!(year.months.is_empty()); + + let average = response.averages.get("2026").expect("2026 should exist"); + assert_eq!(average.overall, 0.0); + assert!(average.months.is_empty()); + + let daily_views = response.daily_views(); + assert_eq!(daily_views.len(), 3); + assert!(daily_views.iter().all(|d| d.views == 0)); + } +} diff --git a/wp_api/tests/wpcom/stats_post_views/post-no-views.json b/wp_api/tests/wpcom/stats_post_views/post-no-views.json new file mode 100644 index 000000000..c32a860fc --- /dev/null +++ b/wp_api/tests/wpcom/stats_post_views/post-no-views.json @@ -0,0 +1,93 @@ +{ + "date": "2026-08-06", + "views": 0, + "years": { + "2025": { + "months": [], + "total": 0 + }, + "2026": { + "months": [], + "total": 0 + } + }, + "averages": { + "2025": { + "months": [], + "overall": 0 + }, + "2026": { + "months": [], + "overall": 0 + } + }, + "weeks": [ + { + "days": [ + { "day": "2026-07-27", "count": 0 }, + { "day": "2026-07-28", "count": 0 }, + { "day": "2026-07-29", "count": 0 }, + { "day": "2026-07-30", "count": 0 }, + { "day": "2026-07-31", "count": 0 }, + { "day": "2026-08-01", "count": 0 }, + { "day": "2026-08-02", "count": 0 } + ], + "total": 0, + "average": 0, + "change": null + }, + { + "days": [ + { "day": "2026-08-03", "count": 0 }, + { "day": "2026-08-04", "count": 0 }, + { "day": "2026-08-05", "count": 0 }, + { "day": "2026-08-06", "count": 0 } + ], + "total": 0, + "average": 0, + "change": 0 + } + ], + "fields": [ + "period", + "views" + ], + "data": [ + ["2026-08-04", 0], + ["2026-08-05", 0], + ["2026-08-06", 0] + ], + "highest_month": 0, + "highest_day_average": 0, + "highest_week_average": 0, + "like_count": 0, + "discussion": { + "comment_count": 0 + }, + "post": { + "ID": 169, + "post_author": "1", + "post_date": "2026-06-11 14:22:01", + "post_date_gmt": "2026-06-11 14:22:01", + "post_content": "", + "post_title": "A Quiet Post", + "post_excerpt": "", + "post_status": "publish", + "comment_status": "open", + "ping_status": "open", + "post_password": "", + "post_name": "a-quiet-post", + "to_ping": "", + "pinged": "", + "post_modified": "2026-06-11 14:22:01", + "post_modified_gmt": "2026-06-11 14:22:01", + "post_content_filtered": "", + "post_parent": 0, + "guid": "https://example.com/?p=169", + "menu_order": 0, + "post_type": "post", + "post_mime_type": "", + "comment_count": "0", + "filter": "raw" + } +} diff --git a/wp_api/tests/wpcom/stats_post_views/post-with-views.json b/wp_api/tests/wpcom/stats_post_views/post-with-views.json new file mode 100644 index 000000000..20dafa219 --- /dev/null +++ b/wp_api/tests/wpcom/stats_post_views/post-with-views.json @@ -0,0 +1,138 @@ +{ + "date": "2026-08-06", + "views": 19096, + "years": { + "2013": { + "months": { + "6": 3224, + "7": 1250, + "8": 562, + "9": 321, + "10": 315, + "11": 258, + "12": 216 + }, + "total": 6146 + }, + "2026": { + "months": { + "1": 23, + "2": 18, + "3": 13, + "4": 16, + "5": 27, + "6": 29, + "7": 12, + "8": 9 + }, + "total": 147 + } + }, + "averages": { + "2013": { + "months": { + "6": 293, + "7": 40, + "8": 18, + "9": 10, + "10": 10, + "11": 8, + "12": 6 + }, + "overall": 31 + }, + "2026": { + "months": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 0, + "6": 0, + "7": 0, + "8": 1 + }, + "overall": 0 + } + }, + "weeks": [ + { + "days": [ + { "day": "2026-06-29", "count": 2 }, + { "day": "2026-06-30", "count": 1 }, + { "day": "2026-07-01", "count": 1 }, + { "day": "2026-07-02", "count": 1 }, + { "day": "2026-07-03", "count": 1 }, + { "day": "2026-07-04", "count": 1 }, + { "day": "2026-07-05", "count": 0 } + ], + "total": 7, + "average": 1, + "change": null + }, + { + "days": [ + { "day": "2026-08-03", "count": 3 }, + { "day": "2026-08-04", "count": 3 }, + { "day": "2026-08-05", "count": 0 }, + { "day": "2026-08-06", "count": 0 } + ], + "total": 6, + "average": 2, + "change": 133.33333333333334 + }, + { + "days": [ + { "day": "2026-08-10", "count": 4 }, + { "day": "2026-08-11", "count": 1 } + ], + "total": 5, + "average": 3, + "change": { "isInfinity": true } + } + ], + "fields": [ + "period", + "views" + ], + "data": [ + ["2013-06-20", 1194], + ["2013-06-21", 504], + ["2013-06-22", 197], + ["2026-08-05", 0], + ["2026-08-06", 0] + ], + "highest_month": 3224, + "highest_day_average": 293, + "highest_week_average": 3, + "like_count": 9, + "discussion": { + "comment_count": 48 + }, + "post": { + "ID": 2729, + "post_author": "5399133", + "post_date": "2013-06-20 09:15:49", + "post_date_gmt": "2013-06-20 13:15:49", + "post_content": "The wait is over.", + "post_title": "The Last Version of FeedDemon is Here, and it's Free", + "post_excerpt": "", + "post_status": "publish", + "comment_status": "closed", + "ping_status": "closed", + "post_password": "", + "post_name": "the-last-version-of-feeddemon-is-here-and-its-free", + "to_ping": "", + "pinged": "", + "post_modified": "2013-06-23 21:57:23", + "post_modified_gmt": "2013-06-24 01:57:23", + "post_content_filtered": "", + "post_parent": 0, + "guid": "https://example.com/?p=2729", + "menu_order": 0, + "post_type": "post", + "post_mime_type": "", + "comment_count": "48", + "filter": "raw" + } +} diff --git a/wp_com_e2e/src/main.rs b/wp_com_e2e/src/main.rs index 2fd759401..0e873a015 100644 --- a/wp_com_e2e/src/main.rs +++ b/wp_com_e2e/src/main.rs @@ -15,6 +15,7 @@ mod stats_city_views_tests; mod stats_country_views_tests; mod stats_emails_summary_tests; mod stats_insights_tests; +mod stats_post_views_tests; mod stats_referrers_tests; mod stats_region_views_tests; mod stats_subscribers_tests; @@ -59,6 +60,7 @@ fn collect_tests(ctx: Arc) -> Vec { tests.extend(stats_city_views_tests::tests(Arc::clone(&ctx))); tests.extend(stats_country_views_tests::tests(Arc::clone(&ctx))); tests.extend(stats_insights_tests::tests(Arc::clone(&ctx))); + tests.extend(stats_post_views_tests::tests(Arc::clone(&ctx))); tests.extend(stats_referrers_tests::tests(Arc::clone(&ctx))); tests.extend(stats_region_views_tests::tests(Arc::clone(&ctx))); tests.extend(stats_summary_tests::tests(Arc::clone(&ctx))); diff --git a/wp_com_e2e/src/stats_post_views_tests.rs b/wp_com_e2e/src/stats_post_views_tests.rs new file mode 100644 index 000000000..ebd36a470 --- /dev/null +++ b/wp_com_e2e/src/stats_post_views_tests.rs @@ -0,0 +1,81 @@ +use libtest_mimic::Trial; +use std::sync::Arc; +use wp_api::{ + posts::PostId, + wp_com::{ + WpComSiteId, + sites::SitesListParams, + stats_top_posts::{StatsTopPostsParams, StatsTopPostsPeriod}, + }, +}; + +use crate::context::TestContext; + +pub fn tests(ctx: Arc) -> Vec { + let mut trials = vec![]; + + // Pre-fetch sites during test collection + let sites_result = ctx + .runtime + .block_on(async { ctx.client.sites().get(&SitesListParams::default()).await }); + + if let Ok(response) = sites_result { + let sites = response.data.sites; + + for site in &sites { + let site_id = site.id; + + // The endpoint needs a real post, so borrow one from the site's top + // posts. Sites without a viewed post are skipped. + let Some(post_id) = most_viewed_post_id(&ctx, &site_id) else { + continue; + }; + + trials.push(Trial::test( + format!("post_views::get_stats_post_views::{}", site_id), + { + let ctx = Arc::clone(&ctx); + move || { + ctx.runtime.block_on(async { + ctx.client + .stats_post_views() + .get_stats_post_views(&site_id, &post_id) + .await + .map_err(|e| e.to_string())?; + Ok(()) + }) + } + }, + )); + } + } + + trials +} + +fn most_viewed_post_id(ctx: &Arc, site_id: &WpComSiteId) -> Option { + // Look back over several years rather than the default single day, so quiet + // test sites still yield a post. + let params = StatsTopPostsParams { + period: Some(StatsTopPostsPeriod::Year), + num: Some(10), + ..Default::default() + }; + + let response = ctx.runtime.block_on(async { + ctx.client + .stats_top_posts() + .get_stats_top_posts(site_id, ¶ms) + .await + }); + + response + .ok()? + .data + .summary? + .postviews + .iter() + // Id 0 is the homepage pseudo-entry, which isn't a real post. + .find(|post_view| post_view.id != 0) + .map(|post_view| PostId(post_view.id as i64)) +} From 8e7036d2f71b6cbb5c38b02105d28ed78b1a38ad Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Thu, 6 Aug 2026 10:09:24 -0400 Subject: [PATCH 2/8] Make `recent_daily_views` walk only the window it returns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It called `daily_views()` first, which materializes the post's entire history — ~4,800 data points, each with a heap-allocated String, on an old post — then sliced and copied the tail. That is the cost the method exists to avoid. It now resolves the column positions once, walks `data` in reverse, and takes only the entries it returns. Both accessors share the row reader via a small private `StatsPostViewsDataColumns`, and both bail early when `fields` doesn't name the columns, matching `stats_visits`. The FFI surface is unchanged — verified by diffing the generated Swift declarations before and after. Also documents why the post row's `comment_count` is dropped in favour of `discussion.comment_count`, and records that the three `change` wire shapes are what 60 real responses across 15 sites produced. --- wp_api/src/wp_com/stats_post_views.rs | 94 +++++++++++++++++++++------ 1 file changed, 75 insertions(+), 19 deletions(-) diff --git a/wp_api/src/wp_com/stats_post_views.rs b/wp_api/src/wp_com/stats_post_views.rs index db19b5731..1bd3195f0 100644 --- a/wp_api/src/wp_com/stats_post_views.rs +++ b/wp_api/src/wp_com/stats_post_views.rs @@ -48,35 +48,63 @@ impl StatsPostViewsResponse { /// Returns an empty list if the response is missing the `period` or `views` /// column. pub fn daily_views(&self) -> Vec { - self.daily_views_iter().collect() + let Some(columns) = self.data_columns() else { + return vec![]; + }; + + self.data + .iter() + .filter_map(|row| columns.data_point(row)) + .collect() } /// The most recent `days` entries of the daily view history, oldest first. /// /// Returns fewer entries if the post has a shorter history. + /// + /// Prefer this over [`Self::daily_views`] when only a trailing window is + /// needed. It walks the history backwards and allocates just the entries it + /// returns, rather than materializing the post's full history first. pub fn recent_daily_views(&self, days: u32) -> Vec { - let days = days as usize; - let all = self.daily_views(); - all[all.len().saturating_sub(days)..].to_vec() + let Some(columns) = self.data_columns() else { + return vec![]; + }; + + let mut recent: Vec = self + .data + .iter() + .rev() + .filter_map(|row| columns.data_point(row)) + .take(days as usize) + .collect(); + recent.reverse(); + recent } } impl StatsPostViewsResponse { - fn daily_views_iter(&self) -> impl Iterator + '_ { - let period_index = self.fields.iter().position(|f| f == "period"); - let views_index = self.fields.iter().position(|f| f == "views"); - - self.data.iter().filter_map(move |row| { - if let (Some(period_index), Some(views_index)) = (period_index, views_index) - && let Some(period) = row.get(period_index).and_then(|v| v.as_string()) - && let Some(views) = row.get(views_index).and_then(|v| v.as_number()) - { - return Some(StatsPostViewsDataPoint { - period: period.clone(), - views, - }); - } - None + /// Resolves the positions of the columns the daily view history is read + /// from, or `None` if [`Self::fields`] doesn't name both of them. + fn data_columns(&self) -> Option { + Some(StatsPostViewsDataColumns { + period: self.fields.iter().position(|field| field == "period")?, + views: self.fields.iter().position(|field| field == "views")?, + }) + } +} + +/// Positions of the columns within a [`StatsPostViewsResponse::data`] row. +#[derive(Clone, Copy)] +struct StatsPostViewsDataColumns { + period: usize, + views: usize, +} + +impl StatsPostViewsDataColumns { + fn data_point(&self, row: &[StatsPostViewsDataValue]) -> Option { + Some(StatsPostViewsDataPoint { + period: row.get(self.period)?.as_string()?.clone(), + views: row.get(self.views)?.as_number()?, }) } } @@ -160,11 +188,19 @@ pub enum StatsPostViewsChange { } /// The wire representations the API uses for a week's `change`. +/// +/// These three shapes — a number, `{"isInfinity": true}`, and `null` (handled by +/// the surrounding `Option`) — are the only ones observed across 60 real +/// responses spanning 15 sites. A week following a zero-view week always reports +/// an integer `0` rather than a not-a-number marker, so there is no `isNan` +/// counterpart to model. #[derive(Deserialize)] #[serde(untagged)] enum RawStatsPostViewsChange { Percentage(f64), Infinite { + // The value is ignored: the API only ever sends `true`, and the presence + // of the key is what identifies the shape. #[allow(dead_code)] #[serde(rename = "isInfinity")] is_infinity: bool, @@ -222,6 +258,10 @@ pub struct StatsPostViewsDiscussion { /// This mirrors WordPress' raw post row, so it carries the post's editorial /// metadata but not a permalink. Fields the API sends that aren't modelled here /// (post content, ping status, and similar) are ignored. +/// +/// The row's `comment_count` is deliberately omitted: the API sends it as a +/// string here, and [`StatsPostViewsDiscussion::comment_count`] carries the same +/// value as a number. #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct StatsPostViewsPost { /// The post's ID. @@ -402,6 +442,22 @@ mod tests { assert!(response.recent_daily_views(0).is_empty()); } + #[test] + fn test_stats_post_views_recent_daily_views_skips_unreadable_rows() { + let mut response = parse(WITH_VIEWS); + + // Drop a row the column reader can't make sense of into the middle of the + // history. It should be skipped rather than counted against `days`. + response + .data + .insert(3, vec![StatsPostViewsDataValue::Null; 2]); + + let recent = response.recent_daily_views(3); + assert_eq!(recent.len(), 3); + assert_eq!(recent[0].period, "2013-06-22"); + assert_eq!(recent[2].period, "2026-08-06"); + } + #[test] fn test_stats_post_views_daily_views_without_known_fields() { let mut response = parse(WITH_VIEWS); From dc6238477748f66da63c82544a253496fe490300 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Thu, 6 Aug 2026 10:50:04 -0400 Subject: [PATCH 3/8] Flatten the daily view history at parse time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `daily_views`/`recent_daily_views` helpers were `#[uniffi::export]` methods on a `uniffi::Record`, which generates callers like: uniffi_wp_api_fn_method_..._recent_daily_views( FfiConverterTypeStatsPostViewsResponse_lower(self), ...) Every call lowered the whole response back into Rust — including the `data` array, thousands of rows on an old post — so the helper cost more than reading `data` natively. That is backwards from why it existed. The `fields`/`data` column table is now flattened into a `daily_views` field while deserializing, and both exported methods are gone. Callers take a trailing window with `dailyViews.suffix(7)` / `takeLast(7)` — no FFI at all. The column positions are still read from `fields` rather than assumed; that just happens once now. `StatsPostViewsDataValue` becomes private along with the raw shape, so it no longer appears in the bindings. Adds a test that reorders the `fields` columns, which nothing previously covered. --- CHANGELOG.md | 2 +- wp_api/src/wp_com/stats_post_views.rs | 281 ++++++++++++++------------ 2 files changed, 156 insertions(+), 127 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 456e21e95..5cecb8068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- WordPress.com `GET /sites//stats/post/` endpoint for per-post stats. Returns the post's view history, like and comment counts, and post metadata — everything the "Latest Post Summary" card needs. Use `recent_daily_views(days)` for the trailing window rather than the full history, which can run to thousands of entries. +- WordPress.com `GET /sites//stats/post/` endpoint for per-post stats. Returns the post's view history, like and comment counts, and post metadata — everything the "Latest Post Summary" card needs. The API's `fields`/`data` column table is flattened into `daily_views` while deserializing; callers wanting a trailing window slice its tail, which can run to thousands of entries. - 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 diff --git a/wp_api/src/wp_com/stats_post_views.rs b/wp_api/src/wp_com/stats_post_views.rs index 1bd3195f0..864a64f5c 100644 --- a/wp_api/src/wp_com/stats_post_views.rs +++ b/wp_api/src/wp_com/stats_post_views.rs @@ -5,28 +5,30 @@ use wp_serde_helper::{deserialize_empty_array_or_hashmap, deserialize_u64_or_str /// Response from the per-post stats endpoint. /// -/// The endpoint returns the post's complete view history, so [`Self::data`] can -/// contain thousands of rows for a long-lived post. Callers that only need a -/// short trailing window (such as the "Latest Post Summary" card) should use -/// [`Self::recent_daily_views`] rather than materializing the whole series. +/// The endpoint returns the post's complete view history, so +/// [`Self::daily_views`] can hold thousands of entries for a long-lived post. +/// Callers that only need a trailing window (such as the "Latest Post Summary" +/// card) should slice the tail of it — `daily_views.suffix(7)` in Swift, +/// `dailyViews.takeLast(7)` in Kotlin. #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +#[serde(from = "RawStatsPostViewsResponse")] pub struct StatsPostViewsResponse { /// The date the stats were generated for (format: YYYY-MM-DD). pub date: String, /// The post's all-time view count. pub views: u64, /// Yearly view totals, keyed by year (e.g. `"2026"`). - #[serde(deserialize_with = "deserialize_empty_array_or_hashmap")] pub years: HashMap, /// Yearly view averages, keyed by year (e.g. `"2026"`). - #[serde(deserialize_with = "deserialize_empty_array_or_hashmap")] pub averages: HashMap, /// The most recent weeks of daily views, oldest first. pub weeks: Vec, - /// Field names for the [`Self::data`] rows, e.g. `["period", "views"]`. - pub fields: Vec, - /// The full daily view history as rows of values corresponding to [`Self::fields`]. - pub data: Vec>, + /// The post's complete daily view history, oldest first. + /// + /// The API sends this as a `fields`/`data` column table; it is flattened + /// while deserializing so callers never handle the column indirection. + /// Empty if the response doesn't name both the `period` and `views` columns. + pub daily_views: Vec, /// The highest view count the post reached in a single month. pub highest_month: u64, /// The highest daily view average the post reached. @@ -41,72 +43,94 @@ pub struct StatsPostViewsResponse { pub post: StatsPostViewsPost, } -#[uniffi::export] -impl StatsPostViewsResponse { - /// The post's complete daily view history, oldest first. - /// - /// Returns an empty list if the response is missing the `period` or `views` - /// column. - pub fn daily_views(&self) -> Vec { - let Some(columns) = self.data_columns() else { - return vec![]; - }; - - self.data - .iter() - .filter_map(|row| columns.data_point(row)) - .collect() - } +/// The response as the API sends it, before the `fields`/`data` column table is +/// flattened into [`StatsPostViewsResponse::daily_views`]. +#[derive(Deserialize)] +struct RawStatsPostViewsResponse { + date: String, + views: u64, + #[serde(deserialize_with = "deserialize_empty_array_or_hashmap")] + years: HashMap, + #[serde(deserialize_with = "deserialize_empty_array_or_hashmap")] + averages: HashMap, + weeks: Vec, + /// Column names for the `data` rows. Always `["period", "views"]` in every + /// response observed, but read rather than assumed. + fields: Vec, + data: Vec>, + highest_month: u64, + highest_day_average: u64, + highest_week_average: u64, + like_count: u64, + discussion: StatsPostViewsDiscussion, + post: StatsPostViewsPost, +} - /// The most recent `days` entries of the daily view history, oldest first. - /// - /// Returns fewer entries if the post has a shorter history. - /// - /// Prefer this over [`Self::daily_views`] when only a trailing window is - /// needed. It walks the history backwards and allocates just the entries it - /// returns, rather than materializing the post's full history first. - pub fn recent_daily_views(&self, days: u32) -> Vec { - let Some(columns) = self.data_columns() else { - return vec![]; - }; - - let mut recent: Vec = self - .data - .iter() - .rev() - .filter_map(|row| columns.data_point(row)) - .take(days as usize) - .collect(); - recent.reverse(); - recent - } +/// A value in a raw `data` row (a string, a number, or null). +#[derive(Deserialize)] +#[serde(untagged)] +enum RawStatsPostViewsDataValue { + String(String), + Number(u64), + Null, } -impl StatsPostViewsResponse { - /// Resolves the positions of the columns the daily view history is read - /// from, or `None` if [`Self::fields`] doesn't name both of them. - fn data_columns(&self) -> Option { - Some(StatsPostViewsDataColumns { - period: self.fields.iter().position(|field| field == "period")?, - views: self.fields.iter().position(|field| field == "views")?, - }) +impl RawStatsPostViewsDataValue { + fn as_string(&self) -> Option<&String> { + match self { + Self::String(string) => Some(string), + _ => None, + } + } + + fn as_number(&self) -> Option { + match self { + Self::Number(number) => Some(*number), + _ => None, + } } } -/// Positions of the columns within a [`StatsPostViewsResponse::data`] row. -#[derive(Clone, Copy)] -struct StatsPostViewsDataColumns { - period: usize, - views: usize, +impl From for StatsPostViewsResponse { + fn from(raw: RawStatsPostViewsResponse) -> Self { + Self { + date: raw.date, + views: raw.views, + years: raw.years, + averages: raw.averages, + weeks: raw.weeks, + daily_views: daily_views(&raw.fields, &raw.data), + highest_month: raw.highest_month, + highest_day_average: raw.highest_day_average, + highest_week_average: raw.highest_week_average, + like_count: raw.like_count, + discussion: raw.discussion, + post: raw.post, + } + } } -impl StatsPostViewsDataColumns { - fn data_point(&self, row: &[StatsPostViewsDataValue]) -> Option { - Some(StatsPostViewsDataPoint { - period: row.get(self.period)?.as_string()?.clone(), - views: row.get(self.views)?.as_number()?, +/// Flattens the `fields`/`data` column table into data points, skipping rows the +/// columns can't be read from. +fn daily_views( + fields: &[String], + data: &[Vec], +) -> Vec { + let (Some(period_index), Some(views_index)) = ( + fields.iter().position(|field| field == "period"), + fields.iter().position(|field| field == "views"), + ) else { + return vec![]; + }; + + data.iter() + .filter_map(|row| { + Some(StatsPostViewsDataPoint { + period: row.get(period_index)?.as_string()?.clone(), + views: row.get(views_index)?.as_number()?, + }) }) - } + .collect() } /// A single day's view count from the daily view history. @@ -118,31 +142,6 @@ pub struct StatsPostViewsDataPoint { pub views: u64, } -/// A value in the daily view history rows (can be a string, a number, or null). -#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, uniffi::Enum)] -#[serde(untagged)] -pub enum StatsPostViewsDataValue { - String(String), - Number(u64), - Null, -} - -impl StatsPostViewsDataValue { - pub fn as_string(&self) -> Option<&String> { - match self { - StatsPostViewsDataValue::String(s) => Some(s), - _ => None, - } - } - - pub fn as_number(&self) -> Option { - match self { - StatsPostViewsDataValue::Number(n) => Some(*n), - _ => None, - } - } -} - /// A year's view totals. #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct StatsPostViewsYear { @@ -307,6 +306,40 @@ mod tests { serde_json::from_reader(file).expect("Unable to parse JSON") } + /// A minimal response carrying the given `fields`/`data` column table, for + /// exercising the flattening in isolation. + fn parse_with_columns(fields: &str, data: &str) -> StatsPostViewsResponse { + let json = format!( + r#"{{ + "date": "2026-08-06", + "views": 0, + "years": {{}}, + "averages": {{}}, + "weeks": [], + "fields": {fields}, + "data": {data}, + "highest_month": 0, + "highest_day_average": 0, + "highest_week_average": 0, + "like_count": 0, + "discussion": {{ "comment_count": 0 }}, + "post": {{ + "ID": 1, + "post_title": "A Post", + "post_date": "2026-01-01 00:00:00", + "post_date_gmt": "2026-01-01 00:00:00", + "post_modified": "2026-01-01 00:00:00", + "post_name": "a-post", + "post_status": "publish", + "post_type": "post", + "post_author": "1", + "guid": "https://example.com/?p=1" + }} + }}"# + ); + serde_json::from_str(&json).expect("Unable to parse JSON") + } + #[rstest] #[case(WITH_VIEWS)] #[case(NO_VIEWS)] @@ -314,7 +347,7 @@ mod tests { let response = parse(json_file_path); assert!(!response.date.is_empty()); - assert_eq!(response.fields, vec!["period", "views"]); + assert!(!response.daily_views.is_empty()); assert!(!response.weeks.is_empty()); } @@ -408,9 +441,8 @@ mod tests { #[test] fn test_stats_post_views_daily_views() { - let response = parse(WITH_VIEWS); + let daily_views = parse(WITH_VIEWS).daily_views; - let daily_views = response.daily_views(); assert_eq!(daily_views.len(), 5); assert_eq!( daily_views[0], @@ -429,42 +461,40 @@ mod tests { } #[test] - fn test_stats_post_views_recent_daily_views() { - let response = parse(WITH_VIEWS); - - let recent = response.recent_daily_views(3); - assert_eq!(recent.len(), 3); - assert_eq!(recent[0].period, "2013-06-22"); - assert_eq!(recent[2].period, "2026-08-06"); + fn test_stats_post_views_daily_views_reads_the_column_table() { + // The column positions are read rather than assumed, so swapping them + // still yields the same data points. + let response = parse_with_columns( + r#"["views", "period"]"#, + r#"[[5, "2026-08-04"], [7, "2026-08-06"]]"#, + ); - // Asking for more days than the post has returns the whole history. - assert_eq!(response.recent_daily_views(100).len(), 5); - assert!(response.recent_daily_views(0).is_empty()); + assert_eq!(response.daily_views.len(), 2); + assert_eq!(response.daily_views[0].period, "2026-08-04"); + assert_eq!(response.daily_views[0].views, 5); } #[test] - fn test_stats_post_views_recent_daily_views_skips_unreadable_rows() { - let mut response = parse(WITH_VIEWS); - - // Drop a row the column reader can't make sense of into the middle of the - // history. It should be skipped rather than counted against `days`. - response - .data - .insert(3, vec![StatsPostViewsDataValue::Null; 2]); - - let recent = response.recent_daily_views(3); - assert_eq!(recent.len(), 3); - assert_eq!(recent[0].period, "2013-06-22"); - assert_eq!(recent[2].period, "2026-08-06"); + fn test_stats_post_views_daily_views_skips_unreadable_rows() { + let response = parse_with_columns( + r#"["period", "views"]"#, + r#"[["2026-08-04", 5], [null, null], ["2026-08-06", 7]]"#, + ); + + assert_eq!( + response.daily_views.len(), + 2, + "the unreadable row should be dropped, not derail the rest" + ); + assert_eq!(response.daily_views[0].period, "2026-08-04"); + assert_eq!(response.daily_views[1].period, "2026-08-06"); } #[test] - fn test_stats_post_views_daily_views_without_known_fields() { - let mut response = parse(WITH_VIEWS); - response.fields = vec!["period".to_string()]; + fn test_stats_post_views_daily_views_without_known_columns() { + let response = parse_with_columns(r#"["period"]"#, r#"[["2026-08-04"]]"#); - assert!(response.daily_views().is_empty()); - assert!(response.recent_daily_views(7).is_empty()); + assert!(response.daily_views.is_empty()); } #[test] @@ -485,8 +515,7 @@ mod tests { assert_eq!(average.overall, 0.0); assert!(average.months.is_empty()); - let daily_views = response.daily_views(); - assert_eq!(daily_views.len(), 3); - assert!(daily_views.iter().all(|d| d.views == 0)); + assert_eq!(response.daily_views.len(), 3); + assert!(response.daily_views.iter().all(|d| d.views == 0)); } } From 319d5088cb0b747a611ea49b1362ccef8c42ca90 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Thu, 6 Aug 2026 11:00:42 -0400 Subject: [PATCH 4/8] Rename `stats_post_views` to `stats_post` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module didn't mirror its endpoint path. Fifteen of the eighteen stats modules do; the three that don't each have a reason (`stats_summary` has no segment to mirror, the location ones would be `stats_location_views_*`). This one had no such excuse, and `_views` undersold the type — the response also carries the like count, comment count, and post metadata, which is precisely what makes the endpoint useful. Two types needed more than the mechanical substitution: - `StatsPostViewsPost` would have become `StatsPostPost`, so it is now `StatsPostDetails`. - `StatsPostViewsDataPoint` would have become `StatsPostDataPoint`, one letter from the existing `StatsPostsDataPoint` in `stats_visits` (posts published per period) and unrelated in meaning. UniFFI's namespace is flat, so both would sit together in Swift and Kotlin autocomplete. It is now `StatsPostDailyView`. --- wp_api/src/wp_com/client.rs | 12 +- wp_api/src/wp_com/endpoint.rs | 2 +- ...ews_endpoint.rs => stats_post_endpoint.rs} | 20 +-- wp_api/src/wp_com/mod.rs | 2 +- .../{stats_post_views.rs => stats_post.rs} | 119 +++++++++--------- .../post-no-views.json | 0 .../post-with-views.json | 0 wp_com_e2e/src/main.rs | 4 +- ...ost_views_tests.rs => stats_post_tests.rs} | 6 +- 9 files changed, 81 insertions(+), 84 deletions(-) rename wp_api/src/wp_com/endpoint/{stats_post_views_endpoint.rs => stats_post_endpoint.rs} (71%) rename wp_api/src/wp_com/{stats_post_views.rs => stats_post.rs} (84%) rename wp_api/tests/wpcom/{stats_post_views => stats_post}/post-no-views.json (100%) rename wp_api/tests/wpcom/{stats_post_views => stats_post}/post-with-views.json (100%) rename wp_com_e2e/src/{stats_post_views_tests.rs => stats_post_tests.rs} (91%) diff --git a/wp_api/src/wp_com/client.rs b/wp_api/src/wp_com/client.rs index 263c8f64b..b02efdbb0 100644 --- a/wp_api/src/wp_com/client.rs +++ b/wp_api/src/wp_com/client.rs @@ -32,7 +32,7 @@ use super::endpoint::{ StatsFileDownloadsRequestBuilder, StatsFileDownloadsRequestExecutor, }, stats_insights_endpoint::{StatsInsightsRequestBuilder, StatsInsightsRequestExecutor}, - stats_post_views_endpoint::{StatsPostViewsRequestBuilder, StatsPostViewsRequestExecutor}, + stats_post_endpoint::{StatsPostRequestBuilder, StatsPostRequestExecutor}, stats_referrers_endpoint::{StatsReferrersRequestBuilder, StatsReferrersRequestExecutor}, stats_region_views_endpoint::{ StatsRegionViewsRequestBuilder, StatsRegionViewsRequestExecutor, @@ -95,7 +95,7 @@ pub struct WpComApiRequestBuilder { stats_emails_summary: Arc, stats_devices_platform: Arc, stats_devices_screensize: Arc, - stats_post_views: Arc, + stats_post: Arc, stats_referrers: Arc, stats_subscribers: Arc, stats_region_views: Arc, @@ -143,7 +143,7 @@ impl WpComApiRequestBuilder { stats_emails_summary, stats_devices_platform, stats_devices_screensize, - stats_post_views, + stats_post, stats_referrers, stats_subscribers, stats_region_views, @@ -202,7 +202,7 @@ pub struct WpComApiClient { stats_emails_summary: Arc, stats_devices_platform: Arc, stats_devices_screensize: Arc, - stats_post_views: Arc, + stats_post: Arc, stats_referrers: Arc, stats_subscribers: Arc, stats_region_views: Arc, @@ -251,7 +251,7 @@ impl WpComApiClient { stats_emails_summary, stats_devices_platform, stats_devices_screensize, - stats_post_views, + stats_post, stats_referrers, stats_subscribers, stats_region_views, @@ -293,7 +293,7 @@ api_client_generate_endpoint_impl!(WpComApi, stats_devices_browser); api_client_generate_endpoint_impl!(WpComApi, stats_emails_summary); api_client_generate_endpoint_impl!(WpComApi, stats_devices_platform); api_client_generate_endpoint_impl!(WpComApi, stats_devices_screensize); -api_client_generate_endpoint_impl!(WpComApi, stats_post_views); +api_client_generate_endpoint_impl!(WpComApi, stats_post); api_client_generate_endpoint_impl!(WpComApi, stats_referrers); api_client_generate_endpoint_impl!(WpComApi, stats_subscribers); api_client_generate_endpoint_impl!(WpComApi, stats_region_views); diff --git a/wp_api/src/wp_com/endpoint.rs b/wp_api/src/wp_com/endpoint.rs index 0b8682665..b937049d9 100644 --- a/wp_api/src/wp_com/endpoint.rs +++ b/wp_api/src/wp_com/endpoint.rs @@ -29,7 +29,7 @@ pub mod stats_devices_screensize_endpoint; pub mod stats_emails_summary_endpoint; pub mod stats_file_downloads_endpoint; pub mod stats_insights_endpoint; -pub mod stats_post_views_endpoint; +pub mod stats_post_endpoint; pub mod stats_referrers_endpoint; pub mod stats_region_views_endpoint; pub mod stats_search_terms_endpoint; diff --git a/wp_api/src/wp_com/endpoint/stats_post_views_endpoint.rs b/wp_api/src/wp_com/endpoint/stats_post_endpoint.rs similarity index 71% rename from wp_api/src/wp_com/endpoint/stats_post_views_endpoint.rs rename to wp_api/src/wp_com/endpoint/stats_post_endpoint.rs index d6e47045c..5c1def46d 100644 --- a/wp_api/src/wp_com/endpoint/stats_post_views_endpoint.rs +++ b/wp_api/src/wp_com/endpoint/stats_post_endpoint.rs @@ -1,17 +1,17 @@ use crate::{ posts::PostId, request::endpoint::{AsNamespace, DerivedRequest}, - wp_com::{WpComNamespace, WpComSiteId, stats_post_views::StatsPostViewsResponse}, + wp_com::{WpComNamespace, WpComSiteId, stats_post::StatsPostResponse}, }; use wp_derive_request_builder::WpDerivedRequest; #[derive(WpDerivedRequest)] -enum StatsPostViewsRequest { - #[get(url = "/sites//stats/post/", output = StatsPostViewsResponse)] - GetStatsPostViews, +enum StatsPostRequest { + #[get(url = "/sites//stats/post/", output = StatsPostResponse)] + GetStatsPost, } -impl DerivedRequest for StatsPostViewsRequest { +impl DerivedRequest for StatsPostRequest { fn namespace(&self) -> impl AsNamespace { WpComNamespace::RestV1_1 } @@ -36,14 +36,14 @@ mod tests { PostId(9007199254740991), "/sites/229889220/stats/post/9007199254740991" )] - fn get_stats_post_views( - endpoint: StatsPostViewsRequestEndpoint, + fn get_stats_post( + endpoint: StatsPostRequestEndpoint, #[case] site_id: WpComSiteId, #[case] post_id: PostId, #[case] expected_path: &str, ) { validate_wp_com_rest_v1_1_endpoint( - endpoint.get_stats_post_views(&site_id, &post_id), + endpoint.get_stats_post(&site_id, &post_id), expected_path, ); } @@ -51,7 +51,7 @@ mod tests { #[fixture] fn endpoint( fixture_wp_com_api_url_resolver: Arc, - ) -> StatsPostViewsRequestEndpoint { - StatsPostViewsRequestEndpoint::new(fixture_wp_com_api_url_resolver) + ) -> StatsPostRequestEndpoint { + StatsPostRequestEndpoint::new(fixture_wp_com_api_url_resolver) } } diff --git a/wp_api/src/wp_com/mod.rs b/wp_api/src/wp_com/mod.rs index 80c727a8f..ba4db7a9b 100644 --- a/wp_api/src/wp_com/mod.rs +++ b/wp_api/src/wp_com/mod.rs @@ -25,7 +25,7 @@ pub mod stats_devices; pub mod stats_emails_summary; pub mod stats_file_downloads; pub mod stats_insights; -pub mod stats_post_views; +pub mod stats_post; pub mod stats_referrers; pub mod stats_region_views; pub mod stats_search_terms; diff --git a/wp_api/src/wp_com/stats_post_views.rs b/wp_api/src/wp_com/stats_post.rs similarity index 84% rename from wp_api/src/wp_com/stats_post_views.rs rename to wp_api/src/wp_com/stats_post.rs index 864a64f5c..b046a15bc 100644 --- a/wp_api/src/wp_com/stats_post_views.rs +++ b/wp_api/src/wp_com/stats_post.rs @@ -11,24 +11,24 @@ use wp_serde_helper::{deserialize_empty_array_or_hashmap, deserialize_u64_or_str /// card) should slice the tail of it — `daily_views.suffix(7)` in Swift, /// `dailyViews.takeLast(7)` in Kotlin. #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] -#[serde(from = "RawStatsPostViewsResponse")] -pub struct StatsPostViewsResponse { +#[serde(from = "RawStatsPostResponse")] +pub struct StatsPostResponse { /// The date the stats were generated for (format: YYYY-MM-DD). pub date: String, /// The post's all-time view count. pub views: u64, /// Yearly view totals, keyed by year (e.g. `"2026"`). - pub years: HashMap, + pub years: HashMap, /// Yearly view averages, keyed by year (e.g. `"2026"`). - pub averages: HashMap, + pub averages: HashMap, /// The most recent weeks of daily views, oldest first. - pub weeks: Vec, + pub weeks: Vec, /// The post's complete daily view history, oldest first. /// /// The API sends this as a `fields`/`data` column table; it is flattened /// while deserializing so callers never handle the column indirection. /// Empty if the response doesn't name both the `period` and `views` columns. - pub daily_views: Vec, + pub daily_views: Vec, /// The highest view count the post reached in a single month. pub highest_month: u64, /// The highest daily view average the post reached. @@ -38,44 +38,44 @@ pub struct StatsPostViewsResponse { /// The post's like count. pub like_count: u64, /// The post's comment counts. - pub discussion: StatsPostViewsDiscussion, + pub discussion: StatsPostDiscussion, /// The post the stats belong to. - pub post: StatsPostViewsPost, + pub post: StatsPostDetails, } /// The response as the API sends it, before the `fields`/`data` column table is -/// flattened into [`StatsPostViewsResponse::daily_views`]. +/// flattened into [`StatsPostResponse::daily_views`]. #[derive(Deserialize)] -struct RawStatsPostViewsResponse { +struct RawStatsPostResponse { date: String, views: u64, #[serde(deserialize_with = "deserialize_empty_array_or_hashmap")] - years: HashMap, + years: HashMap, #[serde(deserialize_with = "deserialize_empty_array_or_hashmap")] - averages: HashMap, - weeks: Vec, + averages: HashMap, + weeks: Vec, /// Column names for the `data` rows. Always `["period", "views"]` in every /// response observed, but read rather than assumed. fields: Vec, - data: Vec>, + data: Vec>, highest_month: u64, highest_day_average: u64, highest_week_average: u64, like_count: u64, - discussion: StatsPostViewsDiscussion, - post: StatsPostViewsPost, + discussion: StatsPostDiscussion, + post: StatsPostDetails, } /// A value in a raw `data` row (a string, a number, or null). #[derive(Deserialize)] #[serde(untagged)] -enum RawStatsPostViewsDataValue { +enum RawStatsPostDataValue { String(String), Number(u64), Null, } -impl RawStatsPostViewsDataValue { +impl RawStatsPostDataValue { fn as_string(&self) -> Option<&String> { match self { Self::String(string) => Some(string), @@ -91,8 +91,8 @@ impl RawStatsPostViewsDataValue { } } -impl From for StatsPostViewsResponse { - fn from(raw: RawStatsPostViewsResponse) -> Self { +impl From for StatsPostResponse { + fn from(raw: RawStatsPostResponse) -> Self { Self { date: raw.date, views: raw.views, @@ -112,10 +112,7 @@ impl From for StatsPostViewsResponse { /// Flattens the `fields`/`data` column table into data points, skipping rows the /// columns can't be read from. -fn daily_views( - fields: &[String], - data: &[Vec], -) -> Vec { +fn daily_views(fields: &[String], data: &[Vec]) -> Vec { let (Some(period_index), Some(views_index)) = ( fields.iter().position(|field| field == "period"), fields.iter().position(|field| field == "views"), @@ -125,7 +122,7 @@ fn daily_views( data.iter() .filter_map(|row| { - Some(StatsPostViewsDataPoint { + Some(StatsPostDailyView { period: row.get(period_index)?.as_string()?.clone(), views: row.get(views_index)?.as_number()?, }) @@ -135,7 +132,7 @@ fn daily_views( /// A single day's view count from the daily view history. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, uniffi::Record)] -pub struct StatsPostViewsDataPoint { +pub struct StatsPostDailyView { /// The day the views were recorded on (format: YYYY-MM-DD). pub period: String, /// The number of views on that day. @@ -144,7 +141,7 @@ pub struct StatsPostViewsDataPoint { /// A year's view totals. #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] -pub struct StatsPostViewsYear { +pub struct StatsPostYear { /// View totals keyed by month number (`"1"` through `"12"`). #[serde(deserialize_with = "deserialize_empty_array_or_hashmap")] pub months: HashMap, @@ -154,7 +151,7 @@ pub struct StatsPostViewsYear { /// A year's view averages. #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] -pub struct StatsPostViewsAverage { +pub struct StatsPostAverage { /// View averages keyed by month number (`"1"` through `"12"`). #[serde(deserialize_with = "deserialize_empty_array_or_hashmap")] pub months: HashMap, @@ -164,20 +161,20 @@ pub struct StatsPostViewsAverage { /// A week of daily views. #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] -pub struct StatsPostViewsWeek { +pub struct StatsPostWeek { /// The days in the week, oldest first. The final week may be partial. - pub days: Vec, + pub days: Vec, /// The total views for the week. pub total: u64, /// The average daily views for the week. pub average: f64, /// The change from the previous week, or `None` for the first week. - pub change: Option, + pub change: Option, } /// The change in views from one week to the next. #[derive(Debug, Clone, Copy, PartialEq, uniffi::Enum)] -pub enum StatsPostViewsChange { +pub enum StatsPostChange { /// The percentage change from the previous week. Percentage { value: f64 }, /// The previous week had no views, so the change is unbounded. The API @@ -195,7 +192,7 @@ pub enum StatsPostViewsChange { /// counterpart to model. #[derive(Deserialize)] #[serde(untagged)] -enum RawStatsPostViewsChange { +enum RawStatsPostChange { Percentage(f64), Infinite { // The value is ignored: the API only ever sends `true`, and the presence @@ -206,19 +203,19 @@ enum RawStatsPostViewsChange { }, } -impl<'de> Deserialize<'de> for StatsPostViewsChange { +impl<'de> Deserialize<'de> for StatsPostChange { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { - Ok(match RawStatsPostViewsChange::deserialize(deserializer)? { - RawStatsPostViewsChange::Percentage(value) => Self::Percentage { value }, - RawStatsPostViewsChange::Infinite { .. } => Self::Infinite, + Ok(match RawStatsPostChange::deserialize(deserializer)? { + RawStatsPostChange::Percentage(value) => Self::Percentage { value }, + RawStatsPostChange::Infinite { .. } => Self::Infinite, }) } } -impl Serialize for StatsPostViewsChange { +impl Serialize for StatsPostChange { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, @@ -236,9 +233,9 @@ impl Serialize for StatsPostViewsChange { } } -/// A single day within a [`StatsPostViewsWeek`]. +/// A single day within a [`StatsPostWeek`]. #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] -pub struct StatsPostViewsDay { +pub struct StatsPostDay { /// The day (format: YYYY-MM-DD). pub day: String, /// The number of views on that day. @@ -247,7 +244,7 @@ pub struct StatsPostViewsDay { /// A post's comment counts. #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] -pub struct StatsPostViewsDiscussion { +pub struct StatsPostDiscussion { /// The number of comments on the post. pub comment_count: u64, } @@ -259,10 +256,10 @@ pub struct StatsPostViewsDiscussion { /// (post content, ping status, and similar) are ignored. /// /// The row's `comment_count` is deliberately omitted: the API sends it as a -/// string here, and [`StatsPostViewsDiscussion::comment_count`] carries the same +/// string here, and [`StatsPostDiscussion::comment_count`] carries the same /// value as a number. #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] -pub struct StatsPostViewsPost { +pub struct StatsPostDetails { /// The post's ID. #[serde(rename = "ID")] pub id: PostId, @@ -298,17 +295,17 @@ mod tests { use super::*; use rstest::*; - const WITH_VIEWS: &str = "tests/wpcom/stats_post_views/post-with-views.json"; - const NO_VIEWS: &str = "tests/wpcom/stats_post_views/post-no-views.json"; + const WITH_VIEWS: &str = "tests/wpcom/stats_post/post-with-views.json"; + const NO_VIEWS: &str = "tests/wpcom/stats_post/post-no-views.json"; - fn parse(json_file_path: &str) -> StatsPostViewsResponse { + fn parse(json_file_path: &str) -> StatsPostResponse { let file = std::fs::File::open(json_file_path).expect("Failed to open file"); serde_json::from_reader(file).expect("Unable to parse JSON") } /// A minimal response carrying the given `fields`/`data` column table, for /// exercising the flattening in isolation. - fn parse_with_columns(fields: &str, data: &str) -> StatsPostViewsResponse { + fn parse_with_columns(fields: &str, data: &str) -> StatsPostResponse { let json = format!( r#"{{ "date": "2026-08-06", @@ -343,7 +340,7 @@ mod tests { #[rstest] #[case(WITH_VIEWS)] #[case(NO_VIEWS)] - fn test_stats_post_views_response_deserialization(#[case] json_file_path: &str) { + fn test_stats_post_response_deserialization(#[case] json_file_path: &str) { let response = parse(json_file_path); assert!(!response.date.is_empty()); @@ -352,7 +349,7 @@ mod tests { } #[test] - fn test_stats_post_views_response_details() { + fn test_stats_post_response_details() { let response = parse(WITH_VIEWS); assert_eq!(response.date, "2026-08-06"); @@ -373,7 +370,7 @@ mod tests { } #[test] - fn test_stats_post_views_post() { + fn test_stats_post_details() { let post = parse(WITH_VIEWS).post; assert_eq!(post.id, PostId(2729)); @@ -396,7 +393,7 @@ mod tests { } #[test] - fn test_stats_post_views_weeks() { + fn test_stats_post_weeks() { let weeks = parse(WITH_VIEWS).weeks; assert_eq!(weeks.len(), 3); @@ -414,18 +411,18 @@ mod tests { assert_eq!(second.total, 6); assert_eq!( second.change, - Some(StatsPostViewsChange::Percentage { + Some(StatsPostChange::Percentage { value: 133.33333333333334 }) ); // The API sends `{"isInfinity": true}` when the previous week had no views. let last = &weeks[2]; - assert_eq!(last.change, Some(StatsPostViewsChange::Infinite)); + assert_eq!(last.change, Some(StatsPostChange::Infinite)); } #[test] - fn test_stats_post_views_change_round_trips() { + fn test_stats_post_change_round_trips() { let weeks = parse(WITH_VIEWS).weeks; assert_eq!(serde_json::to_string(&weeks[0].change).unwrap(), "null"); @@ -440,20 +437,20 @@ mod tests { } #[test] - fn test_stats_post_views_daily_views() { + fn test_stats_post_daily_views() { let daily_views = parse(WITH_VIEWS).daily_views; assert_eq!(daily_views.len(), 5); assert_eq!( daily_views[0], - StatsPostViewsDataPoint { + StatsPostDailyView { period: "2013-06-20".to_string(), views: 1194, } ); assert_eq!( daily_views[4], - StatsPostViewsDataPoint { + StatsPostDailyView { period: "2026-08-06".to_string(), views: 0, } @@ -461,7 +458,7 @@ mod tests { } #[test] - fn test_stats_post_views_daily_views_reads_the_column_table() { + fn test_stats_post_daily_views_reads_the_column_table() { // The column positions are read rather than assumed, so swapping them // still yields the same data points. let response = parse_with_columns( @@ -475,7 +472,7 @@ mod tests { } #[test] - fn test_stats_post_views_daily_views_skips_unreadable_rows() { + fn test_stats_post_daily_views_skips_unreadable_rows() { let response = parse_with_columns( r#"["period", "views"]"#, r#"[["2026-08-04", 5], [null, null], ["2026-08-06", 7]]"#, @@ -491,14 +488,14 @@ mod tests { } #[test] - fn test_stats_post_views_daily_views_without_known_columns() { + fn test_stats_post_daily_views_without_known_columns() { let response = parse_with_columns(r#"["period"]"#, r#"[["2026-08-04"]]"#); assert!(response.daily_views.is_empty()); } #[test] - fn test_stats_post_views_post_without_views() { + fn test_stats_post_without_views() { let response = parse(NO_VIEWS); assert_eq!(response.views, 0); diff --git a/wp_api/tests/wpcom/stats_post_views/post-no-views.json b/wp_api/tests/wpcom/stats_post/post-no-views.json similarity index 100% rename from wp_api/tests/wpcom/stats_post_views/post-no-views.json rename to wp_api/tests/wpcom/stats_post/post-no-views.json diff --git a/wp_api/tests/wpcom/stats_post_views/post-with-views.json b/wp_api/tests/wpcom/stats_post/post-with-views.json similarity index 100% rename from wp_api/tests/wpcom/stats_post_views/post-with-views.json rename to wp_api/tests/wpcom/stats_post/post-with-views.json diff --git a/wp_com_e2e/src/main.rs b/wp_com_e2e/src/main.rs index 0e873a015..d5a5c3912 100644 --- a/wp_com_e2e/src/main.rs +++ b/wp_com_e2e/src/main.rs @@ -15,7 +15,7 @@ mod stats_city_views_tests; mod stats_country_views_tests; mod stats_emails_summary_tests; mod stats_insights_tests; -mod stats_post_views_tests; +mod stats_post_tests; mod stats_referrers_tests; mod stats_region_views_tests; mod stats_subscribers_tests; @@ -60,7 +60,7 @@ fn collect_tests(ctx: Arc) -> Vec { tests.extend(stats_city_views_tests::tests(Arc::clone(&ctx))); tests.extend(stats_country_views_tests::tests(Arc::clone(&ctx))); tests.extend(stats_insights_tests::tests(Arc::clone(&ctx))); - tests.extend(stats_post_views_tests::tests(Arc::clone(&ctx))); + tests.extend(stats_post_tests::tests(Arc::clone(&ctx))); tests.extend(stats_referrers_tests::tests(Arc::clone(&ctx))); tests.extend(stats_region_views_tests::tests(Arc::clone(&ctx))); tests.extend(stats_summary_tests::tests(Arc::clone(&ctx))); diff --git a/wp_com_e2e/src/stats_post_views_tests.rs b/wp_com_e2e/src/stats_post_tests.rs similarity index 91% rename from wp_com_e2e/src/stats_post_views_tests.rs rename to wp_com_e2e/src/stats_post_tests.rs index ebd36a470..5cb4739a4 100644 --- a/wp_com_e2e/src/stats_post_views_tests.rs +++ b/wp_com_e2e/src/stats_post_tests.rs @@ -32,14 +32,14 @@ pub fn tests(ctx: Arc) -> Vec { }; trials.push(Trial::test( - format!("post_views::get_stats_post_views::{}", site_id), + format!("post_stats::get_stats_post::{}", site_id), { let ctx = Arc::clone(&ctx); move || { ctx.runtime.block_on(async { ctx.client - .stats_post_views() - .get_stats_post_views(&site_id, &post_id) + .stats_post() + .get_stats_post(&site_id, &post_id) .await .map_err(|e| e.to_string())?; Ok(()) From af2ad20a63c40509f2970a1d4225903532a50142 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Thu, 6 Aug 2026 12:41:43 -0400 Subject: [PATCH 5/8] Simplify the per-post stats module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from four parallel cleanup reviews (reuse, simplification, efficiency, altitude). Net -67 lines, no behaviour change and no change to the generated FFI surface. - Drop the private `RawStatsPostDataValue` in favour of the existing public `StatsVisitsDataValue`. It was a character-for-character copy, and `stats_subscribers` already imports that type cross-module for the same purpose. - `daily_views` now takes `data` by value and moves each row's period string instead of cloning it, into a pre-sized `Vec`. The caller drops the rows immediately afterwards, so the ~4,800 clones a long-lived post incurred were pure waste. - Replace `StatsPostChange`'s hand-written `Deserialize`/`Serialize` with `#[serde(from, into)]` and two `From` impls — the idiom already used for the response 150 lines above. Also retires an `#[allow(dead_code)]`. - Tests call `daily_views` directly rather than parsing a 26-line JSON envelope to reach it, collapsing three near-identical tests into one `rstest`. Drops a redundant test whose assertions were all made more precisely elsewhere. - The e2e trial resolves its post id inside the closure rather than during collection, so unrelated e2e runs no longer pay a serial network round trip per site. Known follow-up, deliberately not done here: the column-table lookup now exists three times (`stats_visits`, `stats_subscribers`, `stats_post`) and wants a shared helper. That edits two modules outside this branch. --- wp_api/src/wp_com/stats_post.rs | 212 ++++++++++------------------- wp_com_e2e/src/stats_post_tests.rs | 15 +- 2 files changed, 80 insertions(+), 147 deletions(-) diff --git a/wp_api/src/wp_com/stats_post.rs b/wp_api/src/wp_com/stats_post.rs index b046a15bc..727131ea5 100644 --- a/wp_api/src/wp_com/stats_post.rs +++ b/wp_api/src/wp_com/stats_post.rs @@ -1,4 +1,4 @@ -use crate::posts::PostId; +use crate::{posts::PostId, wp_com::stats_visits::StatsVisitsDataValue}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use wp_serde_helper::{deserialize_empty_array_or_hashmap, deserialize_u64_or_string}; @@ -57,7 +57,7 @@ struct RawStatsPostResponse { /// Column names for the `data` rows. Always `["period", "views"]` in every /// response observed, but read rather than assumed. fields: Vec, - data: Vec>, + data: Vec>, highest_month: u64, highest_day_average: u64, highest_week_average: u64, @@ -66,31 +66,6 @@ struct RawStatsPostResponse { post: StatsPostDetails, } -/// A value in a raw `data` row (a string, a number, or null). -#[derive(Deserialize)] -#[serde(untagged)] -enum RawStatsPostDataValue { - String(String), - Number(u64), - Null, -} - -impl RawStatsPostDataValue { - fn as_string(&self) -> Option<&String> { - match self { - Self::String(string) => Some(string), - _ => None, - } - } - - fn as_number(&self) -> Option { - match self { - Self::Number(number) => Some(*number), - _ => None, - } - } -} - impl From for StatsPostResponse { fn from(raw: RawStatsPostResponse) -> Self { Self { @@ -99,7 +74,7 @@ impl From for StatsPostResponse { years: raw.years, averages: raw.averages, weeks: raw.weeks, - daily_views: daily_views(&raw.fields, &raw.data), + daily_views: daily_views(&raw.fields, raw.data), highest_month: raw.highest_month, highest_day_average: raw.highest_day_average, highest_week_average: raw.highest_week_average, @@ -112,7 +87,10 @@ impl From for StatsPostResponse { /// Flattens the `fields`/`data` column table into data points, skipping rows the /// columns can't be read from. -fn daily_views(fields: &[String], data: &[Vec]) -> Vec { +/// +/// Takes `data` by value so each row's period string is moved rather than +/// copied — the history runs to thousands of rows on a long-lived post. +fn daily_views(fields: &[String], data: Vec>) -> Vec { let (Some(period_index), Some(views_index)) = ( fields.iter().position(|field| field == "period"), fields.iter().position(|field| field == "views"), @@ -120,14 +98,23 @@ fn daily_views(fields: &[String], data: &[Vec]) -> Vec= row.len() { + continue; + } + let StatsVisitsDataValue::String(period) = row.swap_remove(period_index) else { + continue; + }; + daily_views.push(StatsPostDailyView { period, views }); + } + daily_views } /// A single day's view count from the daily view history. @@ -173,7 +160,8 @@ pub struct StatsPostWeek { } /// The change in views from one week to the next. -#[derive(Debug, Clone, Copy, PartialEq, uniffi::Enum)] +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, uniffi::Enum)] +#[serde(from = "RawStatsPostChange", into = "RawStatsPostChange")] pub enum StatsPostChange { /// The percentage change from the previous week. Percentage { value: f64 }, @@ -190,45 +178,32 @@ pub enum StatsPostChange { /// responses spanning 15 sites. A week following a zero-view week always reports /// an integer `0` rather than a not-a-number marker, so there is no `isNan` /// counterpart to model. -#[derive(Deserialize)] +#[derive(Serialize, Deserialize)] #[serde(untagged)] enum RawStatsPostChange { Percentage(f64), Infinite { - // The value is ignored: the API only ever sends `true`, and the presence - // of the key is what identifies the shape. - #[allow(dead_code)] + // Only ever `true` on the wire; the presence of the key is what + // identifies the shape. #[serde(rename = "isInfinity")] is_infinity: bool, }, } -impl<'de> Deserialize<'de> for StatsPostChange { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - Ok(match RawStatsPostChange::deserialize(deserializer)? { +impl From for StatsPostChange { + fn from(raw: RawStatsPostChange) -> Self { + match raw { RawStatsPostChange::Percentage(value) => Self::Percentage { value }, RawStatsPostChange::Infinite { .. } => Self::Infinite, - }) + } } } -impl Serialize for StatsPostChange { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - use serde::ser::SerializeMap; - - match self { - Self::Percentage { value } => serializer.serialize_f64(*value), - Self::Infinite => { - let mut map = serializer.serialize_map(Some(1))?; - map.serialize_entry("isInfinity", &true)?; - map.end() - } +impl From for RawStatsPostChange { + fn from(change: StatsPostChange) -> Self { + match change { + StatsPostChange::Percentage { value } => Self::Percentage(value), + StatsPostChange::Infinite => Self::Infinite { is_infinity: true }, } } } @@ -303,49 +278,12 @@ mod tests { serde_json::from_reader(file).expect("Unable to parse JSON") } - /// A minimal response carrying the given `fields`/`data` column table, for - /// exercising the flattening in isolation. - fn parse_with_columns(fields: &str, data: &str) -> StatsPostResponse { - let json = format!( - r#"{{ - "date": "2026-08-06", - "views": 0, - "years": {{}}, - "averages": {{}}, - "weeks": [], - "fields": {fields}, - "data": {data}, - "highest_month": 0, - "highest_day_average": 0, - "highest_week_average": 0, - "like_count": 0, - "discussion": {{ "comment_count": 0 }}, - "post": {{ - "ID": 1, - "post_title": "A Post", - "post_date": "2026-01-01 00:00:00", - "post_date_gmt": "2026-01-01 00:00:00", - "post_modified": "2026-01-01 00:00:00", - "post_name": "a-post", - "post_status": "publish", - "post_type": "post", - "post_author": "1", - "guid": "https://example.com/?p=1" - }} - }}"# - ); - serde_json::from_str(&json).expect("Unable to parse JSON") - } - - #[rstest] - #[case(WITH_VIEWS)] - #[case(NO_VIEWS)] - fn test_stats_post_response_deserialization(#[case] json_file_path: &str) { - let response = parse(json_file_path); - - assert!(!response.date.is_empty()); - assert!(!response.daily_views.is_empty()); - assert!(!response.weeks.is_empty()); + /// Flattens a `fields`/`data` column table given as JSON, for exercising the + /// column handling without a full response envelope. + fn flatten(fields: &[&str], data: &str) -> Vec { + let fields: Vec = fields.iter().map(|f| f.to_string()).collect(); + let data = serde_json::from_str(data).expect("Unable to parse JSON"); + daily_views(&fields, data) } #[test] @@ -457,41 +395,35 @@ mod tests { ); } - #[test] - fn test_stats_post_daily_views_reads_the_column_table() { - // The column positions are read rather than assumed, so swapping them - // still yields the same data points. - let response = parse_with_columns( - r#"["views", "period"]"#, - r#"[[5, "2026-08-04"], [7, "2026-08-06"]]"#, - ); - - assert_eq!(response.daily_views.len(), 2); - assert_eq!(response.daily_views[0].period, "2026-08-04"); - assert_eq!(response.daily_views[0].views, 5); - } - - #[test] - fn test_stats_post_daily_views_skips_unreadable_rows() { - let response = parse_with_columns( - r#"["period", "views"]"#, - r#"[["2026-08-04", 5], [null, null], ["2026-08-06", 7]]"#, - ); - - assert_eq!( - response.daily_views.len(), - 2, - "the unreadable row should be dropped, not derail the rest" - ); - assert_eq!(response.daily_views[0].period, "2026-08-04"); - assert_eq!(response.daily_views[1].period, "2026-08-06"); - } - - #[test] - fn test_stats_post_daily_views_without_known_columns() { - let response = parse_with_columns(r#"["period"]"#, r#"[["2026-08-04"]]"#); + #[rstest] + // The column positions are read rather than assumed, so swapping them still + // yields the same data points. + #[case::reordered_columns( + &["views", "period"], + r#"[[5, "2026-08-04"], [7, "2026-08-06"]]"#, + vec![("2026-08-04", 5), ("2026-08-06", 7)] + )] + // An unreadable row is dropped rather than derailing the rest. + #[case::unreadable_row( + &["period", "views"], + r#"[["2026-08-04", 5], [null, null], ["2026-08-06", 7]]"#, + vec![("2026-08-04", 5), ("2026-08-06", 7)] + )] + #[case::missing_views_column(&["period"], r#"[["2026-08-04"]]"#, vec![])] + fn test_stats_post_daily_views_column_handling( + #[case] fields: &[&str], + #[case] data: &str, + #[case] expected: Vec<(&str, u64)>, + ) { + let expected: Vec = expected + .into_iter() + .map(|(period, views)| StatsPostDailyView { + period: period.to_string(), + views, + }) + .collect(); - assert!(response.daily_views.is_empty()); + assert_eq!(flatten(fields, data), expected); } #[test] diff --git a/wp_com_e2e/src/stats_post_tests.rs b/wp_com_e2e/src/stats_post_tests.rs index 5cb4739a4..c6e0b35b8 100644 --- a/wp_com_e2e/src/stats_post_tests.rs +++ b/wp_com_e2e/src/stats_post_tests.rs @@ -25,17 +25,18 @@ pub fn tests(ctx: Arc) -> Vec { for site in &sites { let site_id = site.id; - // The endpoint needs a real post, so borrow one from the site's top - // posts. Sites without a viewed post are skipped. - let Some(post_id) = most_viewed_post_id(&ctx, &site_id) else { - continue; - }; - trials.push(Trial::test( format!("post_stats::get_stats_post::{}", site_id), { let ctx = Arc::clone(&ctx); move || { + // The endpoint needs a real post, so borrow one from the + // site's top posts. Resolving it here rather than during + // collection keeps the lookup off unrelated test runs. + let Some(post_id) = most_viewed_post_id(&ctx, &site_id) else { + return Ok(()); + }; + ctx.runtime.block_on(async { ctx.client .stats_post() @@ -53,7 +54,7 @@ pub fn tests(ctx: Arc) -> Vec { trials } -fn most_viewed_post_id(ctx: &Arc, site_id: &WpComSiteId) -> Option { +fn most_viewed_post_id(ctx: &TestContext, site_id: &WpComSiteId) -> Option { // Look back over several years rather than the default single day, so quiet // test sites still yield a post. let params = StatsTopPostsParams { From a81f983476bae7b7d9eb924614ce076fb205ed4d Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Thu, 6 Aug 2026 13:22:48 -0400 Subject: [PATCH 6/8] Support home page stats (`PostId(0)`) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/stats/post/0` returns the site's home page — `/stats/top-posts` reports it as a pseudo-entry with that id — and the API answers with a full 200 and complete view history. The home page isn't a post, though, so three fields come back differently: post: false (a boolean, not an object and not null) discussion: null like_count: null All three were modelled as required, so the response failed to deserialize. Comparing a home page payload against a normal post's field by field, those are the only differences — views, years, averages, weeks, fields/data and the highest_* trio are identical. `Option` alone doesn't cover `post`, since `false` is not `null`. Adds a generic `deserialize_false_as_none` to `wp_serde_helper`, which already had this quirk covered for `String` and `u64` but not for arbitrary types. It still errors on a genuinely malformed value rather than quietly returning `None`. The new e2e trial also surfaced that three test sites answer any stats call with `invalid_blog` ("Stats module not enabled"). The existing trial had been passing on them only because the top-posts lookup failed first and returned early, so the trial now tolerates that error the way `stats_region_views_tests` does. --- CHANGELOG.md | 2 +- wp_api/src/wp_com/stats_post.rs | 66 ++++++++++++---- wp_api/tests/wpcom/stats_post/homepage.json | 83 +++++++++++++++++++++ wp_com_e2e/src/stats_post_tests.rs | 39 ++++++++++ wp_serde_helper/src/json.rs | 59 +++++++++++++++ 5 files changed, 233 insertions(+), 16 deletions(-) create mode 100644 wp_api/tests/wpcom/stats_post/homepage.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 716c7e24c..9b88e8b4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- WordPress.com `GET /sites//stats/post/` endpoint for per-post stats. Returns the post's view history, like and comment counts, and post metadata — everything the "Latest Post Summary" card needs. The API's `fields`/`data` column table is flattened into `daily_views` while deserializing; callers wanting a trailing window slice its tail, which can run to thousands of entries. +- WordPress.com `GET /sites//stats/post/` endpoint for per-post stats. Returns the post's view history, like and comment counts, and post metadata — everything the "Latest Post Summary" card needs. The API's `fields`/`data` column table is flattened into `daily_views` while deserializing; callers wanting a trailing window slice its tail, which can run to thousands of entries. Passing `PostId(0)` returns stats for the site's home page, for which `post`, `discussion`, and `like_count` are `None`. - 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 diff --git a/wp_api/src/wp_com/stats_post.rs b/wp_api/src/wp_com/stats_post.rs index 727131ea5..689ae8c1f 100644 --- a/wp_api/src/wp_com/stats_post.rs +++ b/wp_api/src/wp_com/stats_post.rs @@ -1,7 +1,9 @@ use crate::{posts::PostId, wp_com::stats_visits::StatsVisitsDataValue}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use wp_serde_helper::{deserialize_empty_array_or_hashmap, deserialize_u64_or_string}; +use wp_serde_helper::{ + deserialize_empty_array_or_hashmap, deserialize_false_as_none, deserialize_u64_or_string, +}; /// Response from the per-post stats endpoint. /// @@ -10,6 +12,13 @@ use wp_serde_helper::{deserialize_empty_array_or_hashmap, deserialize_u64_or_str /// Callers that only need a trailing window (such as the "Latest Post Summary" /// card) should slice the tail of it — `daily_views.suffix(7)` in Swift, /// `dailyViews.takeLast(7)` in Kotlin. +/// +/// # The site's home page +/// +/// Requesting `PostId(0)` returns view stats for the site's home page, which +/// `/stats/top-posts` reports as a pseudo-entry with that id. The home page +/// isn't a post, so [`Self::post`], [`Self::discussion`] and [`Self::like_count`] +/// are all `None` for it; every view field is populated as usual. #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] #[serde(from = "RawStatsPostResponse")] pub struct StatsPostResponse { @@ -35,12 +44,12 @@ pub struct StatsPostResponse { pub highest_day_average: u64, /// The highest weekly view average the post reached. pub highest_week_average: u64, - /// The post's like count. - pub like_count: u64, - /// The post's comment counts. - pub discussion: StatsPostDiscussion, - /// The post the stats belong to. - pub post: StatsPostDetails, + /// The post's like count. `None` for the site's home page. + pub like_count: Option, + /// The post's comment counts. `None` for the site's home page. + pub discussion: Option, + /// The post the stats belong to. `None` for the site's home page. + pub post: Option, } /// The response as the API sends it, before the `fields`/`data` column table is @@ -61,9 +70,12 @@ struct RawStatsPostResponse { highest_month: u64, highest_day_average: u64, highest_week_average: u64, - like_count: u64, - discussion: StatsPostDiscussion, - post: StatsPostDetails, + // The home page (`PostId(0)`) has no post behind it, so the API sends these + // as `null` — and `post` as boolean `false` rather than `null`. + like_count: Option, + discussion: Option, + #[serde(deserialize_with = "deserialize_false_as_none")] + post: Option, } impl From for StatsPostResponse { @@ -272,6 +284,7 @@ mod tests { const WITH_VIEWS: &str = "tests/wpcom/stats_post/post-with-views.json"; const NO_VIEWS: &str = "tests/wpcom/stats_post/post-no-views.json"; + const HOMEPAGE: &str = "tests/wpcom/stats_post/homepage.json"; fn parse(json_file_path: &str) -> StatsPostResponse { let file = std::fs::File::open(json_file_path).expect("Failed to open file"); @@ -295,8 +308,8 @@ mod tests { assert_eq!(response.highest_month, 3224); assert_eq!(response.highest_day_average, 293); assert_eq!(response.highest_week_average, 3); - assert_eq!(response.like_count, 9); - assert_eq!(response.discussion.comment_count, 48); + assert_eq!(response.like_count, Some(9)); + assert_eq!(response.discussion.expect("present").comment_count, 48); let year = response.years.get("2013").expect("2013 should exist"); assert_eq!(year.total, 6146); @@ -309,7 +322,7 @@ mod tests { #[test] fn test_stats_post_details() { - let post = parse(WITH_VIEWS).post; + let post = parse(WITH_VIEWS).post.expect("a real post has details"); assert_eq!(post.id, PostId(2729)); assert_eq!( @@ -426,14 +439,37 @@ mod tests { assert_eq!(flatten(fields, data), expected); } + #[test] + fn test_stats_post_homepage() { + // `PostId(0)` is the site's home page. It isn't a post, so the API sends + // `like_count` and `discussion` as null and `post` as boolean `false`, + // while every view field is populated as usual. + let response = parse(HOMEPAGE); + + assert!(response.post.is_none()); + assert!(response.discussion.is_none()); + assert!(response.like_count.is_none()); + + assert_eq!(response.views, 74286); + assert_eq!(response.highest_month, 3822); + assert_eq!(response.daily_views.len(), 3); + assert_eq!(response.daily_views[0].period, "2013-05-19"); + assert_eq!(response.daily_views[0].views, 73); + assert_eq!( + response.years.get("2013").expect("2013 should exist").total, + 14276 + ); + assert!(!response.weeks.is_empty()); + } + #[test] fn test_stats_post_without_views() { let response = parse(NO_VIEWS); assert_eq!(response.views, 0); assert_eq!(response.highest_month, 0); - assert_eq!(response.like_count, 0); - assert_eq!(response.discussion.comment_count, 0); + assert_eq!(response.like_count, Some(0)); + assert_eq!(response.discussion.expect("present").comment_count, 0); // The API sends `months` as an empty array rather than an empty object. let year = response.years.get("2026").expect("2026 should exist"); diff --git a/wp_api/tests/wpcom/stats_post/homepage.json b/wp_api/tests/wpcom/stats_post/homepage.json new file mode 100644 index 000000000..a6a1fea2e --- /dev/null +++ b/wp_api/tests/wpcom/stats_post/homepage.json @@ -0,0 +1,83 @@ +{ + "date": "2026-08-06", + "views": 74286, + "years": { + "2013": { + "months": { + "5": 866, + "6": 3822, + "7": 3214, + "8": 1561, + "9": 1263, + "10": 1201, + "11": 1131, + "12": 1218 + }, + "total": 14276 + } + }, + "averages": { + "2013": { + "months": { + "5": 66, + "6": 127, + "7": 103, + "8": 50, + "9": 42, + "10": 38, + "11": 37, + "12": 39 + }, + "overall": 62 + } + }, + "weeks": [ + { + "days": [ + { + "day": "2026-08-03", + "count": 5 + }, + { + "day": "2026-08-04", + "count": 21 + }, + { + "day": "2026-08-05", + "count": 8 + }, + { + "day": "2026-08-06", + "count": 6 + } + ], + "total": 40, + "average": 11, + "change": 1.7094017094017318 + } + ], + "fields": [ + "period", + "views" + ], + "data": [ + [ + "2013-05-19", + 73 + ], + [ + "2013-05-20", + 144 + ], + [ + "2013-05-21", + 14 + ] + ], + "highest_month": 3822, + "highest_day_average": 127, + "highest_week_average": 89, + "like_count": null, + "discussion": null, + "post": false +} diff --git a/wp_com_e2e/src/stats_post_tests.rs b/wp_com_e2e/src/stats_post_tests.rs index c6e0b35b8..bbc1c7855 100644 --- a/wp_com_e2e/src/stats_post_tests.rs +++ b/wp_com_e2e/src/stats_post_tests.rs @@ -1,6 +1,7 @@ use libtest_mimic::Trial; use std::sync::Arc; use wp_api::{ + api_error::WpApiError, posts::PostId, wp_com::{ WpComSiteId, @@ -48,6 +49,44 @@ pub fn tests(ctx: Arc) -> Vec { } }, )); + + // `PostId(0)` is the site's home page rather than a post, so the API + // omits the post, discussion, and like fields. Every site has one, + // so this needs no lookup. + trials.push(Trial::test( + format!("post_stats::get_stats_post_homepage::{}", site_id), + { + let ctx = Arc::clone(&ctx); + move || { + ctx.runtime.block_on(async { + let result = ctx + .client + .stats_post() + .get_stats_post(&site_id, &PostId(0)) + .await; + + match result { + Ok(response) => { + if response.data.post.is_some() { + return Err( + "the home page should have no post details".into() + ); + } + Ok(()) + } + // Test sites without Jetpack Stats have nothing to + // report; that isn't a failure of this endpoint. + Err(WpApiError::UnknownError { response, .. }) + if response.contains("invalid_blog") => + { + Ok(()) + } + Err(e) => Err(format!("{e:?}").into()), + } + }) + } + }, + )); } } diff --git a/wp_serde_helper/src/json.rs b/wp_serde_helper/src/json.rs index b3d10c0d5..9c5d8ee9c 100644 --- a/wp_serde_helper/src/json.rs +++ b/wp_serde_helper/src/json.rs @@ -14,6 +14,33 @@ where Ok(serde_json::from_value(value).ok()) } +/// Deserialize an optional value the API may send as boolean `false` when it is +/// absent, which PHP endpoints do in place of `null`. +/// +/// Accepts: +/// - Boolean `false` (or `true`) → `None` +/// - `null` or a missing field → `None` +/// - Anything else → deserialized as `T` +/// +/// This is the type-generic counterpart of [`crate::deserialize_false_or_string`] +/// and the `false`-tolerant helpers in [`crate::numeric`]. +/// +/// # Errors +/// +/// Returns an error if the value is neither a boolean nor a valid `T`. +pub fn deserialize_false_as_none<'de, T, D>(deserializer: D) -> Result, D::Error> +where + T: DeserializeOwned, + D: de::Deserializer<'de>, +{ + match serde_json::Value::deserialize(deserializer)? { + serde_json::Value::Bool(_) | serde_json::Value::Null => Ok(None), + value => serde_json::from_value(value) + .map(Some) + .map_err(de::Error::custom), + } +} + /// Serialize a value as a JSON string embedded within the parent JSON structure. /// /// This function serializes the value to a JSON string, then embeds that string @@ -215,4 +242,36 @@ mod tests { let result: TreatErrorAsNone = serde_json::from_str(json).unwrap(); assert_eq!(result.inner, None); } + + #[derive(Debug, Deserialize, PartialEq)] + struct FalseAsNoneValue { + id: u64, + } + + #[derive(Debug, Deserialize, PartialEq)] + struct FalseAsNone { + #[serde(deserialize_with = "deserialize_false_as_none")] + inner: Option, + } + + #[rstest] + #[case(r#"{"inner": false}"#, None)] + #[case(r#"{"inner": true}"#, None)] + #[case(r#"{"inner": null}"#, None)] + #[case(r#"{"inner": {"id": 7}}"#, Some(FalseAsNoneValue { id: 7 }))] + fn test_deserialize_false_as_none( + #[case] json: &str, + #[case] expected: Option, + ) { + let result: FalseAsNone = serde_json::from_str(json).unwrap(); + assert_eq!(result.inner, expected); + } + + #[test] + fn test_deserialize_false_as_none_rejects_a_malformed_value() { + // Only booleans and null stand in for absence; a wrong-shaped object is + // still an error rather than being silently dropped. + let result: Result = serde_json::from_str(r#"{"inner": {"id": "x"}}"#); + assert!(result.is_err()); + } } From ae229223a02c28bcb0b729b680201000634d8158 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Thu, 6 Aug 2026 13:58:30 -0400 Subject: [PATCH 7/8] Trim the stats fixtures The three fixtures carried 314 lines where 191 exercise the same code paths. No assertion changed. - Month maps went from 7-8 entries per year to two; the shape under test is the map, not its length. - `homepage.json` came out of the capture with every day and data row expanded over four lines. Reformatting it to match the other two fixtures accounts for about a fifth of the saving on its own. - The unmodelled fields on the post row went from 13 to 3. They exist to prove serde ignores what we don't model, and three do that as well as thirteen. Kept `post_content` (a large ignored field), `comment_count` (the string-typed one we deliberately read from `discussion` instead), and `filter`. - `post-no-views.json` had two identical `months: []` years and two full-length all-zero weeks; one year and two short weeks still cover both quirks it tests. Everything asserted survives: the seven-day first week, the four-day partial week, the `{"isInfinity": true}` third week, all five data rows, and every scalar. --- wp_api/tests/wpcom/stats_post/homepage.json | 62 +++-------------- .../tests/wpcom/stats_post/post-no-views.json | 39 +---------- .../wpcom/stats_post/post-with-views.json | 66 +++---------------- 3 files changed, 22 insertions(+), 145 deletions(-) diff --git a/wp_api/tests/wpcom/stats_post/homepage.json b/wp_api/tests/wpcom/stats_post/homepage.json index a6a1fea2e..eb72d7be0 100644 --- a/wp_api/tests/wpcom/stats_post/homepage.json +++ b/wp_api/tests/wpcom/stats_post/homepage.json @@ -3,80 +3,38 @@ "views": 74286, "years": { "2013": { - "months": { - "5": 866, - "6": 3822, - "7": 3214, - "8": 1561, - "9": 1263, - "10": 1201, - "11": 1131, - "12": 1218 - }, + "months": { "5": 866, "6": 3822 }, "total": 14276 } }, "averages": { "2013": { - "months": { - "5": 66, - "6": 127, - "7": 103, - "8": 50, - "9": 42, - "10": 38, - "11": 37, - "12": 39 - }, + "months": { "5": 66, "6": 127 }, "overall": 62 } }, "weeks": [ { "days": [ - { - "day": "2026-08-03", - "count": 5 - }, - { - "day": "2026-08-04", - "count": 21 - }, - { - "day": "2026-08-05", - "count": 8 - }, - { - "day": "2026-08-06", - "count": 6 - } + { "day": "2026-08-05", "count": 8 }, + { "day": "2026-08-06", "count": 6 } ], "total": 40, "average": 11, "change": 1.7094017094017318 } ], - "fields": [ - "period", - "views" - ], + "fields": ["period", "views"], "data": [ - [ - "2013-05-19", - 73 - ], - [ - "2013-05-20", - 144 - ], - [ - "2013-05-21", - 14 - ] + ["2013-05-19", 73], + ["2013-05-20", 144], + ["2013-05-21", 14] ], "highest_month": 3822, "highest_day_average": 127, "highest_week_average": 89, + + "_comment": "The home page has no post behind it: the API sends `like_count` and `discussion` as null, and `post` as boolean false rather than null.", "like_count": null, "discussion": null, "post": false diff --git a/wp_api/tests/wpcom/stats_post/post-no-views.json b/wp_api/tests/wpcom/stats_post/post-no-views.json index c32a860fc..32ba2b25a 100644 --- a/wp_api/tests/wpcom/stats_post/post-no-views.json +++ b/wp_api/tests/wpcom/stats_post/post-no-views.json @@ -2,20 +2,12 @@ "date": "2026-08-06", "views": 0, "years": { - "2025": { - "months": [], - "total": 0 - }, "2026": { "months": [], "total": 0 } }, "averages": { - "2025": { - "months": [], - "overall": 0 - }, "2026": { "months": [], "overall": 0 @@ -24,11 +16,6 @@ "weeks": [ { "days": [ - { "day": "2026-07-27", "count": 0 }, - { "day": "2026-07-28", "count": 0 }, - { "day": "2026-07-29", "count": 0 }, - { "day": "2026-07-30", "count": 0 }, - { "day": "2026-07-31", "count": 0 }, { "day": "2026-08-01", "count": 0 }, { "day": "2026-08-02", "count": 0 } ], @@ -38,8 +25,6 @@ }, { "days": [ - { "day": "2026-08-03", "count": 0 }, - { "day": "2026-08-04", "count": 0 }, { "day": "2026-08-05", "count": 0 }, { "day": "2026-08-06", "count": 0 } ], @@ -48,10 +33,7 @@ "change": 0 } ], - "fields": [ - "period", - "views" - ], + "fields": ["period", "views"], "data": [ ["2026-08-04", 0], ["2026-08-05", 0], @@ -61,33 +43,18 @@ "highest_day_average": 0, "highest_week_average": 0, "like_count": 0, - "discussion": { - "comment_count": 0 - }, + "discussion": { "comment_count": 0 }, "post": { "ID": 169, "post_author": "1", "post_date": "2026-06-11 14:22:01", "post_date_gmt": "2026-06-11 14:22:01", - "post_content": "", "post_title": "A Quiet Post", - "post_excerpt": "", "post_status": "publish", - "comment_status": "open", - "ping_status": "open", - "post_password": "", "post_name": "a-quiet-post", - "to_ping": "", - "pinged": "", "post_modified": "2026-06-11 14:22:01", - "post_modified_gmt": "2026-06-11 14:22:01", - "post_content_filtered": "", - "post_parent": 0, "guid": "https://example.com/?p=169", - "menu_order": 0, "post_type": "post", - "post_mime_type": "", - "comment_count": "0", - "filter": "raw" + "comment_count": "0" } } diff --git a/wp_api/tests/wpcom/stats_post/post-with-views.json b/wp_api/tests/wpcom/stats_post/post-with-views.json index 20dafa219..b32e5dc36 100644 --- a/wp_api/tests/wpcom/stats_post/post-with-views.json +++ b/wp_api/tests/wpcom/stats_post/post-with-views.json @@ -3,55 +3,21 @@ "views": 19096, "years": { "2013": { - "months": { - "6": 3224, - "7": 1250, - "8": 562, - "9": 321, - "10": 315, - "11": 258, - "12": 216 - }, + "months": { "6": 3224, "7": 1250 }, "total": 6146 }, "2026": { - "months": { - "1": 23, - "2": 18, - "3": 13, - "4": 16, - "5": 27, - "6": 29, - "7": 12, - "8": 9 - }, + "months": { "8": 9 }, "total": 147 } }, "averages": { "2013": { - "months": { - "6": 293, - "7": 40, - "8": 18, - "9": 10, - "10": 10, - "11": 8, - "12": 6 - }, + "months": { "6": 293, "7": 40 }, "overall": 31 }, "2026": { - "months": { - "1": 0, - "2": 0, - "3": 0, - "4": 0, - "5": 0, - "6": 0, - "7": 0, - "8": 1 - }, + "months": { "8": 1 }, "overall": 0 } }, @@ -91,10 +57,7 @@ "change": { "isInfinity": true } } ], - "fields": [ - "period", - "views" - ], + "fields": ["period", "views"], "data": [ ["2013-06-20", 1194], ["2013-06-21", 504], @@ -106,32 +69,21 @@ "highest_day_average": 293, "highest_week_average": 3, "like_count": 9, - "discussion": { - "comment_count": 48 - }, + "discussion": { "comment_count": 48 }, "post": { "ID": 2729, "post_author": "5399133", "post_date": "2013-06-20 09:15:49", "post_date_gmt": "2013-06-20 13:15:49", - "post_content": "The wait is over.", "post_title": "The Last Version of FeedDemon is Here, and it's Free", - "post_excerpt": "", "post_status": "publish", - "comment_status": "closed", - "ping_status": "closed", - "post_password": "", "post_name": "the-last-version-of-feeddemon-is-here-and-its-free", - "to_ping": "", - "pinged": "", "post_modified": "2013-06-23 21:57:23", - "post_modified_gmt": "2013-06-24 01:57:23", - "post_content_filtered": "", - "post_parent": 0, "guid": "https://example.com/?p=2729", - "menu_order": 0, "post_type": "post", - "post_mime_type": "", + + "_comment": "Fields below are sent by the API but deliberately not modelled; they must be ignored rather than break parsing.", + "post_content": "The wait is over.", "comment_count": "48", "filter": "raw" } From df47723e352cf0cd7f456f3178bcf96d34ee24ef Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Sat, 8 Aug 2026 18:43:13 -0400 Subject: [PATCH 8/8] Model the per-post stats response against the wp.com source (#1526) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Reject `true` in `deserialize_false_as_none`, and correct its doc The helper mapped every boolean to `None`, including `true`. Its siblings all reject `true` explicitly — `deserialize_false_or_string`, `deserialize_false_or_string_or_null` and `deserialize_u64_or_none` — so a value the API is not expected to send was being silently swallowed here alone. A test case pinned the divergence. The doc also claimed a missing field yields `None`. Serde does not call `deserialize_with` for an absent key, so without `#[serde(default)]` a missing field is a hard error. The new test asserts both halves of that. Changes: - Match `Bool(false) | Null` for `None` and error on `Bool(true)` - Document the `#[serde(default)]` requirement for omitted fields - Move the `true` case into a new `_errors` test alongside the malformed-value case - Add a test covering a missing field with and without `default` * Model the per-post stats response against the wp.com source Checking the module against `class.wpcom-json-api-stats-post-views-v1-1- endpoint.php` and `stats_get_post()` turned up types that overstate what the API sends, and docs that describe fields it doesn't have. Address the home page as `StatsPostTarget` rather than `PostId(0)`. Zero is not a valid `PostId` anywhere else in the crate, and what the API counts for it is not obvious enough to leave in a doc comment. `stats_utm` already takes a typed path param this way. `average`, `averages.overall` and `averages.months` arrive as integers — PHP casts all three before sending, and FluxC has modelled them as `Int` for years. Only `change` is a genuine float. Changes: - Add `StatsPostTarget`, with `Post { id }` and `HomePage` variants, and take it as the endpoint's path argument - Type the three average fields as `u64` - Type `post_author` as `UserId` and `post_date_gmt` as `WpGmtDateTime` - Add `post_modified_gmt` and `post_excerpt`, both sent but unmodelled - Serialize the response back into the `fields`/`data` column table it parses from, so a serialized response can be read again - Correct `highest_week_average`, which is the highest single-day count of recent weeks rather than a weekly average, and `highest_day_average`, which is a monthly average of daily views - Document that the home page's figures cover the whole site when the front page is static, and that `post` is then `null` rather than `false` - Document that `daily_views` is also empty for a never-viewed target, whose history the API replaces with one unusable placeholder row, and cover that row in the column-handling test - Give `post` a `serde` default, matching the two sibling fields - Make the fixtures' weeks self-consistent: only the current week is partial, `average` follows from the days, and `isInfinity` only follows a zero week - Name the e2e trials `post::` to match the other stats trials, and resolve the borrowed post id with `try_from` * Model `permalink`, and correct the no-views shape against real responses Captured four responses from a live site to settle two claims the source alone couldn't. Both were wrong in the module, in opposite directions. `stats_get_post()` does attach a `permalink`, and it reaches the wire — it is the 25th key on the post row, after `filter`. The doc comment asserted the response carried no permalink, and the field was unmodelled, so callers had no way to reach the post's URL. A never-viewed post does not get the API's no-history fallback row. Its daily history is padded from the publication date to today with integer zeros, so `daily_views` is populated, not empty. What does change is `years` and `averages`: with no view to anchor on, the API reports every year from 1970 to the present, each with an empty month map — 57 entries for a post published last year. `post-no-views.json` described neither shape. It carried a single 2026 year, which no response can produce. Changes: - Add `StatsPostDetails::permalink`, and point `guid` at it - Note that the post row carries no featured image - Replace the `daily_views` emptiness note with the padding behaviour - Document the 1970 year range on `years` - Rebuild `post-no-views.json` from a real never-viewed response, and rename its test to match what it covers - Keep the fallback-row case in the column-handling test, relabelled: the server can still emit it, but it is not the never-viewed path * Trim the per-post stats diff to the changes the API requires Comments and tests that explained serde or documented what the types used to be, rather than what the endpoint sends. Changes: - Drop the `#[serde(default)]` guidance from `deserialize_false_as_none` and the test asserting serde's missing-field behaviour - Drop `#[serde(default)]` from the response's `post` field; the endpoint always sends it - Restore the fixtures' weeks and the assertions over them, and add only the three fields the post row gained - Restore the e2e comments and the `homepage` trial name - Cut the daily-view case for the API's no-history fallback row, which is not a shape the endpoint was seen to send - Cut the notes on how `permalink` is derived and on the absent featured image - Use `fmt::Display` rather than a fully qualified path * Keep the e2e trial names and the post id cast as they were `post_stats::` says what the trials cover; `post::` names a noun and sits next to `top_posts::`, where it reads like a posts endpoint rather than a stats one. The other stats trials drop their `stats_` prefix because what remains still describes the endpoint, which isn't true here. Changes: - Restore the `post_stats::` trial prefix - Restore the `as i64` cast on the borrowed post id * Match the crate's error style, and unattach the column comment Changes: - Report the rejected `true` with `invalid_value`, as the visitors in `numeric.rs` do, rather than a custom message - Use `//` for the note above the two column consts; as a doc comment it attached to `PERIOD_COLUMN` alone * Assert the whole response round trips, over every fixture The test compared only `daily_views`, so it covered the field the column table flattens into and nothing else. Re-serializing the reparsed value and comparing covers every field without needing `PartialEq` on the record. Runs over all three fixtures. The home page is the case worth having: its `post` arrives as boolean `false`, becomes `None`, and serializes as `null`, so it is the only fixture where the round trip changes the wire shape. Compares `serde_json::Value` rather than the serialized strings, since the response holds `HashMap`s and two maps built from the same JSON do not iterate in the same order. * Cover the 1970 year range in the no-views test `years` documents that a target with no views gets an entry for every year from 1970, and the fixture carries them, but nothing asserted it. * Let a missing `permalink` be `None` rather than a parse failure `permalink` is derived per request rather than read from a column, so it is the field in the post row most likely to move. Requiring it meant an unexpected shape cost the whole response, for a URL the response is still useful without. Changes: - Type `permalink` as `Option`, read through `deserialize_false_as_none` with a serde default * Convert a `PostId` to a target, resolving the home page id `/stats/top-posts` reports the home page as a pseudo-entry with id 0, so callers feeding those ids into this endpoint have to know what 0 means. The conversion puts the rule in one place. Changes: - Add `impl From for StatsPostTarget`, mapping the home page id to `HomePage` - Name the id as `HOME_PAGE_POST_ID` rather than repeating the literal * Type the post's author as `WpComUserId` `post_author` on a WordPress.com site carries the account's global id, which `WpComUserId` names. `UserId` is the wp.org site-scoped id. `deserialize_i64_or_string_as_t` had no `u64` counterpart, which `WpComUserId` needs. Changes: - Add `deserialize_u64_or_string_as_t` to `wp_serde_helper` - Type `StatsPostDetails::author_id` as `WpComUserId` --- CHANGELOG.md | 2 +- .../wp_com/endpoint/stats_post_endpoint.rs | 27 +- wp_api/src/wp_com/stats_post.rs | 243 ++++++++++++++---- .../tests/wpcom/stats_post/post-no-views.json | 17 +- .../wpcom/stats_post/post-with-views.json | 3 + wp_com_e2e/src/stats_post_tests.rs | 18 +- wp_serde_helper/src/json.rs | 26 +- wp_serde_helper/src/numeric.rs | 16 ++ 8 files changed, 271 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b88e8b4b..de43f7fd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- WordPress.com `GET /sites//stats/post/` endpoint for per-post stats. Returns the post's view history, like and comment counts, and post metadata — everything the "Latest Post Summary" card needs. The API's `fields`/`data` column table is flattened into `daily_views` while deserializing; callers wanting a trailing window slice its tail, which can run to thousands of entries. Passing `PostId(0)` returns stats for the site's home page, for which `post`, `discussion`, and `like_count` are `None`. - 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 - WordPress.com `POST /sites//domains/primary` endpoint for setting a site's primary domain +- WordPress.com `GET /sites//stats/post/` endpoint for a post's view history, like count, comment count, and metadata ### Changed diff --git a/wp_api/src/wp_com/endpoint/stats_post_endpoint.rs b/wp_api/src/wp_com/endpoint/stats_post_endpoint.rs index 5c1def46d..3912c4eee 100644 --- a/wp_api/src/wp_com/endpoint/stats_post_endpoint.rs +++ b/wp_api/src/wp_com/endpoint/stats_post_endpoint.rs @@ -1,13 +1,15 @@ use crate::{ - posts::PostId, request::endpoint::{AsNamespace, DerivedRequest}, - wp_com::{WpComNamespace, WpComSiteId, stats_post::StatsPostResponse}, + wp_com::{ + WpComNamespace, WpComSiteId, + stats_post::{StatsPostResponse, StatsPostTarget}, + }, }; use wp_derive_request_builder::WpDerivedRequest; #[derive(WpDerivedRequest)] enum StatsPostRequest { - #[get(url = "/sites//stats/post/", output = StatsPostResponse)] + #[get(url = "/sites//stats/post/", output = StatsPostResponse)] GetStatsPost, } @@ -21,6 +23,7 @@ impl DerivedRequest for StatsPostRequest { mod tests { use super::*; use crate::{ + posts::PostId, request::endpoint::ApiUrlResolver, wp_com::endpoint::tests::{ fixture_wp_com_api_url_resolver, validate_wp_com_rest_v1_1_endpoint, @@ -30,20 +33,30 @@ mod tests { use std::sync::Arc; #[rstest] - #[case::numeric_id(WpComSiteId(12345), PostId(2729), "/sites/12345/stats/post/2729")] + #[case::numeric_id( + WpComSiteId(12345), + StatsPostTarget::Post { id: PostId(2729) }, + "/sites/12345/stats/post/2729" + )] #[case::large_ids( WpComSiteId(229889220), - PostId(9007199254740991), + StatsPostTarget::Post { id: PostId(9007199254740991) }, "/sites/229889220/stats/post/9007199254740991" )] + // The API addresses the site's home page as post 0. + #[case::home_page( + WpComSiteId(12345), + StatsPostTarget::HomePage, + "/sites/12345/stats/post/0" + )] fn get_stats_post( endpoint: StatsPostRequestEndpoint, #[case] site_id: WpComSiteId, - #[case] post_id: PostId, + #[case] target: StatsPostTarget, #[case] expected_path: &str, ) { validate_wp_com_rest_v1_1_endpoint( - endpoint.get_stats_post(&site_id, &post_id), + endpoint.get_stats_post(&site_id, &target), expected_path, ); } diff --git a/wp_api/src/wp_com/stats_post.rs b/wp_api/src/wp_com/stats_post.rs index 689ae8c1f..42e62f8ec 100644 --- a/wp_api/src/wp_com/stats_post.rs +++ b/wp_api/src/wp_com/stats_post.rs @@ -1,48 +1,111 @@ -use crate::{posts::PostId, wp_com::stats_visits::StatsVisitsDataValue}; +use crate::{ + date::WpGmtDateTime, + posts::PostId, + wp_com::{me::WpComUserId, stats_visits::StatsVisitsDataValue}, +}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::{collections::HashMap, fmt}; use wp_serde_helper::{ - deserialize_empty_array_or_hashmap, deserialize_false_as_none, deserialize_u64_or_string, + deserialize_empty_array_or_hashmap, deserialize_false_as_none, deserialize_u64_or_string_as_t, }; +// The column names the API uses for the daily view history. +const PERIOD_COLUMN: &str = "period"; +const VIEWS_COLUMN: &str = "views"; + +/// The id the API addresses the site's home page by. +const HOME_PAGE_POST_ID: PostId = PostId(0); + +/// What a per-post stats request is about. +/// +/// The API addresses the site's home page as post `0`, which is not a valid +/// [`PostId`] anywhere else in the crate. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum StatsPostTarget { + /// A specific post or page. + Post { id: PostId }, + /// The site's home page. See [`StatsPostResponse`] for what the API counts + /// for it, which depends on how the site's front page is configured. + HomePage, +} + +impl fmt::Display for StatsPostTarget { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Post { id } => write!(f, "{id}"), + Self::HomePage => write!(f, "0"), + } + } +} + +impl From for StatsPostTarget { + /// Resolves the API's home page id, so callers working from a list that + /// includes it — `/stats/top-posts` reports one — don't each repeat the + /// check. + fn from(id: PostId) -> Self { + if id == HOME_PAGE_POST_ID { + Self::HomePage + } else { + Self::Post { id } + } + } +} + /// Response from the per-post stats endpoint. /// -/// The endpoint returns the post's complete view history, so +/// The endpoint returns the target's complete view history, so /// [`Self::daily_views`] can hold thousands of entries for a long-lived post. /// Callers that only need a trailing window (such as the "Latest Post Summary" -/// card) should slice the tail of it — `daily_views.suffix(7)` in Swift, +/// card) should slice the tail of it — `dailyViews.suffix(7)` in Swift, /// `dailyViews.takeLast(7)` in Kotlin. /// /// # The site's home page /// -/// Requesting `PostId(0)` returns view stats for the site's home page, which -/// `/stats/top-posts` reports as a pseudo-entry with that id. The home page -/// isn't a post, so [`Self::post`], [`Self::discussion`] and [`Self::like_count`] -/// are all `None` for it; every view field is populated as usual. +/// [`StatsPostTarget::HomePage`] requests post `0`, which `/stats/top-posts` +/// also reports as a pseudo-entry. What the view figures cover then depends on +/// how the site's front page is configured, and the two cases are +/// indistinguishable in the response: +/// +/// - a "latest posts" front page — the views recorded against the home page +/// - a static front page — the whole site's view history +/// +/// The home page is not a post either way, so [`Self::post`], +/// [`Self::discussion`] and [`Self::like_count`] are `None` for both. #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] -#[serde(from = "RawStatsPostResponse")] +#[serde(from = "RawStatsPostResponse", into = "RawStatsPostResponse")] pub struct StatsPostResponse { /// The date the stats were generated for (format: YYYY-MM-DD). pub date: String, - /// The post's all-time view count. + /// The target's all-time view count. pub views: u64, /// Yearly view totals, keyed by year (e.g. `"2026"`). + /// + /// A target with no recorded views gets an entry for every year from 1970 + /// to the present, each with an empty `months` map, rather than a map + /// covering only the years the target existed for. pub years: HashMap, - /// Yearly view averages, keyed by year (e.g. `"2026"`). + /// Yearly view averages, keyed by year (e.g. `"2026"`). Populated on the + /// same basis as [`Self::years`]. pub averages: HashMap, /// The most recent weeks of daily views, oldest first. pub weeks: Vec, - /// The post's complete daily view history, oldest first. + /// The target's complete daily view history, oldest first. /// /// The API sends this as a `fields`/`data` column table; it is flattened /// while deserializing so callers never handle the column indirection. - /// Empty if the response doesn't name both the `period` and `views` columns. + /// + /// The history is padded rather than sparse: a target with no views still + /// gets one zero-count entry per day since it was published. + /// + /// Empty if the response doesn't name both the `period` and `views` + /// columns. pub daily_views: Vec, - /// The highest view count the post reached in a single month. + /// The highest view count the target reached in a single month. pub highest_month: u64, - /// The highest daily view average the post reached. + /// The highest monthly average of daily views the target reached. pub highest_day_average: u64, - /// The highest weekly view average the post reached. + /// The highest single-day view count across the last few weeks. Despite the + /// name, this is neither a weekly figure nor an average. pub highest_week_average: u64, /// The post's like count. `None` for the site's home page. pub like_count: Option, @@ -54,7 +117,7 @@ pub struct StatsPostResponse { /// The response as the API sends it, before the `fields`/`data` column table is /// flattened into [`StatsPostResponse::daily_views`]. -#[derive(Deserialize)] +#[derive(Serialize, Deserialize)] struct RawStatsPostResponse { date: String, views: u64, @@ -70,8 +133,8 @@ struct RawStatsPostResponse { highest_month: u64, highest_day_average: u64, highest_week_average: u64, - // The home page (`PostId(0)`) has no post behind it, so the API sends these - // as `null` — and `post` as boolean `false` rather than `null`. + // The home page has no post behind it, so the API sends these as `null` — + // and `post` as boolean `false` rather than `null`. like_count: Option, discussion: Option, #[serde(deserialize_with = "deserialize_false_as_none")] @@ -97,6 +160,35 @@ impl From for StatsPostResponse { } } +impl From for RawStatsPostResponse { + fn from(response: StatsPostResponse) -> Self { + Self { + date: response.date, + views: response.views, + years: response.years, + averages: response.averages, + weeks: response.weeks, + fields: vec![PERIOD_COLUMN.to_string(), VIEWS_COLUMN.to_string()], + data: response + .daily_views + .into_iter() + .map(|daily_view| { + vec![ + StatsVisitsDataValue::String(daily_view.period), + StatsVisitsDataValue::Number(daily_view.views), + ] + }) + .collect(), + highest_month: response.highest_month, + highest_day_average: response.highest_day_average, + highest_week_average: response.highest_week_average, + like_count: response.like_count, + discussion: response.discussion, + post: response.post, + } + } +} + /// Flattens the `fields`/`data` column table into data points, skipping rows the /// columns can't be read from. /// @@ -104,8 +196,8 @@ impl From for StatsPostResponse { /// copied — the history runs to thousands of rows on a long-lived post. fn daily_views(fields: &[String], data: Vec>) -> Vec { let (Some(period_index), Some(views_index)) = ( - fields.iter().position(|field| field == "period"), - fields.iter().position(|field| field == "views"), + fields.iter().position(|field| field == PERIOD_COLUMN), + fields.iter().position(|field| field == VIEWS_COLUMN), ) else { return vec![]; }; @@ -149,13 +241,15 @@ pub struct StatsPostYear { } /// A year's view averages. +/// +/// The API truncates every average to a whole number before sending it. #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct StatsPostAverage { /// View averages keyed by month number (`"1"` through `"12"`). #[serde(deserialize_with = "deserialize_empty_array_or_hashmap")] - pub months: HashMap, + pub months: HashMap, /// The average views across the whole year. - pub overall: f64, + pub overall: u64, } /// A week of daily views. @@ -165,8 +259,9 @@ pub struct StatsPostWeek { pub days: Vec, /// The total views for the week. pub total: u64, - /// The average daily views for the week. - pub average: f64, + /// The average daily views for the week, truncated to a whole number by the + /// API. + pub average: u64, /// The change from the previous week, or `None` for the first week. pub change: Option, } @@ -175,7 +270,7 @@ pub struct StatsPostWeek { #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, uniffi::Enum)] #[serde(from = "RawStatsPostChange", into = "RawStatsPostChange")] pub enum StatsPostChange { - /// The percentage change from the previous week. + /// The percentage change from the previous week. Negative when views fell. Percentage { value: f64 }, /// The previous week had no views, so the change is unbounded. The API /// sends this as `{"isInfinity": true}` because the underlying value is @@ -186,10 +281,9 @@ pub enum StatsPostChange { /// The wire representations the API uses for a week's `change`. /// /// These three shapes — a number, `{"isInfinity": true}`, and `null` (handled by -/// the surrounding `Option`) — are the only ones observed across 60 real -/// responses spanning 15 sites. A week following a zero-view week always reports -/// an integer `0` rather than a not-a-number marker, so there is no `isNan` -/// counterpart to model. +/// the surrounding `Option`) — are the only ones the endpoint produces. A week +/// following a zero-view week reports an integer `0` rather than a not-a-number +/// marker, so there is no `isNan` counterpart to model. #[derive(Serialize, Deserialize)] #[serde(untagged)] enum RawStatsPostChange { @@ -239,8 +333,8 @@ pub struct StatsPostDiscussion { /// The post the stats belong to. /// /// This mirrors WordPress' raw post row, so it carries the post's editorial -/// metadata but not a permalink. Fields the API sends that aren't modelled here -/// (post content, ping status, and similar) are ignored. +/// metadata. Fields the API sends that aren't modelled here (post content, +/// ping status, and similar) are ignored. /// /// The row's `comment_count` is deliberately omitted: the API sends it as a /// string here, and [`StatsPostDiscussion::comment_count`] carries the same @@ -251,17 +345,26 @@ pub struct StatsPostDetails { #[serde(rename = "ID")] pub id: PostId, /// The post's title. + /// + /// When the stats service can't resolve the stored title, the API + /// substitutes a generated placeholder of the form `# (not found)`. #[serde(rename = "post_title")] pub title: String, + /// The post's excerpt. Empty when the post has none. + #[serde(rename = "post_excerpt")] + pub excerpt: String, /// The post's publication date in the site's timezone (format: YYYY-MM-DD HH:MM:SS). #[serde(rename = "post_date")] pub date: String, - /// The post's publication date in GMT (format: YYYY-MM-DD HH:MM:SS). + /// The post's publication date in GMT. #[serde(rename = "post_date_gmt")] - pub date_gmt: String, - /// The date the post was last modified (format: YYYY-MM-DD HH:MM:SS). + pub date_gmt: WpGmtDateTime, + /// The date the post was last modified, in the site's timezone (format: YYYY-MM-DD HH:MM:SS). #[serde(rename = "post_modified")] pub modified: String, + /// The date the post was last modified, in GMT. + #[serde(rename = "post_modified_gmt")] + pub modified_gmt: WpGmtDateTime, /// The post's slug. #[serde(rename = "post_name")] pub slug: String, @@ -271,9 +374,19 @@ pub struct StatsPostDetails { /// The post's type, e.g. `"post"` or `"page"`. pub post_type: String, /// The ID of the post's author. - #[serde(rename = "post_author", deserialize_with = "deserialize_u64_or_string")] - pub author_id: u64, - /// The post's globally unique identifier. Not a permalink. + #[serde( + rename = "post_author", + deserialize_with = "deserialize_u64_or_string_as_t" + )] + pub author_id: WpComUserId, + /// The post's public URL. `None` if the API doesn't supply one. + /// + /// Unlike the fields around it this isn't a stored column — the stats + /// service derives it per request. + #[serde(default, deserialize_with = "deserialize_false_as_none")] + pub permalink: Option, + /// The post's globally unique identifier. Use [`Self::permalink`] for a + /// URL that resolves. pub guid: String, } @@ -299,6 +412,13 @@ mod tests { daily_views(&fields, data) } + #[rstest] + #[case::home_page(PostId(0), StatsPostTarget::HomePage)] + #[case::post(PostId(2729), StatsPostTarget::Post { id: PostId(2729) })] + fn test_stats_post_target_from_post_id(#[case] id: PostId, #[case] expected: StatsPostTarget) { + assert_eq!(StatsPostTarget::from(id), expected); + } + #[test] fn test_stats_post_response_details() { let response = parse(WITH_VIEWS); @@ -316,8 +436,8 @@ mod tests { assert_eq!(year.months.get("6"), Some(&3224)); let average = response.averages.get("2013").expect("2013 should exist"); - assert_eq!(average.overall, 31.0); - assert_eq!(average.months.get("6"), Some(&293.0)); + assert_eq!(average.overall, 31); + assert_eq!(average.months.get("6"), Some(&293)); } #[test] @@ -329,9 +449,14 @@ mod tests { post.title, "The Last Version of FeedDemon is Here, and it's Free" ); + assert_eq!(post.excerpt, "The wait is over."); assert_eq!(post.date, "2013-06-20 09:15:49"); - assert_eq!(post.date_gmt, "2013-06-20 13:15:49"); + assert_eq!(post.date_gmt.0.to_rfc3339(), "2013-06-20T13:15:49+00:00"); assert_eq!(post.modified, "2013-06-23 21:57:23"); + assert_eq!( + post.modified_gmt.0.to_rfc3339(), + "2013-06-24T01:57:23+00:00" + ); assert_eq!( post.slug, "the-last-version-of-feeddemon-is-here-and-its-free" @@ -339,8 +464,14 @@ mod tests { assert_eq!(post.status, "publish"); assert_eq!(post.post_type, "post"); assert_eq!(post.guid, "https://example.com/?p=2729"); + assert_eq!( + post.permalink.as_deref(), + Some( + "https://example.com/2013/06/20/the-last-version-of-feeddemon-is-here-and-its-free/" + ) + ); // The API sends `post_author` as a string. - assert_eq!(post.author_id, 5399133); + assert_eq!(post.author_id, WpComUserId(5399133)); } #[test] @@ -354,7 +485,7 @@ mod tests { assert_eq!(first.days[0].day, "2026-06-29"); assert_eq!(first.days[0].count, 2); assert_eq!(first.total, 7); - assert_eq!(first.average, 1.0); + assert_eq!(first.average, 1); assert!(first.change.is_none(), "the first week has no prior week"); let second = &weeks[1]; @@ -387,6 +518,22 @@ mod tests { ); } + #[rstest] + #[case::with_views(WITH_VIEWS)] + #[case::no_views(NO_VIEWS)] + #[case::homepage(HOMEPAGE)] + fn test_stats_post_response_round_trips(#[case] json_file_path: &str) { + let serialized = + serde_json::to_value(parse(json_file_path)).expect("Unable to serialize response"); + let reparsed: StatsPostResponse = + serde_json::from_value(serialized.clone()).expect("Unable to parse JSON"); + + assert_eq!( + serde_json::to_value(reparsed).expect("Unable to serialize response"), + serialized + ); + } + #[test] fn test_stats_post_daily_views() { let daily_views = parse(WITH_VIEWS).daily_views; @@ -441,7 +588,7 @@ mod tests { #[test] fn test_stats_post_homepage() { - // `PostId(0)` is the site's home page. It isn't a post, so the API sends + // Post 0 is the site's home page. It isn't a post, so the API sends // `like_count` and `discussion` as null and `post` as boolean `false`, // while every view field is populated as usual. let response = parse(HOMEPAGE); @@ -471,13 +618,17 @@ mod tests { assert_eq!(response.like_count, Some(0)); assert_eq!(response.discussion.expect("present").comment_count, 0); + // With no view to anchor on, the API reports every year from 1970. + assert!(response.years.contains_key("1970")); + assert!(response.averages.contains_key("1970")); + // The API sends `months` as an empty array rather than an empty object. let year = response.years.get("2026").expect("2026 should exist"); assert_eq!(year.total, 0); assert!(year.months.is_empty()); let average = response.averages.get("2026").expect("2026 should exist"); - assert_eq!(average.overall, 0.0); + assert_eq!(average.overall, 0); assert!(average.months.is_empty()); assert_eq!(response.daily_views.len(), 3); diff --git a/wp_api/tests/wpcom/stats_post/post-no-views.json b/wp_api/tests/wpcom/stats_post/post-no-views.json index 32ba2b25a..aa9f66da6 100644 --- a/wp_api/tests/wpcom/stats_post/post-no-views.json +++ b/wp_api/tests/wpcom/stats_post/post-no-views.json @@ -2,16 +2,14 @@ "date": "2026-08-06", "views": 0, "years": { - "2026": { - "months": [], - "total": 0 - } + "1970": { "months": [], "total": 0 }, + "1971": { "months": [], "total": 0 }, + "2026": { "months": [], "total": 0 } }, "averages": { - "2026": { - "months": [], - "overall": 0 - } + "1970": { "months": [], "overall": 0 }, + "1971": { "months": [], "overall": 0 }, + "2026": { "months": [], "overall": 0 } }, "weeks": [ { @@ -50,10 +48,13 @@ "post_date": "2026-06-11 14:22:01", "post_date_gmt": "2026-06-11 14:22:01", "post_title": "A Quiet Post", + "post_excerpt": "", "post_status": "publish", "post_name": "a-quiet-post", "post_modified": "2026-06-11 14:22:01", + "post_modified_gmt": "2026-06-11 14:22:01", "guid": "https://example.com/?p=169", + "permalink": "https://example.com/2026/06/11/a-quiet-post/", "post_type": "post", "comment_count": "0" } diff --git a/wp_api/tests/wpcom/stats_post/post-with-views.json b/wp_api/tests/wpcom/stats_post/post-with-views.json index b32e5dc36..f09b5ec7b 100644 --- a/wp_api/tests/wpcom/stats_post/post-with-views.json +++ b/wp_api/tests/wpcom/stats_post/post-with-views.json @@ -76,10 +76,13 @@ "post_date": "2013-06-20 09:15:49", "post_date_gmt": "2013-06-20 13:15:49", "post_title": "The Last Version of FeedDemon is Here, and it's Free", + "post_excerpt": "The wait is over.", "post_status": "publish", "post_name": "the-last-version-of-feeddemon-is-here-and-its-free", "post_modified": "2013-06-23 21:57:23", + "post_modified_gmt": "2013-06-24 01:57:23", "guid": "https://example.com/?p=2729", + "permalink": "https://example.com/2013/06/20/the-last-version-of-feeddemon-is-here-and-its-free/", "post_type": "post", "_comment": "Fields below are sent by the API but deliberately not modelled; they must be ignored rather than break parsing.", diff --git a/wp_com_e2e/src/stats_post_tests.rs b/wp_com_e2e/src/stats_post_tests.rs index bbc1c7855..a106f7c02 100644 --- a/wp_com_e2e/src/stats_post_tests.rs +++ b/wp_com_e2e/src/stats_post_tests.rs @@ -6,6 +6,7 @@ use wp_api::{ wp_com::{ WpComSiteId, sites::SitesListParams, + stats_post::StatsPostTarget, stats_top_posts::{StatsTopPostsParams, StatsTopPostsPeriod}, }, }; @@ -34,14 +35,14 @@ pub fn tests(ctx: Arc) -> Vec { // The endpoint needs a real post, so borrow one from the // site's top posts. Resolving it here rather than during // collection keeps the lookup off unrelated test runs. - let Some(post_id) = most_viewed_post_id(&ctx, &site_id) else { + let Some(target) = most_viewed_post(&ctx, &site_id) else { return Ok(()); }; ctx.runtime.block_on(async { ctx.client .stats_post() - .get_stats_post(&site_id, &post_id) + .get_stats_post(&site_id, &target) .await .map_err(|e| e.to_string())?; Ok(()) @@ -50,9 +51,8 @@ pub fn tests(ctx: Arc) -> Vec { }, )); - // `PostId(0)` is the site's home page rather than a post, so the API - // omits the post, discussion, and like fields. Every site has one, - // so this needs no lookup. + // The home page isn't a post, so the API omits the post, discussion, + // and like fields. Every site has one, so this needs no lookup. trials.push(Trial::test( format!("post_stats::get_stats_post_homepage::{}", site_id), { @@ -62,7 +62,7 @@ pub fn tests(ctx: Arc) -> Vec { let result = ctx .client .stats_post() - .get_stats_post(&site_id, &PostId(0)) + .get_stats_post(&site_id, &StatsPostTarget::HomePage) .await; match result { @@ -93,7 +93,7 @@ pub fn tests(ctx: Arc) -> Vec { trials } -fn most_viewed_post_id(ctx: &TestContext, site_id: &WpComSiteId) -> Option { +fn most_viewed_post(ctx: &TestContext, site_id: &WpComSiteId) -> Option { // Look back over several years rather than the default single day, so quiet // test sites still yield a post. let params = StatsTopPostsParams { @@ -117,5 +117,7 @@ fn most_viewed_post_id(ctx: &TestContext, site_id: &WpComSiteId) -> Option(deserializer: D) -> Result, D::Error> where T: DeserializeOwned, D: de::Deserializer<'de>, { match serde_json::Value::deserialize(deserializer)? { - serde_json::Value::Bool(_) | serde_json::Value::Null => Ok(None), + serde_json::Value::Bool(false) | serde_json::Value::Null => Ok(None), + serde_json::Value::Bool(true) => Err(de::Error::invalid_value( + de::Unexpected::Bool(true), + &"boolean `false`, `null`, or a value", + )), value => serde_json::from_value(value) .map(Some) .map_err(de::Error::custom), @@ -256,7 +261,6 @@ mod tests { #[rstest] #[case(r#"{"inner": false}"#, None)] - #[case(r#"{"inner": true}"#, None)] #[case(r#"{"inner": null}"#, None)] #[case(r#"{"inner": {"id": 7}}"#, Some(FalseAsNoneValue { id: 7 }))] fn test_deserialize_false_as_none( @@ -267,11 +271,11 @@ mod tests { assert_eq!(result.inner, expected); } - #[test] - fn test_deserialize_false_as_none_rejects_a_malformed_value() { - // Only booleans and null stand in for absence; a wrong-shaped object is - // still an error rather than being silently dropped. - let result: Result = serde_json::from_str(r#"{"inner": {"id": "x"}}"#); - assert!(result.is_err()); + #[rstest] + #[case::boolean_true(r#"{"inner": true}"#)] + #[case::malformed_value(r#"{"inner": {"id": "x"}}"#)] + fn test_deserialize_false_as_none_errors(#[case] json: &str) { + let result = serde_json::from_str::(json); + assert!(result.is_err(), "The deserializer should emit an error"); } } diff --git a/wp_serde_helper/src/numeric.rs b/wp_serde_helper/src/numeric.rs index 39f899ee4..fa7e3ffbb 100644 --- a/wp_serde_helper/src/numeric.rs +++ b/wp_serde_helper/src/numeric.rs @@ -51,6 +51,22 @@ where deserialize_i64_or_string(deserializer).map(Into::into) } +/// Deserialize a `u64` and convert it to a type that implements `From`. +/// +/// This is useful for deserializing into newtype wrappers around `u64`. +/// +/// # Errors +/// +/// Returns an error for negative numbers, non-numeric strings, booleans, null, +/// arrays, or objects. +pub fn deserialize_u64_or_string_as_t<'de, D, T>(deserializer: D) -> Result +where + D: Deserializer<'de>, + T: From, +{ + deserialize_u64_or_string(deserializer).map(Into::into) +} + /// Deserialize an optional `u64`, treating `false` and `null` as `None`. /// /// Accepts: