Skip to content

Commit 7a5e128

Browse files
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.
1 parent 92c1741 commit 7a5e128

3 files changed

Lines changed: 298 additions & 2 deletions

File tree

‎src/wp-includes/rest-api.php‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -950,6 +950,7 @@ function _rest_array_intersect_key_recursive( $array1, $array2 ) {
950950
* Filters the REST API response to include only an allow-listed set of response object fields.
951951
*
952952
* @since 4.8.0
953+
* @since 7.2.0 The response is left unchanged when the route disables `allow_fields`.
953954
*
954955
* @param WP_REST_Response $response Current response being served.
955956
* @param WP_REST_Server $server ResponseHandler instance (usually WP_REST_Server).
@@ -961,6 +962,10 @@ function rest_filter_response_fields( $response, $server, $request ) {
961962
return $response;
962963
}
963964

965+
if ( $server instanceof WP_REST_Server && ! $server->is_response_option_enabled( $response, 'allow_fields' ) ) {
966+
return $response;
967+
}
968+
964969
$data = $response->get_data();
965970

966971
$fields = wp_parse_list( $request['_fields'] );

‎src/wp-includes/rest-api/class-wp-rest-server.php‎

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@
4040
* accept_raw: bool,
4141
* show_in_index: bool,
4242
* allow_batch?: array{v1?: bool}|false,
43+
* allow_fields?: bool,
44+
* allow_embed?: bool,
45+
* allow_links?: bool,
4346
* ...
4447
* }
4548
*/
@@ -622,6 +625,7 @@ public function serve_request( $path = null ) {
622625
*
623626
* @since 4.4.0
624627
* @since 5.4.0 The `$embed` parameter can now contain a list of link relations to include.
628+
* @since 7.2.0 Links are not added or embedded when the route disables `allow_links` or `allow_embed`.
625629
*
626630
* @param WP_REST_Response $response Response object.
627631
* @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 ) {
634638
*/
635639
public function response_to_data( $response, $embed ) {
636640
$data = $response->get_data();
637-
$links = self::get_compact_response_links( $response );
641+
$links = $this->is_response_option_enabled( $response, 'allow_links' ) ? self::get_compact_response_links( $response ) : array();
638642

639643
if ( ! empty( $links ) ) {
640644
// Convert links to part of the data.
641645
$data['_links'] = $links;
642646
}
643647

644-
if ( $embed ) {
648+
if ( $embed && $this->is_response_option_enabled( $response, 'allow_embed' ) ) {
645649
$this->embed_cache = array();
646650
// Determine if this is a numeric array.
647651
if ( wp_is_numeric_array( $data ) ) {
@@ -1131,6 +1135,43 @@ public function get_route_options( $route ) {
11311135
return $this->route_options[ $route ];
11321136
}
11331137

1138+
/**
1139+
* Determines whether a response option is enabled for the route that generated a response.
1140+
*
1141+
* Routes, or individual handlers of a route, can set these options to false:
1142+
*
1143+
* - `allow_fields`: Whether the `_fields` parameter filters the response data.
1144+
* - `allow_embed`: Whether the `_embed` parameter embeds linked resources in the response data.
1145+
* - `allow_links`: Whether the response links are added to the response data as `_links`.
1146+
*
1147+
* A handler option takes precedence over the route option of the same name.
1148+
*
1149+
* @since 7.2.0
1150+
*
1151+
* @param WP_HTTP_Response $response Response object.
1152+
* @param string $option Option name. Accepts 'allow_fields', 'allow_embed', or 'allow_links'.
1153+
* @return bool Whether the option is enabled. True when neither the handler nor the route sets it.
1154+
*/
1155+
public function is_response_option_enabled( $response, $option ) {
1156+
if ( ! $response instanceof WP_REST_Response ) {
1157+
return true;
1158+
}
1159+
1160+
$handler = $response->get_matched_handler();
1161+
if ( is_array( $handler ) && isset( $handler[ $option ] ) ) {
1162+
return (bool) $handler[ $option ];
1163+
}
1164+
1165+
$route = $response->get_matched_route();
1166+
if ( ! is_string( $route ) || '' === $route ) {
1167+
return true;
1168+
}
1169+
1170+
$route_options = $this->get_route_options( $route );
1171+
1172+
return ! isset( $route_options[ $option ] ) || (bool) $route_options[ $option ];
1173+
}
1174+
11341175
/**
11351176
* Matches the request to a callback and call it.
11361177
*

‎tests/phpunit/tests/rest-api/rest-server.php‎

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2900,4 +2900,254 @@ public function test_prefers_developer_defined_target_hints() {
29002900
$this->assertArrayHasKey( 'allow', $link['targetHints'] );
29012901
$this->assertSame( array( 'GET', 'PUT' ), $link['targetHints']['allow'] );
29022902
}
2903+
2904+
/**
2905+
* Registers a route for the response option tests, and a route that its link embeds.
2906+
*
2907+
* @param mixed $data The response data.
2908+
* @param bool $with_link Whether the response has an embeddable link.
2909+
* @param array<string, mixed> $handler_options Options for the route handler.
2910+
* @param array<string, mixed> $route_options Options for the route.
2911+
*/
2912+
private function register_response_options_route( $data, bool $with_link, array $handler_options = array(), array $route_options = array() ): void {
2913+
rest_get_server()->register_route(
2914+
'test-ns',
2915+
'/test-ns/response-options',
2916+
array_merge(
2917+
array(
2918+
array_merge(
2919+
array(
2920+
'methods' => 'GET',
2921+
'callback' => static function () use ( $data, $with_link ) {
2922+
$response = new WP_REST_Response( $data );
2923+
if ( $with_link ) {
2924+
$response->add_link( 'alternate', rest_url( '/test-ns/embedded' ), array( 'embeddable' => true ) );
2925+
}
2926+
return $response;
2927+
},
2928+
'permission_callback' => '__return_true',
2929+
),
2930+
$handler_options
2931+
),
2932+
),
2933+
$route_options
2934+
)
2935+
);
2936+
2937+
rest_get_server()->register_route(
2938+
'test-ns',
2939+
'/test-ns/embedded',
2940+
array(
2941+
'methods' => 'GET',
2942+
'callback' => static function () {
2943+
return array( 'hello' => 'world' );
2944+
},
2945+
'permission_callback' => '__return_true',
2946+
)
2947+
);
2948+
}
2949+
2950+
/**
2951+
* Data provider for test_allow_fields_false_leaves_the_data_unfiltered().
2952+
*
2953+
* @return array<string, array{0: mixed, 1: string}>
2954+
*/
2955+
public static function data_allow_fields_false_leaves_the_data_unfiltered(): array {
2956+
return array(
2957+
'associative array' => array(
2958+
array(
2959+
'a' => 1,
2960+
'b' => 2,
2961+
),
2962+
'{"a":1,"b":2}',
2963+
),
2964+
'list' => array( array( array( 'b' => 2 ), 'c' ), '[{"b":2},"c"]' ),
2965+
'empty object' => array( new stdClass(), '{}' ),
2966+
'object' => array( (object) array( 'b' => 2 ), '{"b":2}' ),
2967+
'string' => array( 'value', '"value"' ),
2968+
'integer' => array( 5, '5' ),
2969+
);
2970+
}
2971+
2972+
/**
2973+
* @ticket 66267
2974+
* @dataProvider data_allow_fields_false_leaves_the_data_unfiltered
2975+
*
2976+
* @param mixed $data The response data.
2977+
* @param string $expected The expected response body.
2978+
*/
2979+
public function test_allow_fields_false_leaves_the_data_unfiltered( $data, string $expected ): void {
2980+
$this->register_response_options_route( $data, false, array( 'allow_fields' => false ) );
2981+
2982+
$_GET['_fields'] = 'a';
2983+
rest_get_server()->serve_request( '/test-ns/response-options' );
2984+
2985+
$this->assertSame( $expected, rest_get_server()->sent_body, 'The _fields parameter should not filter the data.' );
2986+
}
2987+
2988+
/**
2989+
* @ticket 66267
2990+
*/
2991+
public function test_fields_are_filtered_by_default(): void {
2992+
$this->register_response_options_route(
2993+
array(
2994+
'a' => 1,
2995+
'b' => 2,
2996+
),
2997+
false
2998+
);
2999+
3000+
$_GET['_fields'] = 'a';
3001+
rest_get_server()->serve_request( '/test-ns/response-options' );
3002+
3003+
$this->assertSame( '{"a":1}', rest_get_server()->sent_body, 'The _fields parameter should filter the data.' );
3004+
}
3005+
3006+
/**
3007+
* Data provider for test_allow_links_false_leaves_out_the_links().
3008+
*
3009+
* @return array<string, array{0: mixed, 1: string}>
3010+
*/
3011+
public static function data_allow_links_false_leaves_out_the_links(): array {
3012+
return array(
3013+
'associative array' => array( array( 'a' => 1 ), '{"a":1}' ),
3014+
'list' => array( array( 'a', 'b' ), '["a","b"]' ),
3015+
'empty object' => array( new stdClass(), '{}' ),
3016+
'string' => array( 'value', '"value"' ),
3017+
);
3018+
}
3019+
3020+
/**
3021+
* @ticket 66267
3022+
* @dataProvider data_allow_links_false_leaves_out_the_links
3023+
*
3024+
* @param mixed $data The response data.
3025+
* @param string $expected The expected response body.
3026+
*/
3027+
public function test_allow_links_false_leaves_out_the_links( $data, string $expected ): void {
3028+
$this->register_response_options_route( $data, true, array( 'allow_links' => false ) );
3029+
3030+
rest_get_server()->serve_request( '/test-ns/response-options' );
3031+
3032+
$this->assertSame( $expected, rest_get_server()->sent_body, 'The response links should not be added.' );
3033+
}
3034+
3035+
/**
3036+
* @ticket 66267
3037+
*/
3038+
public function test_allow_embed_false_keeps_the_links_without_embedding(): void {
3039+
$this->register_response_options_route( array( 'a' => 1 ), true, array( 'allow_embed' => false ) );
3040+
3041+
$_GET['_embed'] = '1';
3042+
rest_get_server()->serve_request( '/test-ns/response-options' );
3043+
$data = json_decode( rest_get_server()->sent_body, true );
3044+
3045+
$this->assertArrayHasKey( '_links', $data, 'The response links should be added.' );
3046+
$this->assertArrayNotHasKey( '_embedded', $data, 'The links should not be embedded.' );
3047+
}
3048+
3049+
/**
3050+
* @ticket 66267
3051+
*/
3052+
public function test_links_are_added_and_embedded_by_default(): void {
3053+
$this->register_response_options_route( array( 'a' => 1 ), true );
3054+
3055+
$_GET['_embed'] = '1';
3056+
rest_get_server()->serve_request( '/test-ns/response-options' );
3057+
$data = json_decode( rest_get_server()->sent_body, true );
3058+
3059+
$this->assertArrayHasKey( '_links', $data, 'The response links should be added.' );
3060+
$this->assertSame( array( array( 'hello' => 'world' ) ), $data['_embedded']['alternate'], 'The link should be embedded.' );
3061+
}
3062+
3063+
/**
3064+
* @ticket 66267
3065+
*/
3066+
public function test_allow_embed_false_applies_to_enveloped_responses(): void {
3067+
$this->register_response_options_route( array( 'a' => 1 ), true, array( 'allow_embed' => false ) );
3068+
3069+
$_GET['_embed'] = '1';
3070+
$_GET['_envelope'] = '1';
3071+
rest_get_server()->serve_request( '/test-ns/response-options' );
3072+
$data = json_decode( rest_get_server()->sent_body, true );
3073+
3074+
$this->assertSame( 200, $data['status'], 'The enveloped response should succeed.' );
3075+
$this->assertArrayHasKey( '_links', $data['body'], 'The response links should be added to the body.' );
3076+
$this->assertArrayNotHasKey( '_embedded', $data['body'], 'The links should not be embedded in the body.' );
3077+
}
3078+
3079+
/**
3080+
* Data provider for test_handler_option_takes_precedence_over_route_option().
3081+
*
3082+
* @return array<string, array{0: array<string, bool>, 1: array<string, bool>, 2: string}>
3083+
*/
3084+
public static function data_handler_option_takes_precedence_over_route_option(): array {
3085+
return array(
3086+
'route disables' => array( array(), array( 'allow_fields' => false ), '{"a":1,"b":2}' ),
3087+
'route disables and handler enables' => array( array( 'allow_fields' => true ), array( 'allow_fields' => false ), '{"a":1}' ),
3088+
'route enables and handler disables' => array( array( 'allow_fields' => false ), array( 'allow_fields' => true ), '{"a":1,"b":2}' ),
3089+
'handler disables another option' => array( array( 'allow_links' => false ), array(), '{"a":1}' ),
3090+
);
3091+
}
3092+
3093+
/**
3094+
* @ticket 66267
3095+
* @dataProvider data_handler_option_takes_precedence_over_route_option
3096+
*
3097+
* @param array<string, bool> $handler_options Options for the route handler.
3098+
* @param array<string, bool> $route_options Options for the route.
3099+
* @param string $expected The expected response body.
3100+
*/
3101+
public function test_handler_option_takes_precedence_over_route_option( array $handler_options, array $route_options, string $expected ): void {
3102+
$this->register_response_options_route(
3103+
array(
3104+
'a' => 1,
3105+
'b' => 2,
3106+
),
3107+
false,
3108+
$handler_options,
3109+
$route_options
3110+
);
3111+
3112+
$_GET['_fields'] = 'a';
3113+
rest_get_server()->serve_request( '/test-ns/response-options' );
3114+
3115+
$this->assertSame( $expected, rest_get_server()->sent_body, 'The handler option should take precedence over the route option.' );
3116+
}
3117+
3118+
/**
3119+
* @ticket 66267
3120+
*/
3121+
public function test_response_options_apply_to_preloaded_requests(): void {
3122+
$this->register_response_options_route(
3123+
array(
3124+
'a' => 1,
3125+
'b' => 2,
3126+
),
3127+
true,
3128+
array(
3129+
'allow_fields' => false,
3130+
'allow_links' => false,
3131+
)
3132+
);
3133+
3134+
$preload = rest_preload_api_request( array(), '/test-ns/response-options?_fields=a&_embed=1' );
3135+
3136+
$this->assertSame(
3137+
array(
3138+
'a' => 1,
3139+
'b' => 2,
3140+
),
3141+
$preload['/test-ns/response-options?_fields=a&_embed=1']['body'],
3142+
'The preloaded data should not be filtered or have links.'
3143+
);
3144+
}
3145+
3146+
/**
3147+
* @ticket 66267
3148+
*/
3149+
public function test_is_response_option_enabled_without_a_matched_route(): void {
3150+
$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.' );
3151+
$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.' );
3152+
}
29033153
}

0 commit comments

Comments
 (0)