Commit 2ef538ce2c for woocommerce
commit 2ef538ce2cfc6a9b1cc9f62512ec2dc4f97fb195
Author: Néstor Soriano <konamiman@konamiman.com>
Date: Tue Nov 25 11:20:57 2025 +0100
Add REST API backend caching and cache control headers configuration (#61986)
Co-authored-by: botwoo <102544806+botwoo@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/pr-61986 b/plugins/woocommerce/changelog/pr-61986
new file mode 100644
index 0000000000..6bb32fd470
--- /dev/null
+++ b/plugins/woocommerce/changelog/pr-61986
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Add REST API backend caching and cache control headers configuration
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-settings.php b/plugins/woocommerce/includes/admin/class-wc-admin-settings.php
index f83af43c96..268a7be6b0 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-settings.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-settings.php
@@ -294,6 +294,10 @@ if ( ! class_exists( 'WC_Admin_Settings', false ) ) :
$value['value'] = self::get_option( $value['id'], $value['default'] );
}
+ if ( ! is_null( $value['fixed_value'] ?? null ) ) {
+ $value['value'] = $value['fixed_value'];
+ }
+
// Custom attribute handling.
$custom_attributes = array();
@@ -336,6 +340,20 @@ if ( ! class_exists( 'WC_Admin_Settings', false ) ) :
echo '</td></tr>';
break;
+ // Notice.
+ case 'notice':
+ $notice_type = $value['notice_type'] ?? 'info';
+ $notice_text = $value['text'] ?? '';
+
+ ?>
+ </table>
+ <div class="notice notice-<?php echo esc_attr( $notice_type ); ?> inline">
+ <p><?php echo wp_kses_post( $notice_text ); ?></p>
+ </div>
+ <table class="form-table" role="presentation">
+ <?php
+ break;
+
// Section Ends.
case 'sectionend':
if ( ! empty( $value['id'] ) ) {
diff --git a/plugins/woocommerce/includes/admin/settings/class-wc-settings-advanced.php b/plugins/woocommerce/includes/admin/settings/class-wc-settings-advanced.php
index 4ff8a01e9c..6c0c347a0c 100644
--- a/plugins/woocommerce/includes/admin/settings/class-wc-settings-advanced.php
+++ b/plugins/woocommerce/includes/admin/settings/class-wc-settings-advanced.php
@@ -5,6 +5,7 @@
* @package WooCommerce\Admin
*/
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
defined( 'ABSPATH' ) || exit;
@@ -46,13 +47,19 @@ class WC_Settings_Advanced extends WC_Settings_Page {
*/
protected function get_own_sections() {
$sections = array(
- '' => __( 'Page setup', 'woocommerce' ),
- 'keys' => __( 'REST API', 'woocommerce' ),
- 'webhooks' => __( 'Webhooks', 'woocommerce' ),
- 'legacy_api' => __( 'Legacy API', 'woocommerce' ),
- 'woocommerce_com' => __( 'WooCommerce.com', 'woocommerce' ),
+ '' => __( 'Page setup', 'woocommerce' ),
+ 'keys' => __( 'REST API keys', 'woocommerce' ),
);
+ $features_controller = wc_get_container()->get( FeaturesController::class );
+ if ( $features_controller->feature_is_enabled( 'rest_api_caching' ) ) {
+ $sections['rest_api_caching'] = __( 'REST API caching', 'woocommerce' );
+ }
+
+ $sections['webhooks'] = __( 'Webhooks', 'woocommerce' );
+ $sections['legacy_api'] = __( 'Legacy API', 'woocommerce' );
+ $sections['woocommerce_com'] = __( 'WooCommerce.com', 'woocommerce' );
+
if ( FeaturesUtil::feature_is_enabled( 'blueprint' ) ) {
$sections['blueprint'] = __( 'Blueprint (beta)', 'woocommerce' );
}
@@ -439,6 +446,73 @@ class WC_Settings_Advanced extends WC_Settings_Page {
return apply_filters( 'woocommerce_settings_rest_api', $settings );
}
+ /**
+ * Get settings for the REST API caching section.
+ *
+ * @return array
+ */
+ protected function get_settings_for_rest_api_caching_section() {
+ $has_object_cache = wp_using_ext_object_cache();
+
+ $settings = array(
+ array(
+ 'title' => __( 'REST API response cache', 'woocommerce' ),
+ 'type' => 'title',
+ 'desc' => __( 'These settings control backend caching and cache control headers for REST API responses.', 'woocommerce' ),
+ 'id' => 'rest_api_cache_options',
+ ),
+ );
+
+ if ( ! $has_object_cache ) {
+ $settings[] = array(
+ 'type' => 'notice',
+ 'id' => 'rest_api_cache_warning',
+ 'notice_type' => 'warning',
+ 'text' => sprintf(
+ /* translators: %1$s and %2$s are opening and closing <a> tags */
+ __( 'Backend caching requires a WordPress object cache plugin (Redis, Memcached, etc.) to be installed and active. %1$sLearn more about object caching%2$s.', 'woocommerce' ),
+ '<a href="https://developer.wordpress.org/reference/classes/wp_object_cache/" target="_blank">',
+ '</a>'
+ ),
+ );
+ }
+
+ $backend_caching_setting = array(
+ 'title' => __( 'Enable backend caching', 'woocommerce' ),
+ 'desc' => __( 'Cache REST API responses on the server', 'woocommerce' ),
+ 'id' => 'woocommerce_rest_api_enable_backend_caching',
+ 'type' => 'checkbox',
+ 'default' => 'no',
+ 'disabled' => ! $has_object_cache,
+ 'fixed_value' => $has_object_cache ? null : 'no',
+ 'desc_tip' => __( 'Enables responses for REST API endpoints configured as cacheable. Requires an external object cache.<br/>This setting should be enabled only if no other plugins that handle caching are active.', 'woocommerce' ),
+ );
+
+ $settings[] = $backend_caching_setting;
+
+ $settings[] = array(
+ 'title' => __( 'Enable cache control headers', 'woocommerce' ),
+ 'desc' => __( 'Send cache control headers and support 304 Not Modified responses', 'woocommerce' ),
+ 'id' => 'woocommerce_rest_api_enable_cache_headers',
+ 'type' => 'checkbox',
+ 'default' => 'yes',
+ 'desc_tip' => __( 'Enables including ETag and Cache-Control headers, and returning 304 Not Modified responses, for REST API endpoints configured as cacheable.', 'woocommerce' ),
+ );
+
+ $settings[] = array(
+ 'type' => 'sectionend',
+ 'id' => 'rest_api_cache_options',
+ );
+
+ /**
+ * Filter REST API cache settings.
+ *
+ * @since 10.5.0
+ * @param array $settings REST API cache settings.
+ */
+ return apply_filters( 'woocommerce_rest_api_cache_settings', $settings );
+ }
+
/**
* Get settings for the Blueprint section.
*
diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
index 4288a3dcaf..44d7e649c6 100644
--- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php
+++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
@@ -558,6 +558,20 @@ class FeaturesController {
'skip_compatibility_checks' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
),
+ 'rest_api_caching' => array(
+ 'name' => __( 'REST API Caching', 'woocommerce' ),
+ 'description' => sprintf(
+ /* translators: %1$s and %2$s are opening and closing <a> tags */
+ __( 'Enable backend caching and cache control headers for REST API responses via the <code>RestApiCache</code> trait. ⚙️ %1$sConfiguration%2$s', 'woocommerce' ),
+ '<a href="' . admin_url( 'admin.php?page=wc-settings&tab=advanced§ion=rest_api_caching' ) . '">',
+ '</a>'
+ ),
+ 'enabled_by_default' => false,
+ 'is_experimental' => true,
+ 'disable_ui' => false,
+ 'skip_compatibility_checks' => true,
+ 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
+ ),
);
if ( ! $tracking_enabled ) {
diff --git a/plugins/woocommerce/src/Internal/Traits/RestApiCache.php b/plugins/woocommerce/src/Internal/Traits/RestApiCache.php
index e3430379d9..0f60800b2f 100644
--- a/plugins/woocommerce/src/Internal/Traits/RestApiCache.php
+++ b/plugins/woocommerce/src/Internal/Traits/RestApiCache.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Automattic\WooCommerce\Internal\Traits;
use Automattic\WooCommerce\Internal\Caches\VersionStringGenerator;
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\CallbackUtil;
use WP_REST_Request;
@@ -144,13 +145,29 @@ trait RestApiCache {
*/
private $is_handling_cached_endpoint = false;
+ /**
+ * Whether the REST API caching feature is enabled.
+ *
+ * @var bool
+ */
+ private bool $rest_api_caching_feature_enabled = false;
+
/**
* Initialize the trait.
* This MUST be called from the controller's constructor.
*/
protected function initialize_rest_api_cache(): void {
- $generator = wc_get_container()->get( VersionStringGenerator::class );
- $this->version_string_generator = $generator->can_use() ? $generator : null;
+ $features_controller = wc_get_container()->get( FeaturesController::class );
+
+ $this->rest_api_caching_feature_enabled = $features_controller->feature_is_enabled( 'rest_api_caching' );
+ if ( ! $this->rest_api_caching_feature_enabled ) {
+ return;
+ }
+
+ $generator = wc_get_container()->get( VersionStringGenerator::class );
+
+ $backend_caching_enabled = 'yes' === get_option( 'woocommerce_rest_api_enable_backend_caching', 'no' );
+ $this->version_string_generator = ( $backend_caching_enabled && $generator->can_use() ) ? $generator : null;
add_filter( 'rest_send_nocache_headers', array( $this, 'handle_rest_send_nocache_headers' ), 10, 1 );
}
@@ -172,14 +189,19 @@ trait RestApiCache {
* @return callable Wrapped callback.
*/
protected function with_cache( callable $callback, array $config = array() ): callable {
- return fn( $request ) => $this->handle_cacheable_request( $request, $callback, $config );
+ return $this->rest_api_caching_feature_enabled
+ ? fn( $request ) => $this->handle_cacheable_request( $request, $callback, $config )
+ : fn( $request ) => call_user_func( $callback, $request );
}
/**
* Handle a request with caching logic.
*
- * Strategy: Try to use cached response if available and valid, otherwise execute the endpoint
- * callback and cache the response (if successful) for future requests.
+ * Strategy:
+ * - If backend caching is enabled: Try to use cached response if available, otherwise execute
+ * the callback and cache the response.
+ * - If only cache headers are enabled: Execute the callback, generate ETag, and return 304
+ * if the client's ETag matches.
*
* @param WP_REST_Request $request The request object.
* @param callable $callback The original endpoint callback.
@@ -187,7 +209,10 @@ trait RestApiCache {
* @return WP_REST_Response|\WP_Error The response.
*/
private function handle_cacheable_request( WP_REST_Request $request, callable $callback, array $config ) {
- if ( is_null( $this->version_string_generator ) ) {
+ $backend_caching_enabled = ! is_null( $this->version_string_generator );
+ $cache_headers_enabled = 'yes' === get_option( 'woocommerce_rest_api_enable_cache_headers', 'yes' );
+
+ if ( ! $backend_caching_enabled && ! $cache_headers_enabled ) {
return call_user_func( $callback, $request );
}
@@ -208,16 +233,20 @@ trait RestApiCache {
$this->is_handling_cached_endpoint = true;
- $cached_response = $this->get_cached_response( $request, $cached_config );
+ if ( $backend_caching_enabled ) {
+ $cached_response = $this->get_cached_response( $request, $cached_config, $cache_headers_enabled );
- if ( $cached_response ) {
- $cached_response->header( 'X-WC-Cache', 'HIT' );
- return $cached_response;
+ if ( $cached_response ) {
+ $cached_response->header( 'X-WC-Cache', 'HIT' );
+ return $cached_response;
+ }
}
$authoritative_response = call_user_func( $callback, $request );
- return $this->maybe_cache_response( $request, $authoritative_response, $cached_config );
+ return $backend_caching_enabled
+ ? $this->maybe_cache_response( $request, $authoritative_response, $cached_config, $cache_headers_enabled )
+ : $this->maybe_add_cache_headers( $request, $authoritative_response, $cached_config );
}
/**
@@ -291,7 +320,7 @@ trait RestApiCache {
}
/**
- * Cache the response if it's successful and return it with appropriate headers.
+ * Cache the response if it's successful and optionally add cache headers.
*
* Only caches responses with 2xx status codes. Always adds the X-WC-Cache header
* with value MISS if the response was cached, or SKIP if it was not cached.
@@ -299,12 +328,13 @@ trait RestApiCache {
* Supports both WP_REST_Response objects and raw data (which will be wrapped in a response object).
* Error objects are returned as-is without caching.
*
- * @param WP_REST_Request $request The request object.
- * @param WP_REST_Response|\WP_Error|array|object $response The response to potentially cache.
- * @param array $cached_config Caching configuration from build_cache_config().
+ * @param WP_REST_Request $request The request object.
+ * @param WP_REST_Response|\WP_Error|array|object $response The response to potentially cache.
+ * @param array $cached_config Caching configuration from build_cache_config().
+ * @param bool $add_cache_headers Whether to add cache control headers.
* @return WP_REST_Response|\WP_Error The response with appropriate cache headers.
*/
- private function maybe_cache_response( WP_REST_Request $request, $response, array $cached_config ) {
+ private function maybe_cache_response( WP_REST_Request $request, $response, array $cached_config, bool $add_cache_headers ) {
if ( is_wp_error( $response ) ) {
return $response;
}
@@ -344,17 +374,95 @@ trait RestApiCache {
);
$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' );
+
+ return $add_cache_headers ?
+ $this->maybe_add_cache_headers( $request, $response, $cached_config ) :
+ $response;
+ }
+
+ /**
+ * Add cache control headers to a response.
+ *
+ * This method generates an ETag from the response data and returns a 304 Not Modified
+ * if the client's If-None-Match header matches. It can be used both with and without
+ * backend caching.
+ *
+ * @param WP_REST_Request $request The request object.
+ * @param WP_REST_Response|\WP_Error|array|object $response The response to add headers to.
+ * @param array $cached_config Caching configuration from build_cache_config().
+ * @return WP_REST_Response|\WP_Error The response with cache headers.
+ */
+ private function maybe_add_cache_headers( WP_REST_Request $request, $response, array $cached_config ) {
+ if ( is_wp_error( $response ) ) {
+ return $response;
+ }
+
+ $response = rest_ensure_response( $response );
+
+ $status = $response->get_status();
+ if ( $status < 200 || $status > 299 ) {
+ return $response;
+ }
+
+ $response_data = $response->get_data();
+ $response_etag_data = is_array( $response_data ) ? $this->get_data_for_etag( $response_data, $request, $cached_config['endpoint_id'] ) : $response_data;
+ $response_etag = '"' . md5( $cached_config['cache_key'] . wp_json_encode( $response_etag_data ) ) . '"';
+
+ $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';
+ $cache_control_value = $cache_visibility . ', must-revalidate, max-age=' . $cached_config['cache_ttl'];
+
+ if ( ! empty( $response_etag ) && $request_etag === $response_etag ) {
+ $not_modified_response = $this->create_not_modified_response( $response_etag, $cache_control_value, $request, $cached_config['endpoint_id'] );
+ if ( $not_modified_response ) {
+ return $not_modified_response;
+ }
+ }
+
+ $response->header( 'ETag', $response_etag );
+ $response->header( 'Cache-Control', $cache_control_value );
+
+ if ( ! array_key_exists( 'X-WC-Cache', $response->get_headers() ) ) {
+ $response->header( 'X-WC-Cache', 'HEADERS' );
+ }
+
return $response;
}
+ /**
+ * Create a 304 Not Modified response if allowed by filters.
+ *
+ * @param string $etag The ETag value.
+ * @param string $cache_control_value The Cache-Control header value.
+ * @param WP_REST_Request $request The request object.
+ * @param string|null $endpoint_id The endpoint identifier.
+ * @return WP_REST_Response|null 304 response if allowed, null otherwise.
+ */
+ private function create_not_modified_response( string $etag, string $cache_control_value, WP_REST_Request $request, ?string $endpoint_id ): ?WP_REST_Response {
+ $response = new WP_REST_Response( null, 304 );
+ $response->header( 'ETag', $etag );
+ $response->header( 'Cache-Control', $cache_control_value );
+ $response->header( 'X-WC-Cache', 'MATCH' );
+
+ /**
+ * Filter the 304 Not Modified response before sending.
+ *
+ * @since 10.5.0
+ *
+ * @param WP_REST_Response|false $response The 304 response object, or false to prevent sending it.
+ * @param WP_REST_Request $request The request object.
+ * @param string|null $endpoint_id The endpoint identifier.
+ */
+ $filtered_response = apply_filters( 'woocommerce_rest_api_not_modified_response', $response, $request, $endpoint_id );
+
+ return false === $filtered_response ? null : rest_ensure_response( $filtered_response );
+ }
+
/**
* Get the default type for entities included in responses.
*
@@ -721,11 +829,12 @@ trait RestApiCache {
/**
* Get a cached response, but only if it's valid (otherwise the cached response will be invalidated).
*
- * @param WP_REST_Request $request The request object.
- * @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().
+ * @param bool $cache_headers_enabled Whether to add cache control headers.
* @return WP_REST_Response|null Cached response, or null if not available or has been invalidated.
*/
- private function get_cached_response( WP_REST_Request $request, array $cached_config ): ?WP_REST_Response {
+ private function get_cached_response( WP_REST_Request $request, array $cached_config, bool $cache_headers_enabled ): ?WP_REST_Response {
$cache_key = $cached_config['cache_key'];
$entity_type = $cached_config['entity_type'];
$cache_ttl = $cached_config['cache_ttl'];
@@ -768,26 +877,36 @@ trait RestApiCache {
// 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' );
+ $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();
+ $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 ( $cache_headers_enabled ) {
+ $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';
- if ( ! empty( $cached_etag ) && $request_etag === $cached_etag ) {
- return new WP_REST_Response( null, 304, $response_headers );
+ 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 ) {
+ $cache_control = $response_headers['Cache-Control'] ?? '';
+ $not_modified_response = $this->create_not_modified_response( $cached_etag, $cache_control, $request, $cached_config['endpoint_id'] );
+ if ( $not_modified_response ) {
+ $not_modified_response->header( 'Date', $response_headers['Date'] );
+ $not_modified_response->header( 'X-WC-Date', $response_headers['X-WC-Date'] );
+ return $not_modified_response;
+ }
+ }
}
+
$response = new WP_REST_Response( $cached['data'], $cached['status_code'] ?? 200 );
foreach ( $response_headers as $name => $value ) {
diff --git a/plugins/woocommerce/tests/php/src/Internal/Traits/RestApiCacheTest.php b/plugins/woocommerce/tests/php/src/Internal/Traits/RestApiCacheTest.php
index 4f1fa406e9..9bc2fb799b 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Traits/RestApiCacheTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Traits/RestApiCacheTest.php
@@ -47,14 +47,21 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
public function setUp(): void {
parent::setUp();
+ remove_all_filters( 'woocommerce_rest_api_not_modified_response' );
+
wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
array(
'time' => fn() => $this->fixed_time,
'get_current_user_id' => fn() => 1,
+ 'is_user_logged_in' => fn() => true,
'wp_using_ext_object_cache' => fn() => true,
)
);
+ update_option( 'woocommerce_feature_rest_api_caching_enabled', 'yes' );
+ update_option( 'woocommerce_rest_api_enable_backend_caching', 'yes' );
+ update_option( 'woocommerce_rest_api_enable_cache_headers', 'yes' );
+
// Needed to ensure VersionStringGenerator uses the mocked wp_using_ext_object_cache.
$this->reset_container_resolutions();
@@ -71,6 +78,8 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
global $wp_rest_server;
$wp_rest_server = null;
+ remove_all_filters( 'woocommerce_rest_api_not_modified_response' );
+
parent::tearDown();
}
@@ -245,7 +254,7 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
}
/**
- * @testdox Caching is skipped when entity versions cache is disabled.
+ * @testdox Backend caching is skipped when object cache is disabled, but cache headers still work.
*/
public function test_caching_skipped_when_entity_cache_disabled() {
wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
@@ -255,8 +264,14 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
$this->sut->reinitialize_cache();
$response = $this->query_endpoint( 'single_entity' );
- $this->assertCacheHeader( $response, null );
+
+ // Backend caching should be skipped but cache headers should still work.
$this->assertCount( 0, $this->get_all_cache_keys() );
+
+ $this->assertCacheHeader( $response, 'HEADERS' );
+ $headers = $response->get_headers();
+ $this->assertArrayHasKey( 'ETag', $headers );
+ $this->assertArrayHasKey( 'Cache-Control', $headers );
}
/**
@@ -1135,6 +1150,15 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
$headers = $response->get_headers();
$this->assertArrayHasKey( 'ETag', $headers );
$this->assertMatchesRegularExpression( '/^"[a-f0-9]{32}"$/', $headers['ETag'] );
+
+ $cache_keys = $this->get_all_cache_keys();
+ $this->assertCount( 1, $cache_keys );
+ $cache_key = $cache_keys[0];
+ $cached_entry = wp_cache_get( $cache_key, 'woocommerce_rest_api_cache' );
+
+ $this->assertIsArray( $cached_entry );
+ $this->assertArrayHasKey( 'etag', $cached_entry, 'ETag should be stored in cache entry' );
+ $this->assertSame( $headers['ETag'], $cached_entry['etag'], 'Cached ETag should match response ETag' );
}
/**
@@ -1300,4 +1324,285 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
$this->assertStringNotContainsString( 'no-cache', $headers['Cache-Control'] );
$this->assertStringNotContainsString( 'no-store', $headers['Cache-Control'] );
}
+
+ /**
+ * @testdox Cache headers work without backend caching when cache headers setting is enabled.
+ */
+ public function test_cache_headers_without_backend_caching() {
+ update_option( 'woocommerce_rest_api_enable_backend_caching', 'no' );
+ update_option( 'woocommerce_rest_api_enable_cache_headers', 'yes' );
+ $this->sut->reinitialize_cache();
+
+ $response1 = $this->query_endpoint( 'single_entity' );
+
+ // Should not cache in backend, but should add cache headers.
+ $this->assertCount( 0, $this->get_all_cache_keys() );
+
+ $headers = $response1->get_headers();
+ $this->assertArrayHasKey( 'ETag', $headers );
+ $this->assertArrayHasKey( 'Cache-Control', $headers );
+ $this->assertCacheHeader( $response1, 'HEADERS' );
+
+ // Should return 304 on subsequent request with matching ETag.
+ $etag = $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->assertCacheHeader( $response2, 'MATCH' );
+ }
+
+ /**
+ * @testdox Backend caching works without cache headers when backend caching is enabled.
+ */
+ public function test_backend_caching_without_cache_headers() {
+ update_option( 'woocommerce_rest_api_enable_backend_caching', 'yes' );
+ update_option( 'woocommerce_rest_api_enable_cache_headers', 'no' );
+ $this->sut->reinitialize_cache();
+
+ $response1 = $this->query_endpoint( 'single_entity' );
+
+ // Should cache in backend, but should not add ETag or Cache-Control headers.
+ $this->assertCount( 1, $this->get_all_cache_keys() );
+ $this->assertCacheHeader( $response1, 'MISS' );
+
+ $headers = $response1->get_headers();
+ $this->assertArrayNotHasKey( 'ETag', $headers );
+ $this->assertArrayNotHasKey( 'Cache-Control', $headers );
+
+ // Second request should return from cache.
+ $response2 = $this->query_endpoint( 'single_entity' );
+ $this->assertCacheHeader( $response2, 'HIT' );
+
+ // Still no ETag or Cache-Control headers.
+ $headers2 = $response2->get_headers();
+ $this->assertArrayNotHasKey( 'ETag', $headers2 );
+ $this->assertArrayNotHasKey( 'Cache-Control', $headers2 );
+ }
+
+ /**
+ * @testdox Both features work together when both settings are enabled.
+ */
+ public function test_both_features_enabled() {
+ update_option( 'woocommerce_rest_api_enable_backend_caching', 'yes' );
+ update_option( 'woocommerce_rest_api_enable_cache_headers', 'yes' );
+ $this->sut->reinitialize_cache();
+
+ $response1 = $this->query_endpoint( 'single_entity' );
+
+ // Should cache in backend and add cache headers.
+ $this->assertCount( 1, $this->get_all_cache_keys() );
+ $this->assertCacheHeader( $response1, 'MISS' );
+
+ $headers = $response1->get_headers();
+ $this->assertArrayHasKey( 'ETag', $headers );
+ $this->assertArrayHasKey( 'Cache-Control', $headers );
+
+ // Second request should return from cache with headers.
+ $response2 = $this->query_endpoint( 'single_entity' );
+ $this->assertCacheHeader( $response2, 'HIT' );
+
+ $headers2 = $response2->get_headers();
+ $this->assertArrayHasKey( 'ETag', $headers2 );
+ $this->assertArrayHasKey( 'Cache-Control', $headers2 );
+ }
+
+ /**
+ * @testdox Neither feature works when both settings are disabled.
+ */
+ public function test_both_features_disabled() {
+ update_option( 'woocommerce_rest_api_enable_backend_caching', 'no' );
+ update_option( 'woocommerce_rest_api_enable_cache_headers', 'no' );
+ $this->sut->reinitialize_cache();
+
+ $response = $this->query_endpoint( 'single_entity' );
+
+ // Should not cache in backend, nor add cache headers.
+ $this->assertCount( 0, $this->get_all_cache_keys() );
+
+ $headers = $response->get_headers();
+ $this->assertArrayNotHasKey( 'ETag', $headers );
+ $this->assertArrayNotHasKey( 'Cache-Control', $headers );
+
+ // Should not have X-WC-Cache header.
+ $this->assertArrayNotHasKey( 'X-WC-Cache', $headers );
+ }
+
+ /**
+ * @testdox Filter woocommerce_rest_api_not_modified_response can prevent 304 response.
+ */
+ public function test_filter_can_prevent_304_response() {
+ update_option( 'woocommerce_rest_api_enable_backend_caching', 'no' );
+ update_option( 'woocommerce_rest_api_enable_cache_headers', 'yes' );
+ $this->sut->reinitialize_cache();
+
+ $response1 = $this->query_endpoint( 'single_entity' );
+ $etag = $response1->get_headers()['ETag'];
+
+ $filter_called = false;
+ $received_response = null;
+ $filter = function ( $response, $request, $endpoint_id ) use ( &$filter_called, &$received_response ) {
+ unset( $request, $endpoint_id ); // Avoid parameter not used PHPCS errors.
+ $filter_called = true;
+ $received_response = $response;
+ return false;
+ };
+ add_filter( 'woocommerce_rest_api_not_modified_response', $filter, 10, 3 );
+
+ // Request with matching 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 );
+
+ // Filter should have been called with a 304 response.
+ $this->assertTrue( $filter_called );
+ $this->assertInstanceOf( WP_REST_Response::class, $received_response );
+ $this->assertSame( 304, $received_response->get_status() );
+
+ // Should return 200 with full data instead of 304.
+ $this->assertSame( 200, $response2->get_status() );
+ $this->assertNotNull( $response2->get_data() );
+ $this->assertCacheHeader( $response2, 'HEADERS' );
+
+ remove_filter( 'woocommerce_rest_api_not_modified_response', $filter, 10 );
+ }
+
+ /**
+ * @testdox Filter woocommerce_rest_api_not_modified_response can modify 304 response.
+ */
+ public function test_filter_can_modify_304_response() {
+ update_option( 'woocommerce_rest_api_enable_backend_caching', 'no' );
+ update_option( 'woocommerce_rest_api_enable_cache_headers', 'yes' );
+ $this->sut->reinitialize_cache();
+
+ $response1 = $this->query_endpoint( 'single_entity' );
+ $etag = $response1->get_headers()['ETag'];
+
+ $filter = function ( $response, $request, $endpoint_id ) {
+ unset( $request, $endpoint_id ); // Avoid parameter not used PHPCS errors.
+ $response->header( 'X-Custom-Header', 'custom-value' );
+ return $response;
+ };
+ add_filter( 'woocommerce_rest_api_not_modified_response', $filter, 10, 3 );
+
+ // Request with matching 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() );
+
+ // Should have custom header.
+ $headers = $response2->get_headers();
+ $this->assertArrayHasKey( 'X-Custom-Header', $headers );
+ $this->assertSame( 'custom-value', $headers['X-Custom-Header'] );
+
+ remove_filter( 'woocommerce_rest_api_not_modified_response', $filter, 10 );
+ }
+
+ /**
+ * @testdox Filter woocommerce_rest_api_not_modified_response is called for cached 304 responses.
+ */
+ public function test_filter_called_for_cached_304_responses() {
+ update_option( 'woocommerce_rest_api_enable_backend_caching', 'yes' );
+ update_option( 'woocommerce_rest_api_enable_cache_headers', 'yes' );
+ $this->sut->reinitialize_cache();
+
+ // First request to cache.
+ $response1 = $this->query_endpoint( 'single_entity' );
+ $etag = $response1->get_headers()['ETag'];
+
+ // Second request to populate cache.
+ $this->query_endpoint( 'single_entity' );
+
+ $filter_called = false;
+ $filter = function ( $response, $request, $endpoint_id ) use ( &$filter_called ) {
+ unset( $response, $request, $endpoint_id ); // Avoid parameter not used PHPCS errors.
+ $filter_called = true;
+ return false;
+ };
+ add_filter( 'woocommerce_rest_api_not_modified_response', $filter, 10, 3 );
+
+ // Third request with matching ETag (should be served from cache).
+ $request = new WP_REST_Request( 'GET', '/wc/v3/rest_api_cache_test/single_entity' );
+ $request->set_header( 'If-None-Match', $etag );
+ $response3 = $this->server->dispatch( $request );
+
+ $this->assertTrue( $filter_called );
+
+ // Should return 200 with full cached data instead of 304.
+ $this->assertSame( 200, $response3->get_status() );
+ $this->assertNotNull( $response3->get_data() );
+ $this->assertCacheHeader( $response3, 'HIT' );
+
+ remove_filter( 'woocommerce_rest_api_not_modified_response', $filter, 10 );
+ }
+
+ /**
+ * @testdox X-WC-Cache header shows HEADERS when only cache headers are enabled.
+ */
+ public function test_x_wc_cache_headers_value() {
+ update_option( 'woocommerce_rest_api_enable_backend_caching', 'no' );
+ update_option( 'woocommerce_rest_api_enable_cache_headers', 'yes' );
+ $this->sut->reinitialize_cache();
+
+ $response = $this->query_endpoint( 'single_entity' );
+
+ $this->assertCacheHeader( $response, 'HEADERS' );
+ }
+
+ /**
+ * @testdox X-WC-Cache header shows MATCH on 304 response.
+ */
+ public function test_x_wc_cache_match_value() {
+ update_option( 'woocommerce_rest_api_enable_backend_caching', 'no' );
+ update_option( 'woocommerce_rest_api_enable_cache_headers', 'yes' );
+ $this->sut->reinitialize_cache();
+
+ $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->assertCacheHeader( $response2, 'MATCH' );
+ }
+
+ /**
+ * @testdox Caching is completely bypassed when rest_api_caching feature is disabled (even with caching options enabled).
+ */
+ public function test_caching_bypassed_when_feature_disabled() {
+ update_option( 'woocommerce_rest_api_enable_backend_caching', 'yes' );
+ update_option( 'woocommerce_rest_api_enable_cache_headers', 'yes' );
+ update_option( 'woocommerce_feature_rest_api_caching_enabled', 'no' );
+ $this->sut->reinitialize_cache();
+
+ $this->reset_rest_server();
+
+ $response = $this->query_endpoint( 'single_entity' );
+
+ $this->assertCount( 0, $this->get_all_cache_keys() );
+
+ $headers = $response->get_headers();
+ $this->assertArrayNotHasKey( 'X-WC-Cache', $headers );
+ $this->assertArrayNotHasKey( 'ETag', $headers );
+ $this->assertArrayNotHasKey( 'Cache-Control', $headers );
+
+ $this->assertEquals( $this->sut->responses['single_entity'], $response->get_data() );
+ }
+
+ /**
+ * @testdox rest_send_nocache_headers filter is not registered when feature is disabled.
+ */
+ public function test_nocache_headers_filter_not_registered_when_feature_disabled() {
+ update_option( 'woocommerce_feature_rest_api_caching_enabled', 'no' );
+
+ // Create a new controller with the feature disabled to test that the filter is not registered.
+ $controller = $this->create_test_controller();
+
+ $has_filter = has_filter( 'rest_send_nocache_headers', array( $controller, 'handle_rest_send_nocache_headers' ) );
+ $this->assertFalse( $has_filter );
+ }
}