mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-08-03 07:01:38 +00:00
Store temporary TME access keys in cache
This commit is contained in:
parent
d3020698a3
commit
18e250abce
2 changed files with 113 additions and 3 deletions
|
|
@ -24,6 +24,7 @@ declare(strict_types=1);
|
|||
namespace App\Services\InfoProviderSystem\Providers;
|
||||
|
||||
use App\Settings\InfoProviderSystem\TMESettings;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
|
||||
|
|
@ -31,16 +32,69 @@ class TMEClient
|
|||
{
|
||||
public const BASE_URI = 'https://api.tme.eu';
|
||||
|
||||
private const CACHE_KEY_PREFIX = 'tme_oauth_token_';
|
||||
|
||||
private ?string $accessToken = null;
|
||||
private ?string $refreshToken = null;
|
||||
private int $tokenExpiry = 0;
|
||||
private bool $loadedFromCache = false;
|
||||
|
||||
public function __construct(private readonly HttpClientInterface $tmeClient, private readonly TMESettings $settings)
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $tmeClient,
|
||||
private readonly TMESettings $settings,
|
||||
private readonly CacheItemPoolInterface $infoProviderCache,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* The cache key is derived from the configured credentials, so changing the
|
||||
* token/secret in the settings automatically invalidates any previously cached token.
|
||||
*/
|
||||
private function getCacheKey(): string
|
||||
{
|
||||
return self::CACHE_KEY_PREFIX . hash('xxh3', $this->settings->apiToken . ':' . $this->settings->apiSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a previously persisted token (if any) into the in-memory properties, so it can be
|
||||
* reused across requests instead of always starting a fresh client_credentials flow.
|
||||
*/
|
||||
private function loadFromCache(): void
|
||||
{
|
||||
if ($this->loadedFromCache) {
|
||||
return;
|
||||
}
|
||||
$this->loadedFromCache = true;
|
||||
|
||||
$item = $this->infoProviderCache->getItem($this->getCacheKey());
|
||||
if ($item->isHit()) {
|
||||
$data = $item->get();
|
||||
$this->accessToken = $data['access_token'];
|
||||
$this->refreshToken = $data['refresh_token'];
|
||||
$this->tokenExpiry = $data['expiry'];
|
||||
}
|
||||
}
|
||||
|
||||
private function saveToCache(): void
|
||||
{
|
||||
$item = $this->infoProviderCache->getItem($this->getCacheKey());
|
||||
$item->set([
|
||||
'access_token' => $this->accessToken,
|
||||
'refresh_token' => $this->refreshToken,
|
||||
'expiry' => $this->tokenExpiry,
|
||||
]);
|
||||
// Keep the refresh token available in the cache for a while after the access token itself expired
|
||||
$item->expiresAfter(max($this->tokenExpiry - time(), 0) + 60 * 60 * 24 * 7);
|
||||
$this->infoProviderCache->save($item);
|
||||
}
|
||||
|
||||
private function getAccessToken(): string
|
||||
{
|
||||
if ($this->accessToken === null) {
|
||||
$this->loadFromCache();
|
||||
}
|
||||
|
||||
// Return cached token if still valid (30-second safety margin before expiry)
|
||||
if ($this->accessToken !== null && time() < $this->tokenExpiry - 30) {
|
||||
return $this->accessToken;
|
||||
|
|
@ -75,6 +129,8 @@ class TMEClient
|
|||
$this->accessToken = $data['access_token'];
|
||||
$this->tokenExpiry = time() + (int) $data['expires_in'];
|
||||
$this->refreshToken = $data['refresh_token'] ?? null;
|
||||
|
||||
$this->saveToCache();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ use App\Services\InfoProviderSystem\Providers\TMEProvider;
|
|||
use App\Settings\InfoProviderSystem\TMESettings;
|
||||
use App\Tests\SettingsTestHelper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
|
||||
|
|
@ -51,7 +52,7 @@ final class TMEProviderTest extends TestCase
|
|||
$this->settings->currency = 'EUR';
|
||||
$this->settings->language = 'en';
|
||||
$this->settings->country = 'DE';
|
||||
$this->provider = new TMEProvider(new TMEClient($this->httpClient, $this->settings), $this->settings);
|
||||
$this->provider = new TMEProvider(new TMEClient($this->httpClient, $this->settings, new ArrayAdapter()), $this->settings, $this->httpClient);
|
||||
}
|
||||
|
||||
// --- Mock response helpers ---
|
||||
|
|
@ -258,7 +259,7 @@ final class TMEProviderTest extends TestCase
|
|||
public function testIsActiveWithoutCredentials(): void
|
||||
{
|
||||
$this->settings->apiToken = null;
|
||||
$provider = new TMEProvider(new TMEClient($this->httpClient, $this->settings), $this->settings);
|
||||
$provider = new TMEProvider(new TMEClient($this->httpClient, $this->settings, new ArrayAdapter()), $this->settings, $this->httpClient);
|
||||
$this->assertFalse($provider->isActive());
|
||||
}
|
||||
|
||||
|
|
@ -286,6 +287,59 @@ final class TMEProviderTest extends TestCase
|
|||
$this->assertNull($this->provider->getIDFromURL('https://www.tme.eu/en/'));
|
||||
}
|
||||
|
||||
public function testAccessTokenIsCachedAcrossClientInstances(): void
|
||||
{
|
||||
// A single ArrayAdapter shared between two independent TMEClient instances simulates
|
||||
// the token cache surviving across separate requests (each request builds a fresh client).
|
||||
$cache = new ArrayAdapter();
|
||||
|
||||
// First client has to fetch a token before making its API call
|
||||
$client1 = new TMEClient($this->httpClient, $this->settings, $cache);
|
||||
$this->httpClient->setResponseFactory([
|
||||
$this->mockTokenResponse(),
|
||||
$this->smd0603SearchResults(),
|
||||
]);
|
||||
$client1->makeRequest('products/search', ['phrase' => 'SMD0603-5K1-1%']);
|
||||
|
||||
// Second client is a fresh instance, but shares the cache pool, so no token request should be made
|
||||
$client2 = new TMEClient($this->httpClient, $this->settings, $cache);
|
||||
$this->httpClient->setResponseFactory([
|
||||
$this->smd0603SearchResults(),
|
||||
]);
|
||||
$response = $client2->makeRequest('products/search', ['phrase' => 'SMD0603-5K1-1%']);
|
||||
|
||||
$this->assertSame('OK', $response->toArray()['status']);
|
||||
}
|
||||
|
||||
public function testAccessTokenIsRefetchedAfterExpiryEvenWithSharedCache(): void
|
||||
{
|
||||
$cache = new ArrayAdapter();
|
||||
|
||||
$client1 = new TMEClient($this->httpClient, $this->settings, $cache);
|
||||
$this->httpClient->setResponseFactory([
|
||||
new MockResponse(json_encode([
|
||||
'access_token' => 'mock_access_token',
|
||||
'token_type' => 'Bearer',
|
||||
'expires_in' => -1, // already expired
|
||||
'refresh_token' => 'mock_refresh_token',
|
||||
])),
|
||||
$this->smd0603SearchResults(),
|
||||
]);
|
||||
$client1->makeRequest('products/search', ['phrase' => 'SMD0603-5K1-1%']);
|
||||
|
||||
// The cached token is expired, and the refresh_token grant fails (no mock queued for it beyond
|
||||
// the token response below), so a fresh client_credentials token must be fetched.
|
||||
$client2 = new TMEClient($this->httpClient, $this->settings, $cache);
|
||||
$this->httpClient->setResponseFactory([
|
||||
new MockResponse('', ['http_code' => 400]), // refresh_token grant fails
|
||||
$this->mockTokenResponse(), // fallback client_credentials grant
|
||||
$this->smd0603SearchResults(),
|
||||
]);
|
||||
$response = $client2->makeRequest('products/search', ['phrase' => 'SMD0603-5K1-1%']);
|
||||
|
||||
$this->assertSame('OK', $response->toArray()['status']);
|
||||
}
|
||||
|
||||
public function testSearchByKeyword(): void
|
||||
{
|
||||
// Request order: POST /auth/token, GET /products/search
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue