Skip to content

Add the content write abilities through the REST API - #1026

Draft
jorgefilipecosta wants to merge 42 commits into
try/core-read-abilities-rest-backendfrom
try/core-content-write-abilities
Draft

jorgefilipecosta wants to merge 42 commits into
try/core-read-abilities-rest-backendfrom
try/core-content-write-abilities

Conversation

@jorgefilipecosta

@jorgefilipecosta jorgefilipecosta commented Sep 11, 2026 •

Copy link
Copy Markdown
Member

What?

Adds core/content-create, core/content-update, and core/content-delete by forwarding each write to the REST posts endpoint. It is the REST-backed alternative to #1025, with the same definitions and tests.

Why?

The posts controller already applies the capability checks, sanitization, and slug rules. Forwarding to it leaves less to get wrong than a second copy.

How?

Stacked on #931, which added Content_Rest, the REST-backed read path.

Differences from #1025

Testing Instructions

  1. Enable the Custom Abilities experiment.
  2. Run core/content-create with { "post_type": "post", "title": "Hello", "status": "draft" }, then core/content-update and core/content-delete with the returned id.
  3. npm run test:php -- --filter 'ContentTest|ContentCreateTest|ContentUpdateTest|ContentDeleteTest', then npm run test:php:rest to also read through REST.
  4. npm run test:e2e -- tests/e2e/specs/abilities

Changelog Entry

Added - core/content-create, core/content-update and core/content-delete abilities for writing content, backed by the REST posts endpoint.

Open WordPress Playground Preview

The read abilities only ever dispatched GET. Extract the dispatch and error
handling into one place and add POST and DELETE on top of it, so the write
abilities can reach the same endpoints.
Content_Rest resolves a post type's REST route, exposes a post type that
abilities can see but REST cannot, and puts the global post context back
afterwards. The write implementations need all three, so move them out of
the read implementation instead of copying them.
Creates, updates and deletes posts through the posts endpoint, so the
capability checks, sanitization and side effects stay with the controller.
The written post is read back through the read implementation, which keeps
one field mapping for both directions.
…lete

Registers them from the read ability's register(), where the placeholder
comment said a write ability would go. The permission callbacks gate on the
post type being exposed to abilities and on the caller's capability for the
post; everything past that is the posts controller's own checks.

The output reuses the read ability's post field definitions, so a written
post is reported the same way a queried one is.
The endpoint is the only implementation, so the tests assert both halves:
the mapping this plugin owns, and the controller behaviour it inherits and
must pass on — the refusal to assign another author, an unparseable date,
an already trashed post, and a post type that does not support trashing.
@github-actions

Copy link
Copy Markdown

✅ WordPress Plugin Check Report

✅ Status: Passed

📊 Report

All checks passed! No errors or warnings found.


🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check

@codecov

codecov Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.67320% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 82.11%. Comparing base (27c393d) to head (cbb2c81).

Files with missing lines Patch % Lines
includes/Abilities/Content/Content.php 99.64% 1 Missing ⚠️
Additional details and impacted files
@@                            Coverage Diff                             @@
##             try/core-read-abilities-rest-backend    #1026      +/-   ##
==========================================================================
+ Coverage                                   81.12%   82.11%   +0.98%     
- Complexity                                   3069     3129      +60     
==========================================================================
  Files                                         126      126              
  Lines                                       12007    12303     +296     
