Skip to content

REST API: Experiment — extract settings and post operations into functions shared with abilities - #13033

Draft
galatanovidiu wants to merge 1 commit into
WordPress:trunkfrom
galatanovidiu:experiment/rest-shared-functions-abilities
Draft

REST API: Experiment — extract settings and post operations into functions shared with abilities#13033
galatanovidiu wants to merge 1 commit into
WordPress:trunkfrom
galatanovidiu:experiment/rest-shared-functions-abilities

Conversation

@galatanovidiu

@galatanovidiu galatanovidiu commented Aug 13, 2026

Copy link
Copy Markdown

This is an experiment. Do not merge it. It is opened as a draft to make a design argument
reviewable, not to propose a change to core. There is no Trac ticket, there are no new tests,
and it modifies files that plugins subclass heavily. See "What this is asking for" at the end.

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-adapter plugin builds abilities out of routes through
wp_register_ability_from_rest_route(). WordPress/ai#931 is 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_Request in 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/settings and the wp/v2/posts write 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:

Function Role
wp_get_registered_setting_options() Registered settings, normalized
wp_prepare_setting_value() Schema casting
wp_current_user_can_manage_settings() The permission rule
wp_get_settings_values() The read operation
wp_update_settings_values() The write operation

WP_REST_Settings_Controller drops from 343 to about 180 lines. Its three public methods are one
line each:

public function get_item_permissions_check( $request ) { return wp_current_user_can_manage_settings(); }
public function get_item( $request )                   { return wp_get_settings_values(); }
public function update_item( $request )                { return wp_update_settings_values( $request->get_params() ); }

src/wp-includes/post.php — fifteen functions:

  • Permissions: 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()
  • Operations: 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 new content category holding core/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_abilities for
every test and the endpoint returned 500. That is not a test artifact. wp_unregister_ability() is
public 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_Controller override prepare_item_for_database(), and four also override
create_item(). Moving the body to a global function would silently break attachment uploads, menu
item 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:

protected function prepare_item_for_database( $request ) {
    $existing_post = null;
    if ( isset( $request['id'] ) ) {
        $existing_post = $this->get_post( $request['id'] );
        if ( is_wp_error( $existing_post ) ) { return $existing_post; }
    }

    $prepared_post = wp_prepare_post_params_for_database(
        $this->post_type, $request->get_params(), $this->get_item_schema(), $existing_post
    );
    if ( is_wp_error( $prepared_post ) ) { return $prepared_post; }

    return apply_filters( "rest_pre_insert_{$this->post_type}", $prepared_post, $request );
}

Field gating stays where each layer needs it. wp_prepare_post_params_for_database() takes an
optional $schema. REST passes get_item_schema(), so its gating is unchanged. Abilities pass
nothing, 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_type is an argument, so one
core/create-item registration covers posts, pages and custom types. Forcing a 1:1 mapping between
route 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 file
upload, sideloading and MIME validation; a generic create cannot cover it. Media would need its own
core/upload-media ability. Specialised operations deserve specialised abilities — that is a
different thing from fragmenting plain CRUD across post types.

has_param() semantics are preserved deliberately. WP_REST_Request::has_param() uses
array_key_exists(), while offsetExists() uses isset(). The difference decides whether "key
present 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 104 apply_filters()
calls pass $request. A filter like rest_prepare_{$post_type}( $response, $post, $request ) cannot
be 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:

Subclass of WP_REST_Posts_Controller overrides create_item overrides prepare_item_for_database
attachments yes yes
font-faces yes yes
font-families yes yes
menu-items yes yes
global-styles no yes
blocks no no

The domain logic barely needs the request. prepare_item_for_database() is 258 lines. Its
$request usage is 44 named-parameter reads, 2 has_param() calls, and 1 whole-$request pass for
rest_pre_insert_{$post_type}. Instance coupling was equally thin: $this->post_type five times
plus 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 ) matches
execute_callback( $input ), and permission_callback matches permission_callback, with the same
mixed|WP_Error and bool|WP_Error contracts. Only the argument type differs. That is the
structural 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_Error usefully

This 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():

