Compare the core read abilities against the REST API - #931
Conversation
✅ WordPress Plugin Check Report
📊 ReportAll checks passed! No errors or warnings found. 🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## develop #931 +/- ##
=============================================
- Coverage 74.39% 74.23% -0.17%
- Complexity 3110 3256 +146
=============================================
Files 132 136 +4
Lines 12166 12520 +354
=============================================
+ Hits 9051 9294 +243
- Misses 3115 3226 +111
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
c7ed7dc to
54e04c8
Compare
|
I think using the REST API as the backend for these Abilities is a good solution. For mature Core endpoints that already own the operation, REST should normally be the preferred and single execution path. Why I believe this is the right direction
This is not perfect, though. REST has some specific weaknesses here:
There are also neutral trade-offs rather than inherent weaknesses:
What I reproduced in this branchI compared this smaller backend with the full Abilities REST Adapter and exercised the risky paths against WordPress. I found five concrete gaps that I think we should fix without importing the generic adapter:
These look like narrow integration gaps, not reasons to move away from REST. My preference is to keep REST as the single execution path for these Abilities, fix the five cases above, and avoid bringing the full generic adapter into this PR. |
|
Thank you for the review. I could reproduce all five findings, and each one is now fixed in its own commit:
Each fix has a test that fails without it. Both suites pass with the same totals, and phpcs and phpstan are clean. |
`core/read-content`, `core/read-settings` and `core/read-users` repeat logic
that the REST API already implements. Each one now ships a second execute
implementation that calls the matching REST endpoint, maps the ability input
to request parameters, and maps the response back to the ability output shape.
Both implementations are always loaded. A constant or filter picks which one
runs, so the same test suite covers both and the two can be compared:
define( 'WPAI_ABILITIES_REST_BACKEND', true );
add_filter( 'wpai_abilities_rest_backend', '__return_true' );
Only the execute callbacks switch. The permission callbacks stay with the
native implementation, because they are the abilities' own authorization
contract, and the endpoints answer a related but different question.
Also aligns the `core/read-users` collection order with the REST users
controller and documents it in the output schema, and narrows the lean
projection cache test to what the ability asks for rather than the queries
that follow.
`Content_Rest::prepare_post_type()` exposes a post type to REST for the length of one request, and unsets the REST server so a fresh one is built with a route for that post type. The restore step put the previous server back, but only when one existed. When none existed, the temporary server stayed in the global with the route still registered, after the post type had already been set back to not exposed. Any later internal request in the same process then saw a route that should not be there. This needs no special setup. It happens in any request where no REST server has been built yet, such as WP-CLI or a regular admin request. Unset the global in that case, so the next caller builds a fresh server from the restored state.
`Settings_Rest::get_values()` returned an empty array when the settings endpoint failed. An empty array means "REST exposes none of these settings", so `Settings::execute_get_settings()` then read every value from `get_option()` and returned it. A request the endpoint refused was answered anyway, from the stored options. On stock WordPress both permission callbacks require `manage_options`, so this is not a privilege escalation today. It still defeats the point of the REST-backed path: it skips the endpoint's policy and its filters at exactly the moment the endpoint says no. Return the error instead, and pass it on from the ability. The fallback to the stored option stays for settings a successful response did not include, which is what it was there for.
Rows that ask for sensitive fields are read one by one through `/wp/v2/users/<id>`. When one of those reads failed, `query_users()` filtered the error out of the list, but `total` and `total_pages` still came from the collection headers. The page then held fewer users than the totals promised, and the caller could not tell a user that was withheld from one that does not exist. Return the error instead, so the whole read fails and says why.
`Rest_Backend::data()` turned any successful response that was not an array into an empty array. A malformed body then looked exactly like a valid empty result: no users, no posts, no settings, and no sign that anything went wrong. Return an error instead, and pass it on from every caller. Collection rows follow the same rule: a row that is not an array, or has no `id`, or does not resolve to a user, now ends the read rather than being skipped. A skipped row leaves the page short while `total` still counts it, which is the problem the previous commit fixed for failed rows. Core alone does not produce these shapes. A filter on the response can, and when it does, "empty" is the wrong thing for a read ability to report.
`Rest_Backend::get()` set each parameter with `set_param()`, which writes to whichever parameter type comes first in the request's parameter order. That order is filterable through `rest_request_parameter_order`. Stock WordPress puts `GET` first, so the parameters land where they belong. With `URL` first they land in the URL parameters instead, and dispatching replaces all of those with the ones matched from the route. The parameters are gone before the endpoint sees them: `include` becomes empty and `per_page` falls back to its default of 10. Write them straight to the query parameters, which is what they would be over HTTP. The behavior no longer depends on a filter that has nothing to do with this request.
Version 1.3.0 shipped without this code, so the new files carry the placeholder that CONTRIBUTING.md asks for. The version is filled in on release.
41fc5a5 to
aa4b048
Compare
What?
core/read-content,core/read-settingsandcore/read-usersreimplement logic the REST API already has. This PR adds a second execute implementation for each one that calls the matching REST endpoint instead, so we can measure how closely our versions match REST behaviour.Both implementations are always loaded. One switch picks which runs:
Why?
The three abilities are kept close to their WordPress core counterparts, and much of what they do overlaps with the REST controllers. Running the same suite against both answers a question we could otherwise only argue about: where do our versions and REST actually disagree?
Result: the same suite passes both ways — 1238 tests, 3563 assertions, 35 skipped, identical on both sides. For everything the tests cover, the two behave the same.
That is the useful finding, and also the limit of the claim: it says the suite cannot tell them apart, not that they are equivalent.
Where they genuinely differ
Every difference runs the same direction — our version is stricter or more careful:
NULL; the endpoint returns nothingshow_avatarsper call; the endpoint caches its schema per requestinheritposts and public-but-not-viewable statusesNone of these look worth changing. The cost of the REST path is a fixed handful of extra queries per request — 8 vs 4 on a lean projection, level once content is rendered.
How?
Only the execute callbacks switch. Permission callbacks stay with the current implementation — they are the abilities' own authorization contract, and the endpoints answer a related but different question (REST hands
rolesto anyone who can list users; the ability wants edit access).Rest_Backend.phprest_do_request()helperContent_Rest.phpGET /wp/v2/<rest_base>and/<id>Users_Rest.phpGET /wp/v2/usersand/wp/v2/users/<id>Settings_Rest.phpGET /wp/v2/settingsThe three ability classes change by 41 lines —
// Plugin:branches at the points where each produced its output. Input parsing and filter validation stay shared.The mapping is mostly shape:
title.rendered→title_rendered,type→post_type, dates to full ISO 8601 (REST omits the offset), the author ID expanded to{id, name}. A few things have no request parameter and travel through scoped query filters instead:permand cache priming for content,has_published_postsfor users. Post types exposed withshow_in_abilitiesbut notshow_in_resthave no route, so the flag is turned on for the length of the request.Two changes to the abilities themselves
core/read-userscollection order. It ordered by user login (theWP_User_Querydefault) while/wp/v2/usersorders by display name, and the output schema documented neither. It is now ordered by display name and stated in the schema.This is the one behaviour change to the plugin here, and it stands on its own. It deserves a separate follow-up PR so it can be reviewed on its own terms instead of riding along with an experiment. It is three self-contained hunks in
Users.php.The lean projection cache test now asserts what the ability asks for (
update_post_meta_cacheon the query it builds) rather than counting the queries that follow. Honoring the request belongs to whoever runs the query. Verified it still fails ifshould_prime_post_caches()is gutted.CI
test.ymlgains one matrix entry (PHP 8.3, WP latest) that runs the suite through the REST path, so the second implementation cannot rot silently. It shows up as its own job alongside the existing ones.Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5
Used for: The REST-backed implementations, the input and output mapping, the CI matrix entry, and this description were drafted with Claude Code and iterated against the existing test suite. The comparison this PR reports was run and verified in a local
wp-envenvironment. I reviewed and edited the result, and I take responsibility for it.Testing Instructions
npm run wp-env:test startnpm run test:php— the implementations in this repo.npm run test:php:rest— the same suite, executed through the REST API.Both should report the same totals.
To compare a single call by hand, run an ability twice with the filter toggled:
Changelog Entry