Commit 21ff64ccad for wordpress.org

commit 21ff64ccad92a83b713fe6c0ecfebea753fcae0c
Author: jorgefilipecosta <jorgefilipecosta@git.wordpress.org>
Date:   Fri Oct 23 15:47:55 2026 +0000

    REST API: Let routes turn off `_fields`, `_embed` and response links.

    Add a `data_processing` option to REST routes and route handlers, so a route can serve its response data as returned. The option is `false` to turn off every feature, or an array with any of the `fields`, `embed` and `links` keys. Every feature is on by default, the handler setting applies over the route setting feature by feature, and turning off `links` also turns off `embed`.
    The settings are resolved once, when the request is matched, and stored on the request attributes, the matched handler and the response, where `rest_filter_response_fields()`, `WP_REST_Controller::get_fields_for_response()` and `WP_REST_Server::response_to_data()` read them. They apply to served, enveloped, batched and preloaded requests, and to responses returned by the `rest_pre_dispatch` filter. The index lists the settings of endpoints that turn a feature off.
    Response data that is not an array is now served as returned even without the option: `_fields` leaves scalars and objects alone and keeps list items that are not arrays, links are not added to them unless the object implements `ArrayAccess`, and `rest_preload_api_request()` no longer casts preloaded data to an array.
    This lets endpoints that own their output schema, such as the abilities run endpoint, return objects, lists and scalar values unchanged.

    Props jorgefilipecosta, gziolo.
    See #66267.
    Fixes #66372.
    Built from https://develop.svn.wordpress.org/trunk@64689


    git-svn-id: http://core.svn.wordpress.org/trunk@63839 1a063a9b-81f0-0310-95a4-ce76da25c4cd

diff --git a/wp-includes/rest-api.php b/wp-includes/rest-api.php
index 9998e14da7..28332a6562 100644
--- a/wp-includes/rest-api.php
+++ b/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/wp-includes/rest-api/class-wp-rest-response.php b/wp-includes/rest-api/class-wp-rest-response.php
index 7624e01d61..f86976e94d 100644
--- a/wp-includes/rest-api/class-wp-rest-response.php
+++ b/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<uppercase-string>,
@@ -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<string, bool> 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 @@ class WP_REST_Response extends WP_HTTP_Response {
 		$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<string, bool> {
+	 *     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<string, bool> $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/wp-includes/rest-api/class-wp-rest-server.php b/wp-includes/rest-api/class-wp-rest-server.php
index 179edb04c8..ed583cc900 100644
--- a/wp-includes/rest-api/class-wp-rest-server.php
+++ b/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 @@ class WP_REST_Server {
 	 *
 	 * @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 @@ class WP_REST_Server {
 		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 @@ class WP_REST_Server {
 				$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 @@ class WP_REST_Server {
 	 * @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<string, string>}|null
+	 */
+	protected function find_handler_for_request( $request ) {
 		$method = $request->get_method();
 		$path   = $request->get_route();

@@ -1265,8 +1402,6 @@ class WP_REST_Server {
 			}

 			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 @@ class WP_REST_Server {
 					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 @@ class WP_REST_Server {

 		$response->set_matched_route( $route );
 		$response->set_matched_handler( $handler );
+		$response->set_data_processing( $handler['data_processing'] ?? true );

 		return $response;
 	}
@@ -1705,10 +1823,11 @@ class WP_REST_Server {
 			'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 @@ class WP_REST_Server {
 				$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 @@ class WP_REST_Server {

 					$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/wp-includes/rest-api/endpoints/class-wp-rest-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-controller.php
index 74337bfd24..4af4f0ed3c 100644
--- a/wp-includes/rest-api/endpoints/class-wp-rest-controller.php
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-controller.php
@@ -565,6 +565,8 @@ abstract class WP_REST_Controller {
 	 * 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 @@ abstract class WP_REST_Controller {

 		$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/wp-includes/version.php b/wp-includes/version.php
index 5bd26c27f5..7ab4fc5b61 100644
--- a/wp-includes/version.php
+++ b/wp-includes/version.php
@@ -16,7 +16,7 @@
  *
  * @global string $wp_version
  */
-$wp_version = '7.2-beta1-64688';
+$wp_version = '7.2-beta1-64689';

 /**
  * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.