From 283288d7b0f38287a81ee067a6382812c5df0167 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 29 May 2026 18:47:40 +0200 Subject: [PATCH 1/5] Abilities API: Normalize `required` schema shape for REST responses When exposing an ability's input and output schemas through the REST API, convert any draft-03 per-property `required` boolean into a draft-04 `required` array of property names on the parent object schema, and drop boolean `required` flags that have no draft-04 equivalent (such as on a scalar root schema). WordPress accepts both forms internally, but only the array form is valid JSON Schema draft-04, so clients consuming `wp-abilities/v1` now receive a conformant schema. The transformation only affects the REST response copy; server-side validation continues to use the stored schema via validate_input(). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...s-wp-rest-abilities-v1-list-controller.php | 34 +- .../wpRestAbilitiesV1ListController.php | 299 ++++++++++++++++++ 2 files changed, 332 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php index c1c20b3052350..063f023a47425 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php @@ -225,11 +225,20 @@ private function is_associative_array( $value ): bool { /** * Transforms an ability schema for REST response output. * + * The input and output schemas are a public contract: REST clients (such as + * the `@wordpress/abilities` JS client) consume them as standard JSON Schema + * and validate ability input and output against them. The response must + * therefore use JSON Schema draft-04 forms that standard validators + * understand, not the WordPress-internal conventions that + * `rest_validate_value_from_schema()` also accepts on the server. + * * Ability schemas may include WordPress-internal properties or unsupported * schema keywords that should not be exposed in REST responses. This method * strips keys not recognized by the REST API schema handling. It also * converts empty array defaults to objects when the schema type is 'object' - * to ensure proper JSON serialization as {} instead of []. + * to ensure proper JSON serialization as {} instead of [], and normalizes + * the `required` keyword from the draft-03 per-property boolean form into + * the draft-04 array of property names. * * @since 7.1.0 * @@ -256,6 +265,29 @@ private function prepare_schema_for_response( array $schema ): array { $schema = array_intersect_key( $schema, $allowed_keywords ); + // Collect draft-03 per-property `required: true` flags into a draft-04 + // `required` array of property names on the parent object schema. + if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) { + $required = ( isset( $schema['required'] ) && is_array( $schema['required'] ) ) ? $schema['required'] : array(); + foreach ( $schema['properties'] as $property => $property_schema ) { + if ( $this->is_associative_array( $property_schema ) && isset( $property_schema['required'] ) && is_bool( $property_schema['required'] ) ) { + if ( true === $property_schema['required'] ) { + $required[] = $property; + } + unset( $schema['properties'][ $property ]['required'] ); + } + } + if ( ! empty( $required ) ) { + $schema['required'] = array_values( array_unique( $required ) ); + } + } + + // A boolean `required` outside of an object's property list has no draft-04 + // equivalent, so drop it rather than emit an invalid keyword. + if ( isset( $schema['required'] ) && is_bool( $schema['required'] ) ) { + unset( $schema['required'] ); + } + // Sub-schema maps: keys are user-defined, values are sub-schemas. // Note: 'dependencies' values can also be property-dependency arrays // (numeric arrays of strings) which are skipped via wp_is_numeric_array(). diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php index 9513d372b16d8..cca7d26c4f1ff 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -1147,4 +1147,303 @@ public function test_unsupported_schema_keywords_stripped_from_nested_sub_schema $this->assertArrayNotHasKey( 'sanitize_callback', $data['output_schema']['additionalItems'] ); $this->assertSame( 'boolean', $data['output_schema']['additionalItems']['type'] ); } + + /** + * Test that per-property `required` booleans become a draft-04 `required` array. + * + * @ticket 64955 + */ + public function test_required_property_booleans_converted_to_draft_04_array(): void { + $this->register_test_ability( + 'test/required-booleans', + array( + 'label' => 'Required Booleans', + 'description' => 'Tests conversion of per-property required booleans.', + 'category' => 'general', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'title' => array( + 'type' => 'string', + 'required' => true, + ), + 'content' => array( + 'type' => 'string', + 'required' => true, + ), + 'optional' => array( + 'type' => 'string', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'required' => true, + ), + ), + ), + 'execute_callback' => static function (): array { + return array( 'id' => 1 ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/required-booleans' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + // The `required` array lists the names of the properties flagged as required. + $this->assertArrayHasKey( 'required', $data['input_schema'] ); + $this->assertSameSets( array( 'title', 'content' ), $data['input_schema']['required'] ); + + // The boolean flag is removed from each property sub-schema. + $this->assertArrayNotHasKey( 'required', $data['input_schema']['properties']['title'] ); + $this->assertArrayNotHasKey( 'required', $data['input_schema']['properties']['content'] ); + $this->assertArrayNotHasKey( 'required', $data['input_schema']['properties']['optional'] ); + + // Output schemas are normalized the same way. + $this->assertSame( array( 'id' ), $data['output_schema']['required'] ); + $this->assertArrayNotHasKey( 'required', $data['output_schema']['properties']['id'] ); + } + + /** + * Test that per-property `required` booleans are converted in nested object schemas. + * + * @ticket 64955 + */ + public function test_required_booleans_converted_in_nested_object_schemas(): void { + $this->register_test_ability( + 'test/required-nested', + array( + 'label' => 'Required Nested', + 'description' => 'Tests conversion within nested object schemas.', + 'category' => 'general', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'address' => array( + 'type' => 'object', + 'required' => true, + 'properties' => array( + 'street' => array( + 'type' => 'string', + 'required' => true, + ), + 'city' => array( + 'type' => 'string', + ), + ), + ), + ), + ), + 'execute_callback' => static function () { + return null; + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/required-nested' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $address = $data['input_schema']['properties']['address']; + + // The outer object lists the nested object as a required property. + $this->assertSame( array( 'address' ), $data['input_schema']['required'] ); + + // The nested object's own boolean flag is replaced by a draft-04 array + // collecting its own required properties (proving the boolean was converted). + $this->assertSame( array( 'street' ), $address['required'] ); + $this->assertArrayNotHasKey( 'required', $address['properties']['street'] ); + $this->assertArrayNotHasKey( 'required', $address['properties']['city'] ); + } + + /** + * Test that `required: false` is removed without emitting an empty `required` array. + * + * @ticket 64955 + */ + public function test_required_false_booleans_removed_without_required_array(): void { + $this->register_test_ability( + 'test/required-false', + array( + 'label' => 'Required False', + 'description' => 'Tests that required:false is stripped.', + 'category' => 'general', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'maybe' => array( + 'type' => 'string', + 'required' => false, + ), + ), + ), + 'execute_callback' => static function () { + return null; + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/required-false' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertArrayNotHasKey( 'required', $data['input_schema'] ); + $this->assertArrayNotHasKey( 'required', $data['input_schema']['properties']['maybe'] ); + } + + /** + * Test that a draft-04 `required` array and per-property booleans merge without duplicates. + * + * @ticket 64955 + */ + public function test_required_draft_04_array_merged_with_booleans(): void { + $this->register_test_ability( + 'test/required-mixed', + array( + 'label' => 'Required Mixed', + 'description' => 'Tests merging of a draft-04 array with draft-03 booleans.', + 'category' => 'general', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'title' ), + 'properties' => array( + 'title' => array( + 'type' => 'string', + 'required' => true, + ), + 'content' => array( + 'type' => 'string', + 'required' => true, + ), + ), + ), + 'execute_callback' => static function () { + return null; + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/required-mixed' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + // 'title' is listed in the array and flagged via boolean, but not duplicated. + $this->assertSameSets( array( 'title', 'content' ), $data['input_schema']['required'] ); + $this->assertCount( 2, $data['input_schema']['required'] ); + } + + /** + * Test that a boolean `required` with no draft-04 equivalent (e.g. on a scalar) is dropped. + * + * @ticket 64955 + */ + public function test_required_boolean_on_scalar_schema_removed(): void { + $this->register_test_ability( + 'test/required-scalar', + array( + 'label' => 'Required Scalar', + 'description' => 'Tests stripping of a boolean required on a scalar schema.', + 'category' => 'general', + 'input_schema' => array( + 'type' => 'string', + 'description' => 'The text to analyze.', + 'required' => true, + ), + 'output_schema' => array( + 'type' => 'string', + 'required' => true, + ), + 'execute_callback' => static function ( $input ) { + return $input; + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/required-scalar' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertArrayNotHasKey( 'required', $data['input_schema'] ); + $this->assertSame( 'string', $data['input_schema']['type'] ); + $this->assertArrayNotHasKey( 'required', $data['output_schema'] ); + } + + /** + * Test that per-property `required` booleans are converted in an array's `items` object. + * + * @ticket 64955 + */ + public function test_required_booleans_converted_in_array_items_object_schemas(): void { + $this->register_test_ability( + 'test/required-array-items', + array( + 'label' => 'Required Array Items', + 'description' => 'Tests conversion within array item object schemas.', + 'category' => 'general', + 'input_schema' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'required' => true, + ), + 'label' => array( + 'type' => 'string', + ), + ), + ), + ), + 'execute_callback' => static function () { + return null; + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/required-array-items' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $items = $data['input_schema']['items']; + + // The object schema inside `items` collects its own required properties + // into a draft-04 array, and the per-property boolean is removed. + $this->assertSame( array( 'id' ), $items['required'] ); + $this->assertArrayNotHasKey( 'required', $items['properties']['id'] ); + $this->assertArrayNotHasKey( 'required', $items['properties']['label'] ); + } } From d1e3966adeb2fff7a7a2267adc7c37fe9df13d67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Zi=C3=B3=C5=82kowski?= Date: Tue, 2 Jun 2026 12:37:01 +0200 Subject: [PATCH 2/5] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Weston Ruter --- .../class-wp-rest-abilities-v1-list-controller.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php index 063f023a47425..d348eeb5aa4dd 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php @@ -268,16 +268,16 @@ private function prepare_schema_for_response( array $schema ): array { // Collect draft-03 per-property `required: true` flags into a draft-04 // `required` array of property names on the parent object schema. if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) { - $required = ( isset( $schema['required'] ) && is_array( $schema['required'] ) ) ? $schema['required'] : array(); - foreach ( $schema['properties'] as $property => $property_schema ) { + $required = ( isset( $schema['required'] ) && is_array( $schema['required'] ) ) ? array_map( 'strval', $schema['required'] ) : array(); + foreach ( $schema['properties'] as $property => &$property_schema ) { if ( $this->is_associative_array( $property_schema ) && isset( $property_schema['required'] ) && is_bool( $property_schema['required'] ) ) { if ( true === $property_schema['required'] ) { - $required[] = $property; + $required[] = (string) $property; } - unset( $schema['properties'][ $property ]['required'] ); + unset( $property_schema['required'] ); } } - if ( ! empty( $required ) ) { + if ( count( $required ) > 0 ) { $schema['required'] = array_values( array_unique( $required ) ); } } From 3c54b7cb78a8a0c7f64d25a6669ae5424f769111 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 2 Jun 2026 12:46:55 +0200 Subject: [PATCH 3/5] Abilities API: Mirror validator precedence for normalized `required` When a draft-04 `required` array is already present on an object schema, `rest_validate_object_value_from_schema()` validates only that array and ignores any draft-03 per-property `required` booleans. Align `prepare_schema_for_response()` with that precedence so the published schema describes exactly what gets enforced instead of merging the two forms (which over-stated requirements for mixed schemas). Also clear the dangling `foreach` reference after collecting flags. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...s-wp-rest-abilities-v1-list-controller.php | 12 ++++++++++-- .../wpRestAbilitiesV1ListController.php | 19 +++++++++++++------ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php index d348eeb5aa4dd..ddd96ca59846e 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php @@ -267,16 +267,24 @@ private function prepare_schema_for_response( array $schema ): array { // Collect draft-03 per-property `required: true` flags into a draft-04 // `required` array of property names on the parent object schema. + // + // This mirrors rest_validate_object_value_from_schema(), where a draft-04 + // `required` array takes precedence: when one is present, per-property + // booleans are ignored during validation. They are therefore left out of + // the array here as well (but still stripped from the output) so the + // published schema describes exactly what gets enforced. if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) { - $required = ( isset( $schema['required'] ) && is_array( $schema['required'] ) ) ? array_map( 'strval', $schema['required'] ) : array(); + $has_required_array = isset( $schema['required'] ) && is_array( $schema['required'] ); + $required = $has_required_array ? array_map( 'strval', $schema['required'] ) : array(); foreach ( $schema['properties'] as $property => &$property_schema ) { if ( $this->is_associative_array( $property_schema ) && isset( $property_schema['required'] ) && is_bool( $property_schema['required'] ) ) { - if ( true === $property_schema['required'] ) { + if ( ! $has_required_array && true === $property_schema['required'] ) { $required[] = (string) $property; } unset( $property_schema['required'] ); } } + unset( $property_schema ); if ( count( $required ) > 0 ) { $schema['required'] = array_values( array_unique( $required ) ); } diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php index cca7d26c4f1ff..20a773bea1628 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -1311,16 +1311,20 @@ public function test_required_false_booleans_removed_without_required_array(): v } /** - * Test that a draft-04 `required` array and per-property booleans merge without duplicates. + * Test that an existing draft-04 `required` array takes precedence over per-property booleans. + * + * This mirrors rest_validate_object_value_from_schema(), which ignores + * per-property `required` booleans when a draft-04 `required` array is + * present, so the published schema matches what is actually enforced. * * @ticket 64955 */ - public function test_required_draft_04_array_merged_with_booleans(): void { + public function test_required_draft_04_array_takes_precedence_over_booleans(): void { $this->register_test_ability( 'test/required-mixed', array( 'label' => 'Required Mixed', - 'description' => 'Tests merging of a draft-04 array with draft-03 booleans.', + 'description' => 'Tests precedence of a draft-04 array over draft-03 booleans.', 'category' => 'general', 'input_schema' => array( 'type' => 'object', @@ -1351,9 +1355,12 @@ public function test_required_draft_04_array_merged_with_booleans(): void { $data = $response->get_data(); - // 'title' is listed in the array and flagged via boolean, but not duplicated. - $this->assertSameSets( array( 'title', 'content' ), $data['input_schema']['required'] ); - $this->assertCount( 2, $data['input_schema']['required'] ); + // The draft-04 array wins: the `content` boolean is ignored, not merged in. + $this->assertSame( array( 'title' ), $data['input_schema']['required'] ); + + // The per-property booleans are still stripped from the output. + $this->assertArrayNotHasKey( 'required', $data['input_schema']['properties']['title'] ); + $this->assertArrayNotHasKey( 'required', $data['input_schema']['properties']['content'] ); } /** From bd946f784b042053106aaa8182cc8dc8a1062b26 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 2 Jun 2026 12:54:05 +0200 Subject: [PATCH 4/5] REST API: Test draft-04 `required` precedence over per-property booleans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the contract that `rest_validate_object_value_from_schema()` validates only the draft-04 `required` array when one is present on an object node, ignoring per-property `required` booleans at the same level. Existing coverage exercised each form independently and mixed them across nesting levels, but never on a single node — the case the Abilities REST schema normalization relies on. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/rest-api/rest-schema-validation.php | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-schema-validation.php b/tests/phpunit/tests/rest-api/rest-schema-validation.php index ce8875c3e9339..f83c4817718c4 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-validation.php +++ b/tests/phpunit/tests/rest-api/rest-schema-validation.php @@ -1505,6 +1505,31 @@ public function data_required_deeply_nested_property() { ); } + /** + * A draft-04 `required` array takes precedence over per-property + * `required` booleans on the same object node: the booleans are ignored. + * + * @ticket 64955 + */ + public function test_required_v4_array_takes_precedence_over_v3_booleans() { + $schema = array( + 'type' => 'object', + 'required' => array( 'listed' ), + 'properties' => array( + 'listed' => array( 'type' => 'string' ), + 'flagged' => array( + 'type' => 'string', + 'required' => true, // Ignored because the array is present. + ), + ), + ); + + // Missing the array-listed prop fails. + $this->assertWPError( rest_validate_value_from_schema( array( 'flagged' => 'x' ), $schema ) ); + // Missing only the boolean-flagged prop passes — the boolean is not enforced. + $this->assertTrue( rest_validate_value_from_schema( array( 'listed' => 'x' ), $schema ) ); + } + /** * @ticket 51023 */ From 55fad0cde68807bdc2ceb135e6a7642998e2fd13 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 2 Jun 2026 14:10:57 +0200 Subject: [PATCH 5/5] REST API: Simplify normalized `required` collection Drop the now-unnecessary deduplication and reindexing when building the draft-04 `required` array. Property keys are unique, so the collected list needs no `array_unique()`/`array_values()`, and when a draft-04 `required` array is already present it is left untouched rather than rebuilt. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../class-wp-rest-abilities-v1-list-controller.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php index ddd96ca59846e..9fd251815b383 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php @@ -275,7 +275,7 @@ private function prepare_schema_for_response( array $schema ): array { // published schema describes exactly what gets enforced. if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) { $has_required_array = isset( $schema['required'] ) && is_array( $schema['required'] ); - $required = $has_required_array ? array_map( 'strval', $schema['required'] ) : array(); + $required = array(); foreach ( $schema['properties'] as $property => &$property_schema ) { if ( $this->is_associative_array( $property_schema ) && isset( $property_schema['required'] ) && is_bool( $property_schema['required'] ) ) { if ( ! $has_required_array && true === $property_schema['required'] ) { @@ -285,8 +285,11 @@ private function prepare_schema_for_response( array $schema ): array { } } unset( $property_schema ); - if ( count( $required ) > 0 ) { - $schema['required'] = array_values( array_unique( $required ) ); + + // Property keys are unique, so the collected list needs no deduplication. + // When a draft-04 array is already present, leave it untouched. + if ( ! $has_required_array && count( $required ) > 0 ) { + $schema['required'] = $required; } }