Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/wp-includes/rest-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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'] );
Expand Down
45 changes: 43 additions & 2 deletions src/wp-includes/rest-api/class-wp-rest-server.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
* ...
* }
*/
Expand Down Expand Up @@ -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.
Expand All @@ -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 ) ) {
Expand Down Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ public function register_routes(): void {
'callback' => array( $this, 'execute_ability' ),
'permission_callback' => array( $this, 'check_ability_permissions' ),
'args' => $this->get_run_args(),

/*
* Serve the ability result as returned, so it follows the ability's output
* schema whether it is an object, an array, or a scalar value.
*/
'allow_fields' => false,
'allow_embed' => false,
'allow_links' => false,
),
'schema' => array( $this, 'get_run_schema' ),
)
Expand Down Expand Up @@ -96,18 +104,10 @@ public function execute_ability( $request ) {
}

/*
* Preserve empty objects in nested properties when encoding an associative
* result as JSON. Preparation can convert empty PHP arrays to stdClass.
*
* The REST server's `_fields` and `_embed` processing expects arrays at the
* response root and for numeric collection items. Converting those values
* to objects would cause type errors, so skip root empty arrays and numeric
* collections until that processing supports objects. An empty object result
* therefore still encodes as [], and a list containing one encodes as [[]].
* Preserve empty objects when encoding the result as JSON. Preparation can
* convert empty PHP arrays to stdClass, also within object results.
*/
if ( is_array( $result ) && ! wp_is_numeric_array( $result ) ) {
$result = wp_prepare_json_value_for_client( $result, $ability->get_output_schema() );
}
$result = wp_prepare_json_value_for_client( $result, $ability->get_output_schema() );

return rest_ensure_response( $result );
}
Expand Down
250 changes: 250 additions & 0 deletions tests/phpunit/tests/rest-api/rest-server.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $handler_options Options for the route handler.
* @param array<string, mixed> $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<string, array{0: mixed, 1: string}>
*/
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<string, array{0: mixed, 1: string}>
*/
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<string, array{0: array<string, bool>, 1: array<string, bool>, 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<string, bool> $handler_options Options for the route handler.
* @param array<string, bool> $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.' );
}
}
Loading
Loading