Commit 35b6740513 for woocommerce
commit 35b674051331f06a5b2f1ddb7c35d4eda48ecfba
Author: Néstor Soriano <konamiman@konamiman.com>
Date: Tue Nov 18 13:42:07 2025 +0100
Add a basic RestApiCache trait (#61798)
diff --git a/plugins/woocommerce/changelog/pr-61798 b/plugins/woocommerce/changelog/pr-61798
new file mode 100644
index 0000000000..8bc78ce463
--- /dev/null
+++ b/plugins/woocommerce/changelog/pr-61798
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a basic RestApiCache trait
diff --git a/plugins/woocommerce/src/Internal/Traits/RestApiCache.php b/plugins/woocommerce/src/Internal/Traits/RestApiCache.php
new file mode 100644
index 0000000000..c9cb496da4
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Traits/RestApiCache.php
@@ -0,0 +1,406 @@
+<?php
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\Traits;
+
+use Automattic\WooCommerce\Internal\Caches\VersionStringGenerator;
+use Automattic\WooCommerce\Proxies\LegacyProxy;
+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.
+ * - 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).
+ * - 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.
+ * - Cached response TTL is fixed to one hour.
+ *
+ * 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; // REQUIRED
+ *
+ * public function __construct() {
+ * parent::__construct();
+ * $this->initialize_rest_api_cache(); // REQUIRED
+ * }
+ *
+ * protected function get_default_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_entity_type() is overridden.
+ * 'entity_type' => 'product',
+ * )
+ * ),
+ * )
+ * );
+ * }
+ * }
+ *
+ * Cache invalidation happens when:
+ * - Entity versions change (tracked via VersionStringGenerator).
+ * - 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.4.0
+ */
+trait RestApiCache {
+ /**
+ * Cache group name for REST API responses.
+ *
+ * @var string
+ */
+ private static string $cache_group = 'woocommerce_rest_api_cache';
+
+ /**
+ * Cache TTL in seconds.
+ *
+ * @var int
+ */
+ private static int $cache_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;
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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' ] )`
+ *
+ * @param callable $callback The original endpoint callback.
+ * @param array $config Caching configuration:
+ * - entity_type: string (falls back to get_default_entity_type()).
+ * @return callable Wrapped callback.
+ */
+ protected function with_cache( callable $callback, array $config = array() ): callable {
+ return fn( $request ) => $this->handle_cacheable_request( $request, $callback, $config );
+ }
+
+ /**
+ * 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.
+ *
+ * @param WP_REST_Request $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 ) {
+ if ( is_null( $this->version_string_generator ) ) {
+ return call_user_func( $callback, $request );
+ }
+
+ $should_skip_cache = ! $this->should_use_cache_for_request( $request );
+ if ( ! $should_skip_cache ) {
+ $cached_config = $this->build_cache_config( $request, $config );
+ $should_skip_cache = is_null( $cached_config );
+ }
+
+ if ( $should_skip_cache ) {
+ $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_response = $this->get_cached_response( $cached_config );
+
+ 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 );
+ }
+
+ /**
+ * Check if caching should be used for a particular incoming request.
+ *
+ * @param WP_REST_Request $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 {
+ $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.4.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 $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 $request The request object.
+ * @param array $config Raw configuration array passed to with_cache.
+ * @return array|null Normalized cache config with keys: entity_type, cache_key. Returns null if entity type is not available.
+ */
+ private function build_cache_config( WP_REST_Request $request, array $config ): ?array {
+ $entity_type = $config['entity_type'] ?? $this->get_default_entity_type();
+
+ if ( ! $entity_type ) {
+ wc_get_container()->get( LegacyProxy::class )->call_function(
+ 'wc_doing_it_wrong',
+ __METHOD__,
+ 'No entity type provided and no default entity type available. Skipping cache.',
+ '10.4.0'
+ );
+ return null;
+ }
+
+ return array(
+ 'entity_type' => $entity_type,
+ 'cache_key' => $this->get_cache_key( $request, $entity_type ),
+ );
+ }
+
+ /**
+ * Cache the response if it's successful and return it with appropriate 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 $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().
+ * @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 ) {
+ 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( $data ) : array();
+
+ $this->store_cached_response(
+ $cached_config['cache_key'],
+ $data,
+ $status,
+ $cached_config['entity_type'],
+ $entity_ids
+ );
+
+ $cached = true;
+ }
+
+ $response->header( 'X-WC-Cache', $cached ? 'MISS' : 'SKIP' );
+ return $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).
+ *
+ * @return string|null Entity type (e.g., 'product', 'order'), or null if no controller-wide default.
+ */
+ protected function get_default_entity_type(): ?string {
+ return null;
+ }
+
+ /**
+ * 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)
+ *
+ * @param array $data Response data.
+ * @return array Array of entity IDs.
+ */
+ private function extract_entity_ids( array $data ): array {
+ $ids = array();
+
+ if ( isset( $data[0] ) && is_array( $data[0] ) ) {
+ foreach ( $data as $item ) {
+ if ( isset( $item['id'] ) ) {
+ $ids[] = $item['id'];
+ }
+ }
+ } elseif ( isset( $data['id'] ) ) {
+ $ids[] = $data['id'];
+ }
+
+ // Filter out null/false values but keep 0 and empty strings as they could be valid IDs.
+ return array_unique(
+ array_filter( $ids, fn ( $id ) => ! is_null( $id ) && false !== $id )
+ );
+ }
+
+ /**
+ * Get cache key information that uniquely identifies a request.
+ *
+ * @param WP_REST_Request $request The request object.
+ * @return array Array of cache key information parts.
+ */
+ private function get_cache_key_info( WP_REST_Request $request ): array {
+ $request_query_params = $request->get_query_params();
+ if ( is_array( $request_query_params ) ) {
+ ksort( $request_query_params );
+ }
+
+ $user_id = wc_get_container()->get( LegacyProxy::class )->call_function( 'get_current_user_id' );
+
+ return array(
+ $request->get_route(),
+ $request->get_method(),
+ wp_json_encode( $request_query_params ),
+ "user_{$user_id}",
+ );
+ }
+
+ /**
+ * Generate a cache key for a given request.
+ *
+ * @param WP_REST_Request $request The request object.
+ * @param string $entity_type The entity type.
+ * @return string Cache key.
+ */
+ private function get_cache_key( WP_REST_Request $request, string $entity_type ): string {
+ $cache_key_parts = $this->get_cache_key_info( $request );
+ $request_hash = md5( implode( '-', $cache_key_parts ) );
+ return "wc_rest_api_cache_{$entity_type}-{$request_hash}";
+ }
+
+ /**
+ * Get a cached response, but only if it's valid (otherwise the cached response will be invalidated).
+ *
+ * @param array $cached_config Built caching configuration from build_cache_config().
+ * @return WP_REST_Response|null Cached response, or null if not available or has been invalidated.
+ */
+ private function get_cached_response( array $cached_config ): ?WP_REST_Response {
+ $cache_key = $cached_config['cache_key'];
+ $entity_type = $cached_config['entity_type'];
+
+ $found = false;
+ $cached = wp_cache_get( $cache_key, self::$cache_group, false, $found );
+
+ if ( ! $found || ! array_key_exists( 'data', $cached ) || ! isset( $cached['entity_versions'], $cached['created_at'] ) ) {
+ return null;
+ }
+
+ $current_time = wc_get_container()->get( LegacyProxy::class )->call_function( 'time' );
+ $expiration_time = $cached['created_at'] + self::$cache_ttl;
+ if ( $current_time >= $expiration_time ) {
+ wp_cache_delete( $cache_key, self::$cache_group );
+ return null;
+ }
+
+ 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.
+ $response = new WP_REST_Response( $cached['data'], $cached['status_code'] ?? 200 );
+
+ return $response;
+ }
+
+ /**
+ * Store a response in cache.
+ *
+ * @param string $cache_key The cache key.
+ * @param mixed $data The response data to cache.
+ * @param int $status_code The HTTP status code of the response.
+ * @param string $entity_type The entity type.
+ * @param array $entity_ids Array of entity IDs in the response.
+ */
+ private function store_cached_response( string $cache_key, $data, int $status_code, string $entity_type, array $entity_ids ): void {
+ $entity_versions = array();
+ foreach ( $entity_ids as $entity_id ) {
+ $version_id = "{$entity_type}_{$entity_id}";
+ $version = $this->version_string_generator->get_version( $version_id );
+ if ( $version ) {
+ $entity_versions[ $entity_id ] = $version;
+ }
+ }
+
+ $cache_data = array(
+ 'data' => $data,
+ 'entity_versions' => $entity_versions,
+ 'created_at' => wc_get_container()->get( LegacyProxy::class )->call_function( 'time' ),
+ );
+
+ if ( 200 !== $status_code ) {
+ $cache_data['status_code'] = $status_code;
+ }
+
+ wp_cache_set( $cache_key, $cache_data, self::$cache_group, self::$cache_ttl );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Traits/RestApiCacheTest.php b/plugins/woocommerce/tests/php/src/Internal/Traits/RestApiCacheTest.php
new file mode 100644
index 0000000000..f7e26a266e
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Traits/RestApiCacheTest.php
@@ -0,0 +1,486 @@
+<?php
+/**
+ * RestApiCacheTest class file.
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Traits;
+
+use Automattic\WooCommerce\Internal\Caches\VersionStringGenerator;
+use Automattic\WooCommerce\Internal\Traits\RestApiCache;
+use Automattic\WooCommerce\Proxies\LegacyProxy;
+use WC_REST_Unit_Test_Case;
+use WP_REST_Controller;
+use WP_REST_Request;
+use WP_REST_Response;
+use WP_REST_Server;
+use WP_Error;
+
+/**
+ * Tests for the simplified RestApiCache trait.
+ */
+class RestApiCacheTest extends WC_REST_Unit_Test_Case {
+
+ private const CACHE_GROUP = 'woocommerce_rest_api_cache';
+
+ /**
+ * System under test.
+ *
+ * @var object
+ */
+ private $sut;
+
+ /**
+ * @var VersionStringGenerator
+ */
+ private VersionStringGenerator $version_generator;
+
+ /**
+ * @var int
+ */
+ private int $fixed_time = 1234567890;
+
+ /**
+ * Set up before each test.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
+ array(
+ 'time' => fn() => $this->fixed_time,
+ 'get_current_user_id' => fn() => 1,
+ 'wp_using_ext_object_cache' => fn() => true,
+ )
+ );
+
+ // Needed to ensure VersionStringGenerator uses the mocked wp_using_ext_object_cache.
+ $this->reset_container_resolutions();
+
+ $this->version_generator = wc_get_container()->get( VersionStringGenerator::class );
+ $this->sut = $this->create_test_controller();
+
+ global $wp_rest_server;
+ $wp_rest_server = new WP_REST_Server();
+ $this->server = $wp_rest_server;
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+ do_action( 'rest_api_init' );
+ $this->sut->register_routes();
+ }
+
+ /**
+ * Tear down after each test.
+ */
+ public function tearDown(): void {
+ global $wp_rest_server;
+ $wp_rest_server = null;
+
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox First request returns MISS and caches response, second request returns HIT with cached data.
+ */
+ public function test_caching_workflow_miss_then_hit() {
+ $response1 = $this->query_endpoint( 'multiple_entities' );
+
+ $this->assertCacheHeader( $response1, 'MISS' );
+ $this->assertEquals( $this->sut->responses['multiple_entities'], $response1->get_data() );
+
+ $cache_keys = $this->get_all_cache_keys();
+ $this->assertCount( 1, $cache_keys );
+ $cache_key = $cache_keys[0];
+ $cached_entry = wp_cache_get( $cache_key, self::CACHE_GROUP );
+
+ $this->assertIsArray( $cached_entry );
+ $this->assertArrayHasKey( 'data', $cached_entry );
+ $this->assertArrayHasKey( 'entity_versions', $cached_entry );
+ $this->assertArrayHasKey( 'created_at', $cached_entry );
+ $this->assertEquals( $this->sut->responses['multiple_entities'], $cached_entry['data'] );
+ $this->assertEquals( $this->fixed_time, $cached_entry['created_at'] );
+ $this->assertCount( 2, $cached_entry['entity_versions'] );
+
+ $this->assertNotEmpty( $this->version_generator->get_version( 'product_2' ) );
+ $this->assertNotEmpty( $this->version_generator->get_version( 'product_3' ) );
+
+ $modified_data = array(
+ array(
+ 'id' => 999,
+ 'name' => 'Modified Product',
+ ),
+ );
+ $cached_entry['data'] = $modified_data;
+ wp_cache_set( $cache_key, $cached_entry, self::CACHE_GROUP, HOUR_IN_SECONDS );
+
+ $response2 = $this->query_endpoint( 'multiple_entities' );
+ $this->assertCacheHeader( $response2, 'HIT' );
+ $this->assertEquals( $modified_data, $response2->get_data() );
+ }
+
+ /**
+ * @testdox Expired cache entries are rejected and deleted.
+ */
+ public function test_expired_cache_entries_are_rejected() {
+ $response1 = $this->query_endpoint( 'single_entity' );
+ $this->assertCacheHeader( $response1, 'MISS' );
+ $this->assertCount( 1, $this->get_all_cache_keys() );
+
+ $response2 = $this->query_endpoint( 'single_entity' );
+ $this->assertCacheHeader( $response2, 'HIT' );
+
+ $this->fixed_time += HOUR_IN_SECONDS + 1;
+
+ $response3 = $this->query_endpoint( 'single_entity' );
+ $this->assertCacheHeader( $response3, 'MISS' );
+ $this->assertCount( 1, $this->get_all_cache_keys() );
+ }
+
+ /**
+ * @testdox Cache is invalidated when entity versions change.
+ * @testWith [2, true]
+ * [3, true]
+ * [999, false]
+ *
+ * @param int $entity_id Entity ID to modify.
+ * @param bool $cache_invalidation_expected Whether cache invalidation is expected.
+ */
+ public function test_cache_invalidated_when_entity_version_changes( int $entity_id, bool $cache_invalidation_expected ) {
+ $response1 = $this->query_endpoint( 'multiple_entities' );
+ $this->assertCacheHeader( $response1, 'MISS' );
+
+ $response2 = $this->query_endpoint( 'multiple_entities' );
+ $this->assertCacheHeader( $response2, 'HIT' );
+
+ $this->version_generator->generate_version( "product_{$entity_id}" );
+ $this->fixed_time += 1;
+
+ $response3 = $this->query_endpoint( 'multiple_entities' );
+ $this->assertCacheHeader( $response3, $cache_invalidation_expected ? 'MISS' : 'HIT' );
+ }
+
+ /**
+ * @testdox Cache keys differ based on query string parameters.
+ */
+ public function test_cache_key_depends_on_query_string() {
+ $response1 = $this->query_endpoint( 'single_entity' );
+ $this->assertCacheHeader( $response1, 'MISS' );
+ $this->assertCount( 1, $this->get_all_cache_keys() );
+
+ $response2 = $this->query_endpoint( 'single_entity', array( 'foo' => 'bar' ) );
+ $this->assertCacheHeader( $response2, 'MISS' );
+ $this->assertCount( 2, $this->get_all_cache_keys() );
+
+ $response3 = $this->query_endpoint( 'single_entity' );
+ $this->assertCacheHeader( $response3, 'HIT' );
+ }
+
+ /**
+ * @testdox Cache keys differ based on HTTP method.
+ */
+ public function test_cache_key_depends_on_http_method() {
+ $response1 = $this->query_endpoint( 'multi_method', null, 'GET' );
+ $this->assertCacheHeader( $response1, 'MISS' );
+ $this->assertCount( 1, $this->get_all_cache_keys() );
+
+ $response2 = $this->query_endpoint( 'multi_method', null, 'POST' );
+ $this->assertCacheHeader( $response2, 'MISS' );
+ $this->assertCount( 2, $this->get_all_cache_keys() );
+
+ $response3 = $this->query_endpoint( 'multi_method', null, 'GET' );
+ $this->assertCacheHeader( $response3, 'HIT' );
+
+ $response4 = $this->query_endpoint( 'multi_method', null, 'POST' );
+ $this->assertCacheHeader( $response4, 'HIT' );
+ }
+
+ /**
+ * @testdox Caching is skipped when _skip_cache parameter is set.
+ * @testWith ["1"]
+ * ["true"]
+ *
+ * @param string $skip_cache_value Value for _skip_cache parameter.
+ */
+ public function test_skip_cache_parameter_bypasses_caching( $skip_cache_value ) {
+ $response = $this->query_endpoint( 'single_entity', array( '_skip_cache' => $skip_cache_value ) );
+ $this->assertCacheHeader( $response, 'SKIP' );
+ $this->assertCount( 0, $this->get_all_cache_keys() );
+ }
+
+ /**
+ * @testdox Caching is skipped when entity versions cache is disabled.
+ */
+ public function test_caching_skipped_when_entity_cache_disabled() {
+ wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
+ array( 'wp_using_ext_object_cache' => fn() => false )
+ );
+ $this->reset_container_resolutions();
+ $this->sut->reinitialize_cache();
+
+ $response = $this->query_endpoint( 'single_entity' );
+ $this->assertCacheHeader( $response, null );
+ $this->assertCount( 0, $this->get_all_cache_keys() );
+ }
+
+ /**
+ * @testdox Caching is skipped when filter returns false.
+ */
+ public function test_caching_skipped_when_filter_returns_false() {
+ add_filter( 'woocommerce_rest_api_enable_response_caching', '__return_false' );
+
+ $response = $this->query_endpoint( 'single_entity' );
+ $this->assertCacheHeader( $response, 'SKIP' );
+ $this->assertCount( 0, $this->get_all_cache_keys() );
+
+ remove_all_filters( 'woocommerce_rest_api_enable_response_caching' );
+ }
+
+ /**
+ * @testdox Custom entity_type can be specified per endpoint.
+ */
+ public function test_custom_entity_type_per_endpoint() {
+ $response1 = $this->query_endpoint( 'custom_entity_type' );
+ $this->assertCacheHeader( $response1, 'MISS' );
+ $this->assertCount( 1, $this->get_all_cache_keys() );
+ $this->assertNotEmpty( $this->version_generator->get_version( 'custom_thing_100' ) );
+
+ $response2 = $this->query_endpoint( 'custom_entity_type' );
+ $this->assertCacheHeader( $response2, 'HIT' );
+
+ $this->version_generator->generate_version( 'custom_thing_100' );
+ $response3 = $this->query_endpoint( 'custom_entity_type' );
+ $this->assertCacheHeader( $response3, 'MISS' );
+ }
+
+ /**
+ * @testdox Cache varies by user ID automatically.
+ */
+ public function test_cache_varies_by_user() {
+ wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
+ array( 'get_current_user_id' => fn() => 1 )
+ );
+ $response1 = $this->query_endpoint( 'single_entity' );
+ $this->assertCacheHeader( $response1, 'MISS' );
+ $this->assertCount( 1, $this->get_all_cache_keys() );
+
+ wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
+ array( 'get_current_user_id' => fn() => 2 )
+ );
+ $response2 = $this->query_endpoint( 'single_entity' );
+ $this->assertCacheHeader( $response2, 'MISS' );
+ $this->assertCount( 2, $this->get_all_cache_keys() );
+ }
+
+ /**
+ * @testdox Non-array responses (scalars, null, 204 No Content) are cached without crashing.
+ * @testWith ["scalar"]
+ * ["null"]
+ * ["no_content"]
+ *
+ * @param string $response_type Type of response to test.
+ */
+ public function test_non_array_responses_are_handled_gracefully( string $response_type ) {
+ $response1 = $this->query_endpoint( 'non_array_response', array( 'type' => $response_type ) );
+ $this->assertCacheHeader( $response1, 'MISS' );
+ $this->assertCount( 1, $this->get_all_cache_keys() );
+
+ $response2 = $this->query_endpoint( 'non_array_response', array( 'type' => $response_type ) );
+ $this->assertCacheHeader( $response2, 'HIT' );
+ $this->assertEquals( $response1->get_data(), $response2->get_data() );
+ }
+
+ /**
+ * Test that controllers can return raw arrays without wrapping in WP_REST_Response.
+ */
+ public function test_raw_array_responses_are_cached() {
+ $response1 = $this->query_endpoint( 'raw_array_response' );
+ $this->assertCacheHeader( $response1, 'MISS' );
+ $this->assertInstanceOf( WP_REST_Response::class, $response1 );
+ $this->assertSame( 200, $response1->get_status() );
+ $this->assertIsArray( $response1->get_data() );
+ $this->assertArrayHasKey( 'id', $response1->get_data() );
+ $this->assertSame( 42, $response1->get_data()['id'] );
+
+ $response2 = $this->query_endpoint( 'raw_array_response' );
+ $this->assertCacheHeader( $response2, 'HIT' );
+ $this->assertEquals( $response1->get_data(), $response2->get_data() );
+ }
+
+ /**
+ * @testdox SKIP header is added to raw array responses when cache is skipped.
+ */
+ public function test_skip_header_added_for_raw_array_responses() {
+ $response = $this->query_endpoint( 'raw_array_response', array( '_skip_cache' => 'true' ) );
+ $this->assertCacheHeader( $response, 'SKIP' );
+ $this->assertInstanceOf( WP_REST_Response::class, $response );
+ $this->assertIsArray( $response->get_data() );
+ $this->assertArrayHasKey( 'id', $response->get_data() );
+ $this->assertSame( 42, $response->get_data()['id'] );
+ $this->assertCount( 0, $this->get_all_cache_keys() );
+ }
+
+ /**
+ * Query an endpoint and return the response.
+ *
+ * @param string $endpoint_name Endpoint name.
+ * @param array|null $query_params Optional query parameters.
+ * @param string|null $method Optional HTTP method (default: GET).
+ */
+ private function query_endpoint( $endpoint_name, $query_params = null, $method = null ) {
+ $request = new WP_REST_Request( $method ?? 'GET', "/wc/v3/rest_api_cache_test/{$endpoint_name}" );
+ if ( ! is_null( $query_params ) ) {
+ $request->set_query_params( $query_params );
+ }
+ return $this->server->dispatch( $request );
+ }
+
+ /**
+ * Assert cache header value (HIT, MISS, SKIP, or null for no header).
+ *
+ * @param WP_REST_Response $response The response to check.
+ * @param string|null $expected_value Expected header value.
+ */
+ private function assertCacheHeader( $response, ?string $expected_value ) {
+ $this->assertInstanceOf( WP_REST_Response::class, $response );
+ if ( is_null( $expected_value ) ) {
+ $this->assertArrayNotHasKey( 'X-WC-Cache', $response->get_headers() );
+ } else {
+ $this->assertEquals( $expected_value, $response->get_headers()['X-WC-Cache'] );
+ }
+ }
+
+ /**
+ * Get all cache keys (works with unit test memory cache only).
+ */
+ private function get_all_cache_keys(): array {
+ global $wp_object_cache;
+ return isset( $wp_object_cache->cache[ self::CACHE_GROUP ] )
+ ? array_keys( $wp_object_cache->cache[ self::CACHE_GROUP ] )
+ : array();
+ }
+
+ /**
+ * Create a test controller.
+ */
+ private function create_test_controller() {
+ return new class() extends WP_REST_Controller {
+ // phpcs:disable Squiz.Commenting
+ use RestApiCache;
+
+ public $responses = array(
+ 'single_entity' => array(
+ 'id' => 1,
+ 'name' => 'Product 1',
+ ),
+ 'multiple_entities' => array(
+ array(
+ 'id' => 2,
+ 'name' => 'Product 2',
+ ),
+ array(
+ 'id' => 3,
+ 'name' => 'Product 3',
+ ),
+ ),
+ 'custom_entity_type' => array(
+ 'id' => 100,
+ 'name' => 'Custom Thing 100',
+ ),
+ );
+ public $default_entity_type = 'product';
+
+ public function __construct() {
+ $this->namespace = 'wc/v3';
+ $this->rest_base = 'rest_api_cache_test';
+ $this->initialize_rest_api_cache();
+ }
+
+ public function register_routes() {
+ $this->register_cached_route( 'single_entity' );
+ $this->register_cached_route( 'multiple_entities' );
+ $this->register_cached_route( 'custom_entity_type', array( 'entity_type' => 'custom_thing' ) );
+ $this->register_cached_route( 'non_array_response', array( 'entity_type' => 'custom_thing' ), true );
+ $this->register_cached_route( 'raw_array_response', array(), false, true );
+ $this->register_multi_method_route();
+ }
+
+ private function register_cached_route( string $endpoint, array $cache_args = array(), bool $non_array_request = false, bool $raw_response = false ) {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/' . $endpoint,
+ array(
+ 'methods' => 'GET',
+ 'callback' => $this->with_cache(
+ function ( $request ) use ( $endpoint, $non_array_request, $raw_response ) {
+ if ( $raw_response ) {
+ return $this->handle_raw_array_request( $endpoint, $request );
+ }
+ return $non_array_request ?
+ $this->handle_non_array_request( $request ) :
+ $this->handle_request( $endpoint, $request );
+ },
+ $cache_args
+ ),
+ 'permission_callback' => '__return_true',
+ )
+ );
+ }
+
+ private function register_multi_method_route() {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/multi_method',
+ array(
+ 'methods' => array( 'GET', 'POST' ),
+ 'callback' => $this->with_cache(
+ function ( $request ) {
+ $method = $request->get_method();
+ return new WP_REST_Response(
+ array(
+ 'id' => 'GET' === $method ? 10 : 20,
+ 'method' => $method,
+ ),
+ 200
+ );
+ }
+ ),
+ 'permission_callback' => '__return_true',
+ )
+ );
+ }
+
+ protected function get_default_entity_type(): ?string {
+ return $this->default_entity_type;
+ }
+
+ private function handle_request( string $endpoint, WP_REST_Request $request ) {
+ return new WP_REST_Response( $this->responses[ $endpoint ], 200 );
+ }
+
+ private function handle_raw_array_request( string $endpoint, WP_REST_Request $request ) {
+ return array(
+ 'id' => 42,
+ 'name' => 'Raw Array Item',
+ );
+ }
+
+ private function handle_non_array_request( WP_REST_Request $request ) {
+ $type = $request->get_param( 'type' );
+ switch ( $type ) {
+ case 'scalar':
+ return new WP_REST_Response( 'string_value', 200 );
+ case 'no_content':
+ return new WP_REST_Response( null, 204 );
+ default:
+ return new WP_REST_Response( null, 200 );
+ }
+ }
+
+ public function reinitialize_cache() {
+ $this->initialize_rest_api_cache();
+ }
+
+ // phpcs:enable Squiz.Commenting
+ };
+ }
+}