From 24228ce43364d6af51b1451fedb635d8f44f93dc Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 22:21:05 +0800 Subject: [PATCH] fix(api-cache): list cache keys cover the request path and session scope List cache keys were built from the table, the session company, the query string and has_callback only. Query callbacks that filter on session values (storefront_store, storefront_network) or on route parameters, and internal and public endpoints that paginate and eager load differently, therefore shared one cache entry per company and query: one store's products, catalogs, reviews or categories were served to another store or network of the same company, and an internal console paginator could be served to the public API (or the reverse). The params hash now also covers the request path and the values of the session keys listed in api.cache.scope.session (default: api_credential, storefront_store, storefront_network). Both are hashed, so credentials do not appear in the key or the X-Cache-Key debug header. --- config/api.php | 9 ++++ src/Support/ApiModelCache.php | 49 ++++++++++++++++- tests/Unit/Support/ApiModelCacheTest.php | 69 ++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 2 deletions(-) diff --git a/config/api.php b/config/api.php index 826cc4cc..16ca6cf0 100644 --- a/config/api.php +++ b/config/api.php @@ -60,6 +60,15 @@ 'relationship' => env('API_CACHE_RELATIONSHIP_TTL', 1800), ], + // List cache scope below the company + 'scope' => [ + // Session keys whose values are part of every list cache key, so lists + // filtered on them are not shared between their values. Add any key a + // query callback reads from the session but not from the query string. + // Default: the API credential and the Storefront store and network + 'session' => ['api_credential', 'storefront_store', 'storefront_network'], + ], + // Cache driver (uses Laravel's cache configuration) // Options: redis, memcached, database, file // Default: uses config('cache.default') diff --git a/src/Support/ApiModelCache.php b/src/Support/ApiModelCache.php index 9ce16762..2e1b99c9 100644 --- a/src/Support/ApiModelCache.php +++ b/src/Support/ApiModelCache.php @@ -59,8 +59,24 @@ class ApiModelCache */ public const RELATIONSHIP_TTL = 1800; + /** + * Session keys that scope list queries below the company (default). + * + * An API credential, or a Storefront store or network key, is resolved into + * these by middleware, and query callbacks filter on them without the value + * appearing in the query string. + */ + public const SESSION_SCOPE = ['api_credential', 'storefront_store', 'storefront_network']; + /** * Generate a cache key for a query. + * + * Besides the table, company and query string, the key covers the request path + * and the configured session scope. The path separates internal from public + * endpoints (which paginate and eager load differently) and endpoints whose + * callbacks read route parameters; the session scope separates API keys, stores + * and networks of one company. Both are hashed, so credentials never appear in + * the key or in the X-Cache-Key debug header. */ public static function generateQueryCacheKey(Model $model, Request $request, array $additionalParams = []): string { @@ -90,8 +106,12 @@ public static function generateQueryCacheKey(Model $model, Request $request, arr $params = array_filter($params, fn ($value) => $value !== null); ksort($params); - // Generate hash of parameters - $paramsHash = md5(json_encode($params)); + // Generate hash of parameters, the endpoint and the session scope + $paramsHash = md5(json_encode([ + 'params' => $params, + 'path' => $request->path(), + 'scope' => static::getSessionScope($request), + ])); // Include company UUID for multi-tenancy $companyPart = $companyUuid ? "company_{$companyUuid}" : 'no_company'; @@ -490,6 +510,31 @@ protected static function getCompanyUuid(Request $request): ?string return $request->input('company_uuid'); } + /** + * Extract the configured session scope values from request. + */ + protected static function getSessionScope(Request $request): array + { + if (!$request->hasSession()) { + return []; + } + + $keys = config('api.cache.scope.session') ?? static::SESSION_SCOPE; + $scope = []; + + foreach ($keys as $key) { + $value = $request->session()->get($key); + + if ($value !== null) { + $scope[$key] = $value; + } + } + + ksort($scope); + + return $scope; + } + /** * Warm up cache for a model. */ diff --git a/tests/Unit/Support/ApiModelCacheTest.php b/tests/Unit/Support/ApiModelCacheTest.php index 047067fb..98978d5b 100644 --- a/tests/Unit/Support/ApiModelCacheTest.php +++ b/tests/Unit/Support/ApiModelCacheTest.php @@ -266,6 +266,7 @@ function api_model_cache_fixture(array $config = []): ApiModelCacheTestStore 'api.cache.ttl.query' => 111, 'api.cache.ttl.model' => 222, 'api.cache.ttl.relationship' => 333, + 'api.cache.scope.session' => null, 'cache.default' => 'array', ], $config)); @@ -300,6 +301,16 @@ function api_model_cache_request(array $query = [], mixed $company = 'company-1' return $request; } +function api_model_cache_scoped_request(string $path, array $session, array $query = []): Request +{ + $request = Request::create($path, 'GET', $query); + $store = new Store('testing', new ApiModelCacheArraySessionHandler()); + $store->put($session); + $request->setLaravelSession($store); + + return $request; +} + function api_model_cache_internal_request(array $query = [], mixed $company = 'company-1'): Request { $request = api_model_cache_request($query, $company); @@ -673,6 +684,64 @@ public function gc(int $max_lifetime): int|false ->and($store->get('api_model_version:orders'))->toBe(2); }); +test('api model cache keeps lists of storefront stores and networks of the same company apart', function () { + api_model_cache_fixture(); + $model = api_model_cache_model(); + $query = ['limit' => '10']; + $callback = ['has_callback' => true]; + $storeA = api_model_cache_scoped_request('/storefront/v1/products', ['company' => 'company-1', 'storefront_store' => 'store-a', 'api_credential' => 'store_key_a'], $query); + $storeB = api_model_cache_scoped_request('/storefront/v1/products', ['company' => 'company-1', 'storefront_store' => 'store-b', 'api_credential' => 'store_key_b'], $query); + $network = api_model_cache_scoped_request('/storefront/v1/products', ['company' => 'company-1', 'storefront_network' => 'network-a', 'api_credential' => 'network_key_a'], $query); + + expect(ApiModelCache::cacheQueryResult($model, $storeA, fn () => collect(['store a product']), $callback))->toEqual(collect(['store a product'])); + $storeAKey = ApiModelCache::getCacheKey(); + + expect(ApiModelCache::cacheQueryResult($model, $storeB, fn () => collect(['store b product']), $callback))->toEqual(collect(['store b product'])) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS') + ->and(ApiModelCache::cacheQueryResult($model, $network, fn () => collect(['network product']), $callback))->toEqual(collect(['network product'])) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS') + ->and(ApiModelCache::cacheQueryResult($model, $storeA, fn () => collect(['not reached']), $callback))->toEqual(collect(['store a product'])) + ->and(ApiModelCache::getCacheStatus())->toBe('HIT') + ->and($storeAKey)->toStartWith('{api_query}:orders:company_company-1:v0:') + ->and($storeAKey)->not->toContain('store_key_a'); +}); + +test('api model cache keeps internal and public lists of the same company and query apart', function () { + api_model_cache_fixture(); + $model = api_model_cache_model(); + $query = ['limit' => '30']; + $callback = ['has_callback' => true]; + $internal = api_model_cache_scoped_request('/storefront/int/v1/catalogs', ['company' => 'company-1'], $query); + $public = api_model_cache_scoped_request('/storefront/v1/catalogs', ['company' => 'company-1', 'storefront_store' => 'store-a', 'api_credential' => 'store_key_a'], $query); + $console = api_model_cache_scoped_request('/storefront/int/v1/catalogs', ['company' => 'company-1'], $query); + + ApiModelCache::cacheQueryResult($model, $internal, fn () => 'internal paginator with editor relations', $callback); + + expect(ApiModelCache::cacheQueryResult($model, $public, fn () => collect(['store a catalog']), $callback))->toEqual(collect(['store a catalog'])) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS') + ->and(ApiModelCache::cacheQueryResult($model, $console, fn () => 'not reached', $callback))->toBe('internal paginator with editor relations') + ->and(ApiModelCache::getCacheStatus())->toBe('HIT'); +}); + +test('api model cache keeps public lists of the same company and query on different endpoints apart', function () { + api_model_cache_fixture(); + $model = api_model_cache_model(); + $session = ['company' => 'company-1', 'api_credential' => 'credential-1']; + $key = fn (string $path) => ApiModelCache::generateQueryCacheKey($model, api_model_cache_scoped_request($path, $session, ['limit' => '10']), ['has_callback' => true]); + + expect($key('/v1/customers/customer_a/places'))->not->toBe($key('/v1/customers/customer_b/places')) + ->and($key('/v1/customers/customer_a/places'))->toBe($key('/v1/customers/customer_a/places')); +}); + +test('api model cache scopes list keys by the configured session keys only', function () { + api_model_cache_fixture(['api.cache.scope.session' => ['customer']]); + $model = api_model_cache_model(); + $key = fn (array $session) => ApiModelCache::generateQueryCacheKey($model, api_model_cache_scoped_request('/v1/orders', ['company' => 'company-1', ...$session])); + + expect($key(['customer' => 'customer-a']))->not->toBe($key(['customer' => 'customer-b'])) + ->and($key(['customer' => 'customer-a', 'storefront_store' => 'store-a']))->toBe($key(['customer' => 'customer-a', 'storefront_store' => 'store-b'])); +}); + test('api model cache resolves tenant scope from authenticated user and request input fallbacks', function () { api_model_cache_fixture(); $model = api_model_cache_model();