From 7a5e12858c53eaac4c5ad25f0c78bf9b66296403 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 21 Oct 2026 20:23:37 +0100 Subject: [PATCH] REST API: Allow routes to disable `_fields`, `_embed` and response links. Add the `allow_fields`, `allow_embed` and `allow_links` route and handler options. When an option is false, the `_fields` parameter does not filter the response data, the `_embed` parameter does not embed linked resources, or the response links are not added to the response data as `_links`. A handler option takes precedence over the route option, as with `allow_batch`. This lets a route serve its response data as returned, for example data that follows a schema of its own or that is not an array. See #66267. --- src/wp-includes/rest-api.php | 5 + .../rest-api/class-wp-rest-server.php | 45 +++- tests/phpunit/tests/rest-api/rest-server.php | 250 ++++++++++++++++++ 3 files changed, 298 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 9998e14da7361..b24fd381b29bf 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -950,6 +950,7 @@ function _rest_array_intersect_key_recursive( $array1, $array2 ) { * Filters the REST API response to include only an allow-listed set of response object fields. * * @since 4.8.0 + * @since 7.2.0 The response is left unchanged when the route disables `allow_fields`. * * @param WP_REST_Response $response Current response being served. * @param WP_REST_Server $server ResponseHandler instance (usually WP_REST_Server). @@ -961,6 +962,10 @@ function rest_filter_response_fields( $response, $server, $request ) { return $response; } + if ( $server instanceof WP_REST_Server && ! $server->is_response_option_enabled( $response, 'allow_fields' ) ) { + return $response; + } + $data = $response->get_data(); $fields = wp_parse_list( $request['_fields'] ); diff --git a/src/wp-includes/rest-api/class-wp-rest-server.php b/src/wp-includes/rest-api/class-wp-rest-server.php index 179edb04c8b68..58713a9933f52 100644 --- a/src/wp-includes/rest-api/class-wp-rest-server.php +++ b/src/wp-includes/rest-api/class-wp-rest-server.php @@ -40,6 +40,9 @@ * accept_raw: bool, * show_in_index: bool, * allow_batch?: array{v1?: bool}|false, + * allow_fields?: bool, + * allow_embed?: bool, + * allow_links?: bool, * ... * } */ @@ -622,6 +625,7 @@ public function serve_request( $path = null ) { * * @since 4.4.0 * @since 5.4.0 The `$embed` parameter can now contain a list of link relations to include. + * @since 7.2.0 Links are not added or embedded when the route disables `allow_links` or `allow_embed`. * * @param WP_REST_Response $response Response object. * @param bool|string[] $embed Whether to embed all links, a filtered list of link relations, or no links. @@ -634,14 +638,14 @@ public function serve_request( $path = null ) { */ public function response_to_data( $response, $embed ) { $data = $response->get_data(); - $links = self::get_compact_response_links( $response ); + $links = $this->is_response_option_enabled( $response, 'allow_links' ) ? self::get_compact_response_links( $response ) : array(); if ( ! empty( $links ) ) { // Convert links to part of the data. $data['_links'] = $links; } - if ( $embed ) { + if ( $embed && $this->is_response_option_enabled( $response, 'allow_embed' ) ) { $this->embed_cache = array(); // Determine if this is a numeric array. if ( wp_is_numeric_array( $data ) ) { @@ -1131,6 +1135,43 @@ public function get_route_options( $route ) { return $this->route_options[ $route ]; } + /** + * Determines whether a response option is enabled for the route that generated a response. + * + * Routes, or individual handlers of a route, can set these options to false: + * + * - `allow_fields`: Whether the `_fields` parameter filters the response data. + * - `allow_embed`: Whether the `_embed` parameter embeds linked resources in the response data. + * - `allow_links`: Whether the response links are added to the response data as `_links`. + * + * A handler option takes precedence over the route option of the same name. + * + * @since 7.2.0 + * + * @param WP_HTTP_Response $response Response object. + * @param string $option Option name. Accepts 'allow_fields', 'allow_embed', or 'allow_links'. + * @return bool Whether the option is enabled. True when neither the handler nor the route sets it. + */ + public function is_response_option_enabled( $response, $option ) { + if ( ! $response instanceof WP_REST_Response ) { + return true; + } + + $handler = $response->get_matched_handler(); + if ( is_array( $handler ) && isset( $handler[ $option ] ) ) { + return (bool) $handler[ $option ]; + } + + $route = $response->get_matched_route(); + if ( ! is_string( $route ) || '' === $route ) { + return true; + } + + $route_options = $this->get_route_options( $route ); + + return ! isset( $route_options[ $option ] ) || (bool) $route_options[ $option ]; + } + /** * Matches the request to a callback and call it. * diff --git a/tests/phpunit/tests/rest-api/rest-server.php b/tests/phpunit/tests/rest-api/rest-server.php index c4d6a883d67b1..3dbacc81b9035 100644 --- a/tests/phpunit/tests/rest-api/rest-server.php +++ b/tests/phpunit/tests/rest-api/rest-server.php @@ -2900,4 +2900,254 @@ public function test_prefers_developer_defined_target_hints() { $this->assertArrayHasKey( 'allow', $link['targetHints'] ); $this->assertSame( array( 'GET', 'PUT' ), $link['targetHints']['allow'] ); } + + /** + * Registers a route for the response option tests, and a route that its link embeds. + * + * @param mixed $data The response data. + * @param bool $with_link Whether the response has an embeddable link. + * @param array $handler_options Options for the route handler. + * @param array $route_options Options for the route. + */ + private function register_response_options_route( $data, bool $with_link, array $handler_options = array(), array $route_options = array() ): void { + rest_get_server()->register_route( + 'test-ns', + '/test-ns/response-options', + array_merge( + array( + array_merge( + array( + 'methods' => 'GET', + 'callback' => static function () use ( $data, $with_link ) { + $response = new WP_REST_Response( $data ); + if ( $with_link ) { + $response->add_link( 'alternate', rest_url( '/test-ns/embedded' ), array( 'embeddable' => true ) ); + } + return $response; + }, + 'permission_callback' => '__return_true', + ), + $handler_options + ), + ), + $route_options + ) + ); + + rest_get_server()->register_route( + 'test-ns', + '/test-ns/embedded', + array( + 'methods' => 'GET', + 'callback' => static function () { + return array( 'hello' => 'world' ); + }, + 'permission_callback' => '__return_true', + ) + ); + } + + /** + * Data provider for test_allow_fields_false_leaves_the_data_unfiltered(). + * + * @return array + */ + public static function data_allow_fields_false_leaves_the_data_unfiltered(): array { + return array( + 'associative array' => array( + array( + 'a' => 1, + 'b' => 2, + ), + '{"a":1,"b":2}', + ), + 'list' => array( array( array( 'b' => 2 ), 'c' ), '[{"b":2},"c"]' ), + 'empty object' => array( new stdClass(), '{}' ), + 'object' => array( (object) array( 'b' => 2 ), '{"b":2}' ), + 'string' => array( 'value', '"value"' ), + 'integer' => array( 5, '5' ), + ); + } + + /** + * @ticket 66267 + * @dataProvider data_allow_fields_false_leaves_the_data_unfiltered + * + * @param mixed $data The response data. + * @param string $expected The expected response body. + */ + public function test_allow_fields_false_leaves_the_data_unfiltered( $data, string $expected ): void { + $this->register_response_options_route( $data, false, array( 'allow_fields' => false ) ); + + $_GET['_fields'] = 'a'; + rest_get_server()->serve_request( '/test-ns/response-options' ); + + $this->assertSame( $expected, rest_get_server()->sent_body, 'The _fields parameter should not filter the data.' ); + } + + /** + * @ticket 66267 + */ + public function test_fields_are_filtered_by_default(): void { + $this->register_response_options_route( + array( + 'a' => 1, + 'b' => 2, + ), + false + ); + + $_GET['_fields'] = 'a'; + rest_get_server()->serve_request( '/test-ns/response-options' ); + + $this->assertSame( '{"a":1}', rest_get_server()->sent_body, 'The _fields parameter should filter the data.' ); + } + + /** + * Data provider for test_allow_links_false_leaves_out_the_links(). + * + * @return array + */ + public static function data_allow_links_false_leaves_out_the_links(): array { + return array( + 'associative array' => array( array( 'a' => 1 ), '{"a":1}' ), + 'list' => array( array( 'a', 'b' ), '["a","b"]' ), + 'empty object' => array( new stdClass(), '{}' ), + 'string' => array( 'value', '"value"' ), + ); + } + + /** + * @ticket 66267 + * @dataProvider data_allow_links_false_leaves_out_the_links + * + * @param mixed $data The response data. + * @param string $expected The expected response body. + */ + public function test_allow_links_false_leaves_out_the_links( $data, string $expected ): void { + $this->register_response_options_route( $data, true, array( 'allow_links' => false ) ); + + rest_get_server()->serve_request( '/test-ns/response-options' ); + + $this->assertSame( $expected, rest_get_server()->sent_body, 'The response links should not be added.' ); + } + + /** + * @ticket 66267 + */ + public function test_allow_embed_false_keeps_the_links_without_embedding(): void { + $this->register_response_options_route( array( 'a' => 1 ), true, array( 'allow_embed' => false ) ); + + $_GET['_embed'] = '1'; + rest_get_server()->serve_request( '/test-ns/response-options' ); + $data = json_decode( rest_get_server()->sent_body, true ); + + $this->assertArrayHasKey( '_links', $data, 'The response links should be added.' ); + $this->assertArrayNotHasKey( '_embedded', $data, 'The links should not be embedded.' ); + } + + /** + * @ticket 66267 + */ + public function test_links_are_added_and_embedded_by_default(): void { + $this->register_response_options_route( array( 'a' => 1 ), true ); + + $_GET['_embed'] = '1'; + rest_get_server()->serve_request( '/test-ns/response-options' ); + $data = json_decode( rest_get_server()->sent_body, true ); + + $this->assertArrayHasKey( '_links', $data, 'The response links should be added.' ); + $this->assertSame( array( array( 'hello' => 'world' ) ), $data['_embedded']['alternate'], 'The link should be embedded.' ); + } + + /** + * @ticket 66267 + */ + public function test_allow_embed_false_applies_to_enveloped_responses(): void { + $this->register_response_options_route( array( 'a' => 1 ), true, array( 'allow_embed' => false ) ); + + $_GET['_embed'] = '1'; + $_GET['_envelope'] = '1'; + rest_get_server()->serve_request( '/test-ns/response-options' ); + $data = json_decode( rest_get_server()->sent_body, true ); + + $this->assertSame( 200, $data['status'], 'The enveloped response should succeed.' ); + $this->assertArrayHasKey( '_links', $data['body'], 'The response links should be added to the body.' ); + $this->assertArrayNotHasKey( '_embedded', $data['body'], 'The links should not be embedded in the body.' ); + } + + /** + * Data provider for test_handler_option_takes_precedence_over_route_option(). + * + * @return array, 1: array, 2: string}> + */ + public static function data_handler_option_takes_precedence_over_route_option(): array { + return array( + 'route disables' => array( array(), array( 'allow_fields' => false ), '{"a":1,"b":2}' ), + 'route disables and handler enables' => array( array( 'allow_fields' => true ), array( 'allow_fields' => false ), '{"a":1}' ), + 'route enables and handler disables' => array( array( 'allow_fields' => false ), array( 'allow_fields' => true ), '{"a":1,"b":2}' ), + 'handler disables another option' => array( array( 'allow_links' => false ), array(), '{"a":1}' ), + ); + } + + /** + * @ticket 66267 + * @dataProvider data_handler_option_takes_precedence_over_route_option + * + * @param array $handler_options Options for the route handler. + * @param array $route_options Options for the route. + * @param string $expected The expected response body. + */ + public function test_handler_option_takes_precedence_over_route_option( array $handler_options, array $route_options, string $expected ): void { + $this->register_response_options_route( + array( + 'a' => 1, + 'b' => 2, + ), + false, + $handler_options, + $route_options + ); + + $_GET['_fields'] = 'a'; + rest_get_server()->serve_request( '/test-ns/response-options' ); + + $this->assertSame( $expected, rest_get_server()->sent_body, 'The handler option should take precedence over the route option.' ); + } + + /** + * @ticket 66267 + */ + public function test_response_options_apply_to_preloaded_requests(): void { + $this->register_response_options_route( + array( + 'a' => 1, + 'b' => 2, + ), + true, + array( + 'allow_fields' => false, + 'allow_links' => false, + ) + ); + + $preload = rest_preload_api_request( array(), '/test-ns/response-options?_fields=a&_embed=1' ); + + $this->assertSame( + array( + 'a' => 1, + 'b' => 2, + ), + $preload['/test-ns/response-options?_fields=a&_embed=1']['body'], + 'The preloaded data should not be filtered or have links.' + ); + } + + /** + * @ticket 66267 + */ + public function test_is_response_option_enabled_without_a_matched_route(): void { + $this->assertTrue( rest_get_server()->is_response_option_enabled( new WP_REST_Response(), 'allow_fields' ), 'A response without a matched route should enable the option.' ); + $this->assertTrue( rest_get_server()->is_response_option_enabled( new WP_HTTP_Response(), 'allow_links' ), 'A response that is not a REST response should enable the option.' ); + } }