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();