Skip to content

Add wp.com GET /sites/<site_id>/stats/post/<post_id> endpoint - #1489

Merged
oguzkocer merged 10 commits into
trunkfrom
per-post-stats
Aug 8, 2026
Merged

Add wp.com GET /sites/<site_id>/stats/post/<post_id> endpoint#1489
oguzkocer merged 10 commits into
trunkfrom
per-post-stats

Conversation

@nbradbury

@nbradbury nbradbury commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

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/posts endpoint.

Changes

  • wp_api/src/wp_com/stats_post.rs — response types, exposed as client.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.
  • Standard wiring: mod.rs, endpoint.rs, six insertions in client.rs
  • Fixtures under wp_api/tests/wpcom/stats_post/, plus a wp_com_e2e trial
  • Ticks the checklist row and adds a CHANGELOG.md entry

Two things worth a reviewer's attention, both found by capturing real responses rather than reasoning from the docs:

  1. A week's change has 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 as StatsPostChange, which round-trips all three.
  2. The daily view history is flattened at parse time. The API sends it as a fields/data column 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 a uniffi::Record lower the whole response back into Rust on every call, so the convenience accessor cost more than reading the array natively. It's now a plain daily_views: Vec<StatsPostDailyView> field, and callers take a trailing window with dailyViews.suffix(7) / takeLast(7) — no FFI. The column positions are still read from fields rather than assumed.

Smaller quirks handled: months inside years/averages arrives as [] rather than {} when empty, and post_author arrives as a string.

Test plan

  • cargo test -p wp_api --lib — 1806 pass, 14 of them new
  • cargo clippy --tests --all-targets --all-features -- -D warnings and cargo fmt --all -- --check — clean
  • cargo run --bin wp_com_e2e -- post_views — passes against live wp.com sites
  • Deserialized 60 real responses across 15 sites through the Rust types; every modeled field's type verified against that sample. That corpus is also what establishes the change shapes: 268 post-zero weeks all report integer 0, so there is no NaN counterpart to model
  • Swift and Kotlin bindings generate cleanly, with no FFI methods on the response type

Test on Android

This Android draft PR can be used to test the additions.

  • View stats for a site that has traffic
  • Switch to the Insights tab
  • Scroll down to show the "Latest post summary" card
  • Tap the card to see post stats

Changelog

  • I've added an entry to CHANGELOG.md under ## [Unreleased], using the Keep a Changelog categories (Added, Changed, Deprecated, Removed, Fixed, Security). Prefix breaking changes with **BREAKING:**.

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.
@wpmobilebot

wpmobilebot commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

XCFramework Build

This PR's XCFramework is available for testing. Add to your Package.swift:

.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.
oguzkocer and others added 2 commits August 8, 2026 18:43
* 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
oguzkocer enabled auto-merge (squash) August 8, 2026 23:21
@oguzkocer
oguzkocer merged commit 6d910f9 into trunk Aug 8, 2026
36 checks passed
@oguzkocer
oguzkocer deleted the per-post-stats branch August 8, 2026 23:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants