diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 9998e14da7361..28332a6562063 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,18 @@ function rest_filter_response_fields( $response, $server, $request ) { return $response; } + $processing = $response->get_data_processing(); + 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 +1005,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 +3060,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 ); if ( 'OPTIONS' === $method ) { $memo[ $method ][ $path ] = array( diff --git a/src/wp-includes/rest-api/class-wp-rest-response.php b/src/wp-includes/rest-api/class-wp-rest-response.php index 7624e01d615b3..f86976e94dfa0 100644 --- a/src/wp-includes/rest-api/class-wp-rest-response.php +++ b/src/wp-includes/rest-api/class-wp-rest-response.php @@ -15,6 +15,7 @@ * @see WP_HTTP_Response * * @phpstan-import-type Route_Handler from WP_REST_Server + * @phpstan-import-type Data_Processing from WP_REST_Server * * @phpstan-type Target_Hints array{ * allow?: list, @@ -69,6 +70,19 @@ class WP_REST_Response extends WP_HTTP_Response { */ protected $matched_handler = null; + /** + * Whether each data processing feature is on for the response. + * + * @since 7.2.0 + * @var array Whether the `fields`, `embed` and `links` features are on. Every feature is on by default. + * @phpstan-var Data_Processing + */ + protected $data_processing = array( + 'fields' => true, + 'embed' => true, + 'links' => true, + ); + /** * Adds a link to the response. * @@ -249,6 +263,50 @@ public function set_matched_handler( $handler ) { $this->matched_handler = $handler; } + /** + * Retrieves whether each data processing feature is on for the response. + * + * The server sets the settings of the matched route when it dispatches a request, for responses + * returned by the `rest_pre_dispatch` filter too. Every feature is on until then. + * + * @since 7.2.0 + * + * @see WP_REST_Server::resolve_data_processing() + * + * @return array { + * Whether each data 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 Data_Processing + */ + public function get_data_processing() { + return $this->data_processing; + } + + /** + * Sets whether each data processing feature is on for the response. + * + * The setting takes the form of the `data_processing` route option: `false` turns off every + * feature, and an array turns off, or on, the features it names. Embedding needs the links, so + * `embed` is off whenever `links` is off. + * + * @since 7.2.0 + * + * @see WP_REST_Server::resolve_data_processing() + * + * @param bool|array $processing Whether every feature is on, or whether the `fields`, `embed` + * and `links` features are on. A feature not named stays on. + * + * @phpstan-param bool|array{fields?: bool, embed?: bool, links?: bool} $processing + */ + public function set_data_processing( $processing ) { + $this->data_processing = WP_REST_Server::resolve_data_processing( array( 'data_processing' => $processing ) ); + } + /** * Checks if the response is an error, i.e. >= 400 response code. * 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..ed583cc900655 100644 --- a/src/wp-includes/rest-api/class-wp-rest-server.php +++ b/src/wp-includes/rest-api/class-wp-rest-server.php @@ -40,8 +40,10 @@ * accept_raw: bool, * show_in_index: bool, * allow_batch?: array{v1?: bool}|false, + * data_processing?: bool|array{fields?: bool, embed?: bool, links?: bool}, * ... * } + * @phpstan-type Data_Processing array{fields: bool, embed: bool, links: bool} */ #[AllowDynamicProperties] class WP_REST_Server { @@ -622,31 +624,41 @@ 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 The links are not added or embedded when the response turns off the `links` or + * `embed` data processing, scalar and object data is returned as it is unless + * the object implements `ArrayAccess`, and list items that are not arrays are left + * as they are when embedding. + * + * @see WP_REST_Response::get_data_processing() * * @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. - * } + * @return mixed Data with sub-requests embedded: an array with the links under `_links` and the embedded + * resources under `_embedded`, or scalar and object data as it is unless the object + * implements `ArrayAccess`. */ public function response_to_data( $response, $embed ) { - $data = $response->get_data(); - $links = self::get_compact_response_links( $response ); + $data = $response->get_data(); + + // Scalar and object data has no place for links or embedded resources, unless the object acts as an array. + if ( null !== $data && ! is_array( $data ) && ! $data instanceof ArrayAccess ) { + return $data; + } + + $processing = $response->get_data_processing(); + $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 ) ) { foreach ( $data as $key => $item ) { - $data[ $key ] = $this->embed_links( $item, $embed ); + $data[ $key ] = is_array( $item ) || $item instanceof ArrayAccess ? $this->embed_links( $item, $embed ) : $item; } } else { $data = $this->embed_links( $data, $embed ); @@ -1131,6 +1143,78 @@ public function get_route_options( $route ) { return $this->route_options[ $route ]; } + /** + * Resolves the data processing settings of a route handler. + * + * A route, or one of its handlers, sets the `data_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. + * + * The server resolves the settings when it matches a request, and sets them on the request attributes, + * on the matched handler and on the response, for a response returned by the `rest_pre_dispatch` filter too. + * + * @since 7.2.0 + * + * @see WP_REST_Response::get_data_processing() + * + * @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 data 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 Data_Processing + */ + public static function resolve_data_processing( $handler, $route_options = null ) { + $processing = array( + 'fields' => true, + 'embed' => true, + 'links' => true, + ); + + $settings = array( + $route_options['data_processing'] ?? null, + $handler['data_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; + } + /** * Matches the request to a callback and call it. * @@ -1167,6 +1251,12 @@ public function dispatch( $request ) { $result = $this->error_to_response( $result ); } + // The response matched no handler, so it follows the settings of the handler the request would match. + $match = $this->find_handler_for_request( $request ); + if ( null !== $match ) { + $result->set_data_processing( $match[1]['data_processing'] ?? true ); + } + array_pop( $this->dispatching_requests ); return $result; } @@ -1232,6 +1322,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; + + 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. + * + * The handler carries its resolved data processing settings under `data_processing`. + * + * @access private + * @since 7.2.0 + * + * @param WP_REST_Request $request The request object. + * @return array|null The route, the handler with its resolved data processing settings, 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 +1402,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 +1411,14 @@ 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(); + // Resolve the data processing settings once, for the request attributes, the matched handler and the response. + $handler['data_processing'] = self::resolve_data_processing( $handler, $this->get_route_options( $route ) ); - 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; } /** @@ -1414,6 +1531,7 @@ protected function respond_to_request( $request, $route, $handler, $response ) { $response->set_matched_route( $route ); $response->set_matched_handler( $handler ); + $response->set_data_processing( $handler['data_processing'] ?? true ); return $response; } @@ -1705,10 +1823,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 +1861,12 @@ public function get_data_for_route( $route, $callbacks, $context = 'view' ) { $endpoint_data['allow_batch'] = $callback_batch; } + $data_processing = self::resolve_data_processing( $callback, $route_options ); + + if ( in_array( false, $data_processing, true ) ) { + $endpoint_data['data_processing'] = $data_processing; + } + if ( isset( $callback['args'] ) ) { $endpoint_data['args'] = array(); @@ -1956,6 +2081,12 @@ public function serve_batch_request_v1( WP_REST_Request $batch_request ) { $result = $this->respond_to_request( $single_request, $route, $handler, $error ); } + } elseif ( ! is_wp_error( $matches[ $i ] ) ) { + // The response matched no handler, so it follows the settings of the handler the request matched. + $result = rest_ensure_response( $result ); + if ( $result instanceof WP_REST_Response ) { + $result->set_data_processing( $matches[ $i ][1]['data_processing'] ?? true ); + } } /** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */ 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..4af4f0ed3c206 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` data 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_data_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/blocks/editor.php b/tests/phpunit/tests/blocks/editor.php index 2c363d4103e92..c5755ff434b82 100644 --- a/tests/phpunit/tests/blocks/editor.php +++ b/tests/phpunit/tests/blocks/editor.php @@ -900,7 +900,7 @@ public function test_ensure_preload_data_script_tag_closes() { $expected = << diff --git a/tests/phpunit/tests/rest-api/rest-controller.php b/tests/phpunit/tests/rest-api/rest-controller.php index 33ac4f3c814f7..1c7202dc8825d 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_data_processing(). + * + * @return array + */ + public static function data_get_fields_for_response_respects_data_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 66372 + * @dataProvider data_get_fields_for_response_respects_data_processing + * + * @param mixed $data_processing The `data_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_data_processing( $data_processing, bool $filtered ): void { + $controller = new WP_REST_Test_Controller(); + $request = new WP_REST_Request( 'GET', '/wp/v2/testroute' ); + $request->set_attributes( array( 'data_processing' => $data_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..3ff82c7231fd1 100644 --- a/tests/phpunit/tests/rest-api/rest-server.php +++ b/tests/phpunit/tests/rest-api/rest-server.php @@ -1678,6 +1678,9 @@ public function test_nocache_headers_on_method_overridden_requests( int $status, $server->serve_request( '/test/status' ); + $_SERVER['REQUEST_METHOD'] = 'GET'; + unset( $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ); + $this->assertSame( $status, $server->status, 'The response status is not the one returned by the callback.' ); $headers = $server->sent_headers; @@ -2900,4 +2903,775 @@ 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 data 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_data_processing_route( $data, bool $with_link, array $handler_options = array(), array $route_options = array() ): void { + rest_get_server()->register_route( + 'test-ns', + '/test-ns/data-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_data_processing(). + * + * @return array, 1: array|null, 2: array}> + */ + public static function data_resolve_data_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( 'data_processing' => false ), null, $all_off ), + 'route false' => array( array(), array( 'data_processing' => false ), $all_off ), + 'route false and handler true' => array( array( 'data_processing' => true ), array( 'data_processing' => false ), $all_on ), + 'route false and handler fields' => array( + array( 'data_processing' => array( 'fields' => true ) ), + array( 'data_processing' => false ), + array( + 'fields' => true, + 'embed' => false, + 'links' => false, + ), + ), + 'handler fields false' => array( + array( 'data_processing' => array( 'fields' => false ) ), + null, + array( + 'fields' => false, + 'embed' => true, + 'links' => true, + ), + ), + 'handler links false turns off embed' => array( + array( 'data_processing' => array( 'links' => false ) ), + null, + array( + 'fields' => true, + 'embed' => false, + 'links' => false, + ), + ), + 'handler embed true needs the links' => array( + array( 'data_processing' => array( 'embed' => true ) ), + array( 'data_processing' => array( 'links' => false ) ), + array( + 'fields' => true, + 'embed' => false, + 'links' => false, + ), + ), + 'route fields false and handler true' => array( + array( 'data_processing' => array( 'fields' => true ) ), + array( 'data_processing' => array( 'fields' => false ) ), + $all_on, + ), + 'unknown keys are ignored' => array( array( 'data_processing' => array( 'envelope' => false ) ), null, $all_on ), + 'other types are ignored' => array( array( 'data_processing' => 'no' ), array( 'data_processing' => 0 ), $all_on ), + ); + } + + /** + * @ticket 66372 + * @dataProvider data_resolve_data_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_data_processing( array $handler, ?array $route_options, array $expected ): void { + $this->assertSame( $expected, WP_REST_Server::resolve_data_processing( $handler, $route_options ), 'The settings should be resolved feature by feature.' ); + } + + /** + * Data provider for test_data_processing_false_serves_the_data_as_returned(). + * + * @return array + */ + public static function data_data_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 66372 + * @dataProvider data_data_processing_false_serves_the_data_as_returned + * + * @param mixed $data The response data. + * @param string $expected The expected response body. + */ + public function test_data_processing_false_serves_the_data_as_returned( $data, string $expected ): void { + $this->register_data_processing_route( $data, true, array( 'data_processing' => false ) ); + + $_GET['_fields'] = 'a'; + $_GET['_embed'] = '1'; + rest_get_server()->serve_request( '/test-ns/data-processing' ); + + $this->assertSame( $expected, rest_get_server()->sent_body, 'The data should be served as returned, without links or filtering.' ); + } + + /** + * @ticket 66372 + */ + public function test_fields_are_filtered_and_links_embedded_by_default(): void { + $this->register_data_processing_route( + array( + 'a' => 1, + 'b' => 2, + ), + true + ); + + $_GET['_fields'] = 'a'; + $_GET['_embed'] = '1'; + rest_get_server()->serve_request( '/test-ns/data-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 66372 + */ + public function test_data_processing_fields_false_leaves_the_data_unfiltered(): void { + $this->register_data_processing_route( + array( + 'a' => 1, + 'b' => 2, + ), + true, + array( 'data_processing' => array( 'fields' => false ) ) + ); + + $_GET['_fields'] = 'a'; + $_GET['_embed'] = '1'; + rest_get_server()->serve_request( '/test-ns/data-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_data_processing_links_false_leaves_out_the_links(). + * + * @return array + */ + public static function data_data_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 66372 + * @dataProvider data_data_processing_links_false_leaves_out_the_links + * + * @param mixed $data The response data. + * @param string $expected The expected response body. + */ + public function test_data_processing_links_false_leaves_out_the_links( $data, string $expected ): void { + $this->register_data_processing_route( $data, true, array( 'data_processing' => array( 'links' => false ) ) ); + + $_GET['_embed'] = '1'; + rest_get_server()->serve_request( '/test-ns/data-processing' ); + + $this->assertSame( $expected, rest_get_server()->sent_body, 'The response links should not be added or embedded.' ); + } + + /** + * @ticket 66372 + */ + public function test_data_processing_embed_false_keeps_the_links_without_embedding(): void { + $this->register_data_processing_route( array( 'a' => 1 ), true, array( 'data_processing' => array( 'embed' => false ) ) ); + + $_GET['_embed'] = '1'; + rest_get_server()->serve_request( '/test-ns/data-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 66372 + */ + public function test_data_processing_false_keeps_the_own_links_of_the_data(): void { + $own_links = array( 'custom' => array( array( 'href' => 'https://example.org/' ) ) ); + $this->register_data_processing_route( + array( + 'a' => 1, + '_links' => $own_links, + ), + true, + array( 'data_processing' => false ) + ); + + $_GET['_embed'] = '1'; + rest_get_server()->serve_request( '/test-ns/data-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 66372 + */ + public function test_data_processing_applies_to_enveloped_responses(): void { + $this->register_data_processing_route( + array( + 'a' => 1, + 'b' => 2, + ), + true, + array( 'data_processing' => false ) + ); + + $_GET['_fields'] = 'a'; + $_GET['_embed'] = '1'; + $_GET['_envelope'] = '1'; + rest_get_server()->serve_request( '/test-ns/data-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( 'data_processing' => false ), array( 'a', 'b' ) ), + 'route false and handler true' => array( array( 'data_processing' => true ), array( 'data_processing' => false ), array( 'a', '_links' ) ), + 'route false and handler fields' => array( array( 'data_processing' => array( 'fields' => true ) ), array( 'data_processing' => false ), array( 'a' ) ), + 'route fields false and handler links off' => array( array( 'data_processing' => array( 'links' => false ) ), array( 'data_processing' => array( 'fields' => false ) ), array( 'a', 'b' ) ), + 'route links false and handler fields off' => array( array( 'data_processing' => array( 'fields' => false ) ), array( 'data_processing' => array( 'links' => false ) ), array( 'a', 'b' ) ), + ); + } + + /** + * @ticket 66372 + * @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_data_processing_route( + array( + 'a' => 1, + 'b' => 2, + ), + true, + $handler_options, + $route_options + ); + + $_GET['_fields'] = 'a'; + rest_get_server()->serve_request( '/test-ns/data-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 66372 + */ + public function test_data_processing_is_set_on_the_request_attributes_the_matched_handler_and_the_response(): void { + $this->register_data_processing_route( array( 'a' => 1 ), false, array( 'data_processing' => array( 'links' => false ) ) ); + + $request = new WP_REST_Request( 'GET', '/test-ns/data-processing' ); + $response = rest_get_server()->dispatch( $request ); + $expected = array( + 'fields' => true, + 'embed' => false, + 'links' => false, + ); + + $this->assertSame( $expected, $request->get_attributes()['data_processing'], 'The request attributes should carry the resolved settings.' ); + $this->assertSame( $expected, $response->get_matched_handler()['data_processing'], 'The matched handler should carry the resolved settings.' ); + $this->assertSame( $expected, $response->get_data_processing(), 'The response should carry the resolved settings.' ); + } + + /** + * @ticket 66372 + */ + public function test_routes_without_the_option_have_every_feature_on(): void { + $this->register_data_processing_route( array( 'a' => 1 ), false ); + + $request = new WP_REST_Request( 'GET', '/test-ns/data-processing' ); + $response = rest_get_server()->dispatch( $request ); + $expected = array( + 'fields' => true, + 'embed' => true, + 'links' => true, + ); + + $this->assertSame( $expected, $request->get_attributes()['data_processing'], 'The request attributes should have every feature on.' ); + $this->assertSame( $expected, $response->get_matched_handler()['data_processing'], 'The matched handler should have every feature on.' ); + $this->assertSame( $expected, $response->get_data_processing(), 'The response should have every feature on.' ); + } + + /** + * Data provider for test_data_processing_applies_to_hijacked_responses(). + * + * @return array + */ + public static function data_data_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 66372 + * @dataProvider data_data_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_data_processing_applies_to_hijacked_responses( $data, string $expected ): void { + $this->register_data_processing_route( array( 'unexpected' => true ), true, array( 'data_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/data-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 66372 + */ + public function test_hijacked_responses_are_processed_by_default(): void { + $this->register_data_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/data-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 66372 + */ + public function test_hijacked_responses_carry_the_settings_of_their_route(): void { + $this->register_data_processing_route( array( 'unexpected' => true ), true, array( 'data_processing' => array( 'links' => false ) ) ); + + add_filter( + 'rest_pre_dispatch', + static function () { + return array( 'a' => 1 ); + } + ); + + $response = rest_get_server()->dispatch( new WP_REST_Request( 'GET', '/test-ns/data-processing' ) ); + + $this->assertSame( + array( + 'fields' => true, + 'embed' => false, + 'links' => false, + ), + $response->get_data_processing(), + 'A response returned by rest_pre_dispatch should carry the settings of the handler the request would match.' + ); + $this->assertSame( '', $response->get_matched_route(), 'A response returned by rest_pre_dispatch should still have no matched route.' ); + $this->assertNull( $response->get_matched_handler(), 'A response returned by rest_pre_dispatch should still have no matched handler.' ); + } + + /** + * @ticket 66372 + */ + public function test_hijacked_responses_for_unknown_routes_have_every_feature_on(): void { + add_filter( + 'rest_pre_dispatch', + static function () { + return array( 'a' => 1 ); + } + ); + + $response = rest_get_server()->dispatch( new WP_REST_Request( 'GET', '/test-ns/no-such-route' ) ); + + $this->assertSame( + array( + 'fields' => true, + 'embed' => true, + 'links' => true, + ), + $response->get_data_processing(), + 'A response returned by rest_pre_dispatch for an unknown route should have every feature on.' + ); + } + + /** + * @ticket 66372 + */ + public function test_response_setting_applies_when_set_before_the_fields_filter(): void { + $this->register_data_processing_route( + array( + 'a' => 1, + 'b' => 2, + ), + true + ); + + // Before rest_filter_response_fields(), which runs on the same filter at priority 10. + add_filter( + 'rest_post_dispatch', + static function ( $response ) { + $response->set_data_processing( false ); + return $response; + }, + 9 + ); + + $_GET['_fields'] = 'a'; + $_GET['_embed'] = '1'; + rest_get_server()->serve_request( '/test-ns/data-processing' ); + + $this->assertSame( '{"a":1,"b":2}', rest_get_server()->sent_body, 'A setting made on the response after dispatch should apply when the response is served.' ); + } + + /** + * Data provider for test_response_set_data_processing(). + * + * @return array}> + */ + public static function data_response_set_data_processing(): array { + $all_on = array( + 'fields' => true, + 'embed' => true, + 'links' => true, + ); + + return array( + 'true' => array( true, $all_on ), + 'false' => array( + false, + array( + 'fields' => false, + 'embed' => false, + 'links' => false, + ), + ), + 'empty array' => array( array(), $all_on ), + 'fields off' => array( + array( 'fields' => false ), + array( + 'fields' => false, + 'embed' => true, + 'links' => true, + ), + ), + 'links off' => array( + array( 'links' => false ), + array( + 'fields' => true, + 'embed' => false, + 'links' => false, + ), + ), + ); + } + + /** + * @ticket 66372 + * @dataProvider data_response_set_data_processing + * + * @param mixed $processing The setting, in the form of the `data_processing` option. + * @param array $expected The expected settings of the response. + */ + public function test_response_set_data_processing( $processing, array $expected ): void { + $response = new WP_REST_Response(); + + $this->assertSame( + array( + 'fields' => true, + 'embed' => true, + 'links' => true, + ), + $response->get_data_processing(), + 'A new response should have every feature on.' + ); + + $response->set_data_processing( $processing ); + + $this->assertSame( $expected, $response->get_data_processing(), 'The setting should be resolved like the route option.' ); + } + + /** + * Data provider for test_data_processing_applies_to_preloaded_requests(). + * + * @return array + */ + public static function data_data_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 66372 + * @dataProvider data_data_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_data_processing_applies_to_preloaded_requests( $data, string $expected ): void { + $this->register_data_processing_route( $data, true, array( 'data_processing' => false ) ); + + $preload = rest_preload_api_request( array(), '/test-ns/data-processing?_fields=a&_embed=1' ); + + $this->assertSame( $expected, wp_json_encode( $preload['/test-ns/data-processing?_fields=a&_embed=1']['body'] ), 'The preloaded body should be the data as returned.' ); + } + + /** + * @ticket 66372 + */ + public function test_data_processing_applies_to_batch_requests(): void { + $this->register_data_processing_route( + array( + 'a' => 1, + 'b' => 2, + ), + true, + array( + 'methods' => 'POST', + 'allow_batch' => array( 'v1' => true ), + 'data_processing' => false, + ) + ); + + $request = new WP_REST_Request( 'POST', '/batch/v1' ); + $request->set_body_params( + array( + 'requests' => array( + array( 'path' => '/test-ns/data-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 66372 + */ + public function test_data_processing_is_listed_in_the_index(): void { + rest_get_server()->register_route( + 'test-ns', + '/test-ns/data-processing', + array( + array( + 'methods' => 'GET', + 'callback' => '__return_null', + 'permission_callback' => '__return_true', + ), + array( + 'methods' => 'POST', + 'callback' => '__return_null', + 'permission_callback' => '__return_true', + 'data_processing' => array( 'fields' => true ), + ), + 'data_processing' => array( 'fields' => false ), + ) + ); + + $request = new WP_REST_Request( 'OPTIONS', '/test-ns/data-processing' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( + array( + 'fields' => false, + 'embed' => true, + 'links' => true, + ), + $data['endpoints'][0]['data_processing'], + 'An endpoint with a feature turned off should list the settings.' + ); + $this->assertArrayNotHasKey( 'data_processing', $data['endpoints'][1], 'An endpoint with every feature on should not list the settings.' ); + } + + /** + * @ticket 66372 + */ + public function test_options_requests_are_processed_by_default(): void { + $this->register_data_processing_route( array( 'a' => 1 ), true, array( 'data_processing' => false ) ); + + $_SERVER['REQUEST_METHOD'] = 'OPTIONS'; + $_GET['_fields'] = 'methods'; + rest_get_server()->serve_request( '/test-ns/data-processing' ); + $_SERVER['REQUEST_METHOD'] = 'GET'; + $data = json_decode( rest_get_server()->sent_body, true ); + + $this->assertSame( array( 'methods' ), array_keys( $data ), 'The _fields parameter should filter the route description of a route with the option.' ); + } + + /** + * @ticket 66372 + */ + public function test_array_access_data_gets_links_and_embedded_resources(): void { + $this->register_data_processing_route( new ArrayObject( array( 'a' => 1 ) ), true ); + + $_GET['_embed'] = '1'; + rest_get_server()->serve_request( '/test-ns/data-processing' ); + $data = json_decode( rest_get_server()->sent_body, true ); + + $this->assertSame( array( 'a', '_links', '_embedded' ), array_keys( $data ), 'An object that acts as an array should get the links and the embedded resources.' ); + } + + /** + * 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"]' ), + 'list of objects' => array( array( (object) array( 'b' => 2 ) ), false, '[{"b":2}]' ), + ); + } + + /** + * @ticket 66372 + * @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_data_processing_route( $data, $with_link ); + + $_GET['_fields'] = 'a'; + $_GET['_embed'] = '1'; + rest_get_server()->serve_request( '/test-ns/data-processing' ); + + $this->assertSame( $expected, rest_get_server()->sent_body, 'Data without fields, or items without fields, should be served as returned.' ); + } }