$has_permissions = $this->check_permissions( $input );
if ( true !== $has_permissions ) {
    if ( is_wp_error( $has_permissions ) ) {
        _doing_it_wrong( __METHOD__, esc_html( $has_permissions->get_error_message() ), '6.9.0' );
    }
    return new WP_Error( 'ability_invalid_permissions', /* generic message */ );
}

A WP_Error returned from a permission callback triggers _doing_it_wrong and is then discarded.
The caller receives a generic ability_invalid_permissions with 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. REST
surfaces all of them. The abilities cannot, so core/create-item and core/update-item cast to bool
and throw the reason away. The real message reaches the debug log only. The
wp_ability_permission_result filter added in 7.1 does see the true error, so a consumer can recover
it by hooking — but never from execute() itself.

Three consequences worth discussing:

  • An agent told "you do not have permission" cannot distinguish "you cannot publish, try draft" from
    "you cannot assign that term" from "that post type is not exposed". The first is actionable; the
    ability makes it unactionable.
  • The intent may have been to avoid leaking information to unauthorised callers. If so, that is a
    deliberate trade and should be documented as one, rather than enforced by discarding the error.
  • _doing_it_wrong firing on a legitimate WP_Error return makes the documented bool|WP_Error
    signature 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() and prepare_value() still exist and
    delegate, but the abilities call the global functions. A subclass overriding those methods no
    longer affects ability output.
  • The same applies to WP_REST_Posts_Controller::check_assign_terms_permission(), now bypassed by
    wp_check_create_post_permission(). No core subclass overrides it.
  • check_read_permission() recurses through the global function, so for inherit-status posts in
    blocks and global-styles — the two types that override it — the parent-chain check no longer
    goes through the override.

Gaps in the generic operations, all deliberate and not attempted: no post meta
(register_meta / WP_REST_Post_Meta_Fields is not wired in); no
update_additional_fields_for_object(), since register_rest_field is REST-only by definition; no
media upload; password is accepted on write but never returned by wp_get_post_item_data(); no
collection or list operation, as get_items was not attempted.

Intentional shape differences between the layers: REST sends taxonomies flat (categories,
tags at top level) while abilities nest them under terms, which lets the input schema stay closed
with additionalProperties: false; the shared permission helper reads them flat, so the ability
merges before calling it. REST returns title as { raw, rendered } and the ability returns a plain
string. REST returns links, _embedded and pagination headers; the ability returns none of that.

Two naming problems left open. 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 are
REST-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.

Suite Command Result
REST API npm run test:php -- --filter 'WP_Test_REST_' 2017 tests, 10887 assertions — pass
Abilities API npm run test:php -- --group abilities-api 361 tests, 962 assertions — pass
Posts npm run test:php -- --filter 'Tests_Post' 851 tests, 2478 assertions — pass
Full PHPUnit suite npm run test:php 30854 tests, 4559467 assertions — 1 failure, unrelated

The single full-suite failure is Tests_Script_Modules_WpScriptModules::test_default_script_module_files_exist,
which asserts that a built content-types script module file exists under src/wp-includes/js. That
path 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 post and page; byte-identical settings data from the ability and the
REST route; matching post data from core/create-item and GET /wp/v2/posts/{id}; term assignment
on create and on update; subscriber denial on every write ability; and rest_already_trashed on
double 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.

  1. Is the dependency direction right — should REST depend on shared functionality rather than
    abilities depending on REST?
  2. Is "refactor the base method body, do not move it" an acceptable way past subclass polymorphism in
    core, given how heavily plugins subclass these controllers?
  3. Are the divergences listed above acceptable, or does any one of them rule the approach out?
  4. Should the permission-error problem be raised as its own issue against the Abilities API?

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_type argument — are mine, and I have reviewed the resulting code.

…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.
@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The 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

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

@jorgefilipecosta jorgefilipecosta left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/wp-includes/post.php
* @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 );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Build the abilities from scratch. Most freedom, but code duplication.
  2. 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.
  3. Extract the functionality and share it between REST and abilities, which is what this PR does. Most complex, but no duplication.
  4. 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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants