diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 9998e14da7361..b24fd381b29bf 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -950,6 +950,7 @@ function _rest_array_intersect_key_recursive( $array1, $array2 ) { * Filters the REST API response to include only an allow-listed set of response object fields. * * @since 4.8.0 + * @since 7.2.0 The response is left unchanged when the route disables `allow_fields`. * * @param WP_REST_Response $response Current response being served. * @param WP_REST_Server $server ResponseHandler instance (usually WP_REST_Server). @@ -961,6 +962,10 @@ function rest_filter_response_fields( $response, $server, $request ) { return $response; } + if ( $server instanceof WP_REST_Server && ! $server->is_response_option_enabled( $response, 'allow_fields' ) ) { + return $response; + } + $data = $response->get_data(); $fields = wp_parse_list( $request['_fields'] ); diff --git a/src/wp-includes/rest-api/class-wp-rest-server.php b/src/wp-includes/rest-api/class-wp-rest-server.php index 179edb04c8b68..58713a9933f52 100644 --- a/src/wp-includes/rest-api/class-wp-rest-server.php +++ b/src/wp-includes/rest-api/class-wp-rest-server.php @@ -40,6 +40,9 @@ * accept_raw: bool, * show_in_index: bool, * allow_batch?: array{v1?: bool}|false, + * allow_fields?: bool, + * allow_embed?: bool, + * allow_links?: bool, * ... * } */ @@ -622,6 +625,7 @@ public function serve_request( $path = null ) { * * @since 4.4.0 * @since 5.4.0 The `$embed` parameter can now contain a list of link relations to include. + * @since 7.2.0 Links are not added or embedded when the route disables `allow_links` or `allow_embed`. * * @param WP_REST_Response $response Response object. * @param bool|string[] $embed Whether to embed all links, a filtered list of link relations, or no links. @@ -634,14 +638,14 @@ public function serve_request( $path = null ) { */ public function response_to_data( $response, $embed ) { $data = $response->get_data(); - $links = self::get_compact_response_links( $response ); + $links = $this->is_response_option_enabled( $response, 'allow_links' ) ? self::get_compact_response_links( $response ) : array(); if ( ! empty( $links ) ) { // Convert links to part of the data. $data['_links'] = $links; } - if ( $embed ) { + if ( $embed && $this->is_response_option_enabled( $response, 'allow_embed' ) ) { $this->embed_cache = array(); // Determine if this is a numeric array. if ( wp_is_numeric_array( $data ) ) { @@ -1131,6 +1135,43 @@ public function get_route_options( $route ) { return $this->route_options[ $route ]; } + /** + * Determines whether a response option is enabled for the route that generated a response. + * + * Routes, or individual handlers of a route, can set these options to false: + * + * - `allow_fields`: Whether the `_fields` parameter filters the response data. + * - `allow_embed`: Whether the `_embed` parameter embeds linked resources in the response data. + * - `allow_links`: Whether the response links are added to the response data as `_links`. + * + * A handler option takes precedence over the route option of the same name. + * + * @since 7.2.0 + * + * @param WP_HTTP_Response $response Response object. + * @param string $option Option name. Accepts 'allow_fields', 'allow_embed', or 'allow_links'. + * @return bool Whether the option is enabled. True when neither the handler nor the route sets it. + */ + public function is_response_option_enabled( $response, $option ) { + if ( ! $response instanceof WP_REST_Response ) { + return true; + } + + $handler = $response->get_matched_handler(); + if ( is_array( $handler ) && isset( $handler[ $option ] ) ) { + return (bool) $handler[ $option ]; + } + + $route = $response->get_matched_route(); + if ( ! is_string( $route ) || '' === $route ) { + return true; + } + + $route_options = $this->get_route_options( $route ); + + return ! isset( $route_options[ $option ] ) || (bool) $route_options[ $option ]; + } + /** * Matches the request to a callback and call it. * diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php index 12f6a75083be7..cca2509b4c712 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php @@ -65,6 +65,14 @@ public function register_routes(): void { 'callback' => array( $this, 'execute_ability' ), 'permission_callback' => array( $this, 'check_ability_permissions' ), 'args' => $this->get_run_args(), + + /* + * Serve the ability result as returned, so it follows the ability's output + * schema whether it is an object, an array, or a scalar value. + */ + 'allow_fields' => false, + 'allow_embed' => false, + 'allow_links' => false, ), 'schema' => array( $this, 'get_run_schema' ), ) @@ -96,18 +104,10 @@ public function execute_ability( $request ) { } /* - * Preserve empty objects in nested properties when encoding an associative - * result as JSON. Preparation can convert empty PHP arrays to stdClass. - * - * The REST server's `_fields` and `_embed` processing expects arrays at the - * response root and for numeric collection items. Converting those values - * to objects would cause type errors, so skip root empty arrays and numeric - * collections until that processing supports objects. An empty object result - * therefore still encodes as [], and a list containing one encodes as [[]]. + * Preserve empty objects when encoding the result as JSON. Preparation can + * convert empty PHP arrays to stdClass, also within object results. */ - if ( is_array( $result ) && ! wp_is_numeric_array( $result ) ) { - $result = wp_prepare_json_value_for_client( $result, $ability->get_output_schema() ); - } + $result = wp_prepare_json_value_for_client( $result, $ability->get_output_schema() ); return rest_ensure_response( $result ); } diff --git a/tests/phpunit/tests/rest-api/rest-server.php b/tests/phpunit/tests/rest-api/rest-server.php index c4d6a883d67b1..3dbacc81b9035 100644 --- a/tests/phpunit/tests/rest-api/rest-server.php +++ b/tests/phpunit/tests/rest-api/rest-server.php @@ -2900,4 +2900,254 @@ public function test_prefers_developer_defined_target_hints() { $this->assertArrayHasKey( 'allow', $link['targetHints'] ); $this->assertSame( array( 'GET', 'PUT' ), $link['targetHints']['allow'] ); } + + /** + * Registers a route for the response option tests, and a route that its link embeds. + * + * @param mixed $data The response data. + * @param bool $with_link Whether the response has an embeddable link. + * @param array $handler_options Options for the route handler. + * @param array $route_options Options for the route. + */ + private function register_response_options_route( $data, bool $with_link, array $handler_options = array(), array $route_options = array() ): void { + rest_get_server()->register_route( + 'test-ns', + '/test-ns/response-options', + array_merge( + array( + array_merge( + array( + 'methods' => 'GET', + 'callback' => static function () use ( $data, $with_link ) { + $response = new WP_REST_Response( $data ); + if ( $with_link ) { + $response->add_link( 'alternate', rest_url( '/test-ns/embedded' ), array( 'embeddable' => true ) ); + } + return $response; + }, + 'permission_callback' => '__return_true', + ), + $handler_options + ), + ), + $route_options + ) + ); + + rest_get_server()->register_route( + 'test-ns', + '/test-ns/embedded', + array( + 'methods' => 'GET', + 'callback' => static function () { + return array( 'hello' => 'world' ); + }, + 'permission_callback' => '__return_true', + ) + ); + } + + /** + * Data provider for test_allow_fields_false_leaves_the_data_unfiltered(). + * + * @return array + */ + public static function data_allow_fields_false_leaves_the_data_unfiltered(): array { + return array( + 'associative array' => array( + array( + 'a' => 1, + 'b' => 2, + ), + '{"a":1,"b":2}', + ), + 'list' => array( array( array( 'b' => 2 ), 'c' ), '[{"b":2},"c"]' ), + 'empty object' => array( new stdClass(), '{}' ), + 'object' => array( (object) array( 'b' => 2 ), '{"b":2}' ), + 'string' => array( 'value', '"value"' ), + 'integer' => array( 5, '5' ), + ); + } + + /** + * @ticket 66267 + * @dataProvider data_allow_fields_false_leaves_the_data_unfiltered + * + * @param mixed $data The response data. + * @param string $expected The expected response body. + */ + public function test_allow_fields_false_leaves_the_data_unfiltered( $data, string $expected ): void { + $this->register_response_options_route( $data, false, array( 'allow_fields' => false ) ); + + $_GET['_fields'] = 'a'; + rest_get_server()->serve_request( '/test-ns/response-options' ); + + $this->assertSame( $expected, rest_get_server()->sent_body, 'The _fields parameter should not filter the data.' ); + } + + /** + * @ticket 66267 + */ + public function test_fields_are_filtered_by_default(): void { + $this->register_response_options_route( + array( + 'a' => 1, + 'b' => 2, + ), + false + ); + + $_GET['_fields'] = 'a'; + rest_get_server()->serve_request( '/test-ns/response-options' ); + + $this->assertSame( '{"a":1}', rest_get_server()->sent_body, 'The _fields parameter should filter the data.' ); + } + + /** + * Data provider for test_allow_links_false_leaves_out_the_links(). + * + * @return array + */ + public static function data_allow_links_false_leaves_out_the_links(): array { + return array( + 'associative array' => array( array( 'a' => 1 ), '{"a":1}' ), + 'list' => array( array( 'a', 'b' ), '["a","b"]' ), + 'empty object' => array( new stdClass(), '{}' ), + 'string' => array( 'value', '"value"' ), + ); + } + + /** + * @ticket 66267 + * @dataProvider data_allow_links_false_leaves_out_the_links + * + * @param mixed $data The response data. + * @param string $expected The expected response body. + */ + public function test_allow_links_false_leaves_out_the_links( $data, string $expected ): void { + $this->register_response_options_route( $data, true, array( 'allow_links' => false ) ); + + rest_get_server()->serve_request( '/test-ns/response-options' ); + + $this->assertSame( $expected, rest_get_server()->sent_body, 'The response links should not be added.' ); + } + + /** + * @ticket 66267 + */ + public function test_allow_embed_false_keeps_the_links_without_embedding(): void { + $this->register_response_options_route( array( 'a' => 1 ), true, array( 'allow_embed' => false ) ); + + $_GET['_embed'] = '1'; + rest_get_server()->serve_request( '/test-ns/response-options' ); + $data = json_decode( rest_get_server()->sent_body, true ); + + $this->assertArrayHasKey( '_links', $data, 'The response links should be added.' ); + $this->assertArrayNotHasKey( '_embedded', $data, 'The links should not be embedded.' ); + } + + /** + * @ticket 66267 + */ + public function test_links_are_added_and_embedded_by_default(): void { + $this->register_response_options_route( array( 'a' => 1 ), true ); + + $_GET['_embed'] = '1'; + rest_get_server()->serve_request( '/test-ns/response-options' ); + $data = json_decode( rest_get_server()->sent_body, true ); + + $this->assertArrayHasKey( '_links', $data, 'The response links should be added.' ); + $this->assertSame( array( array( 'hello' => 'world' ) ), $data['_embedded']['alternate'], 'The link should be embedded.' ); + } + + /** + * @ticket 66267 + */ + public function test_allow_embed_false_applies_to_enveloped_responses(): void { + $this->register_response_options_route( array( 'a' => 1 ), true, array( 'allow_embed' => false ) ); + + $_GET['_embed'] = '1'; + $_GET['_envelope'] = '1'; + rest_get_server()->serve_request( '/test-ns/response-options' ); + $data = json_decode( rest_get_server()->sent_body, true ); + + $this->assertSame( 200, $data['status'], 'The enveloped response should succeed.' ); + $this->assertArrayHasKey( '_links', $data['body'], 'The response links should be added to the body.' ); + $this->assertArrayNotHasKey( '_embedded', $data['body'], 'The links should not be embedded in the body.' ); + } + + /** + * Data provider for test_handler_option_takes_precedence_over_route_option(). + * + * @return array, 1: array, 2: string}> + */ + public static function data_handler_option_takes_precedence_over_route_option(): array { + return array( + 'route disables' => array( array(), array( 'allow_fields' => false ), '{"a":1,"b":2}' ), + 'route disables and handler enables' => array( array( 'allow_fields' => true ), array( 'allow_fields' => false ), '{"a":1}' ), + 'route enables and handler disables' => array( array( 'allow_fields' => false ), array( 'allow_fields' => true ), '{"a":1,"b":2}' ), + 'handler disables another option' => array( array( 'allow_links' => false ), array(), '{"a":1}' ), + ); + } + + /** + * @ticket 66267 + * @dataProvider data_handler_option_takes_precedence_over_route_option + * + * @param array $handler_options Options for the route handler. + * @param array $route_options Options for the route. + * @param string $expected The expected response body. + */ + public function test_handler_option_takes_precedence_over_route_option( array $handler_options, array $route_options, string $expected ): void { + $this->register_response_options_route( + array( + 'a' => 1, + 'b' => 2, + ), + false, + $handler_options, + $route_options + ); + + $_GET['_fields'] = 'a'; + rest_get_server()->serve_request( '/test-ns/response-options' ); + + $this->assertSame( $expected, rest_get_server()->sent_body, 'The handler option should take precedence over the route option.' ); + } + + /** + * @ticket 66267 + */ + public function test_response_options_apply_to_preloaded_requests(): void { + $this->register_response_options_route( + array( + 'a' => 1, + 'b' => 2, + ), + true, + array( + 'allow_fields' => false, + 'allow_links' => false, + ) + ); + + $preload = rest_preload_api_request( array(), '/test-ns/response-options?_fields=a&_embed=1' ); + + $this->assertSame( + array( + 'a' => 1, + 'b' => 2, + ), + $preload['/test-ns/response-options?_fields=a&_embed=1']['body'], + 'The preloaded data should not be filtered or have links.' + ); + } + + /** + * @ticket 66267 + */ + public function test_is_response_option_enabled_without_a_matched_route(): void { + $this->assertTrue( rest_get_server()->is_response_option_enabled( new WP_REST_Response(), 'allow_fields' ), 'A response without a matched route should enable the option.' ); + $this->assertTrue( rest_get_server()->is_response_option_enabled( new WP_HTTP_Response(), 'allow_links' ), 'A response that is not a REST response should enable the option.' ); + } } diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php index 278041158ef90..3139598656c79 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php @@ -196,6 +196,50 @@ public function test_fields_filter_from_query_string_limits_settings(): void { $this->assertEqualSets( array( 'title', 'posts_per_page' ), array_keys( $data ), 'Only the settings from the comma-separated list should be returned.' ); } + /** + * Data provider for test_no_matching_setting_is_encoded_as_an_empty_object(). + * + * @return array}> + */ + public static function data_global_params(): array { + return array( + 'no global parameter' => array( array() ), + '_fields' => array( array( '_fields' => 'title' ) ), + '_embed' => array( array( '_embed' => '1' ) ), + ); + } + + /** + * An empty result is encoded as a JSON object, which the output schema requires, + * also when the global `_fields` or `_embed` parameter is given. + * + * @ticket 66267 + * @dataProvider data_global_params + * + * @param array $global_params The global parameters sent with the request. + */ + public function test_no_matching_setting_is_encoded_as_an_empty_object( array $global_params ): void { + $request = $this->run_request( + array( + 'group' => 'reading', + 'fields' => 'title', + ) + ); + foreach ( $global_params as $name => $value ) { + $request->set_param( $name, $value ); + } + + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Filters that match no setting should be accepted.' ); + + add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 ); + $response = apply_filters( 'rest_post_dispatch', $response, $this->server, $request ); + $data = $this->server->response_to_data( $response, isset( $global_params['_embed'] ) ); + + $this->assertSame( '{}', wp_json_encode( $data ), 'The empty result should be encoded as an empty object.' ); + } + /** * @ticket 64605 */ diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php index 26792b200a85f..9ac3a39e7ef01 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php @@ -443,6 +443,118 @@ private function register_test_abilities(): void { ) ); + // Ability that returns an object result with an empty object property. + $this->register_test_ability( + 'test/nested-empty-object-in-object-output', + array( + 'label' => 'Nested Empty Object In Object Output', + 'description' => 'Returns an object result with an empty object property.', + 'category' => 'general', + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'settings' => array( 'type' => 'object' ), + ), + ), + 'execute_callback' => static function () { + return (object) array( 'settings' => array() ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + + // Ability that returns a JsonSerializable result with an empty object property. + $this->register_test_ability( + 'test/nested-empty-object-in-json-serializable-output', + array( + 'label' => 'Nested Empty Object In JsonSerializable Output', + 'description' => 'Returns a JsonSerializable result with an empty object property.', + 'category' => 'general', + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'settings' => array( 'type' => 'object' ), + ), + ), + 'execute_callback' => static function () { + return new class() implements JsonSerializable { + public function jsonSerialize(): array { + return array( 'settings' => array() ); + } + }; + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + + // Ability that returns an empty list. + $this->register_test_ability( + 'test/empty-list-output', + array( + 'label' => 'Empty List Output', + 'description' => 'Returns an empty list.', + 'category' => 'general', + 'output_schema' => array( + 'type' => 'array', + 'items' => array( 'type' => 'object' ), + ), + 'execute_callback' => static function () { + return array(); + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + + // Ability that returns a list of strings. + $this->register_test_ability( + 'test/string-list-output', + array( + 'label' => 'String List Output', + 'description' => 'Returns a list of strings.', + 'category' => 'general', + 'output_schema' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + 'execute_callback' => static function () { + return array( 'a', 'b' ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + + // Ability that returns a string. + $this->register_test_ability( + 'test/string-output', + array( + 'label' => 'String Output', + 'description' => 'Returns a string.', + 'category' => 'general', + 'output_schema' => array( + 'type' => 'string', + ), + 'execute_callback' => static function () { + return 'value'; + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + // Read-only ability for query params testing. $this->register_test_ability( 'test/query-params', @@ -633,39 +745,73 @@ public function test_output_validation(): void { /** * Data provider for test_output_is_prepared_for_json_clients(). * - * @return array + * Each result is requested without global parameters, with `_fields` and `_embed`, + * and with a link that a `rest_post_dispatch` filter adds, alone and embedded. + * + * @return array */ public static function data_output_is_prepared_for_json_clients(): array { - return array( - 'root empty object stays unchanged' => array( 'test/empty-object-output', '[]' ), - 'nested empty object' => array( 'test/nested-empty-object-output', '{"settings":{}}' ), - 'numeric collection stays unchanged' => array( 'test/empty-object-list-output', '[[]]' ), - ); + $results = array( + 'root empty object' => array( 'test/empty-object-output', '{}' ), + 'nested empty object' => array( 'test/nested-empty-object-output', '{"settings":{}}' ), + 'numeric collection' => array( 'test/empty-object-list-output', '[{}]' ), + 'object result' => array( 'test/nested-empty-object-in-object-output', '{"settings":{}}' ), + 'JsonSerializable result' => array( 'test/nested-empty-object-in-json-serializable-output', '{"settings":{}}' ), + 'root empty list' => array( 'test/empty-list-output', '[]' ), + 'list of strings' => array( 'test/string-list-output', '["a","b"]' ), + 'string' => array( 'test/string-output', '"value"' ), + ); + + $data = array(); + foreach ( $results as $label => $result ) { + foreach ( array( 'none', '_fields', '_embed', 'link', 'embedded link' ) as $global_param ) { + $data[ "{$label}, {$global_param}" ] = array( $result[0], $global_param, $result[1] ); + } + } + + return $data; } /** - * Tests that ability results are prepared for JSON clients without breaking - * the global `_fields` and `_embed` parameters. + * Tests that ability results are prepared for JSON clients, and that the global + * `_fields` and `_embed` parameters and response links leave them unchanged. * * @ticket 66267 * @dataProvider data_output_is_prepared_for_json_clients * * @param string $ability_name The ability name. + * @param string $global_param The global parameter sent with the request, 'link' or + * 'embedded link' for a link added by a filter, or 'none'. * @param string $expected The expected encoded result. */ - public function test_output_is_prepared_for_json_clients( string $ability_name, string $expected ): void { - $request = new WP_REST_Request( 'POST', "/wp-abilities/v1/abilities/{$ability_name}/run" ); - $response = $this->server->dispatch( $request ); + public function test_output_is_prepared_for_json_clients( string $ability_name, string $global_param, string $expected ): void { + $request = new WP_REST_Request( 'POST', "/wp-abilities/v1/abilities/{$ability_name}/run" ); + if ( '_fields' === $global_param ) { + // No result has this field, so filtering would leave none of the result. + $request->set_param( '_fields', 'other' ); + } elseif ( '_embed' === $global_param || 'embedded link' === $global_param ) { + $request->set_param( '_embed', '1' ); + } - $this->assertSame( 200, $response->get_status() ); - $this->assertSame( $expected, wp_json_encode( $response->get_data() ) ); + if ( 'link' === $global_param || 'embedded link' === $global_param ) { + add_filter( + 'rest_post_dispatch', + static function ( $response ) { + $response->add_link( 'alternate', rest_url( '/wp-abilities/v1/abilities' ), array( 'embeddable' => true ) ); + return $response; + } + ); + } + + $response = $this->server->dispatch( $request ); - $request->set_param( '_fields', 'settings' ); + $this->assertSame( 200, $response->get_status(), 'The ability should run successfully.' ); add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 ); $response = apply_filters( 'rest_post_dispatch', $response, $this->server, $request ); + $data = $this->server->response_to_data( $response, $request->has_param( '_embed' ) ); - $this->assertSame( $expected, wp_json_encode( $this->server->response_to_data( $response, true ) ) ); + $this->assertSame( $expected, wp_json_encode( $data ), 'The result should be encoded as expected.' ); } /**