PNG %k25u25%fgd5n!
<?php
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;
use WP_REST_Response;
/**
* This trait provides caching capabilities for REST API endpoints using the WordPress cache.
*
* - The output of all the REST API endpoints whose callback declaration is wrapped
* in a call to 'with_cache' will be cached using wp_cache_* functions.
* - Response headers are cached together with the response data, excluding certain fixed
* headers (like Set-Cookie) and optionally others specified via configuration
* (per-controller or per-endpoint).
* - For the purposes of caching, a request is uniquely identified by its route,
* HTTP method, query string, and user ID.
* - The VersionStringGenerator class is used to track versions of entities included
* in the responses (an "entity" is any object that is uniquely identified by type and id
* and contributes with information to be included in the response),
* so that when those entities change, the relevant cached responses become invalid.
* Modification of entity versions must be done externally by the code that modifies
* those entities (via calls to VersionStringGenerator::generate_version).
* - Various parameters (cached outputs TTL, entity type for a given response, hooks that affect
* the response) can be configured globally for the controller (via overriding protected methods)
* or per-endpoint (via arguments passed to with_cache).
* - Caching can be disabled for a given request by adding a '_skip_cache=true|1'
* to the query string.
* - 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:
*
* class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
* use RestApiCache;
*
* public function __construct() {
* parent::__construct();
* $this->initialize_rest_api_cache(); // REQUIRED
* }
*
* protected function get_default_response_entity_type(): ?string {
* return 'product'; // REQUIRED (or specify entity_type in each with_cache call)
* }
*
* public function register_routes() {
* register_rest_route(
* $this->namespace,
* '/' . $this->rest_base . '/(?P<id>[\d]+)',
* array(
* 'methods' => WP_REST_Server::READABLE,
* 'callback' => $this->with_cache(
* array( $this, 'get_item' ),
* array(
* // String, optional if get_default_response_entity_type() is overridden.
* 'entity_type' => 'product',
* // Optional int, defaults to the controller's get_ttl_for_cached_response().
* 'cache_ttl' => HOUR_IN_SECONDS,
* // Optional array, defaults to the controller's get_hooks_relevant_to_caching().
* 'relevant_hooks' => array( 'filter_name_1', 'filter_name_2' ),
* // Optional array, defaults to the controller's get_files_relevant_to_response_caching().
* // Paths can be absolute or relative to the first directory from
* // get_allowed_directories_for_file_based_response_caching() (WC_ABSPATH by default).
* 'relevant_files' => array( 'data/config.json', '/absolute/path/to/file.php' ),
* // Optional array, defaults to the controller's get_version_strings_relevant_to_caching().
* // Version string IDs to track; cache is invalidated when any version string changes.
* 'relevant_version_strings' => array( 'list_products' ),
* // Optional bool, defaults to the controller's response_cache_vary_by_user().
* 'vary_by_user' => true,
* // Optional array, defaults to the controller's get_response_headers_to_include_in_caching().
* 'include_headers' => array( 'X-Custom-Header' ),
* // Optional array, defaults to the controller's get_response_headers_to_exclude_from_caching().
* 'exclude_headers' => array( 'X-Private-Header' ),
* // Optional, this will be passed to all the caching-related methods.
* 'endpoint_id' => 'get_product'
* )
* ),
* )
* );
* }
* }
*
* Override these methods in your controller as needed:
* - get_default_response_entity_type(): Default entity type for endpoints without explicit config.
* - response_cache_vary_by_user(): Whether cache should be user-specific.
* - get_hooks_relevant_to_caching(): Hook names to track for cache invalidation.
* - get_files_relevant_to_response_caching(): File paths to track for cache invalidation.
* - get_version_strings_relevant_to_caching(): Version string IDs to track for cache invalidation.
* - get_allowed_directories_for_file_based_response_caching(): Directories allowed for file tracking.
* - get_file_check_interval_for_response_caching(): How long to cache file modification checks (default 10 minutes).
* - get_ttl_for_cached_response(): TTL for cached outputs in seconds.
* - get_response_headers_to_include_in_caching(): Headers to include in cache (false = use exclusion mode).
* - get_response_headers_to_exclude_from_caching(): Headers to exclude from cache (when in exclusion mode).
*
* Cache invalidation happens when:
* - Entity versions change (tracked via VersionStringGenerator).
* - Hook callbacks change
* (if the `get_hooks_relevant_to_caching()` call result or the 'relevant_hooks' array isn't empty).
* - Tracked files change or are deleted
* (if the `get_files_relevant_to_response_caching()` call result or the 'relevant_files' array isn't empty).
* - Relevant version strings change or are deleted
* (if the `get_version_strings_relevant_to_caching()` call result or the 'relevant_version_strings' array isn't empty).
* - Cached response TTL expires.
*
* NOTE: This caching mechanism uses the WordPress cache (wp_cache_* functions).
* By default caching is only enabled when an external object cache is enabled
* (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
*/
trait RestApiCache {
/**
* Cache group name for REST API responses.
*
* @var string
*/
private static string $cache_group = 'woocommerce_rest_api_cache';
/**
* Response headers that are always excluded from caching.
*
* @var array
*/
private static array $always_excluded_headers = array(
'X-WC-Cache',
'Set-Cookie',
'Date',
'Expires',
'Last-Modified',
'Age',
'ETag',
'Cache-Control',
'Pragma',
);
/**
* Cache group for warning suppression (separate from main cache to avoid interference).
*
* @var string
*/
private static string $warning_cache_group = 'woocommerce_rest_api_cache_warnings';
/**
* TTL for suppressing duplicate file tracking warnings (1 hour).
*
* @var int
*/
private static int $file_warning_suppression_ttl = HOUR_IN_SECONDS;
/**
* The instance of VersionStringGenerator to use, or null if caching is disabled.
*
* @var VersionStringGenerator|null
*/
private ?VersionStringGenerator $version_string_generator = null;
/**
* Whether we are currently handling a cached endpoint.
*
* @var bool
*/
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.
*
* @since 10.5.0
*/
protected function initialize_rest_api_cache(): void {
// Guard against early instantiation before WooCommerce is fully initialized.
// Some third-party plugins instantiate REST controllers during plugin loading,
// before the WooCommerce container is available.
if ( ! function_exists( 'wc_get_container' ) ) {
return;
}
$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 );
}
/**
* Wrap an endpoint callback declaration with caching logic.
* Usage: `'callback' => $this->with_cache( array( $this, 'endpoint_callback_method' ) )`
* `'callback' => $this->with_cache( array( $this, 'endpoint_callback_method' ), [ 'entity_type' => 'product' ] )`
*
* @since 10.5.0
*
* @param callable $callback The original endpoint callback.
* @param array $config Caching configuration:
* - entity_type: string (falls back to get_default_response_entity_type()).
* - vary_by_user: bool (defaults to response_cache_vary_by_user()).
* - endpoint_id: string|null (optional friendly identifier for the endpoint).
* - cache_ttl: int (defaults to get_ttl_for_cached_response()).
* - relevant_hooks: array (defaults to get_hooks_relevant_to_caching()).
* - relevant_files: array (defaults to get_files_relevant_to_response_caching()).
* - relevant_version_strings: array (defaults to get_version_strings_relevant_to_caching()).
* - include_headers: array|false (defaults to get_response_headers_to_include_in_caching()).
* - exclude_headers: array (defaults to get_response_headers_to_exclude_from_caching()).
* @return callable Wrapped callback.
*/
protected function with_cache( callable $callback, array $config = array() ): callable {
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:
* - 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<array<string, mixed>> $request The request object.
* @param callable $callback The original endpoint callback.
* @param array $config Caching configuration specified for the endpoint.
*
* @return WP_REST_Response|\WP_Error The response.
*/
private function handle_cacheable_request( WP_REST_Request $request, callable $callback, array $config ) { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
$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 );
}
if ( ! $this->should_use_cache_for_request( $request ) ) {
$response = call_user_func( $callback, $request );
if ( ! is_wp_error( $response ) ) {
$response = rest_ensure_response( $response );
$response->header( 'X-WC-Cache', 'SKIP' );
}
return $response;
}
$cached_config = $this->build_cache_config( $request, $config );
$this->is_handling_cached_endpoint = true;
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;
}
}
$authoritative_response = call_user_func( $callback, $request );
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 );
}
/**
* Check if caching should be used for a particular incoming request.
*
* @param WP_REST_Request<array<string, mixed>> $request The request object.
*
* @return bool True if caching should be used, false otherwise.
*/
private function should_use_cache_for_request( WP_REST_Request $request ): bool { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
$skip_cache = $request->get_param( '_skip_cache' );
$should_cache = ! ( 'true' === $skip_cache || '1' === $skip_cache );
/**
* Filter whether to enable response caching for a given REST API controller.
*
* @since 10.5.0
*
* @param bool $enable_caching Whether to enable response caching (result of !_skip_cache evaluation).
* @param object $controller The controller instance.
* @param WP_REST_Request<array<string, mixed>> $request The request object.
* @return bool True to enable response caching, false to disable.
*/
return apply_filters(
'woocommerce_rest_api_enable_response_caching',
$should_cache,
$this,
$request
);
}
/**
* Build the output cache entry configuration from the request and per-endpoint config.
*
* @param WP_REST_Request<array<string, mixed>> $request The request object.
* @param array $config Raw configuration array passed to with_cache.
*
* @return array Normalized cache config with keys: endpoint_id, entity_type, vary_by_user, cache_ttl, relevant_hooks, relevant_files, include_headers, exclude_headers, cache_key.
*
* @throws \InvalidArgumentException If entity_type is not provided and no default is available, or if include_headers is not false or an array.
*/
private function build_cache_config( WP_REST_Request $request, array $config ): array { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
$endpoint_id = $config['endpoint_id'] ?? null;
$entity_type = $config['entity_type'] ?? $this->get_default_response_entity_type();
$vary_by_user = $config['vary_by_user'] ?? $this->response_cache_vary_by_user( $request, $endpoint_id );
if ( ! $entity_type ) {
throw new \InvalidArgumentException(
'REST API cache: No entity type provided in with_cache() config and no default entity type available from get_default_response_entity_type(). ' .
'Either pass "entity_type" in the config array or override get_default_response_entity_type() in your controller.'
);
}
$include_headers = $config['include_headers'] ?? $this->get_response_headers_to_include_in_caching( $request, $endpoint_id );
if ( false !== $include_headers && ! is_array( $include_headers ) ) {
throw new \InvalidArgumentException(
'include_headers must be either false or an array, ' . gettype( $include_headers ) . ' given.' // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
);
}
return array(
'endpoint_id' => $endpoint_id,
'entity_type' => $entity_type,
'vary_by_user' => $vary_by_user,
'cache_ttl' => $config['cache_ttl'] ?? $this->get_ttl_for_cached_response( $request, $endpoint_id ),
'relevant_hooks' => $config['relevant_hooks'] ?? $this->get_hooks_relevant_to_caching( $request, $endpoint_id ),
'relevant_files' => $config['relevant_files'] ?? $this->get_files_relevant_to_response_caching( $request, $endpoint_id ),
'relevant_version_strings' => $config['relevant_version_strings'] ?? $this->get_version_strings_relevant_to_caching( $request, $endpoint_id ),
'include_headers' => $include_headers,
'exclude_headers' => $config['exclude_headers'] ?? $this->get_response_headers_to_exclude_from_caching( $request, $endpoint_id ),
'cache_key' => $this->get_key_for_cached_response( $request, $entity_type, $vary_by_user, $endpoint_id ),
);
}
/**
* 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.
*
* 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<array<string, mixed>> $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, bool $add_cache_headers ) { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
if ( is_wp_error( $response ) ) {
return $response;
}
$response = rest_ensure_response( $response );
$cached = false;
$status = $response->get_status();
if ( $status >= 200 && $status <= 299 ) {
$data = $response->get_data();
$entity_ids = is_array( $data ) ? $this->extract_entity_ids_from_response( $data, $request, $cached_config['endpoint_id'] ) : array();
$response_headers = $response->get_headers();
$cacheable_headers = $this->get_headers_to_cache(
$response_headers,
$cached_config['include_headers'],
$cached_config['exclude_headers'],
$request,
$response,
$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(
array_merge(
$cached_config,
array(
'data' => $data,
'status_code' => $status,
'entity_ids' => $entity_ids,
'headers' => $cacheable_headers,
'etag' => $etag,
)
)
);
$cached = true;
}
$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<array<string, mixed>> $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 ) { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
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' );
$legacy_proxy = wc_get_container()->get( LegacyProxy::class );
$is_user_logged_in = $legacy_proxy->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 ( $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<array<string, mixed>> $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 { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
$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.
*
* This can be customized per-endpoint via the config array
* passed to with_cache() ('entity_type' key).
*
* @since 10.5.0
*
* @return string|null Entity type (e.g., 'product', 'order'), or null if no controller-wide default.
*/
protected function get_default_response_entity_type(): ?string {
return null;
}
/**
* Get data for ETag generation.
*
* Override in classes to exclude fields that change on each request
* (e.g., random recommendations, timestamps).
*
* @since 10.5.0
*
* @param array $data Response data.
* @param WP_REST_Request<array<string, mixed>> $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, Squiz.Commenting.FunctionComment.IncorrectTypeHint
return $data;
}
/**
* Whether the response cache should vary by user.
*
* When true, each user gets their own cached version of the response.
* When false, the same cached response is shared across all users.
*
* This can be customized per-endpoint via the config array
* passed to with_cache() ('vary_by_user' key).
*
* @since 10.5.0
*
* @param WP_REST_Request<array<string, mixed>> $request The request object.
* @param string|null $endpoint_id Optional friendly identifier for the endpoint.
*
* @return bool True to make cache user-specific, false otherwise.
*/
protected function response_cache_vary_by_user( WP_REST_Request $request, ?string $endpoint_id = null ): bool { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
return true;
}
/**
* Get the cache TTL (time to live) for cached responses.
*
* This can be customized per-endpoint via the config array
* passed to with_cache() ('cache_ttl' key).
*
* @since 10.5.0
*
* @param WP_REST_Request<array<string, mixed>> $request The request object.
* @param string|null $endpoint_id Optional friendly identifier for the endpoint.
*
* @return int Cache TTL in seconds.
*/
protected function get_ttl_for_cached_response( WP_REST_Request $request, ?string $endpoint_id = null ): int { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
return HOUR_IN_SECONDS;
}
/**
* Get the names of hooks (filters and actions) that can customize the response.
*
* All the existing instances of add_action/add_filter for these hooks
* will be included in the information that gets cached together with the response,
* and if any of these has changed when the cached response is retrieved,
* the cache entry will be invalidated.
*
* This can be customized per-endpoint via the config array
* passed to with_cache() ('relevant_hooks' key).
*
* @since 10.5.0
*
* @param WP_REST_Request<array<string, mixed>> $request Request object.
* @param string|null $endpoint_id Optional friendly identifier for the endpoint.
*
* @return array Array of hook names to track.
*/
protected function get_hooks_relevant_to_caching( WP_REST_Request $request, ?string $endpoint_id = null ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
return array();
}
/**
* Get the paths of files whose modification affects the response.
*
* All the returned files will be tracked for changes: whenever a response is cached,
* each file's modification time is recorded, and if any file has changed or disappeared
* when the cached response is retrieved, the cache entry will be invalidated.
*
* Paths can be absolute or relative. Relative paths are resolved relative to the first
* directory returned by get_allowed_directories_for_file_based_response_caching().
*
* This can be customized per-endpoint via the config array
* passed to with_cache() ('relevant_files' key).
*
* @since 10.6.0
*
* @param WP_REST_Request<array<string, mixed>> $request Request object.
* @param string|null $endpoint_id Optional friendly identifier for the endpoint.
*
* @return array Array of file paths to track.
*/
protected function get_files_relevant_to_response_caching( WP_REST_Request $request, ?string $endpoint_id = null ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
return array();
}
/**
* Get the identifiers of version strings that affect the response.
*
* All returned version strings will be tracked for changes: whenever a response is cached,
* each version string's current value is recorded, and if any has changed or disappeared
* when the cached response is retrieved, the cache entry will be invalidated.
*
* This is useful for collection endpoints where entities outside the current page
* could affect the response (e.g., a deleted entity shifts pagination).
*
* This can be customized per-endpoint via the config array
* passed to with_cache() ('relevant_version_strings' key).
*
* @since 10.6.0
*
* @param WP_REST_Request<array<string, mixed>> $request Request object.
* @param string|null $endpoint_id Optional friendly identifier for the endpoint.
*
* @return array Array of version string identifiers to track.
*/
protected function get_version_strings_relevant_to_caching( WP_REST_Request $request, ?string $endpoint_id = null ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
return array();
}
/**
* Get directories allowed for file-based response caching.
*
* Returns an array of directory paths that are allowed to contain files tracked
* for cache invalidation. The first directory in the array is also used as the
* base path for resolving relative file paths.
*
* @since 10.6.0
*
* @return array Array of absolute directory paths.
*/
protected function get_allowed_directories_for_file_based_response_caching(): array {
return defined( 'WC_ABSPATH' ) ? array( WC_ABSPATH ) : array();
}
/**
* Get the interval for caching file modification checks.
*
* To avoid checking file modification times on every request, file checks are cached
* for this interval. During this period, files are assumed to be unchanged.
*
* Override this method to customize the interval. Return 0 to disable caching
* and check files on every request.
*
* @since 10.6.0
*
* @return int Interval in seconds. Default is 10 minutes (600 seconds).
*/
protected function get_file_check_interval_for_response_caching(): int {
return 10 * MINUTE_IN_SECONDS;
}
/**
* Get the names of response headers to include in caching.
*
* When this returns an array, ONLY the headers whose names are returned
* will be included in the cache (subject to always-excluded headers).
* When this returns false, all headers will be included except those returned
* by get_response_headers_to_exclude_from_caching().
*
* This can be customized per-endpoint via the config array
* passed to with_cache() ('include_headers' key).
*
* @since 10.5.0
*
* @param WP_REST_Request<array<string, mixed>> $request Request object.
* @param string|null $endpoint_id Optional friendly identifier for the endpoint.
*
* @return array|false Array of header names to include (case-insensitive), or false to use exclusion logic.
*/
protected function get_response_headers_to_include_in_caching( WP_REST_Request $request, ?string $endpoint_id = null ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
return false;
}
/**
* Get the names of response headers to exclude from caching.
*
* These headers will not be stored in the cache, in addition to the
* always-excluded headers (X-WC-Cache, Set-Cookie, Date, Expires, Last-Modified,
* Age, ETag, Cache-Control, Pragma).
*
* This is only used when get_response_headers_to_include_in_caching() returns false.
*
* This can be customized per-endpoint via the config array
* passed to with_cache() ('exclude_headers' key).
*
* @since 10.5.0
*
* @param WP_REST_Request<array<string, mixed>> $request Request object.
* @param string|null $endpoint_id Optional friendly identifier for the endpoint.
*
* @return array Array of header names to exclude (case-insensitive).
*/
protected function get_response_headers_to_exclude_from_caching( WP_REST_Request $request, ?string $endpoint_id = null ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
return array();
}
/**
* Extract entity IDs from response data.
*
* This implementation assumes the response is either:
* - An array with an 'id' field (single item)
* - An array of arrays each having an 'id' field (collection)
*
* Controllers can override this method to customize entity ID extraction.
*
* @since 10.5.0
*
* @param array $response_data Response data.
* @param WP_REST_Request<array<string, mixed>> $request The request object.
* @param string|null $endpoint_id Optional friendly identifier for the endpoint.
*
* @return array Array of entity IDs.
*/
protected function extract_entity_ids_from_response( array $response_data, WP_REST_Request $request, ?string $endpoint_id = null ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
$ids = array();
if ( isset( $response_data[0] ) && is_array( $response_data[0] ) ) {
foreach ( $response_data as $item ) {
if ( isset( $item['id'] ) ) {
$ids[] = $item['id'];
}
}
} elseif ( isset( $response_data['id'] ) ) {
$ids[] = $response_data['id'];
}
// Filter out false values but keep 0 and empty strings as they could be valid IDs.
// Note: null values can't exist here because isset() checks above exclude them.
return array_unique(
array_filter( $ids, fn ( $id ) => false !== $id )
);
}
/**
* Filter response headers to get only those that should be cached.
*
* The filtering process follows these steps:
* 1. If $include_headers is an array, only those headers are included (case-insensitive).
* If $include_headers is false, all headers are included except those in $exclude_headers.
* 2. Always-excluded headers (X-WC-Cache, Set-Cookie, Date, etc.) are removed.
* 3. The woocommerce_rest_api_cached_headers filter is applied, receiving both the candidate
* headers list and all available headers. This allows filters to both add and remove
* headers from the caching list.
* 4. Always-excluded headers are enforced again post-filter to prevent filters from
* re-introducing dangerous headers like Set-Cookie.
* 5. Only headers from the response that are in the filtered list are returned.
*
* @param array $nominal_headers Response headers.
* @param array|false $include_headers Header names to include (false to use exclusion logic).
* @param array $exclude_headers Header names to exclude (case-insensitive).
* @param WP_REST_Request<array<string, mixed>> $request The request object.
* @param WP_REST_Response $response The response object.
* @param string|null $endpoint_id Optional friendly identifier for the endpoint.
*
* @return array Filtered headers array.
*/
private function get_headers_to_cache( array $nominal_headers, $include_headers, array $exclude_headers, WP_REST_Request $request, WP_REST_Response $response, ?string $endpoint_id ): array { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
// Step 1: Determine which headers to consider based on include/exclude.
if ( false !== $include_headers ) {
$include_headers_lowercase = array_map( 'strtolower', $include_headers );
$headers_to_cache = array_filter(
$nominal_headers,
fn( $name ) => in_array( strtolower( $name ), $include_headers_lowercase, true ),
ARRAY_FILTER_USE_KEY
);
} else {
$exclude_headers_lowercase = array_map( 'strtolower', $exclude_headers );
$headers_to_cache = array_filter(
$nominal_headers,
fn( $name ) => ! in_array( strtolower( $name ), $exclude_headers_lowercase, true ),
ARRAY_FILTER_USE_KEY
);
}
// Step 2: Remove always-excluded headers.
$always_exclude_lowercase = array_map( 'strtolower', self::$always_excluded_headers );
$headers_to_cache = array_filter(
$headers_to_cache,
fn( $name ) => ! in_array( strtolower( $name ), $always_exclude_lowercase, true ),
ARRAY_FILTER_USE_KEY
);
// Step 3: Apply filter to header names.
$cached_header_names = array_keys( $headers_to_cache );
$all_header_names = array_keys( $nominal_headers );
/**
* Filter the list of response header names to cache.
*
* @since 10.5.0
*
* @param array $cached_header_names Candidate list of header names to cache.
* @param array $all_header_names All header names available in the response.
* @param WP_REST_Request $request The request object.
* @param WP_REST_Response $response The response object.
* @param string|null $endpoint_id Optional friendly identifier for the endpoint.
* @param object $controller The controller instance.
*
* @return array Filtered list of header names to cache.
*/
$filtered_header_names = apply_filters(
'woocommerce_rest_api_cached_headers',
$cached_header_names,
$all_header_names,
$request,
$response,
$endpoint_id,
$this
);
// Step 4: Enforce always-excluded headers post-filter.
$filtered_header_names_lowercase = array_map( 'strtolower', $filtered_header_names );
$reintroduced_headers = array_filter(
$filtered_header_names,
fn( $name ) => in_array( strtolower( $name ), $always_exclude_lowercase, true )
);
if ( ! empty( $reintroduced_headers ) ) {
$legacy_proxy = wc_get_container()->get( LegacyProxy::class );
$legacy_proxy->call_function(
'wc_doing_it_wrong',
__METHOD__,
sprintf(
/* translators: %s: comma-separated list of header names */
'The woocommerce_rest_api_cached_headers filter attempted to cache always-excluded headers: %s. These headers have been removed for security reasons.',
implode( ', ', $reintroduced_headers )
),
'10.5.0'
);
$filtered_header_names_lowercase = array_filter(
$filtered_header_names_lowercase,
fn( $name ) => ! in_array( $name, $always_exclude_lowercase, true )
);
}
// Step 5: Return only the headers that are in the filtered list.
return array_filter(
$nominal_headers,
fn( $name ) => in_array( strtolower( $name ), $filtered_header_names_lowercase, true ),
ARRAY_FILTER_USE_KEY
);
}
/**
* Get cache key information that uniquely identifies a request.
*
* @since 10.5.0
*
* @param WP_REST_Request<array<string, mixed>> $request The request object.
* @param bool $vary_by_user Whether to include user ID in cache key.
* @param string|null $endpoint_id Optional friendly identifier for the endpoint.
*
* @return array Array of cache key information parts.
*/
protected function get_key_info_for_cached_response( WP_REST_Request $request, bool $vary_by_user = false, ?string $endpoint_id = null ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
$request_query_params = $request->get_query_params();
if ( is_array( $request_query_params ) ) {
ksort( $request_query_params );
}
$cache_key_parts = array(
$request->get_route(),
$request->get_method(),
wp_json_encode( $request_query_params ),
);
if ( $vary_by_user ) {
$legacy_proxy = wc_get_container()->get( LegacyProxy::class );
// @phpstan-ignore-next-line argument.type -- get_current_user_id returns int at runtime.
$user_id = intval( $legacy_proxy->call_function( 'get_current_user_id' ) );
$cache_key_parts[] = "user_{$user_id}";
}
return $cache_key_parts;
}
/**
* Generate a cache key for a given request.
*
* @param WP_REST_Request<array<string, mixed>> $request The request object.
* @param string $entity_type The entity type.
* @param bool $vary_by_user Whether to include user ID in cache key.
* @param string|null $endpoint_id Optional friendly identifier for the endpoint.
*
* @return string Cache key.
*/
private function get_key_for_cached_response( WP_REST_Request $request, string $entity_type, bool $vary_by_user = false, ?string $endpoint_id = null ): string { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
$cache_key_parts = $this->get_key_info_for_cached_response( $request, $vary_by_user, $endpoint_id );
/**
* Filter the information used to generate the cache key for a REST API request.
*
* Allows customization of what uniquely identifies a request for caching purposes.
*
* @since 10.5.0
*
* @param array $cache_key_parts Array of cache key information parts.
* @param WP_REST_Request<array<string, mixed>> $request The request object.
* @param bool $vary_by_user Whether user ID is included in cache key.
* @param string|null $endpoint_id Optional friendly identifier for the endpoint (passed to with_cache).
* @param object $controller The controller instance.
*
* @return array Filtered cache key information parts.
*/
$cache_key_parts = apply_filters(
'woocommerce_rest_api_cache_key_info',
$cache_key_parts,
$request,
$vary_by_user,
$endpoint_id,
$this
);
$request_hash = md5( implode( '-', $cache_key_parts ) );
return "wc_rest_api_cache_{$entity_type}-{$request_hash}";
}
/**
* Generate a hash based on the actual usages of the hooks that affect the response.
*
* @param array $hook_names Array of hook names to track.
*
* @return string Hooks hash.
*/
private function generate_hooks_hash( array $hook_names ): string {
if ( empty( $hook_names ) ) {
return '';
}
$cache_hash_data = array();
foreach ( $hook_names as $hook_name ) {
$signatures = CallbackUtil::get_hook_callback_signatures( $hook_name );
if ( ! empty( $signatures ) ) {
$cache_hash_data[ $hook_name ] = $signatures;
}
}
/**
* Filter the data used to generate the hooks hash for REST API response caching.
*
* @since 10.5.0
*
* @param array $cache_hash_data Hook callbacks data used for hash generation.
* @param array $hook_names Hook names being tracked.
* @param object $controller Controller instance.
*/
$cache_hash_data = apply_filters(
'woocommerce_rest_api_cache_hooks_hash_data',
$cache_hash_data,
$hook_names,
$this
);
$json = wp_json_encode( $cache_hash_data );
return md5( false === $json ? '' : $json );
}
/**
* Generate a hash based on the current values of the relevant version strings.
*
* @since 10.6.0
*
* @param array $version_string_ids Array of version string identifiers to track.
*
* @return string Version strings hash, or empty string if no version strings could be tracked.
*/
private function generate_version_strings_hash( array $version_string_ids ): string {
if ( empty( $version_string_ids ) || is_null( $this->version_string_generator ) ) {
return '';
}
$version_data = array();
foreach ( $version_string_ids as $id ) {
$version = $this->version_string_generator->get_version( $id );
if ( $version ) {
$version_data[ $id ] = $version;
}
}
if ( empty( $version_data ) ) {
return '';
}
/**
* Filter the version strings data used for REST API response cache invalidation.
*
* @since 10.6.0
*
* @param array $version_data Array mapping version string IDs to their current values.
* @param array $version_string_ids Original version string identifiers passed to the method.
* @param object $controller Controller instance.
*/
$version_data = apply_filters(
'woocommerce_rest_api_cache_version_strings_hash_data',
$version_data,
$version_string_ids,
$this
);
if ( empty( $version_data ) ) {
return '';
}
ksort( $version_data );
$json = wp_json_encode( $version_data );
return md5( false === $json ? '' : $json );
}
/**
* Get the filtered list of allowed directories for file-based response caching.
*
* This method retrieves the allowed directories from the protected method
* and applies the woocommerce_rest_api_cache_allowed_file_directories filter.
*
* @since 10.6.0
*
* @return array Array of absolute directory paths.
*/
private function get_filtered_allowed_directories_for_response_caching(): array {
$allowed_directories = $this->get_allowed_directories_for_file_based_response_caching();
/**
* Filter the directories allowed for file-based REST API response caching.
*
* This filter allows extensions to add additional directories that can contain
* files tracked for cache invalidation. The first directory in the array is
* used as the base path for resolving relative file paths.
*
* @since 10.6.0
*
* @param array $allowed_directories Array of absolute directory paths.
* @param object $controller The controller instance.
*
* @return array Filtered array of directory paths.
*/
return apply_filters(
'woocommerce_rest_api_cache_allowed_file_directories',
$allowed_directories,
$this
);
}
/**
* Generate a hash for the given file paths based on their modification times.
*
* This method resolves relative paths (relative to the first allowed directory),
* gets file modification times, and generates a hash for cache invalidation.
* Files that cannot be accessed (permissions, non-existent) are logged as warnings
* and excluded from tracking.
*
* To avoid filesystem calls on every request, file check results are cached
* for the interval returned by get_file_check_interval_for_response_caching().
*
* @since 10.6.0
*
* @param array $file_paths Array of file paths (absolute or relative to the first allowed directory).
*
* @return string Hash string, or empty string if no files could be tracked.
*/
private function generate_files_hash( array $file_paths ): string {
if ( empty( $file_paths ) ) {
return '';
}
$allowed_directories = $this->get_filtered_allowed_directories_for_response_caching();
if ( empty( $allowed_directories ) ) {
$this->log_file_tracking_warning( '', 'No allowed directories configured for file tracking' );
return '';
}
$files_data = null;
$check_interval = $this->get_file_check_interval_for_response_caching();
// Try to get cached file check results to avoid filesystem calls on every request.
if ( $check_interval > 0 ) {
$file_check_cache_key = $this->get_file_check_cache_key( $file_paths, $allowed_directories );
$files_data = wp_cache_get( $file_check_cache_key, self::$cache_group );
if ( false === $files_data ) {
$files_data = null;
}
}
// Cache miss or caching disabled - check all files.
if ( is_null( $files_data ) ) {
$files_data = $this->check_files( $file_paths, $allowed_directories );
// Cache the results if caching is enabled.
if ( $check_interval > 0 && ! empty( $files_data ) ) {
wp_cache_set( $file_check_cache_key, $files_data, self::$cache_group, $check_interval );
}
}
/**
* Filter the file data used for REST API response cache invalidation.
*
* This filter allows modification of the file tracking data before it is stored
* in the cache and used for invalidation checks.
*
* @since 10.6.0
*
* @param array $files_data Array of file data, each with 'path' and 'time' keys.
* @param array $file_paths Original file paths passed to the method.
* @param object $controller Controller instance.
*/
$files_data = apply_filters(
'woocommerce_rest_api_cache_files_hash_data',
$files_data,
$file_paths,
$this
);
if ( empty( $files_data ) ) {
return '';
}
$json = wp_json_encode( $files_data );
return md5( false === $json ? '' : $json );
}
/**
* Generate a cache key for file check results.
*
* @param array $file_paths Array of file paths to track.
* @param array $allowed_directories Array of allowed directory paths.
*
* @return string Cache key.
*/
private function get_file_check_cache_key( array $file_paths, array $allowed_directories ): string {
sort( $file_paths );
sort( $allowed_directories );
$key_data = array(
'files' => $file_paths,
'dirs' => $allowed_directories,
);
$json = wp_json_encode( $key_data );
return 'wc_rest_file_check_' . md5( false === $json ? '' : $json );
}
/**
* Check files and return their tracking data.
*
* @param array $file_paths Array of file paths to check.
* @param array $allowed_directories Array of allowed directory paths.
*
* @return array Array of file data, each with 'path' and 'time' keys.
*/
private function check_files( array $file_paths, array $allowed_directories ): array {
$files_data = array();
foreach ( $file_paths as $file_path ) {
$resolved_path = $this->resolve_file_path( $file_path, $allowed_directories );
if ( is_null( $resolved_path ) ) {
$this->log_file_tracking_warning( $file_path, 'Path could not be resolved or is outside allowed directories' );
continue;
}
$file_entry = $this->get_file_tracking_entry( $resolved_path );
if ( is_null( $file_entry ) ) {
$this->log_file_tracking_warning( $resolved_path, 'File does not exist or cannot be accessed' );
continue;
}
$files_data[] = $file_entry;
}
return $files_data;
}
/**
* Resolve a file path to an absolute path.
*
* Relative paths are resolved relative to the first directory in the allowed directories list.
* All paths are converted to physical paths (symlinks resolved) for consistent comparison.
* Paths that resolve outside the allowed directories are rejected for security.
*
* @param string $file_path The file path to resolve (absolute or relative).
* @param array $allowed_directories Array of allowed directory paths.
*
* @return string|null The resolved absolute path, or null if the path is invalid or outside allowed directories.
*/
private function resolve_file_path( string $file_path, array $allowed_directories ): ?string {
if ( empty( $allowed_directories ) ) {
return null;
}
if ( ! path_is_absolute( $file_path ) ) {
$base_path = trailingslashit( $allowed_directories[0] );
$file_path = $base_path . ltrim( $file_path, '/' );
}
$legacy_proxy = wc_get_container()->get( LegacyProxy::class );
$physical_path = $legacy_proxy->call_function( 'realpath', $file_path );
if ( false === $physical_path ) {
return null;
}
$normalized_path = wp_normalize_path( $physical_path );
foreach ( $allowed_directories as $dir ) {
$real_dir = $legacy_proxy->call_function( 'realpath', $dir );
if ( false === $real_dir ) {
continue;
}
$normalized_dir = trailingslashit( wp_normalize_path( $real_dir ) );
if ( 0 === strpos( $normalized_path, $normalized_dir ) ) {
return $normalized_path;
}
}
return null;
}
/**
* Log a warning about a file that couldn't be tracked.
*
* Each unique file path + reason combination is logged only once per the
* suppression TTL period to avoid flooding the log with repeated warnings.
* With a persistent object cache (Redis, Memcached), this works across requests.
* Without one, it prevents duplicates within the same request.
*
* @since 10.6.0
*
* @param string $file_path The file path that couldn't be tracked.
* @param string $reason The reason the file couldn't be tracked.
*/
private function log_file_tracking_warning( string $file_path, string $reason ): void {
/**
* Filter the TTL for suppressing duplicate file tracking warnings.
*
* By default, each unique warning (file path + reason) is logged only once per hour
* to avoid flooding the log. Use this filter to customize the suppression period.
* Return 0 to disable suppression and log all warnings.
*
* @since 10.6.0
*
* @param int $ttl The suppression TTL in seconds. Default is HOUR_IN_SECONDS.
* @param string $file_path The file path that couldn't be tracked.
* @param string $reason The reason the file couldn't be tracked.
*/
$suppression_ttl = apply_filters(
'woocommerce_rest_api_cache_file_warning_suppression_ttl',
self::$file_warning_suppression_ttl,
$file_path,
$reason
);
if ( $suppression_ttl > 0 ) {
$warning_key = 'wc_rest_file_warning_' . md5( $file_path . '|' . $reason );
if ( false !== wp_cache_get( $warning_key, self::$warning_cache_group ) ) {
return;
}
wp_cache_set( $warning_key, true, self::$warning_cache_group, $suppression_ttl );
}
$logger = wc_get_container()->get( LegacyProxy::class )->call_function( 'wc_get_logger' );
$logger->warning(
sprintf(
'REST API cache: Could not track file "%s" for cache invalidation. Reason: %s',
$file_path,
$reason
),
array( 'source' => 'rest-api-cache' )
);
}
/**
* Get file tracking entry for a resolved path.
*
* @since 10.6.0
*
* @param string $resolved_path The resolved absolute file path.
*
* @return array{path: string, time: int}|null File entry with path and time, or null if file can't be accessed.
*/
private function get_file_tracking_entry( string $resolved_path ): ?array {
$legacy_proxy = wc_get_container()->get( LegacyProxy::class );
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- We handle the error gracefully.
$mtime = @$legacy_proxy->call_function( 'filemtime', $resolved_path );
if ( false === $mtime ) {
return null;
}
return array(
'path' => $resolved_path,
'time' => $mtime,
);
}
/**
* Get a cached response, but only if it's valid (otherwise the cached response will be invalidated).
*
* @param WP_REST_Request<array<string, mixed>> $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, bool $cache_headers_enabled ): ?WP_REST_Response { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
$cache_key = $cached_config['cache_key'];
$entity_type = $cached_config['entity_type'];
$cache_ttl = $cached_config['cache_ttl'];
$relevant_hooks = $cached_config['relevant_hooks'];
$cached = wp_cache_get( $cache_key, self::$cache_group );
if ( ! is_array( $cached ) || ! array_key_exists( 'data', $cached ) || ! isset( $cached['entity_versions'], $cached['created_at'] ) ) {
return null;
}
$legacy_proxy = wc_get_container()->get( LegacyProxy::class );
$current_time = $legacy_proxy->call_function( 'time' );
$expiration_time = $cached['created_at'] + $cache_ttl;
if ( $current_time >= $expiration_time ) {
wp_cache_delete( $cache_key, self::$cache_group );
return null;
}
if ( ! empty( $relevant_hooks ) ) {
$current_hooks_hash = $this->generate_hooks_hash( $relevant_hooks );
$cached_hooks_hash = $cached['hooks_hash'] ?? '';
if ( $current_hooks_hash !== $cached_hooks_hash ) {
wp_cache_delete( $cache_key, self::$cache_group );
return null;
}
}
// Validate files hash if files are being tracked.
$relevant_files = $cached_config['relevant_files'];
if ( ! empty( $relevant_files ) ) {
$cached_files_hash = $cached['files_hash'] ?? '';
$current_files_hash = $this->generate_files_hash( $relevant_files );
if ( $current_files_hash !== $cached_files_hash ) {
wp_cache_delete( $cache_key, self::$cache_group );
return null;
}
}
// Validate version strings hash if version strings are being tracked.
$relevant_version_strings = $cached_config['relevant_version_strings'];
if ( ! empty( $relevant_version_strings ) ) {
$cached_version_strings_hash = $cached['version_strings_hash'] ?? '';
$current_version_strings_hash = $this->generate_version_strings_hash( $relevant_version_strings );
if ( $current_version_strings_hash !== $cached_version_strings_hash ) {
wp_cache_delete( $cache_key, self::$cache_group );
return null;
}
}
if ( ! is_null( $this->version_string_generator ) ) {
foreach ( $cached['entity_versions'] as $entity_id => $cached_version ) {
$version_id = "{$entity_type}_{$entity_id}";
$current_version = $this->version_string_generator->get_version( $version_id );
if ( $current_version !== $cached_version ) {
wp_cache_delete( $cache_key, self::$cache_group );
return null;
}
}
}
// 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' );
$response_headers = array();
if ( $cache_headers_enabled ) {
$legacy_proxy = wc_get_container()->get( LegacyProxy::class );
$is_user_logged_in = $legacy_proxy->call_function( 'is_user_logged_in' );
$cache_visibility = $cached_config['vary_by_user'] && $is_user_logged_in ? 'private' : 'public';
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.
// @phpstan-ignore-next-line argument.type -- created_at is int, stored by store_cached_response.
$created_at = gmdate( 'D, d M Y H:i:s', intval( $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 ) {
$response->header( $name, $value );
}
if ( ! empty( $cached['headers'] ) ) {
foreach ( $cached['headers'] as $name => $value ) {
$response->header( $name, $value );
}
}
return $response;
}
/**
* Store a response in cache.
*
* @param array $args {
* Arguments for storing the cached response.
*
* @type string $cache_key The cache key.
* @type mixed $data The response data to cache.
* @type int $status_code The HTTP status code of the response.
* @type string $entity_type The entity type.
* @type array $entity_ids Array of entity IDs in the response.
* @type int $cache_ttl Cache TTL in seconds.
* @type array $relevant_hooks Hook names to track for invalidation.
* @type array $relevant_files File paths to track for invalidation.
* @type array $relevant_version_strings Version string IDs to track for invalidation.
* @type array $headers Response headers to cache.
* @type string $etag ETag for the response.
* }
*/
private function store_cached_response( array $args ): void {
$status_code = $args['status_code'];
$relevant_hooks = $args['relevant_hooks'];
$relevant_files = $args['relevant_files'];
$relevant_version_strings = $args['relevant_version_strings'];
$headers = $args['headers'] ?? array();
$etag = $args['etag'] ?? '';
$entity_versions = array();
if ( ! is_null( $this->version_string_generator ) ) {
foreach ( $args['entity_ids'] as $entity_id ) {
$version_id = "{$args['entity_type']}_{$entity_id}";
$version = $this->version_string_generator->get_version( $version_id );
if ( $version ) {
$entity_versions[ $entity_id ] = $version;
}
}
}
$legacy_proxy = wc_get_container()->get( LegacyProxy::class );
$cache_data = array(
'data' => $args['data'],
'entity_versions' => $entity_versions,
'created_at' => $legacy_proxy->call_function( 'time' ),
);
if ( 200 !== $status_code ) {
$cache_data['status_code'] = $status_code;
}
if ( ! empty( $relevant_hooks ) ) {
$cache_data['hooks_hash'] = $this->generate_hooks_hash( $relevant_hooks );
}
if ( ! empty( $relevant_files ) ) {
$files_hash = $this->generate_files_hash( $relevant_files );
if ( ! empty( $files_hash ) ) {
$cache_data['files_hash'] = $files_hash;
}
}
if ( ! empty( $relevant_version_strings ) ) {
$version_strings_hash = $this->generate_version_strings_hash( $relevant_version_strings );
if ( ! empty( $version_strings_hash ) ) {
$cache_data['version_strings_hash'] = $version_strings_hash;
}
}
if ( ! empty( $headers ) ) {
$cache_data['headers'] = $headers;
}
if ( ! empty( $etag ) ) {
$cache_data['etag'] = $etag;
}
wp_cache_set( $args['cache_key'], $cache_data, self::$cache_group, $args['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;
}
}