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..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 @@ -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,40 @@ 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. + // + // 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'] ) ) { + $has_required_array = isset( $schema['required'] ) && is_array( $schema['required'] ); + $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'] ) { + $required[] = (string) $property; + } + unset( $property_schema['required'] ); + } + } + unset( $property_schema ); + + // 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; + } + } + + // 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/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 */ diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php index 9513d372b16d8..20a773bea1628 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -1147,4 +1147,310 @@ 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 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_takes_precedence_over_booleans(): void { + $this->register_test_ability( + 'test/required-mixed', + array( + 'label' => 'Required Mixed', + 'description' => 'Tests precedence of a draft-04 array over 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(); + + // 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'] ); + } + + /** + * 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'] ); + } }