Commit 4d5fc86099 for woocommerce
commit 4d5fc860996b985c040c865cda217a00e95df644
Author: Néstor Soriano <konamiman@konamiman.com>
Date: Mon Nov 24 10:58:38 2025 +0100
Implement cache control headers for REST API responses (#61476)
diff --git a/plugins/woocommerce/changelog/pr-61476 b/plugins/woocommerce/changelog/pr-61476
new file mode 100644
index 0000000000..f7747767ff
--- /dev/null
+++ b/plugins/woocommerce/changelog/pr-61476
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Implement cache control headers for REST API responses
diff --git a/plugins/woocommerce/src/Internal/Traits/RestApiCache.php b/plugins/woocommerce/src/Internal/Traits/RestApiCache.php
index e77c0e4f6b..e3430379d9 100644
--- a/plugins/woocommerce/src/Internal/Traits/RestApiCache.php
+++ b/plugins/woocommerce/src/Internal/Traits/RestApiCache.php
@@ -33,6 +33,11 @@ use WP_REST_Response;
* - A X-WC-Cache HTTP header is added to responses to indicate cache status:
* HIT, MISS, or SKIP.
*
+ * Additionally to caching, this trait also handles the sending of appropriate
+ * Cache-Control and ETag headers to instruct clients and proxies on how to cache responses.
+ * The ETag is generated based on the cached response data and cache key, and a request
+ * containing an If-None-Match header with a matching ETag will receive a 304 Not Modified response.
+ *
* Usage: Wrap endpoint callbacks with the `with_cache()` method when registering routes.
*
* Example:
@@ -98,7 +103,7 @@ use WP_REST_Response;
* (checked via call to VersionStringGenerator::can_use()), so the cache is persistent
* across requests and not just for the current request.
*
- * @since 10.5.0
+ * @since 10.5.0
*/
trait RestApiCache {
/**
@@ -132,6 +137,13 @@ trait RestApiCache {
*/
private ?VersionStringGenerator $version_string_generator = null;
+ /**
+ * Whether we are currently handling a cached endpoint.
+ *
+ * @var bool
+ */
+ private $is_handling_cached_endpoint = false;
+
/**
* Initialize the trait.
* This MUST be called from the controller's constructor.
@@ -139,6 +151,8 @@ trait RestApiCache {
protected function initialize_rest_api_cache(): void {
$generator = wc_get_container()->get( VersionStringGenerator::class );
$this->version_string_generator = $generator->can_use() ? $generator : null;
+
+ add_filter( 'rest_send_nocache_headers', array( $this, 'handle_rest_send_nocache_headers' ), 10, 1 );
}
/**
@@ -192,7 +206,9 @@ trait RestApiCache {
return $response;
}
- $cached_response = $this->get_cached_response( $cached_config );
+ $this->is_handling_cached_endpoint = true;
+
+ $cached_response = $this->get_cached_response( $request, $cached_config );
if ( $cached_response ) {
$cached_response->header( 'X-WC-Cache', 'HIT' );
@@ -312,6 +328,9 @@ trait RestApiCache {
$cached_config['endpoint_id']
);
+ $etag_data = is_array( $data ) ? $this->get_data_for_etag( $data, $request, $cached_config['endpoint_id'] ) : $data;
+ $etag = '"' . md5( $cached_config['cache_key'] . wp_json_encode( $etag_data ) ) . '"';
+
$this->store_cached_response(
$cached_config['cache_key'],
$data,
@@ -320,10 +339,16 @@ trait RestApiCache {
$entity_ids,
$cached_config['cache_ttl'],
$cached_config['relevant_hooks'],
- $cacheable_headers
+ $cacheable_headers,
+ $etag
);
$cached = true;
+
+ $is_user_logged_in = wc_get_container()->get( LegacyProxy::class )->call_function( 'is_user_logged_in' );
+ $cache_visibility = $cached_config['vary_by_user'] && $is_user_logged_in ? 'private' : 'public';
+ $response->header( 'ETag', $etag );
+ $response->header( 'Cache-Control', $cache_visibility . ', must-revalidate, max-age=' . $cached_config['cache_ttl'] );
}
$response->header( 'X-WC-Cache', $cached ? 'MISS' : 'SKIP' );
@@ -342,6 +367,21 @@ trait RestApiCache {
return null;
}
+ /**
+ * Get data for ETag generation.
+ *
+ * Override in classes to exclude fields that change on each request
+ * (e.g., random recommendations, timestamps).
+ *
+ * @param array $data Response data.
+ * @param WP_REST_Request $request The request object.
+ * @param string|null $endpoint_id Optional friendly identifier for the endpoint.
+ * @return array Cleaned data for ETag generation.
+ */
+ protected function get_data_for_etag( array $data, WP_REST_Request $request, ?string $endpoint_id = null ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+ return $data;
+ }
+
/**
* Whether the response cache should vary by user.
*
@@ -681,10 +721,11 @@ trait RestApiCache {
/**
* Get a cached response, but only if it's valid (otherwise the cached response will be invalidated).
*
- * @param array $cached_config Built caching configuration from build_cache_config().
+ * @param WP_REST_Request $request The request object.
+ * @param array $cached_config Built caching configuration from build_cache_config().
* @return WP_REST_Response|null Cached response, or null if not available or has been invalidated.
*/
- private function get_cached_response( array $cached_config ): ?WP_REST_Response {
+ private function get_cached_response( WP_REST_Request $request, array $cached_config ): ?WP_REST_Response {
$cache_key = $cached_config['cache_key'];
$entity_type = $cached_config['entity_type'];
$cache_ttl = $cached_config['cache_ttl'];
@@ -724,8 +765,35 @@ trait RestApiCache {
}
// At this point the cached response is valid.
+
+ // Check if client sent an ETag and it matches - if so, return 304 Not Modified.
+ $cached_etag = $cached['etag'] ?? '';
+ $request_etag = $request->get_header( 'if_none_match' );
+
+ $is_user_logged_in = wc_get_container()->get( LegacyProxy::class )->call_function( 'is_user_logged_in' );
+ $cache_visibility = $cached_config['vary_by_user'] && $is_user_logged_in ? 'private' : 'public';
+ $response_headers = array();
+
+ if ( ! empty( $cached_etag ) ) {
+ $response_headers['ETag'] = $cached_etag;
+ }
+ $response_headers['Cache-Control'] = $cache_visibility . ', must-revalidate, max-age=' . $cache_ttl;
+
+ // If the server adds a 'Date' header by itself there will be two such headers in the response.
+ // To help disambiguate them, we add also an 'X-WC-Date' header with the proper value.
+ $created_at = gmdate( 'D, d M Y H:i:s', $cached['created_at'] ) . ' GMT';
+ $response_headers['Date'] = $created_at;
+ $response_headers['X-WC-Date'] = $created_at;
+
+ if ( ! empty( $cached_etag ) && $request_etag === $cached_etag ) {
+ return new WP_REST_Response( null, 304, $response_headers );
+ }
$response = new WP_REST_Response( $cached['data'], $cached['status_code'] ?? 200 );
+ foreach ( $response_headers as $name => $value ) {
+ $response->header( $name, $value );
+ }
+
if ( ! empty( $cached['headers'] ) ) {
foreach ( $cached['headers'] as $name => $value ) {
$response->header( $name, $value );
@@ -746,8 +814,9 @@ trait RestApiCache {
* @param int $cache_ttl Cache TTL in seconds.
* @param array $relevant_hooks Hook names to track for invalidation.
* @param array $headers Response headers to cache.
+ * @param string $etag ETag for the response.
*/
- private function store_cached_response( string $cache_key, $data, int $status_code, string $entity_type, array $entity_ids, int $cache_ttl, array $relevant_hooks, array $headers = array() ): void {
+ private function store_cached_response( string $cache_key, $data, int $status_code, string $entity_type, array $entity_ids, int $cache_ttl, array $relevant_hooks, array $headers = array(), string $etag = '' ): void {
$entity_versions = array();
foreach ( $entity_ids as $entity_id ) {
$version_id = "{$entity_type}_{$entity_id}";
@@ -775,6 +844,27 @@ trait RestApiCache {
$cache_data['headers'] = $headers;
}
+ if ( ! empty( $etag ) ) {
+ $cache_data['etag'] = $etag;
+ }
+
wp_cache_set( $cache_key, $cache_data, self::$cache_group, $cache_ttl );
}
+
+ /**
+ * Handle rest_send_nocache_headers filter to prevent WordPress from overriding our cache headers.
+ *
+ * @internal
+ *
+ * @param bool $send_no_cache_headers Whether to send no-cache headers.
+ * @return bool False if we're handling caching for this request, original value otherwise.
+ */
+ public function handle_rest_send_nocache_headers( bool $send_no_cache_headers ): bool {
+ if ( ! $this->is_handling_cached_endpoint ) {
+ return $send_no_cache_headers;
+ }
+
+ $this->is_handling_cached_endpoint = false;
+ return false;
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Traits/RestApiCacheTest.php b/plugins/woocommerce/tests/php/src/Internal/Traits/RestApiCacheTest.php
index a6348ee5ea..4f1fa406e9 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Traits/RestApiCacheTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Traits/RestApiCacheTest.php
@@ -562,7 +562,10 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
$headers = $response2->get_headers();
$this->assertArrayNotHasKey( 'Set-Cookie', $headers, 'Set-Cookie header should not be cached' );
- $this->assertArrayNotHasKey( 'Date', $headers, 'Date header should not be cached' );
+ $this->assertArrayHasKey( 'Date', $headers, 'Date header should be present with cache timestamp' );
+ $this->assertNotEquals( 'Mon, 01 Jan 2024 00:00:00 GMT', $headers['Date'], 'Date should be cache timestamp, not original' );
+ $this->assertArrayHasKey( 'X-WC-Date', $headers, 'X-WC-Date header should be present' );
+ $this->assertEquals( $headers['Date'], $headers['X-WC-Date'], 'X-WC-Date should match Date' );
$this->assertArrayHasKey( 'X-Custom-Valid', $headers );
$this->assertEquals( 'should-be-present', $headers['X-Custom-Valid'] );
@@ -750,7 +753,10 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
$headers = $response2->get_headers();
$this->assertArrayNotHasKey( 'Set-Cookie', $headers, 'Set-Cookie should not be in cached response' );
- $this->assertArrayNotHasKey( 'Cache-Control', $headers, 'Cache-Control should not be in cached response' );
+ // Cache-Control IS present because we add our own cache control headers, but it should NOT contain the original "no-cache" value.
+ $this->assertArrayHasKey( 'Cache-Control', $headers, 'Cache-Control should be present with our generated cache headers' );
+ $this->assertStringNotContainsString( 'no-cache', $headers['Cache-Control'], 'Original no-cache value should not be in Cache-Control' );
+ $this->assertStringContainsString( 'max-age', $headers['Cache-Control'], 'Our generated Cache-Control should contain max-age' );
$this->assertArrayHasKey( 'X-Custom-Header', $headers );
remove_filter( 'woocommerce_rest_api_cached_headers', $filter_callback, 10 );
@@ -1060,7 +1066,6 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
return $this->default_entity_type;
}
-
protected function response_cache_vary_by_user( WP_REST_Request $request, ?string $endpoint_id = null ): bool {
if ( ! is_null( $endpoint_id ) && isset( $this->endpoint_vary_by_user[ $endpoint_id ] ) ) {
return $this->endpoint_vary_by_user[ $endpoint_id ];
@@ -1119,4 +1124,180 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
// phpcs:enable Squiz.Commenting
};
}
+
+ /**
+ * @testdox Response includes ETag header on cache MISS.
+ */
+ public function test_etag_header_on_cache_miss() {
+ $response = $this->query_endpoint( 'single_entity' );
+
+ $this->assertCacheHeader( $response, 'MISS' );
+ $headers = $response->get_headers();
+ $this->assertArrayHasKey( 'ETag', $headers );
+ $this->assertMatchesRegularExpression( '/^"[a-f0-9]{32}"$/', $headers['ETag'] );
+ }
+
+ /**
+ * @testdox Response includes ETag header on cache HIT.
+ */
+ public function test_etag_header_on_cache_hit() {
+ $response1 = $this->query_endpoint( 'single_entity' );
+ $etag1 = $response1->get_headers()['ETag'];
+
+ $response2 = $this->query_endpoint( 'single_entity' );
+
+ $this->assertCacheHeader( $response2, 'HIT' );
+ $headers = $response2->get_headers();
+ $this->assertArrayHasKey( 'ETag', $headers );
+ $this->assertSame( $etag1, $headers['ETag'] );
+ }
+
+ /**
+ * @testdox 304 Not Modified is returned when If-None-Match header matches ETag.
+ */
+ public function test_304_response_when_etag_matches() {
+ $response1 = $this->query_endpoint( 'single_entity' );
+ $etag = $response1->get_headers()['ETag'];
+
+ $request = new WP_REST_Request( 'GET', '/wc/v3/rest_api_cache_test/single_entity' );
+ $request->set_header( 'If-None-Match', $etag );
+ $response2 = $this->server->dispatch( $request );
+
+ $this->assertSame( 304, $response2->get_status() );
+ $this->assertNull( $response2->get_data() );
+ $headers = $response2->get_headers();
+ $this->assertArrayHasKey( 'ETag', $headers );
+ $this->assertSame( $etag, $headers['ETag'] );
+ }
+
+ /**
+ * @testdox 200 response with full data when If-None-Match header does not match.
+ */
+ public function test_200_response_when_etag_does_not_match() {
+ $this->query_endpoint( 'single_entity' );
+
+ $request = new WP_REST_Request( 'GET', '/wc/v3/rest_api_cache_test/single_entity' );
+ $request->set_header( 'If-None-Match', '"wrong-etag"' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertNotNull( $response->get_data() );
+ $this->assertCacheHeader( $response, 'HIT' );
+ }
+
+ /**
+ * @testdox Cache-Control header is "private" when user is logged in and vary_by_user is true.
+ */
+ public function test_cache_control_private_when_user_logged_in() {
+ wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
+ array( 'is_user_logged_in' => fn() => true )
+ );
+
+ $response = $this->query_endpoint( 'single_entity' );
+
+ $headers = $response->get_headers();
+ $this->assertArrayHasKey( 'Cache-Control', $headers );
+ $this->assertStringContainsString( 'private', $headers['Cache-Control'] );
+ $this->assertStringContainsString( 'must-revalidate', $headers['Cache-Control'] );
+ $this->assertStringContainsString( 'max-age=', $headers['Cache-Control'] );
+ }
+
+ /**
+ * @testdox Cache-Control header is "public" when no user is logged in even with vary_by_user true.
+ */
+ public function test_cache_control_public_when_no_user() {
+ wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
+ array( 'is_user_logged_in' => fn() => false )
+ );
+
+ $response = $this->query_endpoint( 'single_entity' );
+
+ $headers = $response->get_headers();
+ $this->assertArrayHasKey( 'Cache-Control', $headers );
+ $this->assertStringContainsString( 'public', $headers['Cache-Control'] );
+ $this->assertStringContainsString( 'must-revalidate', $headers['Cache-Control'] );
+ $this->assertStringContainsString( 'max-age=', $headers['Cache-Control'] );
+ }
+
+ /**
+ * @testdox Cache-Control header is "public" when vary_by_user is false regardless of login status.
+ */
+ public function test_cache_control_public_when_vary_by_user_false() {
+ wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
+ array( 'is_user_logged_in' => fn() => true )
+ );
+
+ $response = $this->query_endpoint( 'no_vary_by_user' );
+
+ $headers = $response->get_headers();
+ $this->assertArrayHasKey( 'Cache-Control', $headers );
+ $this->assertStringContainsString( 'public', $headers['Cache-Control'] );
+ }
+
+ /**
+ * @testdox Date and X-WC-Date headers are present and correctly formatted on cache HIT.
+ */
+ public function test_date_header_present() {
+ $this->query_endpoint( 'single_entity' );
+ $response = $this->query_endpoint( 'single_entity' );
+
+ $this->assertCacheHeader( $response, 'HIT' );
+ $headers = $response->get_headers();
+ $this->assertArrayHasKey( 'Date', $headers );
+ $this->assertMatchesRegularExpression( '/^[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$/', $headers['Date'] );
+ $this->assertArrayHasKey( 'X-WC-Date', $headers );
+ $this->assertEquals( $headers['Date'], $headers['X-WC-Date'], 'X-WC-Date should match Date' );
+ }
+
+ /**
+ * @testdox ETags are different for different users when vary_by_user is true.
+ */
+ public function test_etags_differ_per_user() {
+ wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
+ array( 'get_current_user_id' => fn() => 1 )
+ );
+ $response1 = $this->query_endpoint( 'single_entity' );
+ $etag1 = $response1->get_headers()['ETag'];
+
+ wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
+ array( 'get_current_user_id' => fn() => 2 )
+ );
+ $response2 = $this->query_endpoint( 'single_entity' );
+ $etag2 = $response2->get_headers()['ETag'];
+
+ $this->assertNotSame( $etag1, $etag2 );
+ }
+
+ /**
+ * @testdox User cannot get 304 with another user's ETag.
+ */
+ public function test_304_not_returned_with_different_user_etag() {
+ wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
+ array( 'get_current_user_id' => fn() => 1 )
+ );
+ $response1 = $this->query_endpoint( 'single_entity' );
+ $etag1 = $response1->get_headers()['ETag'];
+
+ wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
+ array( 'get_current_user_id' => fn() => 2 )
+ );
+ $request = new WP_REST_Request( 'GET', '/wc/v3/rest_api_cache_test/single_entity' );
+ $request->set_header( 'If-None-Match', $etag1 );
+ $response2 = $this->server->dispatch( $request );
+
+ $this->assertSame( 200, $response2->get_status() );
+ $this->assertNotNull( $response2->get_data() );
+ }
+
+ /**
+ * @testdox WordPress no-cache headers are suppressed for cached endpoints.
+ */
+ public function test_nocache_headers_suppressed() {
+ $response = $this->query_endpoint( 'single_entity' );
+
+ $headers = $response->get_headers();
+ $this->assertArrayHasKey( 'Cache-Control', $headers );
+ $this->assertStringNotContainsString( 'no-cache', $headers['Cache-Control'] );
+ $this->assertStringNotContainsString( 'no-store', $headers['Cache-Control'] );
+ }
}