diff --git a/composer.json b/composer.json index 921f82b3..79b92618 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,8 @@ "ramsey/uuid": "^4.7" }, "require-dev": { + "beste/clock": "^3.0", + "beste/in-memory-cache": "^1.4", "friendsofphp/php-cs-fixer": "^3.80.0", "guzzlehttp/guzzle": "^7", "kevinrob/guzzle-cache-middleware": "^4.0", diff --git a/psalm-baseline.xml b/psalm-baseline.xml index bc3d379f..48f6241e 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,5 +1,5 @@ - + @@ -249,6 +249,15 @@ + + + + + + + + + diff --git a/src/LaunchDarkly/Impl/Integrations/FeatureRequesterBase.php b/src/LaunchDarkly/Impl/Integrations/FeatureRequesterBase.php index 91ee002a..158ede8f 100644 --- a/src/LaunchDarkly/Impl/Integrations/FeatureRequesterBase.php +++ b/src/LaunchDarkly/Impl/Integrations/FeatureRequesterBase.php @@ -7,6 +7,7 @@ use LaunchDarkly\Impl\Model\FeatureFlag; use LaunchDarkly\Impl\Model\Segment; use LaunchDarkly\Subsystems\FeatureRequester; +use Psr\Cache\CacheItemPoolInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -71,6 +72,22 @@ protected function readItemStringList(string $namespace): ?array */ protected function createCache(array $options): ?FeatureRequesterCache { + // NOTE: The LDClient constructor accepts a cache parameter. This has + // historically been only for the GuzzleFeatureRequester. + // + // However, that property may be overwritten by the config options + // provided to things like the Redis implementation. In those + // situations, the config is expected to be PSR-6 compatible. + // + // If it is, we will use it here. If it isn't, we will fall back to the + // backwards-compatible behavior of using the 'apc_expiration' option + // to determine whether to use APCu caching. + $cache = $options['cache'] ?? null; + if ($cache instanceof CacheItemPoolInterface) { + $ttl = $options['cache_ttl'] ?? null; + return new Psr6FeatureRequesterCache($cache, $ttl); + } + $expiration = (int)($options['apc_expiration'] ?? 0); return ($expiration > 0) ? new ApcuFeatureRequesterCache($expiration) : null; } diff --git a/src/LaunchDarkly/Impl/Integrations/GuzzleFeatureRequester.php b/src/LaunchDarkly/Impl/Integrations/GuzzleFeatureRequester.php index 41711b02..0da8bb73 100644 --- a/src/LaunchDarkly/Impl/Integrations/GuzzleFeatureRequester.php +++ b/src/LaunchDarkly/Impl/Integrations/GuzzleFeatureRequester.php @@ -15,6 +15,7 @@ use LaunchDarkly\Impl\UnrecoverableHTTPStatusException; use LaunchDarkly\Impl\Util; use LaunchDarkly\Subsystems\FeatureRequester; +use Psr\Cache\CacheItemPoolInterface; use Psr\Log\LoggerInterface; /** @@ -35,9 +36,13 @@ public function __construct(string $baseUri, string $sdkKey, array $options) $this->_logger = $options['logger']; $stack = HandlerStack::create(); if (class_exists('\Kevinrob\GuzzleCache\CacheMiddleware')) { + $cache = $options['cache'] ?? null; + if ($cache !== null && $cache instanceof CacheItemPoolInterface) { + $cache = new \Kevinrob\GuzzleCache\Storage\Psr6CacheStorage($cache); + } $stack->push( new CacheMiddleware( - new PublicCacheStrategy($options['cache'] ?? null) + new PublicCacheStrategy($cache) ), 'cache' ); diff --git a/src/LaunchDarkly/Impl/Integrations/Psr6FeatureRequesterCache.php b/src/LaunchDarkly/Impl/Integrations/Psr6FeatureRequesterCache.php new file mode 100644 index 00000000..19488baa --- /dev/null +++ b/src/LaunchDarkly/Impl/Integrations/Psr6FeatureRequesterCache.php @@ -0,0 +1,46 @@ +pool->getItem($this->sanitizeKey($cacheKey)); + return $item->isHit() ? $item->get() : null; + } + + public function putCachedString(string $cacheKey, ?string $data): void + { + $item = $this->pool->getItem($this->sanitizeKey($cacheKey)); + $item->set($data); + $item->expiresAfter($this->ttl); + $this->pool->save($item); + } + + /** + * PSR-6 only mandates support for A-Za-z0-9._ in cache keys. + * Current keys use ":" and "$" (e.g. "launchdarkly:features:$all"). + * Hex-encode unsafe chars to avoid collisions. + */ + private function sanitizeKey(string $key): string + { + return (string) preg_replace_callback('/[^A-Za-z0-9.]/', function ($m) { + return '_' . bin2hex($m[0]); + }, $key); + } +} diff --git a/tests/Impl/Integrations/GuzzleFeatureRequesterTest.php b/tests/Impl/Integrations/GuzzleFeatureRequesterTest.php index b35e8001..da98eee3 100644 --- a/tests/Impl/Integrations/GuzzleFeatureRequesterTest.php +++ b/tests/Impl/Integrations/GuzzleFeatureRequesterTest.php @@ -2,7 +2,9 @@ namespace LaunchDarkly\Tests\Impl\Integrations; +use Beste\Cache\InMemoryCache; use GuzzleHttp\Client; +use Kevinrob\GuzzleCache\Storage\Psr6CacheStorage; use LaunchDarkly\Impl\Integrations\GuzzleFeatureRequester; use LaunchDarkly\LDClient; use LaunchDarkly\Types\ApplicationInfo; @@ -177,4 +179,128 @@ public function testTimeoutReturnsDefaultValue(): void // The request should timeout and return null (default value) instead of throwing an exception $this->assertNull($result); } + + // --- Cache integration tests --- + + private function configureCacheableFlag(string $flagKey): void + { + $flagJson = json_encode([ + 'key' => $flagKey, + 'version' => 1, + 'on' => false, + 'prerequisites' => [], + 'salt' => '', + 'targets' => [], + 'rules' => [], + 'fallthrough' => ['variation' => 0], + 'offVariation' => 0, + 'variations' => [true], + 'deleted' => false, + ]); + + $mapping = [ + 'request' => [ + 'method' => 'GET', + 'url' => '/sdk/flags/' . $flagKey, + ], + 'response' => [ + 'status' => 200, + 'body' => $flagJson, + 'headers' => [ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'public, max-age=300', + ], + ], + ]; + + $client = new Client(); + $client->request('POST', 'http://localhost:8080/__admin/mappings', ['json' => $mapping]); + } + + private function getServerRequestCount(): int + { + $client = new Client(); + $response = $client->request('GET', 'http://localhost:8080/__admin/requests'); + $body = json_decode($response->getBody()->getContents(), true); + return count($body['requests']); + } + + public function testDefaultCacheServesFromCacheOnSecondRequest(): void + { + $this->configureCacheableFlag('default-cache-flag'); + + /** @var LoggerInterface **/ + $logger = $this->getMockBuilder(LoggerInterface::class)->getMock(); + + $requester = new GuzzleFeatureRequester('http://localhost:8080', 'sdk-key', [ + 'logger' => $logger, + 'timeout' => 3, + 'connect_timeout' => 3, + ]); + + $flag1 = $requester->getFeature('default-cache-flag'); + $flag2 = $requester->getFeature('default-cache-flag'); + + $this->assertNotNull($flag1); + $this->assertNotNull($flag2); + $this->assertEquals('default-cache-flag', $flag1->getKey()); + $this->assertEquals('default-cache-flag', $flag2->getKey()); + + // Only one request should have reached the server — the second was served from cache + $this->assertEquals(1, $this->getServerRequestCount()); + } + + public function testPsr6CacheServesFromCacheOnSecondRequest(): void + { + $this->configureCacheableFlag('psr6-cache-flag'); + + /** @var LoggerInterface **/ + $logger = $this->getMockBuilder(LoggerInterface::class)->getMock(); + + $pool = new InMemoryCache(); + $requester = new GuzzleFeatureRequester('http://localhost:8080', 'sdk-key', [ + 'cache' => $pool, + 'logger' => $logger, + 'timeout' => 3, + 'connect_timeout' => 3, + ]); + + $flag1 = $requester->getFeature('psr6-cache-flag'); + $flag2 = $requester->getFeature('psr6-cache-flag'); + + $this->assertNotNull($flag1); + $this->assertNotNull($flag2); + $this->assertEquals('psr6-cache-flag', $flag1->getKey()); + $this->assertEquals('psr6-cache-flag', $flag2->getKey()); + + // Only one request should have reached the server — the second was served from PSR-6 cache + $this->assertEquals(1, $this->getServerRequestCount()); + } + + public function testCacheStorageInterfaceServesFromCacheOnSecondRequest(): void + { + $this->configureCacheableFlag('storage-cache-flag'); + + /** @var LoggerInterface **/ + $logger = $this->getMockBuilder(LoggerInterface::class)->getMock(); + + $storage = new Psr6CacheStorage(new InMemoryCache()); + $requester = new GuzzleFeatureRequester('http://localhost:8080', 'sdk-key', [ + 'cache' => $storage, + 'logger' => $logger, + 'timeout' => 3, + 'connect_timeout' => 3, + ]); + + $flag1 = $requester->getFeature('storage-cache-flag'); + $flag2 = $requester->getFeature('storage-cache-flag'); + + $this->assertNotNull($flag1); + $this->assertNotNull($flag2); + $this->assertEquals('storage-cache-flag', $flag1->getKey()); + $this->assertEquals('storage-cache-flag', $flag2->getKey()); + + // Only one request should have reached the server — the second was served from CacheStorage cache + $this->assertEquals(1, $this->getServerRequestCount()); + } } diff --git a/tests/Impl/Integrations/GuzzleFeatureRequesterUnitTest.php b/tests/Impl/Integrations/GuzzleFeatureRequesterUnitTest.php new file mode 100644 index 00000000..774d7f43 --- /dev/null +++ b/tests/Impl/Integrations/GuzzleFeatureRequesterUnitTest.php @@ -0,0 +1,63 @@ +createMock(CacheItemPoolInterface::class); + + $requester = new GuzzleFeatureRequester('http://localhost', 'sdk-key', [ + 'cache' => $pool, + 'logger' => new NullLogger(), + 'timeout' => 3, + 'connect_timeout' => 3, + ]); + + self::assertInstanceOf(GuzzleFeatureRequester::class, $requester); + } + + public function testConstructorAcceptsNullCache(): void + { + $requester = new GuzzleFeatureRequester('http://localhost', 'sdk-key', [ + 'cache' => null, + 'logger' => new NullLogger(), + 'timeout' => 3, + 'connect_timeout' => 3, + ]); + + self::assertInstanceOf(GuzzleFeatureRequester::class, $requester); + } + + public function testConstructorAcceptsCacheStorageInterface(): void + { + $storage = $this->createMock(CacheStorageInterface::class); + + $requester = new GuzzleFeatureRequester('http://localhost', 'sdk-key', [ + 'cache' => $storage, + 'logger' => new NullLogger(), + 'timeout' => 3, + 'connect_timeout' => 3, + ]); + + self::assertInstanceOf(GuzzleFeatureRequester::class, $requester); + } + + public function testConstructorAcceptsNoCacheOption(): void + { + $requester = new GuzzleFeatureRequester('http://localhost', 'sdk-key', [ + 'logger' => new NullLogger(), + 'timeout' => 3, + 'connect_timeout' => 3, + ]); + + self::assertInstanceOf(GuzzleFeatureRequester::class, $requester); + } +} diff --git a/tests/Impl/Integrations/Psr6FeatureRequesterCacheTest.php b/tests/Impl/Integrations/Psr6FeatureRequesterCacheTest.php new file mode 100644 index 00000000..2f20d4f3 --- /dev/null +++ b/tests/Impl/Integrations/Psr6FeatureRequesterCacheTest.php @@ -0,0 +1,209 @@ +createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(false); + + $pool = $this->createMock(CacheItemPoolInterface::class); + $pool->method('getItem')->willReturn($item); + + $cache = new Psr6FeatureRequesterCache($pool); + self::assertNull($cache->getCachedString('some-key')); + } + + public function testGetReturnsValueOnHit(): void + { + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(true); + $item->method('get')->willReturn('cached-value'); + + $pool = $this->createMock(CacheItemPoolInterface::class); + $pool->method('getItem')->willReturn($item); + + $cache = new Psr6FeatureRequesterCache($pool); + self::assertEquals('cached-value', $cache->getCachedString('some-key')); + } + + public function testPutStoresValueWithNullTtlByDefault(): void + { + $item = $this->createMock(CacheItemInterface::class); + $item->expects(self::once())->method('set')->with('the-data'); + $item->expects(self::once())->method('expiresAfter')->with(null); + + $pool = $this->createMock(CacheItemPoolInterface::class); + $pool->method('getItem')->willReturn($item); + $pool->expects(self::once())->method('save')->with($item); + + $cache = new Psr6FeatureRequesterCache($pool); + $cache->putCachedString('some-key', 'the-data'); + } + + public function testPutStoresValueWithExplicitTtl(): void + { + $item = $this->createMock(CacheItemInterface::class); + $item->expects(self::once())->method('set')->with('the-data'); + $item->expects(self::once())->method('expiresAfter')->with(60); + + $pool = $this->createMock(CacheItemPoolInterface::class); + $pool->method('getItem')->willReturn($item); + $pool->expects(self::once())->method('save')->with($item); + + $cache = new Psr6FeatureRequesterCache($pool, 60); + $cache->putCachedString('some-key', 'the-data'); + } + + public function testPutStoresNullValue(): void + { + $item = $this->createMock(CacheItemInterface::class); + $item->expects(self::once())->method('set')->with(null); + + $pool = $this->createMock(CacheItemPoolInterface::class); + $pool->method('getItem')->willReturn($item); + $pool->expects(self::once())->method('save')->with($item); + + $cache = new Psr6FeatureRequesterCache($pool); + $cache->putCachedString('some-key', null); + } + + public function testKeySanitizationEncodesUnsafeCharacters(): void + { + $pool = $this->createMock(CacheItemPoolInterface::class); + + $receivedKeys = []; + $pool->method('getItem')->willReturnCallback(function (string $key) use (&$receivedKeys) { + $receivedKeys[] = $key; + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(false); + return $item; + }); + + $cache = new Psr6FeatureRequesterCache($pool); + + // "launchdarkly:features:$all" contains ":" and "$" which are unsafe + $cache->getCachedString('launchdarkly:features:$all'); + + self::assertCount(1, $receivedKeys); + // ":" → "_3a", "$" → "_24" + self::assertEquals('launchdarkly_3afeatures_3a_24all', $receivedKeys[0]); + } + + public function testKeySanitizationPreservesAlphanumericAndDot(): void + { + $pool = $this->createMock(CacheItemPoolInterface::class); + + $receivedKey = null; + $pool->method('getItem')->willReturnCallback(function (string $key) use (&$receivedKey) { + $receivedKey = $key; + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(false); + return $item; + }); + + $cache = new Psr6FeatureRequesterCache($pool); + $cache->getCachedString('safe.key123'); + + // Alphanumeric and dot are preserved as-is + self::assertEquals('safe.key123', $receivedKey); + } + + public function testKeySanitizationEncodesUnderscores(): void + { + $pool = $this->createMock(CacheItemPoolInterface::class); + + $receivedKey = null; + $pool->method('getItem')->willReturnCallback(function (string $key) use (&$receivedKey) { + $receivedKey = $key; + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(false); + return $item; + }); + + $cache = new Psr6FeatureRequesterCache($pool); + $cache->getCachedString('my_key'); + + // Underscores are encoded because "_" is the escape prefix + self::assertEquals('my_5fkey', $receivedKey); + } + + public function testKeySanitizationAvoidCollisions(): void + { + $pool = $this->createMock(CacheItemPoolInterface::class); + + $receivedKeys = []; + $pool->method('getItem')->willReturnCallback(function (string $key) use (&$receivedKeys) { + $receivedKeys[] = $key; + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(false); + return $item; + }); + + $cache = new Psr6FeatureRequesterCache($pool); + + // These two keys must not collide after sanitization + $cache->getCachedString('my-flag'); + $cache->getCachedString('my_flag'); + + self::assertCount(2, $receivedKeys); + // "my-flag" → "my_2dflag" (hyphen encoded) + // "my_flag" → "my_5fflag" (underscore encoded) + self::assertEquals('my_2dflag', $receivedKeys[0]); + self::assertEquals('my_5fflag', $receivedKeys[1]); + self::assertNotEquals($receivedKeys[0], $receivedKeys[1]); + } + + // --- TTL expiration tests using InMemoryCache + FrozenClock --- + + public function testCachedItemPersistsWithoutTtl(): void + { + $clock = FrozenClock::fromUTC(); + $pool = new InMemoryCache($clock); + $cache = new Psr6FeatureRequesterCache($pool); + + $cache->putCachedString('some-key', 'the-data'); + + // Advance time significantly — item should still be present + $clock->setTo($clock->now()->add(new \DateInterval('P1D'))); + + self::assertEquals('the-data', $cache->getCachedString('some-key')); + } + + public function testCachedItemSurvivesBeforeTtlExpires(): void + { + $clock = FrozenClock::fromUTC(); + $pool = new InMemoryCache($clock); + $cache = new Psr6FeatureRequesterCache($pool, 300); + + $cache->putCachedString('some-key', 'the-data'); + + // Advance 2 minutes — within the 5-minute TTL + $clock->setTo($clock->now()->add(new \DateInterval('PT2M'))); + + self::assertEquals('the-data', $cache->getCachedString('some-key')); + } + + public function testCachedItemExpiresAfterTtl(): void + { + $clock = FrozenClock::fromUTC(); + $pool = new InMemoryCache($clock); + $cache = new Psr6FeatureRequesterCache($pool, 300); + + $cache->putCachedString('some-key', 'the-data'); + + // Advance past the 5-minute TTL + $clock->setTo($clock->now()->add(new \DateInterval('PT301S'))); + + self::assertNull($cache->getCachedString('some-key')); + } +}