Make WordPress Core


Ignore:
Timestamp:
03/02/2025 10:05:08 PM (3 months ago)
Author:
TimothyBlynJacobs
Message:

REST API: Improve performance for HEAD requests.

By default, the REST API responds to HEAD rqeuests by calling the GET handler and omitting the body from the response. While convenient, this ends up performing needless work that slows down the API response time.

This commit adjusts the Core controllers to specifically handle HEAD requests by not preparing the response body.

Fixes #56481.
Props antonvlasenko, janusdev, ironprogrammer, swissspidy, spacedmonkey, mukesh27, mamaduka, timothyblynjacobs.

File:
1 edited

Legend:

Unmodified
Added
Removed
  • trunk/tests/phpunit/tests/rest-api/rest-posts-controller.php

    r59801 r59899  
    275275
    276276    /**
     277     * @ticket 56481
     278     */
     279    public function test_get_items_with_head_request_should_not_prepare_post_data() {
     280        $request = new WP_REST_Request( 'HEAD', '/wp/v2/posts' );
     281
     282        $hook_name = 'rest_prepare_post';
     283        $filter    = new MockAction();
     284        $callback  = array( $filter, 'filter' );
     285
     286        add_filter( $hook_name, $callback );
     287        $response = rest_get_server()->dispatch( $request );
     288        remove_filter( $hook_name, $callback );
     289
     290        $this->assertNotWPError( $response );
     291        $response = rest_ensure_response( $response );
     292
     293        $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' );
     294
     295        $headers = $response->get_headers();
     296        $this->assertSame( 0, $filter->get_call_count(), 'The "' . $hook_name . '" filter was called when it should not be for HEAD requests.' );
     297        $this->assertArrayHasKey( 'Link', $headers, 'The "Link" header should be present in the response.' );
     298        $this->assertNull( $response->get_data(), 'The server should not generate a body in response to a HEAD request.' );
     299    }
     300
     301    /**
    277302     * A valid query that returns 0 results should return an empty JSON list.
     303     * In case of a HEAD request, the response should not contain a body.
    278304     *
     305     * @dataProvider data_readable_http_methods
    279306     * @link https://github.com/WP-API/WP-API/issues/862
    280      */
    281     public function test_get_items_empty_query() {
    282         $request = new WP_REST_Request( 'GET', '/wp/v2/posts' );
     307     * @ticket 56481
     308     *
     309     * @covers WP_REST_Posts_Controller::get_items
     310     *
     311     * @param string $method The HTTP method to use.
     312     */
     313    public function test_get_items_empty_query( $method ) {
     314        $request = new WP_REST_Request( $method, '/wp/v2/posts' );
    283315        $request->set_query_params(
    284316            array(
     
    288320        $response = rest_get_server()->dispatch( $request );
    289321
    290         $this->assertEmpty( $response->get_data() );
    291         $this->assertSame( 200, $response->get_status() );
    292     }
    293 
    294     public function test_get_items_author_query() {
     322        if ( $request->is_method( 'HEAD' ) ) {
     323            $this->assertNull( $response->get_data(), 'Failed asserting that response data is null for HEAD request.' );
     324        } else {
     325            $this->assertSame( array(), $response->get_data(), 'Failed asserting that response data is an empty array for GET request.' );
     326        }
     327
     328        $headers = $response->get_headers();
     329        $this->assertSame( 0, $headers['X-WP-Total'], 'Failed asserting that X-WP-Total header is 0.' );
     330        $this->assertSame( 0, $headers['X-WP-TotalPages'], 'Failed asserting that X-WP-TotalPages header is 0.' );
     331    }
     332
     333    /**
     334     * @dataProvider data_readable_http_methods
     335     * @ticket 56481
     336     *
     337     * @param string $method The HTTP method to use.
     338     */
     339    public function test_get_items_author_query( $method ) {
    295340        self::factory()->post->create( array( 'post_author' => self::$editor_id ) );
    296341        self::factory()->post->create( array( 'post_author' => self::$author_id ) );
     
    299344
    300345        // All posts in the database.
    301         $request = new WP_REST_Request( 'GET', '/wp/v2/posts' );
     346        $request = new WP_REST_Request( $method, '/wp/v2/posts' );
    302347        $request->set_param( 'per_page', self::$per_page );
    303348        $response = rest_get_server()->dispatch( $request );
    304349        $this->assertSame( 200, $response->get_status() );
    305         $this->assertCount( $total_posts, $response->get_data() );
     350        if ( $request->is_method( 'get' ) ) {
     351            $this->assertCount( $total_posts, $response->get_data() );
     352
     353        } else {
     354            $this->assertNull( $response->get_data(), 'Failed asserting that response data is null for HEAD request.' );
     355            $headers = $response->get_headers();
     356            $this->assertSame( $total_posts, $headers['X-WP-Total'] );
     357        }
    306358
    307359        // Limit to editor and author.
    308         $request = new WP_REST_Request( 'GET', '/wp/v2/posts' );
     360        $request = new WP_REST_Request( $method, '/wp/v2/posts' );
    309361        $request->set_param( 'author', array( self::$editor_id, self::$author_id ) );
    310362        $response = rest_get_server()->dispatch( $request );
    311363        $this->assertSame( 200, $response->get_status() );
    312364        $data = $response->get_data();
    313         $this->assertCount( 2, $data );
    314         $this->assertSameSets( array( self::$editor_id, self::$author_id ), wp_list_pluck( $data, 'author' ) );
     365        if ( $request->is_method( 'get' ) ) {
     366            $this->assertCount( 2, $data );
     367            $this->assertSameSets( array( self::$editor_id, self::$author_id ), wp_list_pluck( $data, 'author' ) );
     368        } else {
     369            $this->assertNull( $data, 'Failed asserting that response data is null for HEAD request.' );
     370            $headers = $response->get_headers();
     371            $this->assertSame( 2, $headers['X-WP-Total'], 'Failed asserting that X-WP-Total header is 2.' );
     372        }
    315373
    316374        // Limit to editor.
    317         $request = new WP_REST_Request( 'GET', '/wp/v2/posts' );
     375        $request = new WP_REST_Request( $method, '/wp/v2/posts' );
    318376        $request->set_param( 'author', self::$editor_id );
    319377        $response = rest_get_server()->dispatch( $request );
    320378        $this->assertSame( 200, $response->get_status() );
    321379        $data = $response->get_data();
    322         $this->assertCount( 1, $data );
    323         $this->assertSame( self::$editor_id, $data[0]['author'] );
    324     }
    325 
    326     public function test_get_items_author_exclude_query() {
     380        if ( $request->is_method( 'get' ) ) {
     381            $this->assertCount( 1, $data );
     382            $this->assertSame( self::$editor_id, $data[0]['author'] );
     383        } else {
     384            $this->assertNull( $data, 'Failed asserting that response data is null for HEAD request.' );
     385            $headers = $response->get_headers();
     386            $this->assertSame( 1, $headers['X-WP-Total'], 'Failed asserting that X-WP-Total header is 1.' );
     387        }
     388    }
     389
     390    /**
     391     * @dataProvider data_readable_http_methods
     392     * @ticket 56481
     393     *
     394     * @param string $method The HTTP method to use.
     395     */
     396    public function test_get_items_author_exclude_query( $method ) {
    327397        self::factory()->post->create( array( 'post_author' => self::$editor_id ) );
    328398        self::factory()->post->create( array( 'post_author' => self::$author_id ) );
     
    331401
    332402        // All posts in the database.
    333         $request = new WP_REST_Request( 'GET', '/wp/v2/posts' );
     403        $request = new WP_REST_Request( $method, '/wp/v2/posts' );
    334404        $request->set_param( 'per_page', self::$per_page );
    335405        $response = rest_get_server()->dispatch( $request );
    336406        $this->assertSame( 200, $response->get_status() );
    337         $this->assertCount( $total_posts, $response->get_data() );
     407        if ( $request->is_method( 'get' ) ) {
     408            $this->assertCount( $total_posts, $response->get_data() );
     409        } else {
     410            $this->assertNull( $response->get_data(), 'Failed asserting that response data is null for HEAD request.' );
     411            $headers = $response->get_headers();
     412            $this->assertSame( $total_posts, $headers['X-WP-Total'], 'Failed asserting that the number of posts is correct.' );
     413        }
    338414
    339415        // Exclude editor and author.
    340         $request = new WP_REST_Request( 'GET', '/wp/v2/posts' );
     416        $request = new WP_REST_Request( $method, '/wp/v2/posts' );
    341417        $request->set_param( 'per_page', self::$per_page );
    342418        $request->set_param( 'author_exclude', array( self::$editor_id, self::$author_id ) );
     
    344420        $this->assertSame( 200, $response->get_status() );
    345421        $data = $response->get_data();
    346         $this->assertCount( $total_posts - 2, $data );
    347         $this->assertNotEquals( self::$editor_id, $data[0]['author'] );
    348         $this->assertNotEquals( self::$author_id, $data[0]['author'] );
     422        if ( $request->is_method( 'get' ) ) {
     423            $this->assertCount( $total_posts - 2, $data );
     424            $this->assertNotEquals( self::$editor_id, $data[0]['author'] );
     425            $this->assertNotEquals( self::$author_id, $data[0]['author'] );
     426        } else {
     427            $this->assertNull( $response->get_data(), 'Failed asserting that response data is null for HEAD request.' );
     428            $headers = $response->get_headers();
     429            $this->assertSame( $total_posts - 2, $headers['X-WP-Total'], 'Failed asserting that the number of posts is correct.' );
     430        }
    349431
    350432        // Exclude editor.
    351         $request = new WP_REST_Request( 'GET', '/wp/v2/posts' );
     433        $request = new WP_REST_Request( $method, '/wp/v2/posts' );
    352434        $request->set_param( 'per_page', self::$per_page );
    353435        $request->set_param( 'author_exclude', self::$editor_id );
     
    355437        $this->assertSame( 200, $response->get_status() );
    356438        $data = $response->get_data();
    357         $this->assertCount( $total_posts - 1, $data );
    358         $this->assertNotEquals( self::$editor_id, $data[0]['author'] );
    359         $this->assertNotEquals( self::$editor_id, $data[1]['author'] );
     439        if ( $request->is_method( 'get' ) ) {
     440            $this->assertCount( $total_posts - 1, $data );
     441            $this->assertNotEquals( self::$editor_id, $data[0]['author'] );
     442            $this->assertNotEquals( self::$editor_id, $data[1]['author'] );
     443        } else {
     444            $this->assertNull( $response->get_data(), 'Failed asserting that response data is null for HEAD request.' );
     445            $headers = $response->get_headers();
     446            $this->assertSame( $total_posts - 1, $headers['X-WP-Total'], 'Failed asserting that the number of posts is correct.' );
     447        }
    360448
    361449        // Invalid 'author_exclude' should error.
    362         $request = new WP_REST_Request( 'GET', '/wp/v2/posts' );
     450        $request = new WP_REST_Request( $method, '/wp/v2/posts' );
    363451        $request->set_param( 'author_exclude', 'invalid' );
    364452        $response = rest_get_server()->dispatch( $request );
     
    366454    }
    367455
    368     public function test_get_items_include_query() {
     456    /**
     457     * @dataProvider data_readable_http_methods
     458     * @ticket 56481
     459     *
     460     * @param string $method The HTTP method to use.
     461     */
     462    public function test_get_items_include_query( $method ) {
    369463        $id1 = self::factory()->post->create(
    370464            array(
     
    380474        );
    381475
    382         $request = new WP_REST_Request( 'GET', '/wp/v2/posts' );
     476        $request = new WP_REST_Request( $method, '/wp/v2/posts' );
    383477
    384478        // Order defaults to date descending.
     
    386480        $response = rest_get_server()->dispatch( $request );
    387481        $data     = $response->get_data();
    388         $this->assertCount( 2, $data );
    389         $this->assertSame( $id2, $data[0]['id'] );
     482        if ( $request->is_method( 'get' ) ) {
     483            $this->assertCount( 2, $data );
     484            $this->assertSame( $id2, $data[0]['id'] );
     485        } else {
     486            $this->assertNull( $data, 'Failed asserting that response data is null for HEAD request.' );
     487            $headers = $response->get_headers();
     488            $this->assertSame( 2, $headers['X-WP-Total'], 'Failed asserting that the number of posts is correct.' );
     489        }
     490
    390491        $this->assertPostsOrderedBy( '{posts}.post_date DESC' );
    391492
     
    394495        $response = rest_get_server()->dispatch( $request );
    395496        $data     = $response->get_data();
    396         $this->assertCount( 2, $data );
    397         $this->assertSame( $id1, $data[0]['id'] );
     497        if ( $request->is_method( 'get' ) ) {
     498            $this->assertCount( 2, $data );
     499            $this->assertSame( $id1, $data[0]['id'] );
     500        } else {
     501            $this->assertNull( $data, 'Failed asserting that response data is null for HEAD request.' );
     502            $headers = $response->get_headers();
     503            $this->assertSame( 2, $headers['X-WP-Total'], 'Failed asserting that the number of posts is correct.' );
     504        }
     505
    398506        $this->assertPostsOrderedBy( "FIELD({posts}.ID,$id1,$id2)" );
    399507
    400508        // Invalid 'include' should error.
    401         $request = new WP_REST_Request( 'GET', '/wp/v2/posts' );
     509        $request = new WP_REST_Request( $method, '/wp/v2/posts' );
    402510        $request->set_param( 'include', 'invalid' );
    403511        $response = rest_get_server()->dispatch( $request );
     
    17301838    }
    17311839
    1732     public function test_get_items_pagination_headers() {
     1840    /**
     1841     * @dataProvider data_readable_http_methods
     1842     * @ticket 56481
     1843     *
     1844     * @param string $method HTTP method to use.
     1845     */
     1846    public function test_get_items_pagination_headers( $method ) {
    17331847        $total_posts = self::$total_posts;
    17341848        $total_pages = (int) ceil( $total_posts / 10 );
    17351849
    17361850        // Start of the index.
    1737         $request  = new WP_REST_Request( 'GET', '/wp/v2/posts' );
     1851        $request  = new WP_REST_Request( $method, '/wp/v2/posts' );
    17381852        $response = rest_get_server()->dispatch( $request );
    17391853        $headers  = $response->get_headers();
     
    17531867        ++$total_posts;
    17541868        ++$total_pages;
    1755         $request = new WP_REST_Request( 'GET', '/wp/v2/posts' );
     1869        $request = new WP_REST_Request( $method, '/wp/v2/posts' );
    17561870        $request->set_param( 'page', 3 );
    17571871        $response = rest_get_server()->dispatch( $request );
     
    17751889
    17761890        // Last page.
    1777         $request = new WP_REST_Request( 'GET', '/wp/v2/posts' );
     1891        $request = new WP_REST_Request( $method, '/wp/v2/posts' );
    17781892        $request->set_param( 'page', $total_pages );
    17791893        $response = rest_get_server()->dispatch( $request );
     
    17911905
    17921906        // Out of bounds.
    1793         $request = new WP_REST_Request( 'GET', '/wp/v2/posts' );
     1907        $request = new WP_REST_Request( $method, '/wp/v2/posts' );
    17941908        $request->set_param( 'page', 100 );
    17951909        $response = rest_get_server()->dispatch( $request );
     
    17991913        // With query params.
    18001914        $total_pages = (int) ceil( $total_posts / 5 );
    1801         $request     = new WP_REST_Request( 'GET', '/wp/v2/posts' );
     1915        $request     = new WP_REST_Request( $method, '/wp/v2/posts' );
    18021916        $request->set_query_params(
    18031917            array(
     
    18261940        );
    18271941        $this->assertStringContainsString( '<' . $next_link . '>; rel="next"', $headers['Link'] );
     1942    }
     1943
     1944    /**
     1945     * Data provider intended to provide HTTP method names for testing GET and HEAD requests.
     1946     *
     1947     * @return array
     1948     */
     1949    public static function data_readable_http_methods() {
     1950        return array(
     1951            'GET request'  => array( 'GET' ),
     1952            'HEAD request' => array( 'HEAD' ),
     1953        );
     1954    }
     1955
     1956    /**
     1957     * @dataProvider data_readable_http_methods
     1958     * @ticket 56481
     1959     *
     1960     * @param string $method HTTP method to use.
     1961     */
     1962    public function test_get_items_only_fetches_ids_for_head_requests( $method ) {
     1963        $is_head_request = 'HEAD' === $method;
     1964        $request         = new WP_REST_Request( $method, '/wp/v2/posts' );
     1965
     1966        $filter = new MockAction();
     1967
     1968        add_filter( 'posts_pre_query', array( $filter, 'filter' ), 10, 2 );
     1969
     1970        $response = rest_get_server()->dispatch( $request );
     1971
     1972        $this->assertSame( 200, $response->get_status() );
     1973        if ( $is_head_request ) {
     1974            $this->assertEmpty( $response->get_data() );
     1975        } else {
     1976            $this->assertNotEmpty( $response->get_data() );
     1977        }
     1978
     1979        $args = $filter->get_args();
     1980        $this->assertTrue( isset( $args[0][1] ), 'Query parameters were not captured.' );
     1981        $this->assertInstanceOf( WP_Query::class, $args[0][1], 'Query parameters were not captured.' );
     1982
     1983        /** @var WP_Query $query */
     1984        $query = $args[0][1];
     1985
     1986        if ( $is_head_request ) {
     1987            $this->assertArrayHasKey( 'fields', $query->query, 'The fields parameter is not set in the query vars.' );
     1988            $this->assertSame( 'ids', $query->query['fields'], 'The query must fetch only post IDs.' );
     1989            $this->assertArrayHasKey( 'fields', $query->query_vars, 'The fields parameter is not set in the query vars.' );
     1990            $this->assertSame( 'ids', $query->query_vars['fields'], 'The query must fetch only post IDs.' );
     1991            $this->assertArrayHasKey( 'update_post_term_cache', $query->query_vars, 'The "update_post_term_cache" parameter is missing in the query vars.' );
     1992            $this->assertFalse( $query->query_vars['update_post_term_cache'], 'The "update_post_term_cache" parameter must be false for HEAD requests.' );
     1993            $this->assertArrayHasKey( 'update_post_meta_cache', $query->query_vars, 'The "update_post_meta_cache" parameter is missing in the query vars.' );
     1994            $this->assertFalse( $query->query_vars['update_post_meta_cache'], 'The "update_post_meta_cache" parameter must be false for HEAD requests.' );
     1995        } else {
     1996            $this->assertTrue( ! array_key_exists( 'fields', $query->query ) || 'ids' !== $query->query['fields'], 'The fields parameter should not be forced to "ids" for non-HEAD requests.' );
     1997            $this->assertTrue( ! array_key_exists( 'fields', $query->query_vars ) || 'ids' !== $query->query_vars['fields'], 'The fields parameter should not be forced to "ids" for non-HEAD requests.' );
     1998            $this->assertArrayHasKey( 'update_post_term_cache', $query->query_vars, 'The "update_post_term_cache" parameter is missing in the query vars.' );
     1999            $this->assertTrue( $query->query_vars['update_post_term_cache'], 'The "update_post_term_cache" parameter must be true for non-HEAD requests.' );
     2000            $this->assertArrayHasKey( 'update_post_meta_cache', $query->query_vars, 'The "update_post_meta_cache" parameter is missing in the query vars.' );
     2001            $this->assertTrue( $query->query_vars['update_post_meta_cache'], 'The "update_post_meta_cache" parameter must be true for non-HEAD requests.' );
     2002        }
     2003
     2004        if ( ! $is_head_request ) {
     2005            return;
     2006        }
     2007
     2008        global $wpdb;
     2009        $posts_table = preg_quote( $wpdb->posts, '/' );
     2010        $pattern     = '/^SELECT\s+SQL_CALC_FOUND_ROWS\s+' . $posts_table . '\.ID\s+FROM\s+' . $posts_table . '\s+WHERE/i';
     2011
     2012        // Assert that the SQL query only fetches the ID column.
     2013        $this->assertMatchesRegularExpression( $pattern, $query->request, 'The SQL query does not match the expected string.' );
    18282014    }
    18292015
     
    19722158
    19732159        $this->check_get_post_response( $response, 'view' );
     2160    }
     2161
     2162    /**
     2163     * @dataProvider data_readable_http_methods
     2164     * @ticket 56481
     2165     *
     2166     * @param string $method The HTTP method to use.
     2167     */
     2168    public function test_get_item_should_allow_adding_headers_via_filter( $method ) {
     2169        $request = new WP_REST_Request( $method, sprintf( '/wp/v2/posts/%d', self::$post_id ) );
     2170
     2171        $hook_name = 'rest_prepare_' . get_post_type( self::$post_id );
     2172        $filter    = new MockAction();
     2173        $callback  = array( $filter, 'filter' );
     2174        add_filter( $hook_name, $callback );
     2175        $header_filter = new class() {
     2176            public static function add_custom_header( $response ) {
     2177                $response->header( 'X-Test-Header', 'Test' );
     2178
     2179                return $response;
     2180            }
     2181        };
     2182        add_filter( $hook_name, array( $header_filter, 'add_custom_header' ) );
     2183        $response = rest_get_server()->dispatch( $request );
     2184        remove_filter( $hook_name, $callback );
     2185        remove_filter( $hook_name, array( $header_filter, 'add_custom_header' ) );
     2186
     2187        $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' );
     2188        $this->assertSame( 1, $filter->get_call_count(), 'The "' . $hook_name . '" filter was not called when it should be for GET/HEAD requests.' );
     2189        $headers = $response->get_headers();
     2190        $this->assertArrayHasKey( 'Link', $headers, 'The "Link" header should be present in the response.' );
     2191        $this->assertArrayHasKey( 'X-Test-Header', $headers, 'The "X-Test-Header" header should be present in the response.' );
     2192        $this->assertSame( 'Test', $headers['X-Test-Header'], 'The "X-Test-Header" header value should be equal to "Test".' );
     2193        if ( 'HEAD' !== $method ) {
     2194            return null;
     2195        }
     2196        $this->assertNull( $response->get_data(), 'The server should not generate a body in response to a HEAD request.' );
    19742197    }
    19752198
Note: See TracChangeset for help on using the changeset viewer.