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
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 10 additions & 1 deletion psalm-baseline.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<files psalm-version="6.13.1@1e3b7f0a8ab32b23197b91107adc0a7ed8a05b51">
<files psalm-version="6.15.1@28dc127af1b5aecd52314f6f645bafc10d0e11f9">
<file src="src/LaunchDarkly/EvaluationDetail.php">
<ClassMustBeFinal>
<code><![CDATA[EvaluationDetail]]></code>
Expand Down Expand Up @@ -249,6 +249,15 @@
<code><![CDATA[public function getSegment(string $key): ?Segment]]></code>
</MissingOverrideAttribute>
</file>
<file src="src/LaunchDarkly/Impl/Integrations/Psr6FeatureRequesterCache.php">
<ClassMustBeFinal>
<code><![CDATA[Psr6FeatureRequesterCache]]></code>
</ClassMustBeFinal>
<MissingOverrideAttribute>
<code><![CDATA[public function getCachedString(string $cacheKey): ?string]]></code>
<code><![CDATA[public function putCachedString(string $cacheKey, ?string $data): void]]></code>
</MissingOverrideAttribute>
</file>
<file src="src/LaunchDarkly/Impl/Migrations/Executor.php">
<ClassMustBeFinal>
<code><![CDATA[Executor]]></code>
Expand Down
17 changes: 17 additions & 0 deletions src/LaunchDarkly/Impl/Integrations/FeatureRequesterBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use LaunchDarkly\Impl\UnrecoverableHTTPStatusException;
use LaunchDarkly\Impl\Util;
use LaunchDarkly\Subsystems\FeatureRequester;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerInterface;

/**
Expand All @@ -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'
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

namespace LaunchDarkly\Impl\Integrations;

use Psr\Cache\CacheItemPoolInterface;

/**
* @ignore
* @internal
*/
class Psr6FeatureRequesterCache implements FeatureRequesterCache
{
public function __construct(
private CacheItemPoolInterface $pool,
private ?int $ttl = null
) {
}

public function getCachedString(string $cacheKey): ?string
{
$item = $this->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);
}
}
126 changes: 126 additions & 0 deletions tests/Impl/Integrations/GuzzleFeatureRequesterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
}
}
63 changes: 63 additions & 0 deletions tests/Impl/Integrations/GuzzleFeatureRequesterUnitTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

namespace LaunchDarkly\Tests\Impl\Integrations;

use Kevinrob\GuzzleCache\Storage\CacheStorageInterface;
use LaunchDarkly\Impl\Integrations\GuzzleFeatureRequester;
use PHPUnit\Framework\TestCase;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\NullLogger;

class GuzzleFeatureRequesterUnitTest extends TestCase
{
public function testConstructorAcceptsPsr6CachePool(): void
{
$pool = $this->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);
}
}
Loading