Add wp.com GET /sites/<site_id>/stats/post/<post_id> endpoint - #1489
Merged
Conversation
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.
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.
Collaborator
XCFramework BuildThis PR's XCFramework is available for testing. Add to your .package(url: "https://github.com/automattic/wordpress-rs", branch: "pr-build/1489")Built from e328ee3 |
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.
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`.
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.
`/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.
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.
* 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`
oguzkocer
approved these changes
Aug 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Note to reviewers: This PR was created by me entirely with Claude - I'm not a Rust developer. I've run several review and simplification passes with Claude as well as tested it with the Android app, but I can't claim to have reviewed it myself.
Adds the 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 also carries the post's metadata, like count, and comment count, so the card needs no separate post fetch. That matters because this library has no wp.com
/sites/$site/postsendpoint.Changes
wp_api/src/wp_com/stats_post.rs— response types, exposed asclient.stats_post().get_stats_post(&site_id, &post_id)wp_api/src/wp_com/endpoint/stats_post_endpoint.rs— endpoint definition + URL tests. No params struct: unlike the other stats endpoints, this one accepts no query parameters.mod.rs,endpoint.rs, six insertions inclient.rswp_api/tests/wpcom/stats_post/, plus awp_com_e2etrialCHANGELOG.mdentryTwo things worth a reviewer's attention, both found by capturing real responses rather than reasoning from the docs:
changehas three wire shapes, not two:null, a number, or{"isInfinity": true}when the previous week had no views.Option<f64>fails on the third — it broke against a live site. Modelled asStatsPostChange, which round-trips all three.fields/datacolumn table covering the post's entire lifetime — 4,796 rows / 89 KB on a 2013 post. Exposing that raw and offering#[uniffi::export]accessors over it was a trap: methods on auniffi::Recordlower the whole response back into Rust on every call, so the convenience accessor cost more than reading the array natively. It's now a plaindaily_views: Vec<StatsPostDailyView>field, and callers take a trailing window withdailyViews.suffix(7)/takeLast(7)— no FFI. The column positions are still read fromfieldsrather than assumed.Smaller quirks handled:
monthsinsideyears/averagesarrives as[]rather than{}when empty, andpost_authorarrives as a string.Test plan
cargo test -p wp_api --lib— 1806 pass, 14 of them newcargo clippy --tests --all-targets --all-features -- -D warningsandcargo fmt --all -- --check— cleancargo run --bin wp_com_e2e -- post_views— passes against live wp.com siteschangeshapes: 268 post-zero weeks all report integer0, so there is no NaN counterpart to modelTest on Android
This Android draft PR can be used to test the additions.
Changelog
CHANGELOG.mdunder## [Unreleased], using the Keep a Changelog categories (Added,Changed,Deprecated,Removed,Fixed,Security). Prefix breaking changes with**BREAKING:**.