Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions config/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
49 changes: 47 additions & 2 deletions src/Support/ApiModelCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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.
*/
Expand Down
69 changes: 69 additions & 0 deletions tests/Unit/Support/ApiModelCacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
Loading