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-categories-controller.php

    r56746 r59899  
    577577    }
    578578
    579     public function test_get_terms_invalid_parent_arg() {
    580         $request = new WP_REST_Request( 'GET', '/wp/v2/categories' );
     579    /**
     580     * @dataProvider data_readable_http_methods
     581     * @ticket 56481
     582     *
     583     * @param string $method HTTP method to use.
     584     */
     585    public function test_get_terms_invalid_parent_arg( $method ) {
     586        $request = new WP_REST_Request( $method, '/wp/v2/categories' );
    581587        $request->set_param( 'parent', 'invalid-parent' );
    582588        $response = rest_get_server()->dispatch( $request );
     
    610616    }
    611617
    612     public function test_get_terms_pagination_headers() {
     618    /**
     619     * @dataProvider data_readable_http_methods
     620     * @ticket 56481
     621     *
     622     * @param string $method HTTP method to use.
     623     */
     624    public function test_get_terms_pagination_headers( $method ) {
    613625        $total_categories = self::$total_categories;
    614626        $total_pages      = (int) ceil( $total_categories / 10 );
    615627
    616628        // Start of the index + Uncategorized default term.
    617         $request  = new WP_REST_Request( 'GET', '/wp/v2/categories' );
     629        $request  = new WP_REST_Request( $method, '/wp/v2/categories' );
    618630        $response = rest_get_server()->dispatch( $request );
    619631        $headers  = $response->get_headers();
    620632        $this->assertSame( $total_categories, $headers['X-WP-Total'] );
    621633        $this->assertSame( $total_pages, $headers['X-WP-TotalPages'] );
    622         $this->assertCount( 10, $response->get_data() );
     634        if ( 'HEAD' !== $method ) {
     635            $this->assertCount( 10, $response->get_data() );
     636        }
    623637        $next_link = add_query_arg(
    624638            array(
     
    663677        $this->assertSame( $total_categories, $headers['X-WP-Total'] );
    664678        $this->assertSame( $total_pages, $headers['X-WP-TotalPages'] );
    665         $this->assertCount( 1, $response->get_data() );
     679        if ( 'HEAD' !== $method ) {
     680            $this->assertCount( 1, $response->get_data() );
     681        }
    666682        $prev_link = add_query_arg(
    667683            array(
     
    680696        $this->assertSame( $total_categories, $headers['X-WP-Total'] );
    681697        $this->assertSame( $total_pages, $headers['X-WP-TotalPages'] );
    682         $this->assertCount( 0, $response->get_data() );
     698        if ( 'HEAD' !== $method ) {
     699            $this->assertCount( 0, $response->get_data() );
     700        }
    683701        $prev_link = add_query_arg(
    684702            array(
     
    12371255        $this->check_taxonomy_term( $category, $data, $response->get_links() );
    12381256    }
     1257
     1258    /**
     1259     * @dataProvider data_readable_http_methods
     1260     * @ticket 56481
     1261     *
     1262     * @param string $method HTTP method to use.
     1263     */
     1264    public function test_get_items_only_fetches_ids_for_head_requests( $method ) {
     1265        $is_head_request = 'HEAD' === $method;
     1266        $request         = new WP_REST_Request( $method, '/wp/v2/categories' );
     1267
     1268        $filter = new MockAction();
     1269
     1270        add_filter( 'terms_pre_query', array( $filter, 'filter' ), 10, 2 );
     1271
     1272        $response = rest_get_server()->dispatch( $request );
     1273
     1274        $this->assertSame( 200, $response->get_status() );
     1275        if ( $is_head_request ) {
     1276            $this->assertEmpty( $response->get_data() );
     1277        } else {
     1278            $this->assertNotEmpty( $response->get_data() );
     1279        }
     1280
     1281        $args = $filter->get_args();
     1282        $this->assertTrue( isset( $args[0][1] ), 'Query parameters were not captured.' );
     1283        $this->assertInstanceOf( WP_Term_Query::class, $args[0][1], 'Query parameters were not captured.' );
     1284
     1285        /** @var WP_Term_Query $query */
     1286        $query = $args[0][1];
     1287
     1288        if ( $is_head_request ) {
     1289            $this->assertArrayHasKey( 'fields', $query->query_vars, 'The fields parameter is not set in the query vars.' );
     1290            $this->assertSame( 'ids', $query->query_vars['fields'], 'The query must fetch only term IDs.' );
     1291            $this->assertArrayHasKey( 'update_term_meta_cache', $query->query_vars, 'The update_term_meta_cache key is missing in the query vars.' );
     1292            $this->assertFalse( $query->query_vars['update_term_meta_cache'], 'The update_term_meta_cache value should be false for HEAD requests.' );
     1293        } else {
     1294            $this->assertTrue(
     1295                ! array_key_exists( 'fields', $query->query_vars ) || 'ids' !== $query->query_vars['fields'],
     1296                'The fields parameter should not be forced to "ids" for non-HEAD requests.'
     1297            );
     1298            $this->assertArrayHasKey( 'update_term_meta_cache', $query->query_vars, 'The update_term_meta_cache key is missing in the query vars.' );
     1299            $this->assertTrue( $query->query_vars['update_term_meta_cache'], 'The update_term_meta_cache value should be true for HEAD requests.' );
     1300        }
     1301
     1302        if ( ! $is_head_request ) {
     1303            return;
     1304        }
     1305
     1306        global $wpdb;
     1307        $terms_table = preg_quote( $wpdb->terms, '/' );
     1308
     1309        $pattern = '/SELECT\s+t\.term_id.+FROM\s+' . $terms_table . '\s+AS\s+t\s+INNER\s+JOIN/is';
     1310
     1311        // Assert that the SQL query only fetches the term_id column.
     1312        $this->assertMatchesRegularExpression( $pattern, $query->request, 'The SQL query does not match the expected string.' );
     1313    }
     1314
     1315    /**
     1316     * Data provider intended to provide HTTP method names for testing GET and HEAD requests.
     1317     *
     1318     * @return array
     1319     */
     1320    public static function data_readable_http_methods() {
     1321        return array(
     1322            'GET request'  => array( 'GET' ),
     1323            'HEAD request' => array( 'HEAD' ),
     1324        );
     1325    }
     1326
     1327    /**
     1328     * @dataProvider data_readable_http_methods
     1329     * @ticket 56481
     1330     *
     1331     * @param string $method The HTTP method to use.
     1332     */
     1333    public function test_get_item_should_allow_adding_headers_via_filter( $method ) {
     1334        $category_id = self::factory()->category->create();
     1335
     1336        $request = new WP_REST_Request( $method, sprintf( '/wp/v2/categories/%d', $category_id ) );
     1337
     1338        $hook_name = 'rest_prepare_category';
     1339
     1340        $filter   = new MockAction();
     1341        $callback = array( $filter, 'filter' );
     1342        add_filter( $hook_name, $callback );
     1343        $header_filter = new class() {
     1344            public static function add_custom_header( $response ) {
     1345                $response->header( 'X-Test-Header', 'Test' );
     1346
     1347                return $response;
     1348            }
     1349        };
     1350        add_filter( $hook_name, array( $header_filter, 'add_custom_header' ) );
     1351        $response = rest_get_server()->dispatch( $request );
     1352        remove_filter( $hook_name, $callback );
     1353        remove_filter( $hook_name, array( $header_filter, 'add_custom_header' ) );
     1354
     1355        $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' );
     1356        $this->assertSame( 1, $filter->get_call_count(), 'The "' . $hook_name . '" filter was called when it should not be for HEAD requests.' );
     1357        $headers = $response->get_headers();
     1358        $this->assertArrayHasKey( 'X-Test-Header', $headers, 'The "X-Test-Header" header should be present in the response.' );
     1359        $this->assertSame( 'Test', $headers['X-Test-Header'], 'The "X-Test-Header" header value should be equal to "Test".' );
     1360        if ( 'HEAD' !== $method ) {
     1361            return null;
     1362        }
     1363        $this->assertNull( $response->get_data(), 'The server should not generate a body in response to a HEAD request.' );
     1364    }
    12391365}
Note: See TracChangeset for help on using the changeset viewer.