diff --git a/.gitignore b/.gitignore index 87b5b01..80fd9f9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ vendor tests/data .phpunit.result.cache .vscode +cache diff --git a/Makefile b/Makefile index 709b7df..1e10e49 100644 --- a/Makefile +++ b/Makefile @@ -38,6 +38,8 @@ test-data: mkdir -p $(tempDir) git clone -b ${branchName} --depth 1 --single-branch ${githubRepoLink} ${gitDataDir} cp -r ${gitDataDir}ufc ${testDataDir} + mkdir -p ${testDataDir}/configuration-wire + cp -r ${gitDataDir}configuration-wire/*.json ${testDataDir}/configuration-wire/ rm -rf ${tempDir} .PHONY: test diff --git a/src/API/APIResource.php b/src/API/APIResource.php index 94ec80e..f798be2 100644 --- a/src/API/APIResource.php +++ b/src/API/APIResource.php @@ -7,7 +7,7 @@ class APIResource public function __construct( public readonly ?string $body, public readonly bool $isModified, - public readonly ?string $ETag + public readonly ?string $eTag ) { } } diff --git a/src/Config/Configuration.php b/src/Config/Configuration.php new file mode 100644 index 0000000..6aee87e --- /dev/null +++ b/src/Config/Configuration.php @@ -0,0 +1,104 @@ +flagsConfig->response, true); + $banditsJson = json_decode($this->banditsConfig?->response ?? "", true); + $this->flags = FlagConfigResponse::fromJson($flagJson ?? []); + $this->bandits = BanditParametersResponse::fromJson($banditsJson ?? []); + } + + public static function fromUfcResponses(ConfigResponse $flagsConfig, ?ConfigResponse $banditsConfig): Configuration + { + return new self($flagsConfig, $banditsConfig); + } + + public static function fromConfigurationWire(ConfigurationWire $configurationWire): self + { + return new self($configurationWire?->config ?? null, $configurationWire?->bandits ?? null); + } + + public static function fromFlags(array $flags, ?array $bandits = null) + { + $fcr = FlagConfigResponse::fromJson(["flags" => $flags]); + $flagsConfig = new ConfigResponse(response: json_encode($fcr)); + $banditsConfig = $bandits ? new ConfigResponse( + response: json_encode(BanditParametersResponse::fromJson(["bandits" => $bandits])) + ) : null; + return new self($flagsConfig, $banditsConfig); + } + + public static function emptyConfig(): self + { + return self::fromFlags([]); + } + + public function getFlag(string $key): ?Flag + { + if (!isset($this->parsedFlags[$key])) { + $flagObj = $this->flags->flags[$key] ?? null; + if ($flagObj !== null) { + $this->parsedFlags[$key] = Flag::fromJson($flagObj); + } + } + return $this->parsedFlags[$key] ?? null; + } + + public function getBandit(string $banditKey): ?Bandit + { + if (!isset($this->bandits->bandits[$banditKey])) { + return null; + } + return Bandit::fromJson($this->bandits?->bandits[$banditKey]) ?? null; + } + + public function getBanditByVariation(string $flagKey, string $variation): ?string + { + foreach ($this->flags->banditReferences as $banditKey => $banditReferenceObj) { + $banditReference = BanditReference::fromJson($banditReferenceObj); + foreach ($banditReference->flagVariations as $flagVariation) { + if ($flagVariation->flagKey === $flagKey && $flagVariation->variationKey === $variation) { + return $banditKey; + } + } + } + return null; + } + + public function toConfigurationWire(): ConfigurationWire + { + return ConfigurationWire::fromResponses( + flags: $this->flagsConfig, + bandits: $this->banditsConfig + ); + } + + public function getFetchedAt(): ?string + { + return $this?->flagsConfig?->fetchedAt ?? null; + } + + public function getFlagETag(): ?string + { + return $this->flagsConfig?->eTag ?? null; + } +} diff --git a/src/Config/ConfigurationLoader.php b/src/Config/ConfigurationLoader.php index 36e12db..108770a 100644 --- a/src/Config/ConfigurationLoader.php +++ b/src/Config/ConfigurationLoader.php @@ -3,70 +3,28 @@ namespace Eppo\Config; use Eppo\API\APIRequestWrapper; -use Eppo\Bandits\BanditReferenceIndexer; -use Eppo\Bandits\IBanditReferenceIndexer; -use Eppo\Bandits\IBandits; -use Eppo\DTO\Bandit\Bandit; -use Eppo\DTO\BanditReference; -use Eppo\DTO\Flag; +use Eppo\DTO\ConfigurationWire\ConfigResponse; +use Eppo\DTO\FlagConfigResponse; use Eppo\Exception\HttpRequestException; use Eppo\Exception\InvalidApiKeyException; -use Eppo\Exception\InvalidConfigurationException; -use Eppo\Flags\IFlags; -use Eppo\UFCParser; -class ConfigurationLoader implements IFlags, IBandits +class ConfigurationLoader { - private const KEY_BANDIT_TIMESTAMP = "banditTimestamp"; - private const KEY_LOADED_BANDIT_VERSIONS = 'banditModelVersions'; - private UFCParser $parser; - - private const KEY_FLAG_TIMESTAMP = "flagTimestamp"; - private const KEY_FLAG_ETAG = "flagETag"; - public function __construct( private readonly APIRequestWrapper $apiRequestWrapper, - private readonly IConfigurationStore $configurationStore, - private readonly int $cacheAgeLimitMillis = 30 * 1000, - private readonly bool $optimizedBanditLoading = false + public readonly ConfigurationStore $configurationStore, + private readonly int $cacheAgeLimitMillis = 30 * 1000 ) { - $this->parser = new UFCParser(); - } - - /** - * @throws InvalidApiKeyException - * @throws HttpRequestException - * @throws InvalidConfigurationException - */ - public function getFlag(string $key): ?Flag - { - $this->reloadConfigurationIfExpired(); - return $this->configurationStore->getFlag($key); } /** - * @param string $flagKey - * @param string $variation - * @return string|null * @throws HttpRequestException * @throws InvalidApiKeyException - * @throws InvalidConfigurationException - */ - public function getBanditByVariation(string $flagKey, string $variation): ?string - { - $this->reloadConfigurationIfExpired(); - return $this->configurationStore->getBanditReferenceIndexer()->getBanditByVariation($flagKey, $variation); - } - - /** - * @throws HttpRequestException - * @throws InvalidApiKeyException - * @throws InvalidConfigurationException */ public function reloadConfigurationIfExpired(): void { $flagCacheAge = $this->getCacheAgeInMillis(); - if ($flagCacheAge < 0 || $flagCacheAge >= ($this->cacheAgeLimitMillis)) { + if ($flagCacheAge >= ($this->cacheAgeLimitMillis)) { $this->reloadConfiguration(); } } @@ -74,108 +32,47 @@ public function reloadConfigurationIfExpired(): void /** * @throws HttpRequestException * @throws InvalidApiKeyException - * @throws InvalidConfigurationException */ - public function fetchAndStoreConfigurations(?string $flagETag): void + public function fetchAndStoreConfiguration(?string $flagETag): void { $response = $this->apiRequestWrapper->getUFC($flagETag); if ($response->isModified) { - // Decode and set the data. + $configResponse = new ConfigResponse( + $response->body, + date('c'), + $response->eTag + ); $responseData = json_decode($response->body, true); - if (!$responseData) { + + if ($responseData === null) { syslog(LOG_WARNING, "[Eppo SDK] Empty or invalid response from the configuration server."); return; } - - $inflated = array_map(fn($object) => $this->parser->parseFlag($object), $responseData['flags']); - - // Create a handy helper class from the `banditReferences` to help connect flags to bandits. - if (isset($responseData['banditReferences'])) { - $banditReferences = array_map( - function ($json) { - return BanditReference::fromJson($json); - }, - $responseData['banditReferences'] - ); - $indexer = BanditReferenceIndexer::from($banditReferences); - } else { - syslog(LOG_WARNING, "[EPPO SDK] No bandit-flag variations found in UFC response."); - $indexer = BanditReferenceIndexer::empty(); + $fcr = FlagConfigResponse::fromJson($responseData); + $banditResponse = null; + // TODO: Also check current bandit models loaded for optimized bandit loading. + if (count($fcr->banditReferences) > 0) { + $bandits = $this->apiRequestWrapper->getBandits(); + $banditResponse = new ConfigResponse($bandits->body, date('c'), $bandits->eTag); } - $this->configurationStore->setUnifiedFlagConfiguration($inflated, $indexer); - - // Only load bandits if there are any referenced by the flags. - if ($indexer->hasBandits()) { - $this->fetchBanditsAsRequired($indexer); - } - - // Store metadata for next time. - $this->configurationStore->setMetadata(self::KEY_FLAG_TIMESTAMP, $this->millitime()); - $this->configurationStore->setMetadata(self::KEY_FLAG_ETAG, $response->ETag); + $configuration = Configuration::fromUfcResponses($configResponse, $banditResponse); + $this->configurationStore->setConfiguration($configuration); } } private function getCacheAgeInMillis(): int { - $timestamp = $this->configurationStore->getMetadata(self::KEY_FLAG_TIMESTAMP); - if ($timestamp != null) { - return $this->millitime() - $timestamp; - } - return -1; - } - - public function getBanditReferenceIndexer(): IBanditReferenceIndexer - { - return $this->configurationStore->getBanditReferenceIndexer(); - } - - /** - * @throws HttpRequestException - * @throws InvalidApiKeyException - * @throws InvalidConfigurationException - */ - private function fetchAndStoreBandits(): void - { - $banditModelResponse = json_decode($this->apiRequestWrapper->getBandits()->body, true); - if (!$banditModelResponse || !isset($banditModelResponse['bandits'])) { - syslog(LOG_WARNING, "[Eppo SDK] Empty or invalid response from the configuration server."); - $bandits = []; - } else { - $bandits = array_map(fn($json) => Bandit::fromJson($json), $banditModelResponse['bandits']); + $timestamp = $this->configurationStore->getConfiguration()->getFetchedAt(); + if (!$timestamp) { + return PHP_INT_MAX; } - $banditModelVersions = array_map(fn($bandit) => $bandit->modelVersion, $bandits); - - $this->configurationStore->setBandits($bandits); - $this->configurationStore->setMetadata(self::KEY_LOADED_BANDIT_VERSIONS, $banditModelVersions); - $this->configurationStore->setMetadata(self::KEY_BANDIT_TIMESTAMP, time()); - } - - public function getBandit(string $banditKey): ?Bandit - { - return $this->configurationStore->getBandit($banditKey); - } - - /** - * Loads bandits unless `optimizedBanditLoading` is `true` in which case, currently loaded bandit models are - * compared to those required by flags to determine whether to (re)load bandit models. - * - * @param IBanditReferenceIndexer $indexer - * @return void - * @throws HttpRequestException - * @throws InvalidApiKeyException - * @throws InvalidConfigurationException - */ - private function fetchBanditsAsRequired(IBanditReferenceIndexer $indexer): void - { - // Get the currently loaded bandits to determine if they satisfy what's required by the flags - $currentlyLoadedBanditModels = $this->configurationStore->getMetadata( - self::KEY_LOADED_BANDIT_VERSIONS - ) ?? []; - $references = $indexer->getBanditModelKeys(); - - if (array_diff($references, $currentlyLoadedBanditModels)) { - $this->fetchAndStoreBandits(); + try { + $dateTime = new \DateTime($timestamp); + $timestampMillis = (int)($dateTime->format('U.u') * 1000); + return $this->milliTime() - $timestampMillis; + } catch (\Exception $e) { + return PHP_INT_MAX; } } @@ -183,15 +80,14 @@ private function fetchBanditsAsRequired(IBanditReferenceIndexer $indexer): void * @return void * @throws HttpRequestException * @throws InvalidApiKeyException - * @throws InvalidConfigurationException */ public function reloadConfiguration(): void { - $flagETag = $this->configurationStore->getMetadata(self::KEY_FLAG_ETAG); - $this->fetchAndStoreConfigurations($flagETag); + $flagETag = $this->configurationStore->getConfiguration()->getFlagETag(); + $this->fetchAndStoreConfiguration($flagETag); } - private function millitime(): int + private function milliTime(): int { return intval(microtime(true) * 1000); } diff --git a/src/Config/ConfigurationStore.php b/src/Config/ConfigurationStore.php index 34c8c21..410b663 100644 --- a/src/Config/ConfigurationStore.php +++ b/src/Config/ConfigurationStore.php @@ -2,170 +2,54 @@ namespace Eppo\Config; -use Eppo\Bandits\BanditReferenceIndexer; -use Eppo\Bandits\IBanditReferenceIndexer; -use Eppo\Cache\CacheType; -use Eppo\Cache\NamespaceCache; -use Eppo\DTO\Bandit\Bandit; -use Eppo\DTO\Flag; -use Eppo\Exception\EppoClientException; -use Eppo\Exception\InvalidArgumentException; -use Eppo\Exception\InvalidConfigurationException; -use Eppo\Validator; +use Eppo\DTO\ConfigurationWire\ConfigurationWire; use Psr\SimpleCache\CacheInterface; +use Throwable; -class ConfigurationStore implements IConfigurationStore +class ConfigurationStore { - private CacheInterface $flagCache; - private CacheInterface $banditCache; - private CacheInterface $metadataCache; + private const CONFIG_KEY = "EPPO_configuration_v1"; + private ?Configuration $configuration = null; - // Key for storing bandit variations in the metadata cache. - private const BANDIT_VARIATION_KEY = 'banditVariations'; - - /** - * @param CacheInterface $cache - */ - public function __construct(CacheInterface $cache) + public function __construct(private readonly CacheInterface $cache) { - $this->flagCache = new NamespaceCache(CacheType::FLAG, $cache); - $this->banditCache = new NamespaceCache(CacheType::BANDIT, $cache); - $this->metadataCache = new NamespaceCache(CacheType::META, $cache); } - /** - * @param string $key - * @return Flag|null - */ - public function getFlag(string $key): ?Flag + public function getConfiguration(): Configuration { - try { - $result = $this->flagCache->get($key); - if ($result == null) { - return null; - } - - $inflated = $result; - return $inflated === false ? null : $inflated; - } catch (\Psr\SimpleCache\InvalidArgumentException $e) { - // Simple cache throws exceptions when a keystring is not a legal value (characters {}()/@: are illegal) - syslog(LOG_WARNING, "[EPPO SDK] Illegal flag key ${key}: " . $e->getMessage()); - return null; + if ($this->configuration !== null) { + return $this->configuration; } - } - - /** - * @param array $flags - * @param IBanditReferenceIndexer|null $banditVariations - * @throws EppoClientException - */ - public function setUnifiedFlagConfiguration(array $flags, ?IBanditReferenceIndexer $banditVariations = null): void - { try { - // Clear stored config before setting data. - $this->flagCache->clear(); - - $this->setFlags($flags); - if ($banditVariations == null) { - $this->metadataCache->delete(self::BANDIT_VARIATION_KEY); - } else { - $this->metadataCache->set(self::BANDIT_VARIATION_KEY, $banditVariations); + $cachedConfig = $this->cache->get(self::CONFIG_KEY); + if (!$cachedConfig) { + return Configuration::emptyConfig(); // Empty config } - } catch (\Psr\SimpleCache\InvalidArgumentException $e) { - throw EppoClientException::from($e); - } - } - - /** - * @param Flag[] $flags - * @return void - */ - private function setFlags(array $flags): void - { - $keyed = []; - array_walk($flags, function (Flag &$value) use (&$keyed) { - $keyed[$value->key] = $value; - }); - - try { - $this->flagCache->setMultiple($keyed); - } catch (\Psr\SimpleCache\InvalidArgumentException $e) { - // Simple cache throws exceptions when a keystring is not a legal value (characters {}()/@: are illegal) - syslog(LOG_WARNING, "[EPPO SDK] Illegal flag key: " . $e->getMessage()); - } - } - public function getMetadata(string $key): mixed - { - try { - return $this->metadataCache->get($key); - } catch (\Psr\SimpleCache\InvalidArgumentException $e) { - syslog(LOG_WARNING, "[EPPO SDK] Illegal flag key: " . $e->getMessage()); - } - - return null; - } - - public function getBanditReferenceIndexer(): IBanditReferenceIndexer - { - try { - $data = $this->metadataCache->get(self::BANDIT_VARIATION_KEY); - if ($data !== null) { - return $data; + $arr = json_decode($cachedConfig, true); + if ($arr === null) { + return Configuration::emptyConfig(); } - return BanditReferenceIndexer::empty(); - } catch (\Psr\SimpleCache\InvalidArgumentException $e) { - // We know that the key does not contain illegal characters so we should not end up here. - throw EppoClientException::From($e); - } - } - /** - * @throws InvalidArgumentException - */ - public function setMetadata(string $key, mixed $metadata): void - { - Validator::validateNotEqual( - $key, - self::BANDIT_VARIATION_KEY, - 'Unable to use reserved key, ' . self::BANDIT_VARIATION_KEY - ); - try { - $this->metadataCache->set($key, $metadata); - } catch (\Psr\SimpleCache\InvalidArgumentException $e) { - syslog(LOG_WARNING, "[EPPO SDK] Illegal flag key: " . $e->getMessage()); - } - } + $configurationWire = ConfigurationWire::fromArray($arr); + $this->configuration = Configuration::fromConfigurationWire($configurationWire); - /** - * @throws InvalidConfigurationException - */ - public function setBandits(array $bandits): void - { - try { - $this->banditCache->clear(); - $keyed = []; - array_walk($bandits, function (Bandit &$value) use (&$keyed) { - $keyed[$value->banditKey] = $value; - }); - $this->banditCache->setMultiple($keyed); - } catch (\Psr\SimpleCache\InvalidArgumentException $e) { - throw InvalidConfigurationException::from($e); + return $this->configuration; + } catch (Throwable $e) { + // Safe to ignore as the const `CONFIG_KEY` contains no invalid characters + syslog(LOG_ERR, "[Eppo SDK] Error loading config from cache " . $e->getMessage()); + return Configuration::emptyConfig(); } } - - public function getBandit(string $banditKey): ?Bandit + public function setConfiguration(Configuration $configuration): void { + $this->configuration = $configuration; try { - return $this->banditCache->get($banditKey); - } catch (\Psr\SimpleCache\InvalidArgumentException $e) { - // Simple cache throws exceptions when a keystring is not a legal value (characters {}()/@: are illegal) - throw new InvalidConfigurationException( - "Illegal bandit key ${$banditKey}: " . $e->getMessage(), - $e->getCode(), - $e - ); + $this->cache->set(self::CONFIG_KEY, json_encode($configuration->toConfigurationWire()->toArray())); + } catch (Throwable $e) { + // Safe to ignore as the const `CONFIG_KEY` contains no invalid characters + syslog(LOG_ERR, "[Eppo SDK] Error loading config from cache " . $e->getMessage()); } } } diff --git a/src/Config/IConfigurationStore.php b/src/Config/IConfigurationStore.php deleted file mode 100644 index f0125ac..0000000 --- a/src/Config/IConfigurationStore.php +++ /dev/null @@ -1,45 +0,0 @@ -getMessage() + ); $updatedAt = new DateTime(); } finally { return new Bandit( diff --git a/src/DTO/BanditParametersResponse.php b/src/DTO/BanditParametersResponse.php new file mode 100644 index 0000000..b8f9390 --- /dev/null +++ b/src/DTO/BanditParametersResponse.php @@ -0,0 +1,19 @@ +bandits = $bandits; + } +} diff --git a/src/DTO/BanditReference.php b/src/DTO/BanditReference.php index e91a743..ba96495 100644 --- a/src/DTO/BanditReference.php +++ b/src/DTO/BanditReference.php @@ -16,9 +16,16 @@ public function __construct( public static function fromJson(mixed $json): BanditReference { + $flagVariations = []; + if (isset($json['flagVariations']) && is_array($json['flagVariations'])) { + foreach ($json['flagVariations'] as $variation) { + $flagVariations[] = BanditFlagVariation::fromJson($variation); + } + } + return new BanditReference( modelVersion: $json['modelVersion'], - flagVariations: array_map(fn($v) => BanditFlagVariation::fromJson($v), $json['flagVariations']) + flagVariations: $flagVariations ); } diff --git a/src/DTO/ConfigurationWire/ConfigResponse.php b/src/DTO/ConfigurationWire/ConfigResponse.php new file mode 100644 index 0000000..9901769 --- /dev/null +++ b/src/DTO/ConfigurationWire/ConfigResponse.php @@ -0,0 +1,19 @@ +version = 1; + } + + public function toArray(): array + { + $arr = ['version' => $this->version]; + if ($this->config) { + $arr['config'] = $this->config->toArray(); + } + if ($this->bandits) { + $arr['bandits'] = $this->bandits->toArray(); + } + return $arr; + } + + public static function fromArray(array $arr): self + { + $dto = new self(); + $dto->version = $arr['version'] ?? 1; + if (isset($arr['config'])) { + $dto->config = ConfigResponse::fromJson($arr['config']); + } + if (isset($arr['bandits'])) { + $dto->bandits = ConfigResponse::fromJson($arr['bandits']); + } + return $dto; + } + + public static function fromResponses(ConfigResponse $flags, ?ConfigResponse $bandits): self + { + $dto = new self(); + $dto->config = $flags; + $dto->bandits = $bandits; + return $dto; + } + + public static function fromJsonString(string $jsonEncodedString): self + { + return ConfigurationWire::fromArray(json_decode($jsonEncodedString, associative: true)); + } + + public function toJsonString(): string + { + return json_encode($this->toArray()); + } +} diff --git a/src/DTO/Flag.php b/src/DTO/Flag.php index 5ee62aa..523b876 100644 --- a/src/DTO/Flag.php +++ b/src/DTO/Flag.php @@ -21,4 +21,89 @@ public function __construct( public int $totalShards ) { } + + public static function fromJson(array $json): Flag + { + $variationType = VariationType::from($json['variationType']); + $variations = self::parseVariations($json['variations'], $variationType); + $allocations = self::parseAllocations($json['allocations']); + + return new Flag( + $json['key'], + $json['enabled'], + $allocations, + $variationType, + $variations, + $json['totalShards'] + ); + } + + + /** + * @param array $variations + * @param VariationType $variationType + * @return Variation[] + */ + private static function parseVariations(array $variations, VariationType $variationType): array + { + return array_map(function ($variationConfig) use ($variationType) { + $typedValue = $variationType === VariationType::JSON ? json_decode( + $variationConfig['value'], + true + ) : $variationConfig['value']; + return new Variation($variationConfig['key'], $typedValue); + }, + $variations); + } + + /** + * @param array $allocations + * @return Allocation[] + */ + private static function parseAllocations(array $allocations): array + { + return array_map(function ($allocationConfig) { + $rules = + !array_key_exists('rules', $allocationConfig) ? + null : + array_map(function ($ruleConfig) { + $conditions = array_map(function ($conditionConfig) { + return new Condition( + $conditionConfig['attribute'], + $conditionConfig['operator'], + $conditionConfig['value'] + ); + }, $ruleConfig['conditions']); + return new Rule($conditions); + }, $allocationConfig['rules']); + + $splits = array_map(function ($splitConfig) { + $shards = array_map(function ($shardConfig) { + $ranges = array_map(function ($rangeConfig) { + return new ShardRange($rangeConfig['start'], $rangeConfig['end']); + }, $shardConfig['ranges']); + + return new Shard( + $shardConfig['salt'], + $ranges + ); + }, $splitConfig['shards']); + + return new Split( + $splitConfig['variationKey'], + $shards, + array_key_exists('extraLogging', $splitConfig) ? $splitConfig['extraLogging'] : [] + ); + }, $allocationConfig['splits']); + + return new Allocation( + $allocationConfig['key'], + $rules, + $splits, + !(array_key_exists('doLog', $allocationConfig) && $allocationConfig['doLog'] === false), + array_key_exists('startAt', $allocationConfig) ? strtotime($allocationConfig['startAt']) : null, + array_key_exists('endAt', $allocationConfig) ? strtotime($allocationConfig['endAt']) : null + ); + }, $allocations); + } } diff --git a/src/DTO/FlagConfigResponse.php b/src/DTO/FlagConfigResponse.php new file mode 100644 index 0000000..bc92efc --- /dev/null +++ b/src/DTO/FlagConfigResponse.php @@ -0,0 +1,29 @@ + + */ + public array $banditReferences; + + public function __construct() + { + $this->banditReferences = []; + $this->format = "SERVER"; + } +} diff --git a/src/EppoClient.php b/src/EppoClient.php index c651023..3a76ed5 100644 --- a/src/EppoClient.php +++ b/src/EppoClient.php @@ -6,8 +6,9 @@ use Eppo\Bandits\BanditEvaluator; use Eppo\Bandits\IBanditEvaluator; use Eppo\Cache\DefaultCacheFactory; -use Eppo\Config\ConfigurationLoader; use Eppo\Config\ConfigurationStore; +use Eppo\Config\Configuration; +use Eppo\Config\ConfigurationLoader; use Eppo\Config\SDKData; use Eppo\DTO\Bandit\AttributeSet; use Eppo\DTO\Bandit\BanditResult; @@ -45,6 +46,7 @@ class EppoClient private IBanditEvaluator $banditEvaluator; /** + * @param ConfigurationStore $configurationStore * @param ConfigurationLoader $configurationLoader * @param PollerInterface $poller * @param LoggerInterface|null $eventLogger optional logger. Please @see LoggerInterface @@ -52,6 +54,7 @@ class EppoClient * @param IBanditEvaluator|null $banditEvaluator */ protected function __construct( + private readonly ConfigurationStore $configurationStore, private readonly ConfigurationLoader $configurationLoader, private readonly PollerInterface $poller, private readonly ?LoggerInterface $eventLogger = null, @@ -140,15 +143,23 @@ function () use ($configLoader) { } ); - self::$instance = self::createAndInitClient($configLoader, $poller, $assignmentLogger, $isGracefulMode, throwOnFailedInit: $throwOnFailedInit); + self::$instance = self::createAndInitClient( + $configStore, + $configLoader, + $poller, + $assignmentLogger, + $isGracefulMode, + throwOnFailedInit: $throwOnFailedInit + ); return self::$instance; } /** - * @throws EppoClientInitializationException|InvalidConfigurationException + * @throws EppoClientInitializationException */ private static function createAndInitClient( + ConfigurationStore $configStore, ConfigurationLoader $configLoader, PollerInterface $poller, ?LoggerInterface $assignmentLogger, @@ -158,7 +169,7 @@ private static function createAndInitClient( ): EppoClient { try { $configLoader->reloadConfigurationIfExpired(); - } catch (HttpRequestException | InvalidApiKeyException $e) { + } catch (Exception | HttpRequestException | InvalidApiKeyException $e) { $message = 'Unable to initialize Eppo Client: ' . $e->getMessage(); if ($throwOnFailedInit) { throw new EppoClientInitializationException( @@ -168,7 +179,7 @@ private static function createAndInitClient( syslog(LOG_INFO, "[Eppo SDK] " . $message); } } - return new self($configLoader, $poller, $assignmentLogger, $isGracefulMode, $banditEvaluator); + return new self($configStore, $configLoader, $poller, $assignmentLogger, $isGracefulMode, $banditEvaluator); } /** @@ -330,20 +341,22 @@ public function getJSONAssignment( * an error was encountered, or an expected type was provided that didn't match the variation's typed * value. * @throws InvalidArgumentException - * @throws InvalidApiKeyException - * @throws HttpRequestException - * @throws InvalidConfigurationException */ private function getAssignmentDetail( string $flagKey, string $subjectKey, array $subjectAttributes = [], - VariationType $expectedVariationType = null + VariationType $expectedVariationType = null, + ?Configuration $config = null, ): ?Variation { Validator::validateNotBlank($subjectKey, 'Invalid argument: subjectKey cannot be blank'); Validator::validateNotBlank($flagKey, 'Invalid argument: flagKey cannot be blank'); - $flag = $this->configurationLoader->getFlag($flagKey); + if ($config === null) { + $config = $this->configurationStore->getConfiguration(); + } + + $flag = $config->getFlag($flagKey); if (!$flag) { syslog(LOG_WARNING, "[EPPO SDK] No assigned variation; flag not found ${flagKey}"); @@ -490,9 +503,6 @@ public function getBanditAction( * @param string $defaultValue * @return BanditResult * - * @throws InvalidConfigurationException - * @throws HttpRequestException - * @throws InvalidApiKeyException * @throws InvalidArgumentException * @throws BanditEvaluationException * @throws EppoClientException @@ -506,23 +516,29 @@ private function getBanditDetail( ): BanditResult { Validator::validateNotBlank($flagKey, 'Invalid argument: flagKey cannot be blank'); + $config = $this->configurationStore->getConfiguration(); + try { - $variation = $this->getStringAssignment( + $variation = $this->getAssignmentDetail( $flagKey, $subjectKey, $subject->toArray(), - $defaultValue - ); + VariationType::STRING, + $config + )?->key; + if ($variation === null) { + return new BanditResult($defaultValue); + } } catch (EppoException $e) { syslog(LOG_WARNING, "[Eppo SDK] Error computing experiment assignment: " . $e->getMessage()); - $variation = $defaultValue; + return new BanditResult($defaultValue); } - $banditKey = $this->configurationLoader->getBanditByVariation($flagKey, $variation); + $banditKey = $config->getBanditByVariation($flagKey, $variation); if ($banditKey !== null && !empty($actionsWithContext)) { // Evaluate the bandit, log and return. - $bandit = $this->configurationLoader->getBandit($banditKey); + $bandit = $config->getBandit($banditKey); if ($bandit == null) { if (!$this->isGracefulMode) { throw new EppoClientException( @@ -579,14 +595,10 @@ private function checkExpectedType( /** * @throws EppoClientException */ - public function fetchAndActivateConfiguration(bool $skipModifiedCheck = false): void + public function fetchAndActivateConfiguration(): void { try { - if ($skipModifiedCheck) { - $this->configurationLoader->fetchAndStoreConfigurations(null); - } else { - $this->configurationLoader->reloadConfiguration(); - } + $this->configurationLoader->fetchAndStoreConfiguration(null); } catch (HttpRequestException | InvalidApiKeyException | InvalidConfigurationException $e) { if ($this->isGracefulMode) { error_log('[Eppo SDK] Error fetching configuration ' . $e->getMessage()); @@ -631,15 +643,18 @@ private function handleException( * Only used for unit-tests. * Do not use for production. * + * @param ConfigurationStore $configStore * @param ConfigurationLoader $configurationLoader * @param PollerInterface $poller * @param LoggerInterface|null $logger * @param bool|null $isGracefulMode * @param IBanditEvaluator|null $banditEvaluator + * @param bool|null $throwOnFailedInit * @return EppoClient * @throws EppoClientInitializationException */ public static function createTestClient( + ConfigurationStore $configStore, ConfigurationLoader $configurationLoader, PollerInterface $poller, ?LoggerInterface $logger = null, @@ -648,6 +663,7 @@ public static function createTestClient( ?bool $throwOnFailedInit = true, ): EppoClient { return self::createAndInitClient( + $configStore, $configurationLoader, $poller, $logger, diff --git a/src/Traits/StaticFromJson.php b/src/Traits/StaticFromJson.php new file mode 100644 index 0000000..54d2100 --- /dev/null +++ b/src/Traits/StaticFromJson.php @@ -0,0 +1,21 @@ + $value) { + if (property_exists($dto, $key)) { + $dto->$key = $value; + } + } + + return $dto; + } +} diff --git a/src/Traits/ToArray.php b/src/Traits/ToArray.php new file mode 100644 index 0000000..439108d --- /dev/null +++ b/src/Traits/ToArray.php @@ -0,0 +1,13 @@ +getUFC(); $this->assertNotNull($result); $this->assertTrue($result->isModified); - $this->assertEquals($ETag, $result->ETag); + $this->assertEquals($ETag, $result->eTag); $this->assertEquals($body, $result->body); } @@ -273,7 +273,7 @@ public function testSendsLastETagAndComputesIsModified(): void $this->assertNotNull($result); $this->assertTrue($result->isModified); - $this->assertEquals($ETag, $result->ETag); + $this->assertEquals($ETag, $result->eTag); $this->assertEquals($body, $result->body); // Second requests uses the ETag from the first. @@ -281,7 +281,7 @@ public function testSendsLastETagAndComputesIsModified(): void $this->assertNotNull($result); $this->assertFalse($result->isModified); - $this->assertEquals($ETag, $result->ETag); + $this->assertEquals($ETag, $result->eTag); $this->assertNull($result->body); } } diff --git a/tests/BanditClientTest.php b/tests/BanditClientTest.php index b59478f..1f4c006 100644 --- a/tests/BanditClientTest.php +++ b/tests/BanditClientTest.php @@ -2,35 +2,36 @@ namespace Eppo\Tests; -use DateTime; -use Eppo\Bandits\IBanditEvaluator; use Eppo\Cache\DefaultCacheFactory; +use Eppo\Config\Configuration; use Eppo\Config\ConfigurationLoader; -use Eppo\DTO\Allocation; +use Eppo\Config\ConfigurationStore; use Eppo\DTO\Bandit\AttributeSet; -use Eppo\DTO\Bandit\Bandit; -use Eppo\DTO\Bandit\BanditEvaluation; -use Eppo\DTO\Bandit\BanditModelData; use Eppo\DTO\Bandit\BanditResult; -use Eppo\DTO\Flag; -use Eppo\DTO\Shard; -use Eppo\DTO\ShardRange; -use Eppo\DTO\Split; -use Eppo\DTO\Variation; -use Eppo\DTO\VariationType; +use Eppo\DTO\ConfigurationWire\ConfigResponse; +use Eppo\DTO\ConfigurationWire\ConfigurationWire; use Eppo\EppoClient; use Eppo\Exception\EppoClientException; use Eppo\Exception\EppoException; use Eppo\Logger\BanditActionEvent; use Eppo\Logger\IBanditLogger; use Eppo\PollerInterface; +use Eppo\Tests\Config\MockCache; use Eppo\Tests\WebServer\MockWebServer; use Exception; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; class BanditClientTest extends TestCase { private const TEST_DATA_PATH = __DIR__ . '/data/ufc/bandit-tests'; + private const CONFIG_DATA_PATH = __DIR__ . '/data/configuration-wire/'; + private const DEFAULT_FLAG_KEY = 'banner_bandit_flag'; + private const DEFAULT_SUBJECT_KEY = 'Alice'; + private const DEFAULT_SUBJECT_ATTRIBUTES = ['country' => 'USA', 'age' => 25]; + private const DEFAULT_ACTIONS = ['nike', 'adidas', 'reebok']; + private const DEFAULT_VALUE = 'default'; + private const BANDIT_KEY = 'banner_bandit'; private static ?EppoClient $client; private static MockWebServer $mockServer; @@ -56,188 +57,66 @@ public static function tearDownAfterClass(): void DefaultCacheFactory::clearCache(); } - public function testBanditWithEmptyActions(): void - { - $flagKey = 'bandit'; - $actions = []; - $subjectKey = 'user123'; - $subject = ['country' => 'USA', 'age' => 25]; - $default = 'defaultVariation'; - - - $config = $this->getMockBuilder(ConfigurationLoader::class)->disableOriginalConstructor()->getMock(); - - $banditKeyAndVariationValue = 'banditVariation'; - $bandit = new Bandit( - $banditKeyAndVariationValue, - 'falcon', - new DateTime(), - 'v123', - new BanditModelData( - 1.0, - [], - 0.1, - 0.1 - ) - ); - - $config->expects($this->once()) - ->method('getBanditByVariation') - ->with($flagKey, $banditKeyAndVariationValue) - ->willReturn($banditKeyAndVariationValue); - $config->expects($this->never()) // Should not get to loading the bandit. - ->method('getBandit') - ->with($banditKeyAndVariationValue) - ->willReturn($bandit); - - $config->expects($this->once()) - ->method('getFlag')->with($flagKey)->willReturn( - $this->makeFlagThatMapsAllTo($flagKey, $banditKeyAndVariationValue) - ); - - $client = EppoClient::createTestClient($config, poller: $this->getPollerMock()); - - - $result = $client->getBanditAction($flagKey, $subjectKey, $subject, $actions, $default); - - $this->assertEquals($banditKeyAndVariationValue, $result->variation); - $this->assertNull($result->action); - } - - public function testNonBandit(): void - { - $flagKey = 'non_bandit'; - $actions = []; - $subjectKey = 'user123'; - $subject = ['country' => 'USA', 'age' => 25]; - $default = 'defaultVariation'; - - - $config = $this->getMockBuilder(ConfigurationLoader::class)->disableOriginalConstructor()->getMock(); - - $config->expects($this->once()) - ->method('getBanditByVariation') - ->with($flagKey, $default) - ->willReturn(null); - - $client = EppoClient::createTestClient($config, poller: $this->getPollerMock()); - - $result = $client->getBanditAction($flagKey, $subjectKey, $subject, $actions, $default); - $this->assertEquals($default, $result->variation); - $this->assertEquals(null, $result->action); - } - public function testBanditModelDoesNotExist(): void { - $flagKey = 'bandit'; - $actions = ['foo', 'bar', 'baz']; - $subjectKey = 'user123'; - $subject = ['country' => 'USA', 'age' => 25]; - $default = 'defaultVariation'; + // Remove the bandit model from the configuration wire + $configurationWire = $this->getBanditConfigurationWire(); + $bandits = json_decode($configurationWire->bandits->response, true)['bandits']; + unset($bandits[self::BANDIT_KEY]); - - $config = $this->getMockBuilder(ConfigurationLoader::class)->disableOriginalConstructor()->getMock(); - - $config->expects($this->once()) - ->method('getBanditByVariation') - ->with($flagKey, $default) - ->willReturn('DNEBanditKey'); - - $client = EppoClient::createTestClient($config, poller: $this->getPollerMock()); + $client = $this->createTestClientWithModifiedBandits($bandits, false); $this->expectException(EppoClientException::class); $this->expectExceptionCode(EppoException::BANDIT_EVALUATION_FAILED_BANDIT_MODEL_NOT_PRESENT); - $client->getBanditAction($flagKey, $subjectKey, $subject, $actions, $default); + $result = $client->getBanditAction( + self::DEFAULT_FLAG_KEY, + self::DEFAULT_SUBJECT_KEY, + self::DEFAULT_SUBJECT_ATTRIBUTES, + self::DEFAULT_ACTIONS, + self::DEFAULT_VALUE + ); + + $this->assertEquals(self::BANDIT_KEY, $result->variation); + $this->assertNull($result->action); } + public function testBanditModelDoesNotExistGracefulNoThrows(): void { - $flagKey = 'bandit'; - $actions = ['foo', 'bar', 'baz']; - $subjectKey = 'user123'; - $subject = ['country' => 'USA', 'age' => 25]; - $default = 'defaultVariation'; - $banditKeyVariation = 'banditKey'; - - $config = $this->getMockBuilder(ConfigurationLoader::class)->disableOriginalConstructor()->getMock(); - - $config->expects($this->once()) - ->method('getBanditByVariation') - ->with($flagKey, $default) - ->willReturn($banditKeyVariation); - - // This is what we're actually testing here. - $config->expects($this->once()) - ->method('getBandit') - ->with($banditKeyVariation) - ->willReturn(null); - - $client = EppoClient::createTestClient($config, poller: $this->getPollerMock(), isGracefulMode: true); - + // Remove the bandit model from the configuration wire + $configurationWire = $this->getBanditConfigurationWire(); + $bandits = json_decode($configurationWire->bandits->response, true)['bandits']; + unset($bandits[self::BANDIT_KEY]); + + $client = $this->createTestClientWithModifiedBandits($bandits, isGracefulMode: true); + + $result = $client->getBanditAction( + self::DEFAULT_FLAG_KEY, + self::DEFAULT_SUBJECT_KEY, + self::DEFAULT_SUBJECT_ATTRIBUTES, + self::DEFAULT_ACTIONS, + self::DEFAULT_VALUE + ); - $result = $client->getBanditAction($flagKey, $subjectKey, $subject, $actions, $default); - $this->assertEquals($default, $result->variation); + $this->assertEquals(self::BANDIT_KEY, $result->variation); $this->assertNull($result->action); } public function testBanditSelectionLogged(): void { - $flagKey = 'bandit_flag'; - $actions = ['foo', 'bar', 'baz']; - $subjectKey = 'user123'; - $subject = ['country' => 'USA', 'age' => 25]; - $default = 'defaultVariation'; - $banditKey = $default; - - $bandit = new Bandit( - $banditKey, - 'falcon', - new DateTime(), - 'v123', - new BanditModelData( - 1.0, - [], - 0.1, - 0.1 - ) - ); - - $evaluation = new BanditEvaluation( - $flagKey, - $subjectKey, - AttributeSet::fromArray($subject), - 'banditAction', - AttributeSet::fromArray([]), - 200, - 0.5, - 1.0, - 50 - ); - $expectedResult = new BanditResult('defaultVariation', 'banditAction'); - - - $config = $this->getMockBuilder(ConfigurationLoader::class)->disableOriginalConstructor()->getMock(); - - // We know the assignment will evaluate to the default so let's use that shortcut to give us a bandit. - $config->expects($this->once()) - ->method('getBanditByVariation') - ->with($flagKey, $default) - ->willReturn($banditKey); - $config->expects($this->once()) - ->method('getBandit') - ->with($banditKey) - ->willReturn($bandit); + $flagKey = self::DEFAULT_FLAG_KEY; + $actions = self::DEFAULT_ACTIONS; + $subjectKey = strtolower(self::DEFAULT_SUBJECT_KEY); + $subject = self::DEFAULT_SUBJECT_ATTRIBUTES; + $default = self::DEFAULT_VALUE; + $banditKey = self::BANDIT_KEY; - $banditEvaluator = $this->getMockBuilder(IBanditEvaluator::class)->getMock(); - $banditEvaluator->expects($this->once()) - ->method('evaluateBandit') - ->willReturn($evaluation); + $expectedResult = new BanditResult(self::BANDIT_KEY, 'nike'); $mockLogger = $this->getMockBuilder(IBanditLogger::class)->getMock(); - // EppoClient won't log this assignment as it's not computed, just returning the default. - $mockLogger->expects($this->never())->method('logAssignment'); + $mockLogger->expects($this->once())->method('logAssignment'); $mockLogger->expects($this->once())->method('logBanditAction') ->with( @@ -245,23 +124,24 @@ public function testBanditSelectionLogged(): void return $bee->banditKey == $banditKey && $bee->subjectKey == $subjectKey && $bee->flagKey == $flagKey && - $bee->action == 'banditAction'; + $bee->action == 'nike'; }) ); - $client = EppoClient::createTestClient( - $config, - poller: $this->getPollerMock(), - logger: $mockLogger, - banditEvaluator: $banditEvaluator + $client = EppoClient::init( + 'dummy', + self::$mockServer->serverAddress, + assignmentLogger: $mockLogger, ); $result = $client->getBanditAction($flagKey, $subjectKey, $subject, $actions, $default); - $this->assertEquals($expectedResult, $result); } + /** + * Test all bandit test cases from the repository + */ public function testRepoTestCases(): void { // Load all the test cases. @@ -295,17 +175,58 @@ public function testRepoTestCases(): void $this->assertEquals( $subject['assignment']['variation'], $result->variation, - "Test failure for {$subject['subjectKey']} in $testFile" + 'Test failure for ' . $subject['subjectKey'] . ' in ' . $testFile ); $this->assertEquals( $subject['assignment']['action'], $result->action, - "Test failure {$subject['subjectKey']} in $testFile" + 'Test failure ' . $subject['subjectKey'] . ' in ' . $testFile ); } } } + + /** + * Create a test client with a modified bandit configuration + * + * @param array $bandits The modified bandits configuration to use + * @param bool $isGracefulMode Whether to run in graceful mode + * @return EppoClient The configured test client + */ + private function createTestClientWithModifiedBandits(array $bandits, bool $isGracefulMode = false): EppoClient + { + $configurationWire = $this->getBanditConfigurationWire(); + + $newConfig = ConfigurationWire::fromResponses( + flags: $configurationWire->config, + bandits: new ConfigResponse( + response: json_encode(['bandits' => $bandits]) + ) + ); + + $configuration = Configuration::fromConfigurationWire($newConfig); + + $mockCache = new MockCache(); + $configStore = new ConfigurationStore($mockCache); + + $configStore->setConfiguration($configuration); + + $configLoader = $this->getMockBuilder(ConfigurationLoader::class)->disableOriginalConstructor()->getMock(); + + return EppoClient::createTestClient( + $configStore, + $configLoader, + poller: $this->getPollerMock(), + isGracefulMode: $isGracefulMode + ); + } + + private function getPollerMock(): MockObject + { + return $this->getMockBuilder(PollerInterface::class)->getMock(); + } + private function loadTestCases(): array { $files = scandir(self::TEST_DATA_PATH); @@ -320,25 +241,14 @@ private function loadTestCases(): array return $tests; } - private function getPollerMock() + private function getBanditConfigurationWire(): ConfigurationWire { - return $this->getMockBuilder(PollerInterface::class)->getMock(); - } + $jsonData = file_get_contents(self::CONFIG_DATA_PATH . 'bandit-flags-v1.json'); + $this->assertNotFalse($jsonData, 'Failed to load test data file'); - private function makeFlagThatMapsAllTo(string $flagKey, string $variationValue) - { - $totalShards = 10_000; - $allocations = [ - new Allocation( - 'defaultAllocation', - [], - [new Split($variationValue, [new Shard("na", [new ShardRange(0, $totalShards)])], [])], - false - ) - ]; - $variations = [ - $variationValue => new Variation($variationValue, $variationValue) - ]; - return new Flag($flagKey, true, $allocations, VariationType::STRING, $variations, $totalShards); + $configData = json_decode($jsonData, true); + $this->assertIsArray($configData, 'Failed to parse JSON data'); + + return ConfigurationWire::fromArray($configData); } } diff --git a/tests/Config/ConfigurationLoaderTest.php b/tests/Config/ConfigurationLoaderTest.php index 9120df2..0454568 100644 --- a/tests/Config/ConfigurationLoaderTest.php +++ b/tests/Config/ConfigurationLoaderTest.php @@ -9,7 +9,6 @@ use Eppo\Config\ConfigurationStore; use Eppo\DTO\Bandit\Bandit; use Eppo\DTO\Flag; -use Eppo\UFCParser; use Http\Discovery\Psr17Factory; use Http\Discovery\Psr18Client; use PHPUnit\Framework\TestCase; @@ -37,7 +36,7 @@ public function testLoadsConfiguration(): void "ETAG" ); $flagsJson = json_decode($flagsRaw, true); - $flags = array_map(fn($flag) => (new UFCParser())->parseFlag($flag), $flagsJson['flags']); + $flags = array_map(fn($flag) => (Flag::fromJson($flag)), $flagsJson['flags']); $banditsRaw = '{ "bandits": { "cold_start_bandit": { @@ -70,20 +69,23 @@ public function testLoadsConfiguration(): void $configStore = new ConfigurationStore(DefaultCacheFactory::create()); $loader = new ConfigurationLoader($apiWrapper, $configStore); - $loader->fetchAndStoreConfigurations(null); + $loader->fetchAndStoreConfiguration(null); - $flag = $loader->getFlag(self::FLAG_KEY); + $flag = $loader->configurationStore->getConfiguration()->getFlag(self::FLAG_KEY); $this->assertInstanceOf(Flag::class, $flag); $this->assertEquals(self::FLAG_KEY, $flag->key); $this->assertEquals($flags[self::FLAG_KEY], $flag); $this->assertEquals( 'cold_start_bandit', - $loader->getBanditByVariation('cold_start_bandit_flag', 'cold_start_bandit') + $loader->configurationStore->getConfiguration()->getBanditByVariation( + 'cold_start_bandit_flag', + 'cold_start_bandit' + ) ); - $bandit = $loader->getBandit('cold_start_bandit'); + $bandit = $loader->configurationStore->getConfiguration()->getBandit('cold_start_bandit'); $this->assertNotNull($bandit); $this->assertInstanceOf(Bandit::class, $bandit); $this->assertEquals('cold_start_bandit', $bandit->banditKey); @@ -125,205 +127,149 @@ function (?string $eTag) use ($flagsResourceResponse, $flagsRaw) { $configStore = new ConfigurationStore(DefaultCacheFactory::create()); $loader = new ConfigurationLoader($apiWrapper, $configStore); - $loader->fetchAndStoreConfigurations(null); + $loader->fetchAndStoreConfiguration(null); - $timestamp1 = $configStore->getMetadata("flagTimestamp"); - $storedEtag = $configStore->getMetadata("flagETag"); + $timestamp1 = $configStore->getConfiguration()->getFetchedAt(); + $this->assertNotNull($timestamp1); + + $storedEtag = $configStore->getConfiguration()->getFlagETag(); $this->assertEquals("ETAG", $storedEtag); usleep(50 * 1000); // Sleep long enough for cache to expire. - $loader->fetchAndStoreConfigurations("ETAG"); + $loader->fetchAndStoreConfiguration("ETAG"); - $this->assertEquals("ETAG", $configStore->getMetadata("flagETag")); + $this->assertEquals("ETAG", $configStore->getConfiguration()->getFlagETag()); // The timestamp should not have changed; the config did not change, so the timestamp should not be updated. - $this->assertEquals($timestamp1, $configStore->getMetadata("flagTimestamp")); - } - - public function testLoadsOnGet(): void - { - // Arrange: Load some flag data to be returned by the APIRequestWrapper - // Load mock response data - $flagsRaw = file_get_contents(self::MOCK_RESPONSE_FILENAME); - $banditsRaw = '{"bandits": {}}'; - - $apiWrapper = $this->getMockBuilder(APIRequestWrapper::class)->disableOriginalConstructor()->getMock(); - - $cache = DefaultCacheFactory::create(); - // Act: Create a new FCL and retrieve a flag - $loader = new ConfigurationLoader($apiWrapper, new ConfigurationStore($cache)); - - // Mocks verify interaction of loader <--> API requests and loader <--> config store - $apiWrapper->expects($this->once()) - ->method('getUFC') - ->willReturn(new APIResource($flagsRaw, true, "ETAG")); - $apiWrapper->expects($this->once()) - ->method('getBandits') - ->willReturn(new APIResource($banditsRaw, true, "ETAG")); - - $flag = $loader->getFlag(self::FLAG_KEY); - - // Assert: non-null flag, api called only once via Mock `expects` above. - $this->assertNotNull($flag); - } - - public function testOnlyLoadsBanditsWhereNeeded(): void - { - // Set up mock response data. - $initialFlagsRaw = '{ - "flags": { - }, - "banditReferences": { - "cold_starting_bandit": { - "modelVersion": "cold start", - "flagVariations": [ - { - "key": "cold_starting_bandit", - "flagKey": "cold_start_flag", - "allocationKey": "cold_start_allocation", - "variationKey": "cold_starting_bandit", - "variationValue": "cold_starting_bandit" - } - ] - } - } - }'; - - $warmFlagsRaw = '{ - "flags": { - }, - "banditReferences": { - "cold_starting_bandit": { - "modelVersion": "v1", - "flagVariations": [ - { - "key": "cold_starting_bandit", - "flagKey": "cold_start_flag", - "allocationKey": "cold_start_allocation", - "variationKey": "cold_starting_bandit", - "variationValue": "cold_starting_bandit" - } - ] - } - } - }'; - - $coldBanditsRaw = '{ - "bandits": { - "cold_starting_bandit" : { - "banditKey": "cold_starting_bandit", - "modelName": "falcon", - "updatedAt": "2023-09-13T04:52:06.462Z", - "modelVersion": "cold start", - "modelData": { - "gamma": 1.0, - "defaultActionScore": 0.0, - "actionProbabilityFloor": 0.0, - "coefficients": {} - } - } - } - }'; - - $warmBanditsRaw = '{ - "bandits": { - "cold_starting_bandit" : { - "banditKey": "cold_starting_bandit", - "modelName": "falcon", - "updatedAt": "2023-09-13T04:52:06.462Z", - "modelVersion": "v1", - "modelData": { - "gamma": 1.0, - "defaultActionScore": 0.0, - "actionProbabilityFloor": 0.0, - "coefficients": {} - } - } - } - }'; - - - $apiWrapper = $this->getMockBuilder(APIRequestWrapper::class)->disableOriginalConstructor()->getMock(); - - $apiWrapper->expects($this->exactly(3)) - ->method('getUFC') - ->willReturnOnConsecutiveCalls( - new APIResource($initialFlagsRaw, true, "initial"), - new APIResource($initialFlagsRaw, true, "initialButForced"), - new APIResource($warmFlagsRaw, true, "warm"), - ); - - $apiWrapper->expects($this->exactly(2)) - ->method('getBandits') - ->willReturnOnConsecutiveCalls( - new APIResource($coldBanditsRaw, true, null), - new APIResource($warmBanditsRaw, true, null), - ); - - $configStore = new ConfigurationStore(DefaultCacheFactory::create()); - $loader = new ConfigurationLoader($apiWrapper, $configStore, optimizedBanditLoading: true); - - - // First fetch has the bandit cold - $loader->fetchAndStoreConfigurations(null); - - $bandit = $loader->getBandit('cold_starting_bandit'); - $this->assertNotNull($bandit); - $this->assertInstanceOf(Bandit::class, $bandit); - $this->assertEquals('cold_starting_bandit', $bandit->banditKey); - $this->assertEquals('cold start', $bandit->modelVersion); - - - // Trigger a reload, second fetch shows the bandit as still cold - $loader->fetchAndStoreConfigurations('initial'); - - $bandit = $loader->getBandit('cold_starting_bandit'); - $this->assertNotNull($bandit); - $this->assertInstanceOf(Bandit::class, $bandit); - $this->assertEquals('cold_starting_bandit', $bandit->banditKey); - $this->assertEquals('cold start', $bandit->modelVersion); - - // Trigger a reload, third fetch has the bandit warm with v1 - $loader->fetchAndStoreConfigurations('initialButForced'); - - $bandit = $loader->getBandit('cold_starting_bandit'); - $this->assertNotNull($bandit); - $this->assertInstanceOf(Bandit::class, $bandit); - $this->assertEquals('cold_starting_bandit', $bandit->banditKey); - $this->assertEquals('v1', $bandit->modelVersion); + $this->assertEquals($timestamp1, $configStore->getConfiguration()->getFetchedAt()); } - public function testReloadsOnExpiredCache(): void - { - // Arrange: Load some flag data to be returned by the APIRequestWrapper - // Load mock response data - $flagsRaw = file_get_contents(self::MOCK_RESPONSE_FILENAME); - $flagsJson = json_decode($flagsRaw, true); - $banditsRaw = '{"bandits": {}}'; - - $apiWrapper = $this->getMockBuilder(APIRequestWrapper::class)->disableOriginalConstructor()->getMock(); - - $cache = DefaultCacheFactory::create(); - // Act: Create a new FCL with a 0sec ttl and retrieve a flag - $loader = new ConfigurationLoader($apiWrapper, new ConfigurationStore($cache), cacheAgeLimitMillis: 0); - - // Mocks verify interaction of loader <--> API requests and loader <--> config store - $apiWrapper->expects($this->exactly(2)) - ->method('getUFC') - ->willReturn(new APIResource($flagsRaw, true, "ETAG")); - $apiWrapper->expects($this->exactly(2)) - ->method('getBandits') - ->willReturn(new APIResource($banditsRaw, true, "ETAG")); - - $flag = $loader->getFlag(self::FLAG_KEY); - $flagAgain = $loader->getFlag(self::FLAG_KEY); - - // Assert: non-null flag, api called only once via Mock `expects` above. - $this->assertNotNull($flag); - $this->assertNotNull($flagAgain); - $this->assertEquals($flag, $flagAgain); - } +// public function testOnlyLoadsBanditsWhereNeeded(): void +// { +// // Set up mock response data. +// $initialFlagsRaw = '{ +// "flags": { +// }, +// "banditReferences": { +// "cold_starting_bandit": { +// "modelVersion": "cold start", +// "flagVariations": [ +// { +// "key": "cold_starting_bandit", +// "flagKey": "cold_start_flag", +// "allocationKey": "cold_start_allocation", +// "variationKey": "cold_starting_bandit", +// "variationValue": "cold_starting_bandit" +// } +// ] +// } +// } +// }'; +// +// $warmFlagsRaw = '{ +// "flags": { +// }, +// "banditReferences": { +// "cold_starting_bandit": { +// "modelVersion": "v1", +// "flagVariations": [ +// { +// "key": "cold_starting_bandit", +// "flagKey": "cold_start_flag", +// "allocationKey": "cold_start_allocation", +// "variationKey": "cold_starting_bandit", +// "variationValue": "cold_starting_bandit" +// } +// ] +// } +// } +// }'; +// +// $coldBanditsRaw = '{ +// "bandits": { +// "cold_starting_bandit" : { +// "banditKey": "cold_starting_bandit", +// "modelName": "falcon", +// "updatedAt": "2023-09-13T04:52:06.462Z", +// "modelVersion": "cold start", +// "modelData": { +// "gamma": 1.0, +// "defaultActionScore": 0.0, +// "actionProbabilityFloor": 0.0, +// "coefficients": {} +// } +// } +// } +// }'; +// +// $warmBanditsRaw = '{ +// "bandits": { +// "cold_starting_bandit" : { +// "banditKey": "cold_starting_bandit", +// "modelName": "falcon", +// "updatedAt": "2023-09-13T04:52:06.462Z", +// "modelVersion": "v1", +// "modelData": { +// "gamma": 1.0, +// "defaultActionScore": 0.0, +// "actionProbabilityFloor": 0.0, +// "coefficients": {} +// } +// } +// } +// }'; +// +// +// $apiWrapper = $this->getMockBuilder(APIRequestWrapper::class)->disableOriginalConstructor()->getMock(); +// +// $apiWrapper->expects($this->exactly(3)) +// ->method('getUFC') +// ->willReturnOnConsecutiveCalls( +// new APIResource($initialFlagsRaw, true, "initial"), +// new APIResource($initialFlagsRaw, true, "initialButForced"), +// new APIResource($warmFlagsRaw, true, "warm"), +// ); +// +// $apiWrapper->expects($this->exactly(2)) +// ->method('getBandits') +// ->willReturnOnConsecutiveCalls( +// new APIResource($coldBanditsRaw, true, null), +// new APIResource($warmBanditsRaw, true, null), +// ); +// +// $configStore = new ConfigStore(DefaultCacheFactory::create()); +// $loader = new ConfigurationLoader($apiWrapper, $configStore); +// +// +// // First fetch has the bandit cold +// $loader->fetchAndStoreConfiguration(null); +// +// $bandit = $loader->getBandit('cold_starting_bandit'); +// $this->assertNotNull($bandit); +// $this->assertInstanceOf(Bandit::class, $bandit); +// $this->assertEquals('cold_starting_bandit', $bandit->banditKey); +// $this->assertEquals('cold start', $bandit->modelVersion); +// +// +// // Trigger a reload, second fetch shows the bandit as still cold +// $loader->fetchAndStoreConfiguration('initial'); +// +// $bandit = $loader->getBandit('cold_starting_bandit'); +// $this->assertNotNull($bandit); +// $this->assertInstanceOf(Bandit::class, $bandit); +// $this->assertEquals('cold_starting_bandit', $bandit->banditKey); +// $this->assertEquals('cold start', $bandit->modelVersion); +// +// // Trigger a reload, third fetch has the bandit warm with v1 +// $loader->fetchAndStoreConfiguration('initialButForced'); +// +// $bandit = $loader->getBandit('cold_starting_bandit'); +// $this->assertNotNull($bandit); +// $this->assertInstanceOf(Bandit::class, $bandit); +// $this->assertEquals('cold_starting_bandit', $bandit->banditKey); +// $this->assertEquals('v1', $bandit->modelVersion); +// } public function testRunsWithoutBandits(): void { @@ -346,7 +292,8 @@ public function testRunsWithoutBandits(): void // Act: Load a flag, expecting the Config loader not to throw and to successfully return the flag. $cache = DefaultCacheFactory::create(); $loader = new ConfigurationLoader($apiWrapper, new ConfigurationStore($cache)); - $flag = $loader->getFlag(self::FLAG_KEY); + $loader->reloadConfiguration(); + $flag = $loader->configurationStore->getConfiguration()->getFlag(self::FLAG_KEY); // Assert. $this->assertNotNull($flag); diff --git a/tests/Config/ConfigurationStoreTest.php b/tests/Config/ConfigurationStoreTest.php index bd2c7b6..fb54f3e 100644 --- a/tests/Config/ConfigurationStoreTest.php +++ b/tests/Config/ConfigurationStoreTest.php @@ -3,16 +3,17 @@ namespace Eppo\Tests\Config; use DateTime; -use Eppo\Bandits\BanditReferenceIndexer; use Eppo\Cache\DefaultCacheFactory; +use Eppo\Config\Configuration; use Eppo\Config\ConfigurationStore; use Eppo\DTO\Bandit\Bandit; use Eppo\DTO\Bandit\BanditModelData; use Eppo\DTO\BanditFlagVariation; use Eppo\DTO\BanditReference; +use Eppo\DTO\ConfigurationWire\ConfigResponse; +use Eppo\DTO\ConfigurationWire\ConfigurationWire; use Eppo\DTO\Flag; use Eppo\DTO\VariationType; -use Eppo\Exception\InvalidArgumentException; use PHPUnit\Framework\TestCase; class ConfigurationStoreTest extends TestCase @@ -22,33 +23,32 @@ public function tearDown(): void DefaultCacheFactory::clearCache(); } - public function testFlushesCacheOnReload(): void + public function testActivatesNewConfiguration(): void { $flag1 = new Flag('flag1', true, [], VariationType::STRING, [], 10_000); $flag2 = new Flag('flag2', true, [], VariationType::STRING, [], 10_000); $flag3 = new Flag('flag3', true, [], VariationType::STRING, [], 10_000); - $firstFlags = [$flag1, $flag2]; - - $secondFlags = [$flag1, $flag3]; + $firstFlags = ['flag1' => $flag1, 'flag2' => $flag2]; + $secondFlags = ['flag1' => $flag1, 'flag3' => $flag3]; $configStore = new ConfigurationStore(DefaultCacheFactory::create()); - - $configStore->setUnifiedFlagConfiguration($firstFlags); + $configuration = Configuration::fromFlags($firstFlags); + $configStore->setConfiguration($configuration); $this->assertHasFlag($flag1, 'flag1', $configStore); $this->assertHasFlag($flag2, 'flag2', $configStore); $this->assertHasFlag($flag3, 'flag3', $configStore, hasFlag: false); - $configStore->setUnifiedFlagConfiguration($secondFlags); + $configStore->setConfiguration(Configuration::fromFlags($secondFlags)); $this->assertHasFlag($flag1, 'flag1', $configStore); $this->assertHasFlag($flag2, 'flag2', $configStore, hasFlag: false); $this->assertHasFlag($flag3, 'flag3', $configStore); } - public function testSetsEmptyVariationsWhenNull(): void + public function testStoresBanditVariations(): void { $configStore = new ConfigurationStore(DefaultCacheFactory::create()); @@ -67,88 +67,161 @@ public function testSetsEmptyVariationsWhenNull(): void ) ]; - $banditVariations = BanditReferenceIndexer::from($banditReferences); + $banditsConfig = new ConfigResponse(); - $configStore->setUnifiedFlagConfiguration([], $banditVariations); - - // Verify Object has been stored. - $recoveredBanditVariations = $configStore->getBanditReferenceIndexer(); - $this->assertNotNull($recoveredBanditVariations); - $this->assertTrue($recoveredBanditVariations->hasBandits()); + $flagsConfig = new ConfigResponse( + response: json_encode(['banditReferences' => $banditReferences]) + ); - // The action that we're testing - $configStore->setUnifiedFlagConfiguration([], null); + $configStore->setConfiguration(Configuration::fromUfcResponses($flagsConfig, $banditsConfig)); - // Assert the variations have been emptied. - $recoveredBanditVariations = $configStore->getBanditReferenceIndexer(); - $this->assertNotNull($recoveredBanditVariations); - $this->assertFalse($recoveredBanditVariations->hasBandits()); + $this->assertEquals( + "bandit", + $configStore->getConfiguration()->getBanditByVariation('bandit_flag', 'bandit_flag_variation') + ); + $this->assertNull( + $configStore->getConfiguration()->getBanditByVariation('bandit', 'bandit_flag_variation') + ); } - public function testStoresBanditVariations(): void + public function testStoresBandits(): void { - $configStore = new ConfigurationStore(DefaultCacheFactory::create()); - - $banditReferences = [ - 'bandit' => new BanditReference( + $bandits = [ + 'banditIndex' => new Bandit( + 'banditKey', + 'falcon', + new DateTime(), 'v123', - [ - new BanditFlagVariation( - 'bandit', - 'bandit_flag', - 'bandit_flag_allocation', - 'bandit_flag_variation', - 'bandit_flag_variation' - ) - ] + new BanditModelData(1.0, [], 0.1, 0.1) ) ]; - $banditVariations = BanditReferenceIndexer::from($banditReferences); + $configStore = new ConfigurationStore(DefaultCacheFactory::create()); + $configStore->setConfiguration(Configuration::fromFlags([], $bandits)); - $configStore->setUnifiedFlagConfiguration([], $banditVariations); + $banditOne = $configStore->getConfiguration()->getBandit('banditIndex'); - $recoveredBanditVariations = $configStore->getBanditReferenceIndexer(); + $this->assertNull($configStore->getConfiguration()->getBandit('banditKey')); + $this->assertNotNull($banditOne); - $this->assertFalse($banditVariations === $recoveredBanditVariations); - $this->assertEquals( - $banditVariations->getBanditByVariation('bandit_flag', 'bandit_flag_variation'), - $recoveredBanditVariations->getBanditByVariation('bandit_flag', 'bandit_flag_variation') - ); + $this->assertEquals('banditKey', $banditOne->banditKey); + $this->assertEquals('falcon', $banditOne->modelName); } - public function testThrowsOnReservedKey(): void + public function testGetConfigurationFromCache(): void { - $this->expectException(InvalidArgumentException::class); + $mockCache = new MockCache(); + $configKey = "EPPO_configuration_v1"; - $configStore = new ConfigurationStore(DefaultCacheFactory::create()); + $flag = new Flag('test_flag', true, [], VariationType::STRING, [], 10_000); + $flags = ['test_flag' => $flag]; + $configuration = Configuration::fromFlags($flags); + $configWire = $configuration->toConfigurationWire(); + + $mockCache->set($configKey, json_encode($configWire->toArray())); + + $configStore = new ConfigurationStore($mockCache); - $configStore->setMetadata('banditVariations', ["foo" => "bar"]); + $retrievedConfig = $configStore->getConfiguration(); + + $this->assertNotNull($retrievedConfig); + $this->assertNotNull($retrievedConfig->getFlag('test_flag')); + $this->assertEquals($flag, $retrievedConfig->getFlag('test_flag')); } - public function testStoresBandits(): void + public function testSetConfigurationToCache(): void { - $bandits = [ - 'weaklyTheBanditKey' => new Bandit( - 'stronglyTheBanditKey', - 'falcon', - new DateTime(), - 'v123', - new BanditModelData(1.0, [], 0.1, 0.1) - ) - ]; + $mockCache = new MockCache(); + $configStore = new ConfigurationStore($mockCache); - $configStore = new ConfigurationStore(DefaultCacheFactory::create()); - $configStore->setBandits($bandits); + $flag = new Flag('test_flag', true, [], VariationType::STRING, [], 10_000); + $flags = ['test_flag' => $flag]; + $configuration = Configuration::fromFlags($flags); - $banditOne = $configStore->getBandit('stronglyTheBanditKey'); + $configStore->setConfiguration($configuration); - $this->assertNull($configStore->getBandit('weaklyTheBanditKey')); - $this->assertNotNull($banditOne); + $cacheData = $mockCache->getCache(); + $this->assertArrayHasKey("EPPO_configuration_v1", $cacheData); - $this->assertEquals('stronglyTheBanditKey', $banditOne->banditKey); - $this->assertEquals('falcon', $banditOne->modelName); + $cachedConfig = json_decode($cacheData["EPPO_configuration_v1"], true); + $this->assertIsArray($cachedConfig); + + $configWire = ConfigurationWire::fromArray($cachedConfig); + $reconstructedConfig = Configuration::fromConfigurationWire($configWire); + + $this->assertNotNull($reconstructedConfig->getFlag('test_flag')); + $this->assertEquals($flag->key, $reconstructedConfig->getFlag('test_flag')->key); + } + + public function testGetConfigurationWithEmptyCache(): void + { + $mockCache = new MockCache(); + $configStore = new ConfigurationStore($mockCache); + + $configuration = $configStore->getConfiguration(); + + $this->assertNotNull($configuration); + $this->assertNull($configuration->getFlag('any_flag')); + } + + public function testGetConfigurationWithCacheException(): void + { + $mockCache = new MockCache(throwOnGet: true); + $configStore = new ConfigurationStore($mockCache); + + $configuration = $configStore->getConfiguration(); + + $this->assertNotNull($configuration); + $this->assertNull($configuration->getFlag('any_flag')); + } + + public function testSetConfigurationWithCacheException(): void + { + $mockCache = new MockCache(throwOnSet: true); + $configStore = new ConfigurationStore($mockCache); + + $flag = new Flag('test_flag', true, [], VariationType::STRING, [], 10_000); + $flags = ['test_flag' => $flag]; + $configuration = Configuration::fromFlags($flags); + + try { + $configStore->setConfiguration($configuration); + $this->assertTrue(true); // If we get here, no exception was thrown + } catch (\Exception $e) { + $this->fail('setConfiguration should not throw exceptions from cache'); + } + } + + public function testConfigurationInAndOut(): void + { + $mockCache = new MockCache(); + $configStore = new ConfigurationStore($mockCache); + $configuration = Configuration::fromConfigurationWire($this->getBanditConfigurationWire()); + $configStore->setConfiguration($configuration); + + $newConfigStore = new ConfigurationStore($mockCache); + $retrievedConfig = $newConfigStore->getConfiguration(); + + $this->assertNotNull($retrievedConfig); + $this->assertNotNull($retrievedConfig->getFlag('banner_bandit_flag')); + + $this->assertEquals( + $configuration->toConfigurationWire()->toArray(), + $retrievedConfig->toConfigurationWire()->toArray() + ); + } + + + private function getBanditConfigurationWire(): ConfigurationWire + { + $jsonData = file_get_contents(dirname(__DIR__) . '/data/configuration-wire/bandit-flags-v1.json'); + $this->assertNotFalse($jsonData, 'Failed to load test data file'); + + $configData = json_decode($jsonData, true); + $this->assertIsArray($configData, 'Failed to parse JSON data'); + + return ConfigurationWire::fromArray($configData); } private function assertHasFlag( @@ -157,7 +230,8 @@ private function assertHasFlag( ConfigurationStore $configStore, bool $hasFlag = true ): void { - $actual = $configStore->getFlag($flagKey); + $config = $configStore->getConfiguration(); + $actual = $config->getFlag($flagKey); if (!$hasFlag) { $this->assertNull($actual); return; diff --git a/tests/Config/ConfigurationTest.php b/tests/Config/ConfigurationTest.php new file mode 100644 index 0000000..4875da2 --- /dev/null +++ b/tests/Config/ConfigurationTest.php @@ -0,0 +1,56 @@ +testDataPath = dirname(__DIR__) . '/data/configuration-wire/'; + } + + public function testFromConfigurationWire(): void + { + $jsonData = file_get_contents($this->testDataPath . 'bandit-flags-v1.json'); + $this->assertNotFalse($jsonData, 'Failed to load test data file'); + + $configData = json_decode($jsonData, true); + $this->assertIsArray($configData, 'Failed to parse JSON data'); + + $configurationWire = ConfigurationWire::fromArray($configData); + $configuration = Configuration::fromConfigurationWire($configurationWire); + + $this->assertInstanceOf(Configuration::class, $configuration); + + $nonBanditFlag = $configuration->getFlag('non_bandit_flag'); + $this->assertNotNull($nonBanditFlag); + $this->assertEquals('non_bandit_flag', $nonBanditFlag->key); + $this->assertTrue($nonBanditFlag->enabled); + + $bannerBanditFlag = $configuration->getFlag('banner_bandit_flag'); + $this->assertNotNull($bannerBanditFlag); + $this->assertEquals('banner_bandit_flag', $bannerBanditFlag->key); + $this->assertTrue($bannerBanditFlag->enabled); + + $banditKey = $configuration->getBanditByVariation('banner_bandit_flag', 'banner_bandit'); + $this->assertEquals('banner_bandit', $banditKey); + + $bandit = $configuration->getBandit('banner_bandit'); + $this->assertNotNull($bandit); + $this->assertEquals('banner_bandit', $bandit->banditKey); + $this->assertEquals('123', $bandit->modelVersion); + + $newConfigurationWire = $configuration->toConfigurationWire(); + $this->assertInstanceOf(ConfigurationWire::class, $newConfigurationWire); + $this->assertEquals(1, $newConfigurationWire->version); + $this->assertNotNull($newConfigurationWire->config); + $this->assertNotNull($newConfigurationWire->bandits); + } +} diff --git a/tests/Config/MockCache.php b/tests/Config/MockCache.php new file mode 100644 index 0000000..6a459e3 --- /dev/null +++ b/tests/Config/MockCache.php @@ -0,0 +1,87 @@ +throwOnGet = $throwOnGet; + $this->throwOnSet = $throwOnSet; + } + + public function get(string $key, mixed $default = null): mixed + { + if ($this->throwOnGet) { + throw new class extends \Exception implements InvalidArgumentException { + }; + } + + return $this->cache[$key] ?? $default; + } + + public function set(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool + { + if ($this->throwOnSet) { + throw new class extends \Exception implements InvalidArgumentException { + }; + } + + $this->cache[$key] = $value; + return true; + } + + public function delete(string $key): bool + { + unset($this->cache[$key]); + return true; + } + + public function clear(): bool + { + $this->cache = []; + return true; + } + + public function getMultiple(iterable $keys, mixed $default = null): iterable + { + $result = []; + foreach ($keys as $key) { + $result[$key] = $this->get($key, $default); + } + return $result; + } + + public function setMultiple(iterable $values, null|int|\DateInterval $ttl = null): bool + { + foreach ($values as $key => $value) { + $this->set($key, $value, $ttl); + } + return true; + } + + public function deleteMultiple(iterable $keys): bool + { + foreach ($keys as $key) { + $this->delete($key); + } + return true; + } + + public function has(string $key): bool + { + return isset($this->cache[$key]); + } + + public function getCache(): array + { + return $this->cache; + } +} diff --git a/tests/UFCParserTest.php b/tests/DTO/FlagDTOTest.php similarity index 90% rename from tests/UFCParserTest.php rename to tests/DTO/FlagDTOTest.php index b900acb..7069a14 100644 --- a/tests/UFCParserTest.php +++ b/tests/DTO/FlagDTOTest.php @@ -1,25 +1,23 @@ parseFlag($flags[self::FLAG_KEY]); + $flag = Flag::fromJson($flags[self::FLAG_KEY]); $this->assertInstanceOf(Flag::class, $flag); diff --git a/tests/EppoClientTest.php b/tests/EppoClientTest.php index 0fc0bdb..6e632d0 100644 --- a/tests/EppoClientTest.php +++ b/tests/EppoClientTest.php @@ -5,9 +5,9 @@ use Eppo\API\APIRequestWrapper; use Eppo\API\APIResource; use Eppo\Cache\DefaultCacheFactory; +use Eppo\Config\Configuration; use Eppo\Config\ConfigurationLoader; use Eppo\Config\ConfigurationStore; -use Eppo\Config\IConfigurationStore; use Eppo\Config\SDKData; use Eppo\DTO\VariationType; use Eppo\EppoClient; @@ -16,6 +16,7 @@ use Eppo\Exception\HttpRequestException; use Eppo\Logger\LoggerInterface; use Eppo\PollerInterface; +use Eppo\PollingOptions; use Eppo\Tests\WebServer\MockWebServer; use Exception; use GuzzleHttp\Psr7\Utils; @@ -26,7 +27,6 @@ use PsrMock\Psr17\RequestFactory; use PsrMock\Psr7\Response; use Throwable; -use Eppo\PollingOptions; class EppoClientTest extends TestCase { @@ -58,11 +58,30 @@ public function setUp(): void public function testGracefulModeDoesNotThrow() { $pollerMock = $this->getPollerMock(); - $mockConfigRequester = $this->getFlagConfigurationLoaderMock([], new Exception('config loader error')); + + $apiRequestWrapper = $this->getMockBuilder(APIRequestWrapper::class)->setConstructorArgs( + ['', [], new Psr18Client(), new Psr17Factory()] + )->getMock(); + + $configStore = $this->getMockBuilder(ConfigurationStore::class)->disableOriginalConstructor()->getMock(); + $config = $this->getMockBuilder(Configuration::class)->disableOriginalConstructor()->getMock(); + $mockLogger = $this->getMockBuilder(LoggerInterface::class)->getMock(); + $configStore->expects($this->atLeastOnce()) + ->method('getConfiguration') + ->willReturn($config); + $config->expects($this->once()) + ->method('getFetchedAt') + ->willReturn(date('c')); + $config->expects($this->atLeastOnce()) + ->method('getFlag') + ->willThrowException(new Exception()); + + $loader = new ConfigurationLoader($apiRequestWrapper, $configStore); + $subjectAttributes = [['foo' => 3]]; - $client = EppoClient::createTestClient($mockConfigRequester, $pollerMock, $mockLogger, true); + $client = EppoClient::createTestClient($configStore, $loader, $pollerMock, $mockLogger, true); $defaultObj = json_decode('{}', true); @@ -91,22 +110,26 @@ public function testNoGracefulModeThrowsOnGetAssignment() ['', [], new Psr18Client(), new Psr17Factory()] )->getMock(); - $apiRequestWrapper->expects($this->any()) - ->method('getUFC') - ->willThrowException(new HttpRequestException()); + $configStore = $this->getMockBuilder(ConfigurationStore::class)->disableOriginalConstructor()->getMock(); + $config = $this->getMockBuilder(Configuration::class)->disableOriginalConstructor()->getMock(); - $configStore = $this->getMockBuilder(IConfigurationStore::class)->getMock(); $mockLogger = $this->getMockBuilder(LoggerInterface::class)->getMock(); - $this->expectException(EppoClientException::class); - - $flags = $this->getMockBuilder(ConfigurationLoader::class)->disableOriginalConstructor()->getMock(); - - $flags->expects($this->once()) + $configStore->expects($this->atLeastOnce()) + ->method('getConfiguration') + ->willReturn($config); + $config->expects($this->once()) + ->method('getFetchedAt') + ->willReturn(date('c')); + $config->expects($this->once()) ->method('getFlag') - ->with(self::EXPERIMENT_NAME) ->willThrowException(new Exception()); - $client = EppoClient::createTestClient($flags, $pollerMock, $mockLogger, false); + + $loader = new ConfigurationLoader($apiRequestWrapper, $configStore); + + $client = EppoClient::createTestClient($configStore, $loader, $pollerMock, $mockLogger, false); + + $this->expectException(EppoClientException::class); $result = $client->getStringAssignment(self::EXPERIMENT_NAME, 'subject-10', [], "default"); } @@ -122,16 +145,21 @@ public function testNoGracefulModeThrowsOnInit() ->method('getUFC') ->willThrowException(new HttpRequestException()); - $configStore = $this->getMockBuilder(IConfigurationStore::class)->getMock(); - $configStore->expects($this->any())->method('getMetadata')->willReturn(null); + $configStore = $this->getMockBuilder(ConfigurationStore::class)->disableOriginalConstructor()->getMock(); + $configStore->expects($this->atLeastOnce()) + ->method('getConfiguration') + ->willThrowException(new Exception("Expected Exception")); + $mockLogger = $this->getMockBuilder(LoggerInterface::class)->getMock(); $this->expectException(EppoClientInitializationException::class); $client = EppoClient::createTestClient( + $configStore, new ConfigurationLoader($apiRequestWrapper, $configStore), $pollerMock, $mockLogger, - false + isGracefulMode: false, + throwOnFailedInit: true ); } @@ -147,16 +175,21 @@ public function testGracefulModeThrowsOnInit() ->method('getUFC') ->willThrowException(new HttpRequestException()); - $configStore = $this->getMockBuilder(IConfigurationStore::class)->getMock(); - $configStore->expects($this->any())->method('getMetadata')->willReturn(null); + $configStore = $this->getMockBuilder(ConfigurationStore::class)->disableOriginalConstructor()->getMock(); + $configStore->expects($this->atLeastOnce()) + ->method('getConfiguration') + ->willThrowException(new Exception("Expected Exception")); + $mockLogger = $this->getMockBuilder(LoggerInterface::class)->getMock(); $this->expectException(EppoClientInitializationException::class); $client = EppoClient::createTestClient( + $configStore, new ConfigurationLoader($apiRequestWrapper, $configStore), $pollerMock, $mockLogger, - true + isGracefulMode: true, + throwOnFailedInit: true ); } @@ -172,11 +205,16 @@ public function testSuppressInitExceptionThrow() ->method('getUFC') ->willThrowException(new HttpRequestException()); - $configStore = $this->getMockBuilder(IConfigurationStore::class)->getMock(); - $configStore->expects($this->any())->method('getMetadata')->willReturn(null); + $configStore = $this->getMockBuilder(ConfigurationStore::class)->disableOriginalConstructor()->getMock(); + $configStore->expects($this->atLeastOnce()) + ->method('getConfiguration') + ->willThrowException(new Exception("Expected Exception")); + $mockLogger = $this->getMockBuilder(LoggerInterface::class)->getMock(); + $client = EppoClient::createTestClient( + $configStore, new ConfigurationLoader($apiRequestWrapper, $configStore), $pollerMock, $mockLogger, @@ -188,7 +226,6 @@ public function testSuppressInitExceptionThrow() 'default', $client->getStringAssignment(self::EXPERIMENT_NAME, 'subject-10', [], 'default') ); - // No exceptions thrown, default assignments. } @@ -197,7 +234,7 @@ public function testReturnsDefaultWhenExperimentConfigIsAbsent() $configLoaderMock = $this->getFlagConfigurationLoaderMock([]); $pollerMock = $this->getPollerMock(); - $client = EppoClient::createTestClient($configLoaderMock, $pollerMock); + $client = EppoClient::createTestClient($configLoaderMock->configurationStore, $configLoaderMock, $pollerMock); $this->assertEquals( 'DEFAULT', $client->getStringAssignment(self::EXPERIMENT_NAME, 'subject-10', [], 'DEFAULT') @@ -307,22 +344,6 @@ private function getPollerMock() return $this->getMockBuilder(PollerInterface::class)->getMock(); } - private function getLoggerMock() - { - $mockLogger = $this->getMockBuilder(LoggerInterface::class)->getMock(); - $mockLogger->expects($this->once())->method('logAssignment')->with( - 'mock-experiment-allocation1', - 'control', - 'subject-10', - $this->greaterThan(0), - $this->anything(), - 'allocation1', - 'mock-experiment' - ); - - return $mockLogger; - } - private function loadTestCases(): array { $files = scandir(self::TEST_DATA_PATH); @@ -341,25 +362,27 @@ private function loadTestCases(): array * @throws EppoClientInitializationException * @throws EppoClientException */ - public function testInitWithPollingOptions(): void + public function testCacheExpiring(): void { $apiKey = 'dummy-api-key'; $pollingOptions = new PollingOptions( - cacheAgeLimitMillis: 50, + cacheAgeLimitMillis: 1000, pollingIntervalMillis: 10000, pollingJitterMillis: 2000 ); $response = new Response(stream: Utils::streamFor(file_get_contents(__DIR__ . '/data/ufc/flags-v1.json'))); - $secondResponse = new Response(stream: Utils::streamFor( - file_get_contents(__DIR__ . '/data/ufc/bandit-flags-v1.json') - )); + $secondResponse = new Response( + stream: Utils::streamFor( + file_get_contents(__DIR__ . '/data/ufc/bandit-flags-v1.json') + ) + ); $httpClient = $this->createMock(ClientInterface::class); $httpClient->expects($this->atLeast(2)) ->method('sendRequest') - ->willReturnOnConsecutiveCalls($response, $secondResponse, $secondResponse); + ->willReturnOnConsecutiveCalls($response, $secondResponse, $secondResponse, $secondResponse); $client = EppoClient::init( $apiKey, @@ -374,12 +397,35 @@ public function testInitWithPollingOptions(): void 3.1415926, $client->getNumericAssignment(self::EXPERIMENT_NAME, 'subject-10', [], 0) ); + + $client2 = EppoClient::init( + $apiKey, + "fake address", + httpClient: $httpClient, + isGracefulMode: false, + pollingOptions: $pollingOptions, + throwOnFailedInit: true + ); + + $this->assertEquals( + 3.1415926, + $client2->getNumericAssignment(self::EXPERIMENT_NAME, 'subject-10', [], 0) + ); + // Wait a little bit for the cache to age out and the mock server to spin up. - usleep(75 * 1000); + usleep(1000 * 1000); + $client3 = EppoClient::init( + $apiKey, + "fake address", + httpClient: $httpClient, + isGracefulMode: false, + pollingOptions: $pollingOptions, + throwOnFailedInit: true + ); $this->assertEquals( 0, - $client->getNumericAssignment(self::EXPERIMENT_NAME, 'subject-10', [], 0) + $client3->getNumericAssignment(self::EXPERIMENT_NAME, 'subject-10', [], 0) ); } } diff --git a/tests/MockConfigurationStore.php b/tests/MockConfigurationStore.php new file mode 100644 index 0000000..4d89f52 --- /dev/null +++ b/tests/MockConfigurationStore.php @@ -0,0 +1,25 @@ +config; + } + + public function setConfiguration(Configuration $configuration): void + { + $this->config = $configuration; + } +} diff --git a/tests/Traits/StaticCreateSelfTest.php b/tests/Traits/StaticCreateSelfTest.php new file mode 100644 index 0000000..5a661e9 --- /dev/null +++ b/tests/Traits/StaticCreateSelfTest.php @@ -0,0 +1,26 @@ + '{"key": "value"}', + 'eTag' => 'etag123', + 'fetchedAt' => '2023-10-01T12:00:00Z' + ]; + + // Leverage a class that uses the StaticCreateSelf trait. + $configResponse = ConfigResponse::fromJson($data); + + $this->assertInstanceOf(ConfigResponse::class, $configResponse); + $this->assertEquals('{"key": "value"}', $configResponse->response); + $this->assertEquals('etag123', $configResponse->eTag); + $this->assertEquals('2023-10-01T12:00:00Z', $configResponse->fetchedAt); + } +} diff --git a/tests/Traits/ToArrayTest.php b/tests/Traits/ToArrayTest.php new file mode 100644 index 0000000..1a1f101 --- /dev/null +++ b/tests/Traits/ToArrayTest.php @@ -0,0 +1,28 @@ +response = '{"key": "value"}'; + $configResponse->eTag = 'etag123'; + $configResponse->fetchedAt = '2023-10-01T12:00:00Z'; + + $array = $configResponse->toArray(); + + $expected = [ + 'response' => '{"key": "value"}', + 'eTag' => 'etag123', + 'fetchedAt' => '2023-10-01T12:00:00Z' + ]; + + $this->assertEquals($expected, $array); + } +}