diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 9998e14da7361..4f0d6e9fd8be0 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -950,6 +950,8 @@ 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 turns off the `fields` response + * processing or when the response data is not an array. * * @param WP_REST_Response $response Current response being served. * @param WP_REST_Server $server ResponseHandler instance (usually WP_REST_Server). @@ -961,8 +963,21 @@ function rest_filter_response_fields( $response, $server, $request ) { return $response; } + if ( $server instanceof WP_REST_Server ) { + $processing = $server->get_response_processing( $request, $response ); + + if ( ! $processing['fields'] ) { + return $response; + } + } + $data = $response->get_data(); + // Only arrays have fields to filter. + if ( ! is_array( $data ) ) { + return $response; + } + $fields = wp_parse_list( $request['_fields'] ); if ( 0 === count( $fields ) ) { @@ -993,7 +1008,7 @@ function rest_filter_response_fields( $response, $server, $request ) { if ( wp_is_numeric_array( $data ) ) { $new_data = array(); foreach ( $data as $item ) { - $new_data[] = _rest_array_intersect_key_recursive( $item, $fields_as_keyed ); + $new_data[] = is_array( $item ) ? _rest_array_intersect_key_recursive( $item, $fields_as_keyed ) : $item; } } else { $new_data = _rest_array_intersect_key_recursive( $data, $fields_as_keyed ); @@ -3048,7 +3063,7 @@ function rest_preload_api_request( $memo, $path ) { /** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */ $response = apply_filters( 'rest_post_dispatch', rest_ensure_response( $response ), $server, $request ); $embed = $request->has_param( '_embed' ) ? rest_parse_embed_param( $request['_embed'] ) : false; - $data = (array) $server->response_to_data( $response, $embed ); + $data = $server->response_to_data( $response, $embed, $request ); if ( 'OPTIONS' === $method ) { $memo[ $method ][ $path ] = array( 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..13a8c22f28a53 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,7 @@ * accept_raw: bool, * show_in_index: bool, * allow_batch?: array{v1?: bool}|false, + * response_processing?: bool|array{fields?: bool, embed?: bool, links?: bool}, * ... * } */ @@ -512,7 +513,7 @@ public function serve_request( $path = null ) { // Wrap the response in an envelope if asked for. if ( isset( $_GET['_envelope'] ) ) { $embed = isset( $_GET['_embed'] ) ? rest_parse_embed_param( $_GET['_embed'] ) : false; - $result = $this->envelope_response( $result, $embed ); + $result = $this->envelope_response( $result, $embed, $request ); } // Send extra data from response objects. @@ -568,7 +569,7 @@ public function serve_request( $path = null ) { // Embed links inside the request. $embed = isset( $_GET['_embed'] ) ? rest_parse_embed_param( $_GET['_embed'] ) : false; - $result = $this->response_to_data( $result, $embed ); + $result = $this->response_to_data( $result, $embed, $request ); /** * Filters the REST API response. @@ -622,26 +623,35 @@ 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. - * - * @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. - * @return array { - * Data with sub-requests embedded. - * - * @type array $_links Links. - * @type array $_embedded Embedded objects. - * } + * @since 7.2.0 Added the `$request` parameter. The links are not added or embedded when the + * route turns off the `links` or `embed` response processing, and data that is + * not an array is returned as it is. + * + * @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. + * @param WP_REST_Request|null $request Optional. The request that generated the response. It resolves the + * response processing settings when the response has no matched handler. + * Default null. + * @return mixed Data with sub-requests embedded: an array with the links under `_links` and the embedded + * resources under `_embedded`, or data that is not an array as it is. */ - public function response_to_data( $response, $embed ) { - $data = $response->get_data(); - $links = self::get_compact_response_links( $response ); + public function response_to_data( $response, $embed, $request = null ) { + $data = $response->get_data(); + + // Scalar and object data has no place for links or embedded resources. + if ( ! is_array( $data ) && null !== $data ) { + return $data; + } + + $processing = $this->get_response_processing( $request, $response ); + $links = $processing['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 && $processing['embed'] ) { $this->embed_cache = array(); // Determine if this is a numeric array. if ( wp_is_numeric_array( $data ) ) { @@ -878,7 +888,7 @@ protected function embed_links( $data, $embed = true ) { /** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */ $response = apply_filters( 'rest_post_dispatch', rest_ensure_response( $response ), $this, $request ); - $this->embed_cache[ $item['href'] ] = $this->response_to_data( $response, false ); + $this->embed_cache[ $item['href'] ] = $this->response_to_data( $response, false, $request ); } $embeds[] = $this->embed_cache[ $item['href'] ]; @@ -908,14 +918,16 @@ protected function embed_links( $data, $embed = true ) { * * @since 4.4.0 * @since 6.0.0 The `$embed` parameter can now contain a list of link relations to include. + * @since 7.2.0 Added the `$request` parameter. * - * @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. + * @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. + * @param WP_REST_Request|null $request Optional. The request that generated the response. Default null. * @return WP_REST_Response New response with wrapped data */ - public function envelope_response( $response, $embed ) { + public function envelope_response( $response, $embed, $request = null ) { $envelope = array( - 'body' => $this->response_to_data( $response, $embed ), + 'body' => $this->response_to_data( $response, $embed, $request ), 'status' => $response->get_status(), 'headers' => $response->get_headers(), ); @@ -1131,6 +1143,118 @@ public function get_route_options( $route ) { return $this->route_options[ $route ]; } + /** + * Resolves the response processing settings of a route handler. + * + * A route, or one of its handlers, sets the `response_processing` option to serve its data as returned. + * The option is `false` to turn off every feature, or an array with any of these keys: + * + * - `fields`: Whether the `_fields` parameter filters the response data. + * - `embed`: Whether the `_embed` parameter embeds linked resources in the response data. + * - `links`: Whether the response links are added to the response data as `_links`. + * + * Each feature is resolved on its own: the handler setting applies, then the route setting, and the + * feature stays on when neither sets it. Embedding needs the links, so `embed` is off whenever `links` + * is off. + * + * The option covers these three features only. Authentication, permission checks, status codes, headers + * and JSON encoding apply as usual, and keys of the data itself, such as its own `_links`, stay as returned. + * + * @since 7.2.0 + * + * @param array $handler The route handler. + * @param array|null $route_options Optional. The options of the handler's route. Default null. + * @return array { + * Whether each response processing feature is on. + * + * @type bool $fields Whether the `_fields` parameter filters the response data. + * @type bool $embed Whether the `_embed` parameter embeds linked resources in the response data. + * @type bool $links Whether the response links are added to the response data as `_links`. + * } + * + * @phpstan-return array{fields: bool, embed: bool, links: bool} + */ + public static function resolve_response_processing( $handler, $route_options = null ) { + $processing = array( + 'fields' => true, + 'embed' => true, + 'links' => true, + ); + + $settings = array( + $route_options['response_processing'] ?? null, + $handler['response_processing'] ?? null, + ); + + foreach ( $settings as $setting ) { + if ( is_bool( $setting ) ) { + $processing = array_fill_keys( array_keys( $processing ), $setting ); + continue; + } + + if ( ! is_array( $setting ) ) { + continue; + } + + foreach ( array_keys( $processing ) as $feature ) { + if ( isset( $setting[ $feature ] ) ) { + $processing[ $feature ] = (bool) $setting[ $feature ]; + } + } + } + + if ( ! $processing['links'] ) { + $processing['embed'] = false; + } + + return $processing; + } + + /** + * Retrieves the response processing settings for a request. + * + * The settings are those of the handler the request matched. A response returned by the + * `rest_pre_dispatch` filter matches no handler, so the request is matched to a handler here + * without changing the request. + * + * @since 7.2.0 + * + * @see WP_REST_Server::resolve_response_processing() + * + * @param WP_REST_Request|null $request The request, or null when only the response is known. + * @param WP_REST_Response|null $response Optional. The response generated for the request. Default null. + * @return array Whether each feature is on, keyed by `fields`, `embed` and `links`. Every feature is on + * when no handler matches. + * + * @phpstan-return array{fields: bool, embed: bool, links: bool} + */ + public function get_response_processing( $request, $response = null ) { + if ( $response instanceof WP_REST_Response && is_array( $response->get_matched_handler() ) ) { + $route = $response->get_matched_route(); + + return self::resolve_response_processing( + $response->get_matched_handler(), + is_string( $route ) ? $this->get_route_options( $route ) : null + ); + } + + if ( ! $request instanceof WP_REST_Request ) { + return self::resolve_response_processing( array() ); + } + + $attributes = $request->get_attributes(); + if ( isset( $attributes['response_processing'] ) ) { + return self::resolve_response_processing( $attributes ); + } + + $match = $this->find_handler_for_request( $request ); + if ( null === $match ) { + return self::resolve_response_processing( array() ); + } + + return self::resolve_response_processing( $match[1], $this->get_route_options( $match[0] ) ); + } + /** * Matches the request to a callback and call it. * @@ -1232,6 +1356,53 @@ public function is_dispatching() { * @phpstan-return array{non-empty-string, Route_Handler}|WP_Error */ protected function match_request_to_handler( $request ) { + $match = $this->find_handler_for_request( $request ); + + if ( null === $match ) { + return new WP_Error( + 'rest_no_route', + __( 'No route was found matching the URL and request method.' ), + array( 'status' => 404 ) + ); + } + + list( $route, $handler, $args ) = $match; + + // Resolve the response processing settings once, for the request attributes and the matched handler. + $handler['response_processing'] = self::resolve_response_processing( $handler, $this->get_route_options( $route ) ); + + if ( ! is_callable( $handler['callback'] ) ) { + return array( $route, $handler ); + } + + $request->set_url_params( $args ); + $request->set_attributes( $handler ); + + $defaults = array(); + + foreach ( $handler['args'] as $arg => $options ) { + if ( isset( $options['default'] ) ) { + $defaults[ $arg ] = $options['default']; + } + } + + $request->set_default_params( $defaults ); + + return array( $route, $handler ); + } + + /** + * Finds the route and the handler that a request matches, without changing the request. + * + * @access private + * @since 7.2.0 + * + * @param WP_REST_Request $request The request object. + * @return array|null The route, the handler and the URL parameters, or null when no handler matches. + * + * @phpstan-return array{non-empty-string, Route_Handler, array}|null + */ + protected function find_handler_for_request( $request ) { $method = $request->get_method(); $path = $request->get_route(); @@ -1265,8 +1436,6 @@ protected function match_request_to_handler( $request ) { } foreach ( $handlers as $handler ) { - $callback = $handler['callback']; - // Fallback to GET method if no HEAD method is registered. $checked_method = $method; if ( 'HEAD' === $method && empty( $handler['methods']['HEAD'] ) ) { @@ -1276,32 +1445,11 @@ protected function match_request_to_handler( $request ) { continue; } - if ( ! is_callable( $callback ) ) { - return array( $route, $handler ); - } - - $request->set_url_params( $args ); - $request->set_attributes( $handler ); - - $defaults = array(); - - foreach ( $handler['args'] as $arg => $options ) { - if ( isset( $options['default'] ) ) { - $defaults[ $arg ] = $options['default']; - } - } - - $request->set_default_params( $defaults ); - - return array( $route, $handler ); + return array( $route, $handler, $args ); } } - return new WP_Error( - 'rest_no_route', - __( 'No route was found matching the URL and request method.' ), - array( 'status' => 404 ) - ); + return null; } /** @@ -1705,10 +1853,11 @@ public function get_data_for_route( $route, $callbacks, $context = 'view' ) { 'endpoints' => array(), ); - $allow_batch = false; + $allow_batch = false; + $route_options = $this->get_route_options( $route ); - if ( isset( $this->route_options[ $route ] ) ) { - $options = $this->route_options[ $route ]; + if ( null !== $route_options ) { + $options = $route_options; if ( isset( $options['namespace'] ) ) { $data['namespace'] = $options['namespace']; @@ -1742,6 +1891,12 @@ public function get_data_for_route( $route, $callbacks, $context = 'view' ) { $endpoint_data['allow_batch'] = $callback_batch; } + $response_processing = self::resolve_response_processing( $callback, $route_options ); + + if ( in_array( false, $response_processing, true ) ) { + $endpoint_data['response_processing'] = $response_processing; + } + if ( isset( $callback['args'] ) ) { $endpoint_data['args'] = array(); @@ -1961,7 +2116,7 @@ public function serve_batch_request_v1( WP_REST_Request $batch_request ) { /** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */ $result = apply_filters( 'rest_post_dispatch', rest_ensure_response( $result ), $this, $single_request ); - $responses[] = $this->envelope_response( $result, false )->get_data(); + $responses[] = $this->envelope_response( $result, false, $single_request )->get_data(); } return new WP_REST_Response( array( 'responses' => $responses ), WP_Http::MULTI_STATUS ); 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..7da5b524504a6 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,13 @@ 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: `_fields`, + * `_embed` and response links leave it unchanged. + */ + 'response_processing' => false, ), 'schema' => array( $this, 'get_run_schema' ), ) @@ -96,18 +103,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/src/wp-includes/rest-api/endpoints/class-wp-rest-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-controller.php index 74337bfd2442c..0b65682863017 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-controller.php @@ -565,6 +565,8 @@ protected function get_object_type() { * Included fields are based on item schema and `_fields=` request argument. * * @since 4.9.6 + * @since 7.2.0 The `_fields` argument is ignored when the matched route turns off + * the `fields` response processing. * * @param WP_REST_Request $request Full details about the request. * @return string[] Fields to be included in the response. @@ -609,7 +611,9 @@ public function get_fields_for_response( $request ) { $fields = array_unique( $fields ); - if ( ! isset( $request['_fields'] ) ) { + $processing = WP_REST_Server::resolve_response_processing( $request->get_attributes() ); + + if ( ! isset( $request['_fields'] ) || ! $processing['fields'] ) { return $fields; } $requested_fields = wp_parse_list( $request['_fields'] ); diff --git a/tests/phpunit/tests/rest-api/rest-controller.php b/tests/phpunit/tests/rest-api/rest-controller.php index 33ac4f3c814f7..15b260eb6a631 100644 --- a/tests/phpunit/tests/rest-api/rest-controller.php +++ b/tests/phpunit/tests/rest-api/rest-controller.php @@ -441,6 +441,43 @@ public function test_get_fields_for_response_respects_embed() { $this->assertContains( '_embedded', $controller->get_fields_for_response( $request ) ); } + /** + * Data provider for test_get_fields_for_response_respects_response_processing(). + * + * @return array + */ + public static function data_get_fields_for_response_respects_response_processing(): array { + return array( + 'false' => array( false, false ), + 'fields false' => array( array( 'fields' => false ), false ), + 'links false' => array( array( 'links' => false ), true ), + 'true' => array( true, true ), + ); + } + + /** + * @ticket 66267 + * @dataProvider data_get_fields_for_response_respects_response_processing + * + * @param mixed $response_processing The `response_processing` setting of the matched handler. + * @param bool $filtered Whether the `_fields` parameter should filter the fields. + */ + public function test_get_fields_for_response_respects_response_processing( $response_processing, bool $filtered ): void { + $controller = new WP_REST_Test_Controller(); + $request = new WP_REST_Request( 'GET', '/wp/v2/testroute' ); + $request->set_attributes( array( 'response_processing' => $response_processing ) ); + $request->set_param( '_fields', 'somestring' ); + + $fields = $controller->get_fields_for_response( $request ); + + if ( $filtered ) { + $this->assertSame( array( 'somestring' ), $fields, 'The _fields parameter should filter the fields.' ); + } else { + $this->assertContains( 'someinteger', $fields, 'The _fields parameter should be ignored when the fields processing is off.' ); + $this->assertContains( '_links', $fields, 'The _fields parameter should be ignored when the fields processing is off.' ); + } + } + public function test_get_fields_for_response_filters_by_context() { $controller = new WP_REST_Test_Controller(); diff --git a/tests/phpunit/tests/rest-api/rest-server.php b/tests/phpunit/tests/rest-api/rest-server.php index c4d6a883d67b1..17fbfbf9627ef 100644 --- a/tests/phpunit/tests/rest-api/rest-server.php +++ b/tests/phpunit/tests/rest-api/rest-server.php @@ -2900,4 +2900,613 @@ 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 processing 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_processing_route( $data, bool $with_link, array $handler_options = array(), array $route_options = array() ): void { + rest_get_server()->register_route( + 'test-ns', + '/test-ns/response-processing', + 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_resolve_response_processing(). + * + * @return array, 1: array|null, 2: array}> + */ + public static function data_resolve_response_processing(): array { + $all_on = array( + 'fields' => true, + 'embed' => true, + 'links' => true, + ); + $all_off = array( + 'fields' => false, + 'embed' => false, + 'links' => false, + ); + + return array( + 'no setting' => array( array(), null, $all_on ), + 'empty route options' => array( array(), array(), $all_on ), + 'handler false' => array( array( 'response_processing' => false ), null, $all_off ), + 'route false' => array( array(), array( 'response_processing' => false ), $all_off ), + 'route false and handler true' => array( array( 'response_processing' => true ), array( 'response_processing' => false ), $all_on ), + 'route false and handler fields' => array( + array( 'response_processing' => array( 'fields' => true ) ), + array( 'response_processing' => false ), + array( + 'fields' => true, + 'embed' => false, + 'links' => false, + ), + ), + 'handler fields false' => array( + array( 'response_processing' => array( 'fields' => false ) ), + null, + array( + 'fields' => false, + 'embed' => true, + 'links' => true, + ), + ), + 'handler links false turns off embed' => array( + array( 'response_processing' => array( 'links' => false ) ), + null, + array( + 'fields' => true, + 'embed' => false, + 'links' => false, + ), + ), + 'handler embed true needs the links' => array( + array( 'response_processing' => array( 'embed' => true ) ), + array( 'response_processing' => array( 'links' => false ) ), + array( + 'fields' => true, + 'embed' => false, + 'links' => false, + ), + ), + 'route fields false and handler true' => array( + array( 'response_processing' => array( 'fields' => true ) ), + array( 'response_processing' => array( 'fields' => false ) ), + $all_on, + ), + 'unknown keys are ignored' => array( array( 'response_processing' => array( 'envelope' => false ) ), null, $all_on ), + 'other types are ignored' => array( array( 'response_processing' => 'no' ), array( 'response_processing' => 0 ), $all_on ), + ); + } + + /** + * @ticket 66267 + * @dataProvider data_resolve_response_processing + * + * @param array $handler The route handler. + * @param array|null $route_options The route options. + * @param array $expected The expected settings. + */ + public function test_resolve_response_processing( array $handler, ?array $route_options, array $expected ): void { + $this->assertSame( $expected, WP_REST_Server::resolve_response_processing( $handler, $route_options ), 'The settings should be resolved feature by feature.' ); + } + + /** + * Data provider for test_response_processing_false_serves_the_data_as_returned(). + * + * @return array + */ + public static function data_response_processing_false_serves_the_data_as_returned(): 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 array' => array( array(), '[]' ), + '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_response_processing_false_serves_the_data_as_returned + * + * @param mixed $data The response data. + * @param string $expected The expected response body. + */ + public function test_response_processing_false_serves_the_data_as_returned( $data, string $expected ): void { + $this->register_response_processing_route( $data, true, array( 'response_processing' => false ) ); + + $_GET['_fields'] = 'a'; + $_GET['_embed'] = '1'; + rest_get_server()->serve_request( '/test-ns/response-processing' ); + + $this->assertSame( $expected, rest_get_server()->sent_body, 'The data should be served as returned, without links or filtering.' ); + } + + /** + * @ticket 66267 + */ + public function test_fields_are_filtered_and_links_embedded_by_default(): void { + $this->register_response_processing_route( + array( + 'a' => 1, + 'b' => 2, + ), + true + ); + + $_GET['_fields'] = 'a'; + $_GET['_embed'] = '1'; + rest_get_server()->serve_request( '/test-ns/response-processing' ); + $data = json_decode( rest_get_server()->sent_body, true ); + + $this->assertSame( array( 'a', '_links', '_embedded' ), array_keys( $data ), 'The _fields parameter should filter the data, and the links should be added.' ); + $this->assertSame( array( array( 'hello' => 'world' ) ), $data['_embedded']['alternate'], 'The link should be embedded.' ); + } + + /** + * @ticket 66267 + */ + public function test_response_processing_fields_false_leaves_the_data_unfiltered(): void { + $this->register_response_processing_route( + array( + 'a' => 1, + 'b' => 2, + ), + true, + array( 'response_processing' => array( 'fields' => false ) ) + ); + + $_GET['_fields'] = 'a'; + $_GET['_embed'] = '1'; + rest_get_server()->serve_request( '/test-ns/response-processing' ); + $data = json_decode( rest_get_server()->sent_body, true ); + + $this->assertSame( array( 'a', 'b', '_links', '_embedded' ), array_keys( $data ), 'Only the _fields parameter should be ignored.' ); + } + + /** + * Data provider for test_response_processing_links_false_leaves_out_the_links(). + * + * @return array + */ + public static function data_response_processing_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_response_processing_links_false_leaves_out_the_links + * + * @param mixed $data The response data. + * @param string $expected The expected response body. + */ + public function test_response_processing_links_false_leaves_out_the_links( $data, string $expected ): void { + $this->register_response_processing_route( $data, true, array( 'response_processing' => array( 'links' => false ) ) ); + + $_GET['_embed'] = '1'; + rest_get_server()->serve_request( '/test-ns/response-processing' ); + + $this->assertSame( $expected, rest_get_server()->sent_body, 'The response links should not be added or embedded.' ); + } + + /** + * @ticket 66267 + */ + public function test_response_processing_embed_false_keeps_the_links_without_embedding(): void { + $this->register_response_processing_route( array( 'a' => 1 ), true, array( 'response_processing' => array( 'embed' => false ) ) ); + + $_GET['_embed'] = '1'; + rest_get_server()->serve_request( '/test-ns/response-processing' ); + $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_response_processing_false_keeps_the_own_links_of_the_data(): void { + $own_links = array( 'custom' => array( array( 'href' => 'https://example.org/' ) ) ); + $this->register_response_processing_route( + array( + 'a' => 1, + '_links' => $own_links, + ), + true, + array( 'response_processing' => false ) + ); + + $_GET['_embed'] = '1'; + rest_get_server()->serve_request( '/test-ns/response-processing' ); + $data = json_decode( rest_get_server()->sent_body, true ); + + $this->assertSame( $own_links, $data['_links'], 'The _links key of the data should be served as returned.' ); + $this->assertArrayNotHasKey( '_embedded', $data, 'The links of the data should not be embedded.' ); + } + + /** + * @ticket 66267 + */ + public function test_response_processing_applies_to_enveloped_responses(): void { + $this->register_response_processing_route( + array( + 'a' => 1, + 'b' => 2, + ), + true, + array( 'response_processing' => false ) + ); + + $_GET['_fields'] = 'a'; + $_GET['_embed'] = '1'; + $_GET['_envelope'] = '1'; + rest_get_server()->serve_request( '/test-ns/response-processing' ); + $data = json_decode( rest_get_server()->sent_body, true ); + + $this->assertSame( 200, $data['status'], 'The enveloped response should succeed.' ); + $this->assertSame( + array( + 'a' => 1, + 'b' => 2, + ), + $data['body'], + 'The envelope should wrap the data as returned.' + ); + } + + /** + * Data provider for test_handler_setting_takes_precedence_over_route_setting(). + * + * @return array, 1: array, 2: string[]}> + */ + public static function data_handler_setting_takes_precedence_over_route_setting(): array { + return array( + 'route false' => array( array(), array( 'response_processing' => false ), array( 'a', 'b' ) ), + 'route false and handler true' => array( array( 'response_processing' => true ), array( 'response_processing' => false ), array( 'a', '_links' ) ), + 'route false and handler fields' => array( array( 'response_processing' => array( 'fields' => true ) ), array( 'response_processing' => false ), array( 'a' ) ), + 'route fields false and handler links off' => array( array( 'response_processing' => array( 'links' => false ) ), array( 'response_processing' => array( 'fields' => false ) ), array( 'a', 'b' ) ), + 'route links false and handler fields off' => array( array( 'response_processing' => array( 'fields' => false ) ), array( 'response_processing' => array( 'links' => false ) ), array( 'a', 'b' ) ), + ); + } + + /** + * @ticket 66267 + * @dataProvider data_handler_setting_takes_precedence_over_route_setting + * + * @param array $handler_options Options for the route handler. + * @param array $route_options Options for the route. + * @param string[] $expected_keys The expected keys of the response body. + */ + public function test_handler_setting_takes_precedence_over_route_setting( array $handler_options, array $route_options, array $expected_keys ): void { + $this->register_response_processing_route( + array( + 'a' => 1, + 'b' => 2, + ), + true, + $handler_options, + $route_options + ); + + $_GET['_fields'] = 'a'; + rest_get_server()->serve_request( '/test-ns/response-processing' ); + $data = json_decode( rest_get_server()->sent_body, true ); + + $this->assertSame( $expected_keys, array_keys( $data ), 'The handler setting should take precedence over the route setting, feature by feature.' ); + } + + /** + * @ticket 66267 + */ + public function test_response_processing_is_set_on_the_request_attributes_and_the_matched_handler(): void { + $this->register_response_processing_route( array( 'a' => 1 ), false, array( 'response_processing' => array( 'links' => false ) ) ); + + $request = new WP_REST_Request( 'GET', '/test-ns/response-processing' ); + $response = rest_get_server()->dispatch( $request ); + $expected = array( + 'fields' => true, + 'embed' => false, + 'links' => false, + ); + + $this->assertSame( $expected, $request->get_attributes()['response_processing'], 'The request attributes should carry the resolved settings.' ); + $this->assertSame( $expected, $response->get_matched_handler()['response_processing'], 'The matched handler should carry the resolved settings.' ); + $this->assertSame( $expected, rest_get_server()->get_response_processing( $request, $response ), 'The settings of the response should be the resolved ones.' ); + } + + /** + * @ticket 66267 + */ + public function test_routes_without_the_option_have_every_feature_on(): void { + $this->register_response_processing_route( array( 'a' => 1 ), false ); + + $request = new WP_REST_Request( 'GET', '/test-ns/response-processing' ); + $response = rest_get_server()->dispatch( $request ); + $expected = array( + 'fields' => true, + 'embed' => true, + 'links' => true, + ); + + $this->assertSame( $expected, $request->get_attributes()['response_processing'], 'The request attributes should have every feature on.' ); + $this->assertSame( $expected, $response->get_matched_handler()['response_processing'], 'The matched handler should have every feature on.' ); + $this->assertSame( $expected, rest_get_server()->get_response_processing( $request, $response ), 'The settings of the response should have every feature on.' ); + } + + /** + * Data provider for test_response_processing_applies_to_hijacked_responses(). + * + * @return array + */ + public static function data_response_processing_applies_to_hijacked_responses(): array { + return array( + 'associative array' => array( + array( + 'a' => 1, + 'b' => 2, + ), + '{"a":1,"b":2}', + ), + 'string' => array( 'value', '"value"' ), + ); + } + + /** + * @ticket 66267 + * @dataProvider data_response_processing_applies_to_hijacked_responses + * + * @param mixed $data The data returned by the `rest_pre_dispatch` filter. + * @param string $expected The expected response body. + */ + public function test_response_processing_applies_to_hijacked_responses( $data, string $expected ): void { + $this->register_response_processing_route( array( 'unexpected' => true ), true, array( 'response_processing' => false ) ); + + add_filter( + 'rest_pre_dispatch', + static function () use ( $data ) { + return $data; + } + ); + + $_GET['_fields'] = 'a'; + $_GET['_embed'] = '1'; + rest_get_server()->serve_request( '/test-ns/response-processing' ); + + $this->assertSame( $expected, rest_get_server()->sent_body, 'A response returned by rest_pre_dispatch should follow the settings of the route it was returned for.' ); + } + + /** + * @ticket 66267 + */ + public function test_hijacked_responses_are_processed_by_default(): void { + $this->register_response_processing_route( array( 'unexpected' => true ), true ); + + add_filter( + 'rest_pre_dispatch', + static function () { + return array( + 'a' => 1, + 'b' => 2, + ); + } + ); + + $_GET['_fields'] = 'a'; + rest_get_server()->serve_request( '/test-ns/response-processing' ); + + $this->assertSame( '{"a":1}', rest_get_server()->sent_body, 'A response returned by rest_pre_dispatch for a route without the option should be filtered.' ); + } + + /** + * @ticket 66267 + */ + public function test_get_response_processing_without_a_matched_handler(): void { + $all_on = array( + 'fields' => true, + 'embed' => true, + 'links' => true, + ); + + $this->assertSame( $all_on, rest_get_server()->get_response_processing( new WP_REST_Request( 'GET', '/test-ns/no-such-route' ) ), 'A request that matches no route should enable every feature.' ); + $this->assertSame( $all_on, rest_get_server()->get_response_processing( null, new WP_REST_Response() ), 'A response without a matched handler should enable every feature.' ); + $this->assertSame( $all_on, rest_get_server()->get_response_processing( null ), 'The defaults should apply without a request.' ); + } + + /** + * Data provider for test_response_processing_applies_to_preloaded_requests(). + * + * @return array + */ + public static function data_response_processing_applies_to_preloaded_requests(): array { + return array( + 'associative array' => array( + array( + 'a' => 1, + 'b' => 2, + ), + '{"a":1,"b":2}', + ), + 'empty object' => array( new stdClass(), '{}' ), + 'string' => array( 'value', '"value"' ), + ); + } + + /** + * @ticket 66267 + * @dataProvider data_response_processing_applies_to_preloaded_requests + * + * @param mixed $data The response data. + * @param string $expected The expected JSON encoding of the preloaded body. + */ + public function test_response_processing_applies_to_preloaded_requests( $data, string $expected ): void { + $this->register_response_processing_route( $data, true, array( 'response_processing' => false ) ); + + $preload = rest_preload_api_request( array(), '/test-ns/response-processing?_fields=a&_embed=1' ); + + $this->assertSame( $expected, wp_json_encode( $preload['/test-ns/response-processing?_fields=a&_embed=1']['body'] ), 'The preloaded body should be the data as returned.' ); + } + + /** + * @ticket 66267 + */ + public function test_response_processing_applies_to_batch_requests(): void { + $this->register_response_processing_route( + array( + 'a' => 1, + 'b' => 2, + ), + true, + array( + 'methods' => 'POST', + 'allow_batch' => array( 'v1' => true ), + 'response_processing' => false, + ) + ); + + $request = new WP_REST_Request( 'POST', '/batch/v1' ); + $request->set_body_params( + array( + 'requests' => array( + array( 'path' => '/test-ns/response-processing?_fields=a' ), + ), + ) + ); + + $response = rest_do_request( $request ); + + $this->assertSame( 207, $response->get_status(), 'The batch request should succeed.' ); + $this->assertSame( + array( + 'a' => 1, + 'b' => 2, + ), + $response->get_data()['responses'][0]['body'], + 'The batched response body should be the data as returned.' + ); + } + + /** + * @ticket 66267 + */ + public function test_response_processing_is_listed_in_the_index(): void { + rest_get_server()->register_route( + 'test-ns', + '/test-ns/response-processing', + array( + array( + 'methods' => 'GET', + 'callback' => '__return_null', + 'permission_callback' => '__return_true', + ), + array( + 'methods' => 'POST', + 'callback' => '__return_null', + 'permission_callback' => '__return_true', + 'response_processing' => array( 'fields' => true ), + ), + 'response_processing' => array( 'fields' => false ), + ) + ); + + $request = new WP_REST_Request( 'OPTIONS', '/test-ns/response-processing' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( + array( + 'fields' => false, + 'embed' => true, + 'links' => true, + ), + $data['endpoints'][0]['response_processing'], + 'An endpoint with a feature turned off should list the settings.' + ); + $this->assertArrayNotHasKey( 'response_processing', $data['endpoints'][1], 'An endpoint with every feature on should not list the settings.' ); + } + + /** + * Data provider for test_non_array_data_is_served_as_returned_by_default(). + * + * @return array + */ + public static function data_non_array_data_is_served_as_returned_by_default(): array { + return array( + 'string' => array( 'value', true, '"value"' ), + 'integer' => array( 5, true, '5' ), + 'object' => array( (object) array( 'b' => 2 ), true, '{"b":2}' ), + 'empty object' => array( new stdClass(), true, '{}' ), + 'list of scalars' => array( array( 'a', 'b' ), false, '["a","b"]' ), + 'mixed list' => array( array( array( 'b' => 2 ), 'c' ), false, '[[],"c"]' ), + ); + } + + /** + * @ticket 66267 + * @dataProvider data_non_array_data_is_served_as_returned_by_default + * + * @param mixed $data The response data. + * @param bool $with_link Whether the response has an embeddable link. + * @param string $expected The expected response body. + */ + public function test_non_array_data_is_served_as_returned_by_default( $data, bool $with_link, string $expected ): void { + $this->register_response_processing_route( $data, $with_link ); + + $_GET['_fields'] = 'a'; + $_GET['_embed'] = '1'; + rest_get_server()->serve_request( '/test-ns/response-processing' ); + + $this->assertSame( $expected, rest_get_server()->sent_body, 'Data without fields, or items without fields, should be served as returned.' ); + } } 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.' ); } /** diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index d3ba47bef2fb6..fcf6ff01d9e44 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12682,6 +12682,11 @@ mockedApiResponse.Schema = { "PATCH", "DELETE" ], + "response_processing": { + "fields": false, + "embed": false, + "links": false + }, "args": { "name": { "description": "Unique identifier for the ability.",