REST API: Experiment — extract settings and post operations into functions shared with abilities - #13033
Conversation
…with abilities. Move the settings and post write operations out of their REST controllers into plain functions in `option.php` and `post.php`, so that the REST API and the Abilities API can both consume them without either depending on the other. Fifteen controller methods now delegate to twenty functions. No method was removed or renamed, every signature and return contract is unchanged, and every filter still fires from its original layer. Two constraints shaped the approach: - A method can only be extracted if its extension points do not receive `WP_REST_Request` and it is not a subclass override point. All six core subclasses of `WP_REST_Posts_Controller` override `prepare_item_for_database()`, so the base method body calls the shared function while the method itself stays in place. Overrides and `parent::` calls keep working. - Controllers do not call abilities. `wp_unregister_ability()` is public API, so a controller that dispatched through an ability could be disabled by a plugin unregistering it. Six abilities are registered over the same functions. The four content abilities take `post_type` as an argument rather than being registered per post type. This is an experiment opened for discussion. It adds no test coverage of its own.
Test using WordPress PlaygroundThe changes in this pull request can previewed and tested using a WordPress Playground instance. WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser. Some things to be aware of
For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation. |
jorgefilipecosta
left a comment
There was a problem hiding this comment.
Nice exploration @galatanovidiu, it addresses the issue on how to avoid duplication between REST and abilities.
Abilities using REST although a fast way to try the shape, has the issue of being architecturally wrong.
REST using abilities would be the ideal architectural approach but given all the existing REST filters it is not something we could do.
This approach extracts what is common between REST and abilities into generic functions which are then shared across abilities and REST. It could be a promising solution. The main issue Is that functions which supposed to be generic (on post.php, option.php etc) apply REST filters so we leak REST details like rest_base etc into post and option files. The other potential problem is that some REST filters affect abilities output (the ones inside extracted files) while out REST filters don't the ones on the REST controller. From the implementation point of view this distinction makes sense, but for consumers the inconsistency may not be easy to understand. That is not a blocker there are solutions like flags to specific if the filter runs or not or we could think of other alternatives.
I think the approach looks promising.
| * @param string $post_type The current post type being processed. | ||
| * @param stdClass|WP_Post $prepared_post The prepared post object. | ||
| */ | ||
| $content_like_post_types = apply_filters( 'rest_block_hooks_post_types', $content_like_post_types, $post_type, $prepared_post ); |
There was a problem hiding this comment.
The main problem I see with this approach is that we are applying the REST filters on functions which are supposed to be general and not REST specific. In practical terms some REST filters will affect the abilities output other REST filters (the ones still on the controller) will affect abilities output. I guess we should have an answrer for the inconsistency.
There was a problem hiding this comment.
Yes, you are right about the leak. wp_get_registered_setting_options() is gated on show_in_rest, and rest_pre_get_setting / rest_pre_update_setting now fire from option.php. The names stay REST-shaped even though the code no longer is, and renaming them would break backward compatibility. The filter inconsistency is real too: a filter that moved into the extracted function reaches both layers, a filter left in the controller reaches only REST.
To be clear on the scope, and as I said before: this is an exploration, not a proposal. If we want to follow this path, there is a lot of work ahead, and it is a complex solution.
I see four ways to do this:
- Build the abilities from scratch. Most freedom, but code duplication.
- Build abilities on top of the REST API, the way the [ (https://github.com/galatanovidiu/abilities-rest-adapter) plugin and #931 do it. Easiest and fastest, but it looks like a quick fix.
- Extract the functionality and share it between REST and abilities, which is what this PR does. Most complex, but no duplication.
- Replicate the REST functionality on the abilities side. This is option 1, automated. The good part is that we duplicate code we know works and that has been tested for years. The bad part is that it is still duplication.
Option 4 only came to me while writing this answer, so I have not tried it.
Which of these do you think is worth taking further?
Summary
The Abilities API (core since 6.9) and the REST API expose overlapping functionality. The work
connecting them today points the dependency one way, ability → REST. The
abilities-rest-adapterplugin builds abilities out of routes throughwp_register_ability_from_rest_route().WordPress/ai#931is narrower: it adds a second,REST-backed implementation of three core read abilities behind a switch, to measure how far the
hand-written versions drift from REST. That suite passes both ways over 1238 tests, which is direct
evidence that the duplication between the layers is real.
An ability is functionality exposure. It is not an HTTP concern. An ability derived from a route
inherits REST's accidents — verbs, path parameters,
WP_REST_Requestin permission callbacks,response envelopes — and ends up describing an endpoint instead of a capability.
This branch tests the inverse: extract the functionality into plain functions, and let REST and
abilities both consume them. It covers
wp/v2/settingsand thewp/v2/postswrite path.The result is that the inversion works, but only under two conditions, and the binding constraint
turned out to be subclass polymorphism rather than request-carrying filters.
Nothing was removed. Every REST method that existed still exists with the same name, signature and
return contract. Fifteen of them now delegate to plain functions.
What changed
Five files, +1513 / −588.
src/wp-includes/option.php— five functions:wp_get_registered_setting_options()wp_prepare_setting_value()wp_current_user_can_manage_settings()wp_get_settings_values()wp_update_settings_values()WP_REST_Settings_Controllerdrops from 343 to about 180 lines. Its three public methods are oneline each:
src/wp-includes/post.php— fifteen functions:wp_is_post_type_exposed(),wp_check_read_post_permission(),wp_check_edit_post_permission(),wp_check_delete_post_permission(),wp_check_create_post_permission(),wp_check_update_post_permission(),wp_check_post_status_permission(),wp_check_post_terms_assign_permission()wp_prepare_post_params_for_database(),wp_create_post_item(),wp_update_post_item(),wp_delete_post_item(),wp_set_post_terms_from_params(),wp_get_post_item_data(),_wp_apply_post_item_extras()Twenty functions in total; nineteen public, one underscore-prefixed.
src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php— ten methods delegate:create_item_permissions_check,update_item_permissions_check,prepare_item_for_database,handle_status_param,handle_terms,check_assign_terms_permission,check_is_post_type_allowed,check_read_permission,check_update_permission,check_delete_permission.src/wp-includes/abilities.php— six abilities over the same functions:core/get-settings,core/update-settings, and a newcontentcategory holdingcore/get-item,core/create-item,core/update-item,core/delete-item.Decisions
The shared unit is an operation, not a callback. Cutting at callback granularity reproduces the
duplication, because callbacks are override points and carry transport concerns. Cutting at
operations — the permission rule, the parameter mapping, term assignment — gives real reuse. This is
why the settings permission callback is literally the same function name in both layers, with no
adapter and nothing to keep in sync.
REST does not call abilities. The first working version had
WP_REST_Settings_Controller::get_item()call$ability->execute(). All 33 settings tests passed,but only while core abilities were registered; the harness unhooks
wp_register_core_abilitiesforevery test and the endpoint returned 500. That is not a test artifact.
wp_unregister_ability()ispublic API, so a plugin could delete a core REST endpoint by unregistering an ability. Shared
functions give the same single-implementation guarantee without that coupling, and pass with the
harness untouched.
The base method body is refactored, never moved. All six core subclasses of
WP_REST_Posts_Controlleroverrideprepare_item_for_database(), and four also overridecreate_item(). Moving the body to a global function would silently break attachment uploads, menuitem resolution and font face handling. Instead the base method keeps its place and calls the shared
function, so subclass overrides and
parent::calls keep working:Field gating stays where each layer needs it.
wp_prepare_post_params_for_database()takes anoptional
$schema. REST passesget_item_schema(), so its gating is unchanged. Abilities passnothing, so every field is accepted and gating comes from
post_type_supports()instead.The two layers are allowed to disagree on shape. REST keeps per-post-type controllers, subclass
overrides and every filter. The content abilities are unified:
post_typeis an argument, so onecore/create-itemregistration covers posts, pages and custom types. Forcing a 1:1 mapping betweenroute and ability is what makes both wrapping directions feel wrong. Unified abilities also matter
for agent tooling, where tool-count caps are real (OpenAI 128, Google 512) and one ability per post
type does not scale.
Unification is not universal.
WP_REST_Attachments_Controller::create_item()handles fileupload, sideloading and MIME validation; a generic create cannot cover it. Media would need its own
core/upload-mediaability. Specialised operations deserve specialised abilities — that is adifferent thing from fragmenting plain CRUD across post types.
has_param()semantics are preserved deliberately.WP_REST_Request::has_param()usesarray_key_exists(), whileoffsetExists()usesisset(). The difference decides whether "keypresent with a null value" differs from "key absent" — resetting a post date, deleting a setting.
Both endpoints depend on it, so any flattening of a request to an array has to keep it.
Findings
Two conditions decide whether an endpoint can be extracted. A REST method can move out only if
(a) its extension points do not carry
$request, and (b) it is not a subclass override point.Condition (a) is measurable: in
src/wp-includes/rest-api/endpoints/, 57 of 104apply_filters()calls pass
$request. A filter likerest_prepare_{$post_type}( $response, $post, $request )cannotbe moved out without either faking a request — transport leaking back in — or letting REST and the
ability return different data.
Condition (b) is the harder one, and it is what posts exposed:
WP_REST_Posts_Controllercreate_itemprepare_item_for_databaseThe domain logic barely needs the request.
prepare_item_for_database()is 258 lines. Its$requestusage is 44 named-parameter reads, 2has_param()calls, and 1 whole-$requestpass forrest_pre_insert_{$post_type}. Instance coupling was equally thin:$this->post_typefive timesplus three helper calls. The request object was almost entirely serving as an associative array, and
$request->get_params()replaces it exactly.The two systems already have the same two slots.
callback( $request )matchesexecute_callback( $input ), andpermission_callbackmatchespermission_callback, with the samemixed|WP_Errorandbool|WP_Errorcontracts. Only the argument type differs. That is thestructural argument for this whole branch — but it argues that both should point at the same
function, not that one should call the other.
Open question: ability permission callbacks cannot return
WP_ErrorusefullyThis is the most important unresolved item, and the reason the write abilities in this branch look
worse than the REST routes they sit beside.
WP_Ability::execute():A
WP_Errorreturned from a permission callback triggers_doing_it_wrongand is then discarded.The caller receives a generic
ability_invalid_permissionswith no status and no reason.The extracted permission functions return rich errors —
rest_cannot_publish,rest_cannot_assign_term,rest_cannot_edit_others, each carrying a 401 or 403 status. RESTsurfaces all of them. The abilities cannot, so
core/create-itemandcore/update-itemcast to booland throw the reason away. The real message reaches the debug log only. The
wp_ability_permission_resultfilter added in 7.1 does see the true error, so a consumer can recoverit by hooking — but never from
execute()itself.Three consequences worth discussing:
"you cannot assign that term" from "that post type is not exposed". The first is actionable; the
ability makes it unactionable.
deliberate trade and should be documented as one, rather than enforced by discarding the error.
_doing_it_wrongfiring on a legitimateWP_Errorreturn makes the documentedbool|WP_Errorsignature effectively
bool.No change was made here. This needs a conversation, not a patch, and it probably belongs in its own
issue rather than in this branch.
Known divergences and gaps
Behavioural divergences introduced, all consequences of a method delegating to a global function:
WP_REST_Settings_Controller::get_registered_options()andprepare_value()still exist anddelegate, but the abilities call the global functions. A subclass overriding those methods no
longer affects ability output.
WP_REST_Posts_Controller::check_assign_terms_permission(), now bypassed bywp_check_create_post_permission(). No core subclass overrides it.check_read_permission()recurses through the global function, so forinherit-status posts inblocksandglobal-styles— the two types that override it — the parent-chain check no longergoes through the override.
Gaps in the generic operations, all deliberate and not attempted: no post meta
(
register_meta/WP_REST_Post_Meta_Fieldsis not wired in); noupdate_additional_fields_for_object(), sinceregister_rest_fieldis REST-only by definition; nomedia upload;
passwordis accepted on write but never returned bywp_get_post_item_data(); nocollection or list operation, as
get_itemswas not attempted.Intentional shape differences between the layers: REST sends taxonomies flat (
categories,tagsat top level) while abilities nest them underterms, which lets the input schema stay closedwith
additionalProperties: false; the shared permission helper reads them flat, so the abilitymerges before calling it. REST returns
titleas{ raw, rendered }and the ability returns a plainstring. REST returns links,
_embeddedand pagination headers; the ability returns none of that.Two naming problems left open.
wp_get_registered_setting_options()is gated onshow_in_rest,and
rest_pre_get_setting/rest_pre_update_settingnow fire fromoption.php. The names areREST-shaped even though the code no longer is. Renaming would break backward compatibility; keeping
them is a permanent misnomer.
Testing
All suites were run against the unmodified test harness.
npm run test:php -- --filter 'WP_Test_REST_'npm run test:php -- --group abilities-apinpm run test:php -- --filter 'Tests_Post'npm run test:phpThe single full-suite failure is
Tests_Script_Modules_WpScriptModules::test_default_script_module_files_exist,which asserts that a built
content-typesscript module file exists undersrc/wp-includes/js. Thatpath is gitignored build output and the module was not produced by the local build. These changes are
PHP-only and touch no JavaScript. The 86 warnings are the suite's pre-existing PHPUnit 10 deprecation
notices, all present before this branch.
No new permanent tests are included. Ability behaviour was verified with temporary tests that
were removed afterwards. They covered: the full create/get/update/delete lifecycle; the same
abilities operating on both
postandpage; byte-identical settings data from the ability and theREST route; matching post data from
core/create-itemandGET /wp/v2/posts/{id}; term assignmenton create and on update; subscriber denial on every write ability; and
rest_already_trashedondouble delete. All passed. No manual or browser testing was done.
What this is asking for, and where to discuss it
This is not a merge request, and the missing test coverage alone disqualifies it as one. It exists so
the approach can be argued against working code instead of a description of code. The questions
below have been asked before in the abstract; the point of this branch is that they can now be
answered by reading five files and running the suite.
abilities depending on REST?
core, given how heavily plugins subclass these controllers?
Trac ticket: none, deliberately. There is nothing here to commit, so there is nothing for a
ticket to track, and splitting the discussion across two places would separate every comment from
the lines it is about. Please keep the discussion in this pull request. If any part of this turns out
to be worth pursuing, a ticket will be filed for that part on its own, with its own patch and tests.
Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5
Used for: implementing the extraction, running the test suites, and drafting this description. The
design decisions — inverting the dependency, rejecting the ability-dispatch version, cutting at
operation granularity, refactoring base method bodies rather than moving them, and unifying the
content abilities on a
post_typeargument — are mine, and I have reviewed the resulting code.