==========================================================================
+ Hits                                         9741    10102     +361     
+ Misses                                       2266     2201      -65     
Flag Coverage Δ
unit 82.11% <99.67%> (+0.98%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Content_Write and Content_Write_Rest read as the native/REST pair the read
ability has, and there is no such pair here: the endpoint is the only way
these abilities write. One class says that without a docblock having to.
@jeffpaul jeffpaul added this to the 1.5.0 milestone Sep 17, 2026
@jeffpaul jeffpaul moved this from Triage to In progress in WordPress AI Roadmap Sep 17, 2026
@jeffpaul jeffpaul modified the milestones: 1.5.0, Future Release Sep 21, 2026
The run endpoint sends core/content-delete as a DELETE request and reads
its input from the query string, where every value is a string. empty()
treated "false" as set, so a delete with force=false removed the post for
good instead of moving it to the trash. Read the flag the way REST does.
The run endpoint picks the HTTP method from the annotations, and sends an
ability that is both destructive and idempotent as a DELETE with its
input in the query string. The update was flagged that way, so its post
content and password travelled in URLs and server logs, and a POST was
refused. Flag it non-idempotent so it stays on POST.
absint() turned an id of -5 into 5, so an update or a delete aimed at a
post that does not exist acted on another one. The schema now requires a
positive integer, and the callbacks only resolve a positive whole number,
in any form the schema accepts.
The posts endpoint assumes content round-trips through the block editor:
a read inserts the hooked blocks into the raw content, and a write marks
every block that could be hooked there as ignored. The read ability
reports content as stored, so an agent that read a post and wrote it back
stopped hooked blocks from rendering without ever seeing them, and the
write reported raw content that was never stored. Turn the handling off
for the abilities' own requests; hooked blocks are still inserted when
the post renders.
The written post is read back with the caller's own permissions. A user
who may delete a post but not read it, such as a private post of another
author, had a forced delete refused and a trash reported as a 403 after
the post was already in the trash. The delete capability is now the only
one a delete needs, and a post that cannot be read back is reported by
its ID alone, so a write that happened is never reported as a failure.
The posts endpoint drops a field its post type does not support and
writes the rest: parent or menu_order on a post, sticky on a page, an
excerpt on a post type without excerpts. The caller was told the write
succeeded and believed the field was set. Check the input against the
endpoint's own schema first and report content_invalid_field.
With no post type exposed there is nothing to write to, and
core/content-query registers nothing in that case either. The write
abilities were registered anyway, with an empty post type enum.
Both were already mapped to the endpoint's parameters, but the input
schema had no property for either, so they were refused before reaching
it.
The endpoint resets the date when it is null: the post is dated now and
a draft gets a floating date. The input schema only allowed a string.
The endpoint accepts the status a post already has even when it is
internal, so a trashed post can be edited while it stays in the trash.
The update schema listed only the selectable statuses and refused that.
List them in the description instead and leave the check to the endpoint.
A field named twice in fields is a mistake the schema can catch.
core/content-query returns neither whether a post is sticky nor its
password, so a caller could not read back what it wrote. Remove both
inputs until the query ability exposes them; reverting this commit
brings them back.
core/content-query does not return a post's format, so a caller could not
read back what it wrote. Remove the input until the query ability exposes
it; reverting this commit brings it back.
… now

core/content-query returns neither a post's menu order nor its comment and
ping status, so a caller could not read back what it wrote. Remove the
three inputs until the query ability exposes them; reverting this commit
brings them back.
core/content-query does not return a post's template, so a caller could
not read back what it wrote. Remove the input until the query ability
exposes it; reverting this commit brings it back.
Define core/content-create, core/content-update and core/content-delete
in Content with the schemas, permission callbacks and field projection of
the native implementation, and send each write to the posts endpoint of
the post type through Content_Rest::write_post(). The endpoint checks,
sanitizes and writes the fields, and its errors are passed on unchanged.
Content_Write, Post_Type_Route and the Rest_Backend write helpers go.

The tests are the native implementation's, so both pass the same suite.
One is left out: creating a draft with a taken slug makes the endpoint
read an undefined $prepared_post->id, and PHPUnit turns that warning
into an exception.
The write abilities name each error after the posts endpoint's, with
content_ in place of the rest_ prefix, so an error no longer reads as if
the REST API had answered the caller. Rename the codes of the errors the
endpoint returns the same way, keeping their messages and data, so the
shared tests pass unchanged.
The input schema accepts a stdClass for the title, content, and excerpt,
but the endpoint reads them as arrays, so a PHP caller passing one got
"Cannot use object of type stdClass as array" instead of a post.
The input schema accepts a stdClass for the title, content, and excerpt,
as it does for any object, so a PHP caller can pass one.
Both checks run while the post is written, after the permission callback
has only checked that the post can be edited, so nothing tested that an
update cannot publish without the capability or give the post away.
A `rest_prepare_{$post_type}` filter that adds a `previous` key made a
create or update fail output validation after the post was written, and
made a trash look like a permanent deletion. Only the endpoint's answer
to a permanent deletion carries `deleted: true` next to `previous`.
The write errors are renamed now, the write paths no longer read the post
back, and Content_Rest serves the write abilities too, whose answers the
endpoint always gives in the edit context.
The posts endpoint validates the status before it runs its permission
checks, so an update with an unknown status and another author is
refused as an invalid parameter. The native implementation now checks
them in the same order; share the test that pins it.
The posts endpoint answers a deletion in the edit context, so a role that
can delete a post but not edit it still gets its raw fields. The native
implementation now returns them too; share the test that pins it.
The `fields` input and the post output schema of core/content-query were
moved into helpers so the write abilities could reuse them, which
changed code the read ability already ships. Keep develop's inline
schemas in the read ability, and let the helpers serve only the write
abilities.
The write abilities return the raw fields of the written post whether or
not the user can edit it, as the posts endpoint answers a write in the
edit context. Only the read ability requires edit access for them, so
drop that sentence from the `fields` description of the write abilities.
A null in either date field resets the date, as in the posts endpoint,
so `date_gmt: null` next to a date is not ignored: a post scheduled that
way is published at once. The endpoint does not describe the precedence
between the two fields, so drop the sentence instead of qualifying it.
The create and update descriptions said that fields the post type does
not support are ignored, and that an update leaves omitted fields as they
are. Neither holds everywhere: the author is still checked on a post type
without author support, as in the endpoint, and wp_update_post() re-dates
a draft with a floating date and schedules a published post given a
future date. The endpoint makes neither promise, so drop both sentences.
The date fields are `format: date-time`, and the JavaScript Abilities
client validates that format strictly, as RFC 3339 does: a date without
a timezone offset, such as 2026-10-05T09:00:00, is refused before the
ability runs. The descriptions invited exactly that form for `date`, and
did not say which form `date_gmt` takes. Describe the forms the client
accepts: a timezone offset for `date`, and `Z` for `date_gmt`.
One test docblock described less input than the test sends, and another
gave a reason for ignoring an author of 0 that does not hold, since the
read ability returns the author as an object. The posts endpoint ignores
an empty author, so say that instead.
The core tests these are ported from check the HTTP status as well as
the error code: 403 for publishing without the publish capability, and
400 for an author or parent that does not exist. Check them here too.
Core tests the 501 answer for a post type without trash support through
the media endpoint: attachments cannot be trashed while MEDIA_TRASH is
off. Nothing here covered that answer. Port the test, exposing
attachments for its length and resetting them with the other post types.
The core tests put the whole post they read back, so the request carries
the GMT date as well as the date, and they pin that the date wins: a
floating draft keeps its floating date when both are sent unchanged, and
a new date replaces it even next to the old GMT date. The ports sent only
the date. Send the GMT date as read too.
Core tests that an empty excerpt or content string clears the field, and
that a parent of 0 moves a child page to the top level. The ports only
covered empty `raw` objects, and nothing set a parent of 0 on update.
Port the three tests.
The test resets the date with `date_gmt: null`, as the core test it is
ported from does, but its docblock said a null date.
The write abilities reuse the read ability's field descriptions, which
say that the raw title, excerpt, and content are present only when the
current user can edit the post. A write returns them in the edit context
whether or not the user can edit the post afterwards, as the posts
endpoint does. Give the write output schema its own descriptions for the
three raw fields, and compare the write and read output schemas by field
names and types instead of requiring them to be identical.
@jeffpaul jeffpaul modified the milestones: Future Release, 1.5.0 Oct 7, 2026
@gziolo gziolo mentioned this pull request Oct 8, 2026
6 of 7 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

2 participants