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'] );
+	}
 }