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 85464fd9dd302..c1c20b3052350 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 @@ -189,15 +189,23 @@ public function get_item_permissions_check( $request ) { } /** - * WordPress-internal schema keywords to strip from REST responses. + * Additional schema keywords to preserve in REST responses. * - * @since 7.0.0 - * @var array + * Ability schemas are exposed to clients as JSON Schema. Preserve additional + * draft-04 keywords so clients can validate richer schemas, even when some + * of those keywords are not enforced by the server-side REST schema validator. + * + * @since 7.1.0 + * @var string[] */ - private const INTERNAL_SCHEMA_KEYWORDS = array( - 'sanitize_callback' => true, - 'validate_callback' => true, - 'arg_options' => true, + private const ADDITIONAL_ALLOWED_SCHEMA_KEYWORDS = array( + 'required', + 'allOf', + 'not', + '$ref', + 'definitions', + 'dependencies', + 'additionalItems', ); /** @@ -217,12 +225,11 @@ private function is_associative_array( $value ): bool { /** * Transforms an ability schema for REST response output. * - * Ability schemas may include WordPress-internal properties like - * `sanitize_callback`, `validate_callback`, and `arg_options` that are - * used server-side but are not valid JSON Schema keywords. This method - * removes those specific keys so they are not exposed in REST responses. - * It also converts empty array defaults to objects when the schema type is - * 'object' to ensure proper JSON serialization as {} instead of []. + * 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 []. * * @since 7.1.0 * @@ -237,7 +244,17 @@ private function prepare_schema_for_response( array $schema ): array { } } - $schema = array_diff_key( $schema, self::INTERNAL_SCHEMA_KEYWORDS ); + // Computed once and reused across the recursive calls for every schema node. + static $allowed_keywords = null; + $allowed_keywords ??= array_fill_keys( + array_merge( + rest_get_allowed_schema_keywords(), + self::ADDITIONAL_ALLOWED_SCHEMA_KEYWORDS + ), + true + ); + + $schema = array_intersect_key( $schema, $allowed_keywords ); // Sub-schema maps: keys are user-defined, values are sub-schemas. // Note: 'dependencies' values can also be property-dependency arrays diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php index 5ba688cb57c79..9513d372b16d8 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -829,23 +829,28 @@ public function test_filter_by_namespace_still_respects_show_in_rest(): void { } /** - * Test that WordPress-internal schema keywords are stripped from ability schemas in REST response. + * Test that schema keywords outside the allow-list are stripped from ability schemas in REST response. * * @ticket 65035 */ - public function test_internal_schema_keywords_stripped_from_response(): void { + public function test_unsupported_schema_keywords_stripped_from_response(): void { $this->register_test_ability( - 'test/with-internal-keywords', + 'test/with-unsupported-keywords', array( - 'label' => 'Test Internal Keywords', - 'description' => 'Tests stripping of internal schema keywords', + 'label' => 'Test Unsupported Keywords', + 'description' => 'Tests stripping of unsupported schema keywords', 'category' => 'general', 'input_schema' => array( 'type' => 'object', + 'required' => array( 'content' ), 'properties' => array( 'content' => array( 'type' => 'string', 'description' => 'The content value.', + 'example' => 'example content', + 'examples' => array( 'example content' ), + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => 'is_string', 'arg_options' => array( 'sanitize_callback' => 'wp_kses_post' ), @@ -854,7 +859,13 @@ public function test_internal_schema_keywords_stripped_from_response(): void { ), 'output_schema' => array( 'type' => 'string', + 'example' => 'example output', + 'examples' => array( 'example output' ), + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => 'is_string', + 'arg_options' => array( 'sanitize_callback' => 'wp_kses_post' ), ), 'execute_callback' => static function ( $input ) { return $input['content']; @@ -864,7 +875,7 @@ public function test_internal_schema_keywords_stripped_from_response(): void { ) ); - $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/with-internal-keywords' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/with-unsupported-keywords' ); $response = $this->server->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); @@ -875,18 +886,29 @@ public function test_internal_schema_keywords_stripped_from_response(): void { $this->assertArrayHasKey( 'content', $data['input_schema']['properties'] ); $this->assertArrayHasKey( 'output_schema', $data ); - // Verify internal keywords are stripped from input_schema properties. + // Verify unsupported schema keywords are stripped from input_schema properties. $content_schema = $data['input_schema']['properties']['content']; $this->assertArrayNotHasKey( 'sanitize_callback', $content_schema ); $this->assertArrayNotHasKey( 'validate_callback', $content_schema ); $this->assertArrayNotHasKey( 'arg_options', $content_schema ); + $this->assertArrayNotHasKey( 'example', $content_schema ); + $this->assertArrayNotHasKey( 'examples', $content_schema ); + $this->assertArrayNotHasKey( 'context', $content_schema ); + $this->assertArrayNotHasKey( 'readonly', $content_schema ); // Verify valid JSON Schema keywords are preserved. $this->assertSame( 'string', $content_schema['type'] ); $this->assertSame( 'The content value.', $content_schema['description'] ); + $this->assertSame( array( 'content' ), $data['input_schema']['required'] ); // Verify internal keywords are stripped from output_schema. $this->assertArrayNotHasKey( 'sanitize_callback', $data['output_schema'] ); + $this->assertArrayNotHasKey( 'validate_callback', $data['output_schema'] ); + $this->assertArrayNotHasKey( 'arg_options', $data['output_schema'] ); + $this->assertArrayNotHasKey( 'example', $data['output_schema'] ); + $this->assertArrayNotHasKey( 'examples', $data['output_schema'] ); + $this->assertArrayNotHasKey( 'context', $data['output_schema'] ); + $this->assertArrayNotHasKey( 'readonly', $data['output_schema'] ); $this->assertSame( 'string', $data['output_schema']['type'] ); } @@ -947,19 +969,20 @@ public function test_nested_empty_object_schema_defaults_prepared_for_response() } /** - * Test that internal schema keywords are stripped from nested sub-schema locations. + * Test that schema keywords outside the allow-list are stripped from nested sub-schema locations. * * @ticket 64098 */ - public function test_internal_schema_keywords_stripped_from_nested_sub_schemas(): void { + public function test_unsupported_schema_keywords_stripped_from_nested_sub_schemas(): void { $this->register_test_ability( - 'test/nested-internal-keywords', + 'test/nested-unsupported-keywords', array( - 'label' => 'Test Nested Keywords', + 'label' => 'Test Nested Unsupported Keywords', 'description' => 'Tests stripping from all sub-schema locations', 'category' => 'general', 'input_schema' => array( 'type' => 'object', + '$ref' => '#/definitions/address', 'anyOf' => array( array( 'type' => 'object', @@ -1053,7 +1076,7 @@ public function test_internal_schema_keywords_stripped_from_nested_sub_schemas() ) ); - $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/nested-internal-keywords' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/nested-unsupported-keywords' ); $response = $this->server->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); @@ -1061,6 +1084,7 @@ public function test_internal_schema_keywords_stripped_from_nested_sub_schemas() $data = $response->get_data(); // Verify internal keywords are stripped from anyOf sub-schemas. + $this->assertSame( '#/definitions/address', $data['input_schema']['$ref'] ); $this->assertArrayHasKey( 'anyOf', $data['input_schema'] ); $this->assertArrayNotHasKey( 'sanitize_callback', $data['input_schema']['anyOf'][0] ); $this->assertSame( 'object', $data['input_schema']['anyOf'][0]['type'] );