Skip to content

Commit 6d910f9

Browse files
nbradburyoguzkocer
andauthored
Add wp.com GET /sites/<site_id>/stats/post/<post_id> endpoint (#1489)
* Add wp.com `GET /sites/<site_id>/stats/post/<post_id>` endpoint 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. * Make `recent_daily_views` walk only the window it returns 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. * Flatten the daily view history at parse time 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. * Rename `stats_post_views` to `stats_post` 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`. * Simplify the per-post stats module 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. * Support home page stats (`PostId(0)`) `/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. * 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. * Model the per-post stats response against the wp.com source (#1526) * 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<String>`, 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<PostId> 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` --------- Co-authored-by: Oguz Kocer <oguzkocer@users.noreply.github.com> Co-authored-by: Oguz Kocer <oguz.kocer@automattic.com>
1 parent 00f147b commit 6d910f9

14 files changed

Lines changed: 1116 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- WordPress.com `GET /sites/<site_id>/purchases` endpoint for listing a site's purchases (plans, domains, and other subscriptions)
1414
- 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
1515
- WordPress.com `POST /sites/<site_id>/domains/primary` endpoint for setting a site's primary domain
16+
- WordPress.com `GET /sites/<site_id>/stats/post/<post_id>` endpoint for a post's view history, like count, comment count, and metadata
1617
- WordPress.com `GET /sites/<site_id>/plans` endpoint for listing the plans a site can buy, priced for that site, with the plan it's currently on flagged.
1718
- `RequestExecutionErrorReason` gained `isSiteUnreachable` and `isDeviceOffline` for distinguishing a site that could not be reached (most reliably, a DNS failure) from a device with no network connection. Previously consumers had to match the `NonExistentSiteError` / `DeviceIsOfflineError` variants themselves. Available on both platforms as properties on the reason, which is reachable from `WpRequestResult.RequestExecutionFailed` and `WpApiException.RequestExecutionFailed` on Kotlin. Swift additionally exposes both as convenience properties on `WpApiError` and `RequestExecutionError`.
1819

WPCOM_REST_API_CHECKLIST.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ investigate the relevant code before making decisions based on this document.
329329
- [ ] `GET /rest/v1.1/sites/$site/stats/comments` — top commenters and most-commented posts
330330
- [ ] `GET /rest/v1.1/sites/$site/stats/followers` — site followers (filterable by type)
331331
- [x] `GET /rest/v1.1/sites/$site/stats/insights` — most popular day/hour, yearly aggregates
332-
- [ ] `GET /rest/v1.1/sites/$site/stats/post/$post_id` — per-post view stats
332+
- [x] `GET /rest/v1.1/sites/$site/stats/post/$post_id` — per-post view stats
333333
- [ ] `GET /rest/v1.1/sites/$site/stats/publicize` — social media follower counts
334334
- [ ] `GET /rest/v1.1/sites/$site/stats/streak` — posting activity/streak data
335335
- [ ] `GET /rest/v1.1/sites/$site/stats/summary` — total likes, comments, followers

wp_api/src/wp_com/client.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ use super::endpoint::{
3333
StatsFileDownloadsRequestBuilder, StatsFileDownloadsRequestExecutor,
3434
},
3535
stats_insights_endpoint::{StatsInsightsRequestBuilder, StatsInsightsRequestExecutor},
36+
stats_post_endpoint::{StatsPostRequestBuilder, StatsPostRequestExecutor},
3637
stats_referrers_endpoint::{StatsReferrersRequestBuilder, StatsReferrersRequestExecutor},
3738
stats_region_views_endpoint::{
3839
StatsRegionViewsRequestBuilder, StatsRegionViewsRequestExecutor,
@@ -96,6 +97,7 @@ pub struct WpComApiRequestBuilder {
9697
stats_emails_summary: Arc<StatsEmailsSummaryRequestBuilder>,
9798
stats_devices_platform: Arc<StatsDevicesPlatformRequestBuilder>,
9899
stats_devices_screensize: Arc<StatsDevicesScreensizeRequestBuilder>,
100+
stats_post: Arc<StatsPostRequestBuilder>,
99101
stats_referrers: Arc<StatsReferrersRequestBuilder>,
100102
stats_subscribers: Arc<StatsSubscribersRequestBuilder>,
101103
stats_region_views: Arc<StatsRegionViewsRequestBuilder>,
@@ -144,6 +146,7 @@ impl WpComApiRequestBuilder {
144146
stats_emails_summary,
145147
stats_devices_platform,
146148
stats_devices_screensize,
149+
stats_post,
147150
stats_referrers,
148151
stats_subscribers,
149152
stats_region_views,
@@ -203,6 +206,7 @@ pub struct WpComApiClient {
203206
stats_emails_summary: Arc<StatsEmailsSummaryRequestExecutor>,
204207
stats_devices_platform: Arc<StatsDevicesPlatformRequestExecutor>,
205208
stats_devices_screensize: Arc<StatsDevicesScreensizeRequestExecutor>,
209+
stats_post: Arc<StatsPostRequestExecutor>,
206210
stats_referrers: Arc<StatsReferrersRequestExecutor>,
207211
stats_subscribers: Arc<StatsSubscribersRequestExecutor>,
208212
stats_region_views: Arc<StatsRegionViewsRequestExecutor>,
@@ -252,6 +256,7 @@ impl WpComApiClient {
252256
stats_emails_summary,
253257
stats_devices_platform,
254258
stats_devices_screensize,
259+
stats_post,
255260
stats_referrers,
256261
stats_subscribers,
257262
stats_region_views,
@@ -294,6 +299,7 @@ api_client_generate_endpoint_impl!(WpComApi, stats_devices_browser);
294299
api_client_generate_endpoint_impl!(WpComApi, stats_emails_summary);
295300
api_client_generate_endpoint_impl!(WpComApi, stats_devices_platform);
296301
api_client_generate_endpoint_impl!(WpComApi, stats_devices_screensize);
302+
api_client_generate_endpoint_impl!(WpComApi, stats_post);
297303
api_client_generate_endpoint_impl!(WpComApi, stats_referrers);
298304
api_client_generate_endpoint_impl!(WpComApi, stats_subscribers);
299305
api_client_generate_endpoint_impl!(WpComApi, stats_region_views);

wp_api/src/wp_com/endpoint.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ pub mod stats_devices_screensize_endpoint;
3030
pub mod stats_emails_summary_endpoint;
3131
pub mod stats_file_downloads_endpoint;
3232
pub mod stats_insights_endpoint;
33+
pub mod stats_post_endpoint;
3334
pub mod stats_referrers_endpoint;
3435
pub mod stats_region_views_endpoint;
3536
pub mod stats_search_terms_endpoint;
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
use crate::{
2+
request::endpoint::{AsNamespace, DerivedRequest},
3+
wp_com::{
4+
WpComNamespace, WpComSiteId,
5+
stats_post::{StatsPostResponse, StatsPostTarget},
6+
},
7+
};
8+
use wp_derive_request_builder::WpDerivedRequest;
9+
10+
#[derive(WpDerivedRequest)]
11+
enum StatsPostRequest {
12+
#[get(url = "/sites/<wp_com_site_id>/stats/post/<stats_post_target>", output = StatsPostResponse)]
13+
GetStatsPost,
14+
}
15+
16+
impl DerivedRequest for StatsPostRequest {
17+
fn namespace(&self) -> impl AsNamespace {
18+
WpComNamespace::RestV1_1
19+
}
20+
}
21+
22+
#[cfg(test)]
23+
mod tests {
24+
use super::*;
25+
use crate::{
26+
posts::PostId,
27+
request::endpoint::ApiUrlResolver,
28+
wp_com::endpoint::tests::{
29+
fixture_wp_com_api_url_resolver, validate_wp_com_rest_v1_1_endpoint,
30+
},
31+
};
32+
use rstest::*;
33+
use std::sync::Arc;
34+
35+
#[rstest]
36+
#[case::numeric_id(
37+
WpComSiteId(12345),
38+
StatsPostTarget::Post { id: PostId(2729) },
39+
"/sites/12345/stats/post/2729"
40+
)]
41+
#[case::large_ids(
42+
WpComSiteId(229889220),
43+
StatsPostTarget::Post { id: PostId(9007199254740991) },
44+
"/sites/229889220/stats/post/9007199254740991"
45+
)]
46+
// The API addresses the site's home page as post 0.
47+
#[case::home_page(
48+
WpComSiteId(12345),
49+
StatsPostTarget::HomePage,
50+
"/sites/12345/stats/post/0"
51+
)]
52+
fn get_stats_post(
53+
endpoint: StatsPostRequestEndpoint,
54+
#[case] site_id: WpComSiteId,
55+
#[case] target: StatsPostTarget,
56+
#[case] expected_path: &str,
57+
) {
58+
validate_wp_com_rest_v1_1_endpoint(
59+
endpoint.get_stats_post(&site_id, &target),
60+
expected_path,
61+
);
62+
}
63+
64+
#[fixture]
65+
fn endpoint(
66+
fixture_wp_com_api_url_resolver: Arc<dyn ApiUrlResolver>,
67+
) -> StatsPostRequestEndpoint {
68+
StatsPostRequestEndpoint::new(fixture_wp_com_api_url_resolver)
69+
}
70+
}

wp_api/src/wp_com/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ pub mod stats_devices;
2626
pub mod stats_emails_summary;
2727
pub mod stats_file_downloads;
2828
pub mod stats_insights;
29+
pub mod stats_post;
2930
pub mod stats_referrers;
3031
pub mod stats_region_views;
3132
pub mod stats_search_terms;

0 commit comments

Comments
 (0)