Repository navigation
Add the content write abilities through the REST API - #1026
Draft
jorgefilipecosta wants to merge 42 commits into
Draft
jorgefilipecosta wants to merge 42 commits into
jorgefilipecosta wants to merge 42 commits into
Conversation
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.
✅ 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 @@
## 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
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:
|
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What?
Adds
core/content-create,core/content-update, andcore/content-deleteby 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.Content.phpis Addcore/content-create,core/content-update, andcore/content-deleteabilities #1025's, except for the execute paths and [Verification utility] Compare the core read abilities against the REST API #931's switch lines.POST /wp/v2/<rest_base>,POST …/<id>, orDELETE …/<id>throughContent_Rest::write_post(), and map the answer to the ability output. Errors keep the endpoint's codes withcontent_in place ofrest_, as in Addcore/content-create,core/content-update, andcore/content-deleteabilities #1025.Content_Restdoes for reads.Differences from #1025
core/content-create,core/content-update, andcore/content-deleteabilities #1025's, withoutContentCreateTest::test_draft_post_does_not_have_the_same_slug_as_existing_post. When the endpoint de-duplicates a new draft's slug, it reads the unset$prepared_post->id(and->post_parentwhen no parent is given). PHPUnit fails on the warnings, though the slug is still made unique.core/content-create,core/content-update, andcore/content-deleteabilities #1025's spec plus a call toenableExperiments(), which turns on the global Enable AI toggle that [Verification utility] Compare the core read abilities against the REST API #931's base still has (develop removed it in Remove Enable AI header toggle #985). Drop the call once [Verification utility] Compare the core read abilities against the REST API #931 takes develop.content_raw. Addcore/content-create,core/content-update, andcore/content-deleteabilities #1025 writes and returns the content as stored.rest_pre_insert_*,rest_insert_*,rest_after_insert_*,rest_delete_*, andrest_*_trashable.core/content-create,core/content-update, andcore/content-deleteabilities #1025's create and update leavecreate_postsandedit_postto the permission callback.5.0or"5.0"to 5, where Addcore/content-create,core/content-update, andcore/content-deleteabilities #1025 answerscontent_invalid_author. From 7.1 the run endpoint casts it first.content_invalid_param(400) in both, but here it carries the endpoint's message and details.Testing Instructions
core/content-createwith{ "post_type": "post", "title": "Hello", "status": "draft" }, thencore/content-updateandcore/content-deletewith the returnedid.npm run test:php -- --filter 'ContentTest|ContentCreateTest|ContentUpdateTest|ContentDeleteTest', thennpm run test:php:restto also read through REST.npm run test:e2e -- tests/e2e/specs/abilitiesChangelog Entry