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 0eedb0396bcc4..17d620f6fb6e0 100644 --- a/src/wp-includes/rest-api/class-wp-rest-server.php +++ b/src/wp-includes/rest-api/class-wp-rest-server.php @@ -636,13 +636,75 @@ public static function get_response_links( $response ) { foreach ( $items as $item ) { $attributes = $item['attributes']; $attributes['href'] = $item['href']; - $data[ $rel ][] = $attributes; + + if ( 'self' !== $rel ) { + $data[ $rel ][] = $attributes; + continue; + } + + $target_hints = self::get_target_hints_for_link( $attributes ); + if ( $target_hints ) { + $attributes['targetHints'] = $target_hints; + } + + $data[ $rel ][] = $attributes; } } return $data; } + /** + * Gets the target links for a REST API Link. + * + * @since 6.7.0 + * + * @param array $link + * + * @return array|null + */ + protected static function get_target_hints_for_link( $link ) { + // Prefer targetHints that were specifically designated by the developer. + if ( isset( $link['targetHints']['allow'] ) ) { + return null; + } + + $request = WP_REST_Request::from_url( $link['href'] ); + if ( ! $request ) { + return null; + } + + $server = rest_get_server(); + $match = $server->match_request_to_handler( $request ); + + if ( is_wp_error( $match ) ) { + return null; + } + + if ( is_wp_error( $request->has_valid_params() ) ) { + return null; + } + + if ( is_wp_error( $request->sanitize_params() ) ) { + return null; + } + + $target_hints = array(); + + $response = new WP_REST_Response(); + $response->set_matched_route( $match[0] ); + $response->set_matched_handler( $match[1] ); + $headers = rest_send_allow_header( $response, $server, $request )->get_headers(); + + foreach ( $headers as $name => $value ) { + $name = WP_REST_Request::canonicalize_header_name( $name ); + + $target_hints[ $name ] = array_map( 'trim', explode( ',', $value ) ); + } + + return $target_hints; + } + /** * Retrieves the CURIEs (compact URIs) used for relations. * diff --git a/tests/phpunit/tests/rest-api/rest-server.php b/tests/phpunit/tests/rest-api/rest-server.php index 7bcc6d68e7497..378b51f606cc9 100644 --- a/tests/phpunit/tests/rest-api/rest-server.php +++ b/tests/phpunit/tests/rest-api/rest-server.php @@ -9,6 +9,8 @@ */ class Tests_REST_Server extends WP_Test_REST_TestCase { protected static $icon_id; + protected static $admin_id; + protected static $post_id; /** * Called before setting up all tests. @@ -21,12 +23,20 @@ public static function set_up_before_class() { } public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { - $filename = DIR_TESTDATA . '/images/test-image-large.jpg'; - self::$icon_id = $factory->attachment->create_upload_object( $filename ); + $filename = DIR_TESTDATA . '/images/test-image-large.jpg'; + self::$icon_id = $factory->attachment->create_upload_object( $filename ); + self::$admin_id = $factory->user->create( + array( + 'role' => 'administrator', + ) + ); + self::$post_id = $factory->post->create(); } public static function tear_down_after_class() { wp_delete_attachment( self::$icon_id, true ); + self::delete_user( self::$admin_id ); + wp_delete_post( self::$post_id ); parent::tear_down_after_class(); } @@ -2431,6 +2441,147 @@ public function test_rest_allowed_cors_headers_filter_receives_request_object() $this->assertSame( '/test-allowed-cors-headers', $mock_hook->get_events()[0]['args'][1]->get_route() ); } + /** + * @ticket 61739 + */ + public function test_validates_request_when_building_target_hints() { + register_rest_route( + 'test-ns/v1', + '/test/(?P\d+)', + array( + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => static function () { + return new \WP_REST_Response(); + }, + 'permission_callback' => '__return_true', + 'args' => array( + 'id' => array( + 'type' => 'integer', + ), + ), + ), + ) + ); + + $response = new WP_REST_Response(); + $response->add_link( 'self', rest_url( 'test-ns/v1/test/garbage' ) ); + + $links = rest_get_server()::get_response_links( $response ); + + $this->assertArrayHasKey( 'self', $links ); + $this->assertArrayNotHasKey( 'targetHints', $links['self'][0] ); + } + + /** + * @ticket 61739 + */ + public function test_sanitizes_request_when_building_target_hints() { + $validated_param = null; + register_rest_route( + 'test-ns/v1', + '/test/(?P\d+)', + array( + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => static function () { + return new \WP_REST_Response(); + }, + 'permission_callback' => function ( WP_REST_Request $request ) use ( &$validated_param ) { + $validated_param = $request['id']; + + return true; + }, + 'args' => array( + 'id' => array( + 'type' => 'integer', + ), + ), + ), + ) + ); + + $response = new WP_REST_Response(); + $response->add_link( 'self', rest_url( 'test-ns/v1/test/5' ) ); + + $links = rest_get_server()::get_response_links( $response ); + + $this->assertArrayHasKey( 'self', $links ); + $this->assertArrayHasKey( 'targetHints', $links['self'][0] ); + $this->assertIsInt( $validated_param ); + } + + /** + * @ticket 61739 + */ + public function test_populates_target_hints_for_administrator() { + wp_set_current_user( self::$admin_id ); + $response = rest_do_request( '/wp/v2/posts' ); + $post = $response->get_data()[0]; + + $link = $post['_links']['self'][0]; + $this->assertArrayHasKey( 'targetHints', $link ); + $this->assertArrayHasKey( 'allow', $link['targetHints'] ); + $this->assertSame( array( 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' ), $link['targetHints']['allow'] ); + } + + /** + * @ticket 61739 + */ + public function test_populates_target_hints_for_logged_out_user() { + $response = rest_do_request( '/wp/v2/posts' ); + $post = $response->get_data()[0]; + + $link = $post['_links']['self'][0]; + $this->assertArrayHasKey( 'targetHints', $link ); + $this->assertArrayHasKey( 'allow', $link['targetHints'] ); + $this->assertSame( array( 'GET' ), $link['targetHints']['allow'] ); + } + + /** + * @ticket 61739 + */ + public function test_does_not_error_on_invalid_urls() { + $response = new WP_REST_Response(); + $response->add_link( 'self', 'this is not a real URL' ); + + $links = rest_get_server()::get_response_links( $response ); + $this->assertArrayNotHasKey( 'targetHints', $links['self'][0] ); + } + + /** + * @ticket 61739 + */ + public function test_does_not_error_on_bad_rest_api_routes() { + $response = new WP_REST_Response(); + $response->add_link( 'self', rest_url( '/this/is/not/a/real/route' ) ); + + $links = rest_get_server()::get_response_links( $response ); + $this->assertArrayNotHasKey( 'targetHints', $links['self'][0] ); + } + + /** + * @ticket 61739 + */ + public function test_prefers_developer_defined_target_hints() { + $response = new WP_REST_Response(); + $response->add_link( + 'self', + '/wp/v2/posts/' . self::$post_id, + array( + 'targetHints' => array( + 'allow' => array( 'GET', 'PUT' ), + ), + ) + ); + + $links = rest_get_server()::get_response_links( $response ); + $link = $links['self'][0]; + $this->assertArrayHasKey( 'targetHints', $link ); + $this->assertArrayHasKey( 'allow', $link['targetHints'] ); + $this->assertSame( array( 'GET', 'PUT' ), $link['targetHints']['allow'] ); + } + public function _validate_as_integer_123( $value, $request, $key ) { if ( ! is_int( $value ) ) { return new WP_Error( 'some-error', 'This is not valid!' ); diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 7e97f19b18588..b3917a35eee93 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12334,7 +12334,16 @@ mockedApiResponse.PostsCollection = [ "_links": { "self": [ { - "href": "http://example.org/index.php?rest_route=/wp/v2/posts/4" + "href": "http://example.org/index.php?rest_route=/wp/v2/posts/4", + "targetHints": { + "allow": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ] + } } ], "collection": [ @@ -12641,7 +12650,16 @@ mockedApiResponse.PagesCollection = [ "_links": { "self": [ { - "href": "http://example.org/index.php?rest_route=/wp/v2/pages/7" + "href": "http://example.org/index.php?rest_route=/wp/v2/pages/7", + "targetHints": { + "allow": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ] + } } ], "collection": [ @@ -12932,7 +12950,16 @@ mockedApiResponse.MediaCollection = [ "_links": { "self": [ { - "href": "http://example.org/index.php?rest_route=/wp/v2/media/10" + "href": "http://example.org/index.php?rest_route=/wp/v2/media/10", + "targetHints": { + "allow": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ] + } } ], "collection": [ @@ -13629,7 +13656,15 @@ mockedApiResponse.CategoriesCollection = [ "_links": { "self": [ { - "href": "http://example.org/index.php?rest_route=/wp/v2/categories/1" + "href": "http://example.org/index.php?rest_route=/wp/v2/categories/1", + "targetHints": { + "allow": [ + "GET", + "POST", + "PUT", + "PATCH" + ] + } } ], "collection": [ @@ -13694,7 +13729,16 @@ mockedApiResponse.TagsCollection = [ "_links": { "self": [ { - "href": "http://example.org/index.php?rest_route=/wp/v2/tags/2" + "href": "http://example.org/index.php?rest_route=/wp/v2/tags/2", + "targetHints": { + "allow": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ] + } } ], "collection": [ @@ -13758,7 +13802,16 @@ mockedApiResponse.UsersCollection = [ "_links": { "self": [ { - "href": "http://example.org/index.php?rest_route=/wp/v2/users/1" + "href": "http://example.org/index.php?rest_route=/wp/v2/users/1", + "targetHints": { + "allow": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ] + } } ], "collection": [ @@ -13786,7 +13839,16 @@ mockedApiResponse.UsersCollection = [ "_links": { "self": [ { - "href": "http://example.org/index.php?rest_route=/wp/v2/users/2" + "href": "http://example.org/index.php?rest_route=/wp/v2/users/2", + "targetHints": { + "allow": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ] + } } ], "collection": [ @@ -13859,7 +13921,16 @@ mockedApiResponse.CommentsCollection = [ "_links": { "self": [ { - "href": "http://example.org/index.php?rest_route=/wp/v2/comments/2" + "href": "http://example.org/index.php?rest_route=/wp/v2/comments/2", + "targetHints": { + "allow": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ] + } } ], "collection": [