diff --git a/src/Flagsmith.php b/src/Flagsmith.php index 6e9e26e..1b4850d 100644 --- a/src/Flagsmith.php +++ b/src/Flagsmith.php @@ -10,12 +10,12 @@ use Flagsmith\Engine\Segments\SegmentEvaluator; use Flagsmith\Engine\Utils\Collections\FeatureStateModelList; use Flagsmith\Engine\Utils\Collections\IdentityTraitList; -use Flagsmith\Exceptions\APIException; use Flagsmith\Exceptions\FlagsmithAPIError; use Flagsmith\Exceptions\FlagsmithClientError; use Flagsmith\Exceptions\FlagsmithThrowable; use Flagsmith\Models\Flags; use Flagsmith\Models\Segment; +use Flagsmith\Offline\IOfflineHandler; use Flagsmith\Utils\AnalyticsProcessor; use Flagsmith\Utils\IdentitiesGenerator; use Flagsmith\Utils\Retry; @@ -32,10 +32,11 @@ class Flagsmith { use HasWith; public const DEFAULT_API_URL = 'https://edge.api.flagsmith.com/api/v1'; - private string $apiKey; - private string $host = self::DEFAULT_API_URL; + private ?string $apiKey; + private ?string $host; private ?object $customHeaders = null; private ?int $environmentTtl = null; + private bool $enableLocalEvaluation = false; private Retry $retries; private ?AnalyticsProcessor $analyticsProcessor = null; private ?\Closure $defaultFlagHandler = null; @@ -52,38 +53,65 @@ class Flagsmith private bool $useCacheAsFailover = false; private array $headers = []; private ?EnvironmentModel $environment = null; + private bool $offlineMode = false; + private ?IOfflineHandler $offlineHandler = null; /** * @throws ValueError */ public function __construct( - string $apiKey, - string $host = self::DEFAULT_API_URL, + string $apiKey = null, + string $host = null, object $customHeaders = null, int $environmentTtl = null, Retry $retries = null, bool $enableAnalytics = false, - \Closure $defaultFlagHandler = null + \Closure $defaultFlagHandler = null, + bool $offlineMode = false, + IOfflineHandler $offlineHandler = null ) { - $this->apiKey = $apiKey; - $this->host = rtrim($host, '/'); - $this->customHeaders = $customHeaders ?? $this->customHeaders; - $this->environmentTtl = $environmentTtl ?? $this->environmentTtl; - $this->retries = $retries ?? new Retry(3); - $this->analyticsProcessor = $enableAnalytics ? new AnalyticsProcessor($apiKey, $host) : null; - $this->defaultFlagHandler = $defaultFlagHandler ?? $this->defaultFlagHandler; + if ($offlineMode and is_null($offlineHandler)) { + throw new ValueError('offlineHandler must be provided to use offline mode.'); + } + + if (!is_null($offlineHandler) and !is_null($defaultFlagHandler)) { + throw new ValueError('Cannot use both defaultFlagHandler and offlineHandler.'); + } + if (is_int($environmentTtl)) { - if (stripos($this->apiKey, 'ser.') === false) { + if (stripos($apiKey, 'ser.') === false) { throw new ValueError( 'In order to use local evaluation, please generate a server key in the environment settings page.' ); } } - //We default to using Guzzle for the HTTP client (as this is how it worked in 1.0) - $this->client = Psr18ClientDiscovery::find(); - $this->requestFactory = Psr17FactoryDiscovery::findRequestFactory(); - $this->streamFactory = Psr17FactoryDiscovery::findStreamFactory(); + $this->offlineMode = $offlineMode; + $this->offlineHandler = $offlineHandler; + + if (!is_null($offlineHandler)) { + $this->environment = $offlineHandler->getEnvironment(); + } + + if (!$offlineMode) { + if (is_null($apiKey)) { + throw new ValueError('apiKey is required.'); + } + + $this->apiKey = $apiKey; + $this->host = !is_null($host) ? rtrim($host, '/') : self::DEFAULT_API_URL; + $this->customHeaders = $customHeaders ?? $this->customHeaders; + $this->environmentTtl = $environmentTtl ?? $this->environmentTtl; + $this->enableLocalEvaluation = !is_null($environmentTtl); + $this->retries = $retries ?? new Retry(3); + $this->analyticsProcessor = $enableAnalytics ? new AnalyticsProcessor($apiKey, $host) : null; + $this->defaultFlagHandler = $defaultFlagHandler ?? $this->defaultFlagHandler; + + //We default to using Guzzle for the HTTP client (as this is how it worked in 1.0) + $this->client = Psr18ClientDiscovery::find(); + $this->requestFactory = Psr17FactoryDiscovery::findRequestFactory(); + $this->streamFactory = Psr17FactoryDiscovery::findStreamFactory(); + } } /** @@ -283,7 +311,7 @@ public function getEnvironment(): ?EnvironmentModel */ public function getEnvironmentFlags(): Flags { - if ($this->environment) { + if (($this->offlineMode || $this->enableLocalEvaluation) && $this->environment) { return $this->getEnvironmentFlagsFromDocument(); } @@ -309,7 +337,7 @@ public function getEnvironmentFlags(): Flags public function getIdentityFlags(string $identifier, ?object $traits = null, ?bool $transient = false): Flags { $traits = $traits ?? (object)[]; - if ($this->environment) { + if (($this->offlineMode || $this->enableLocalEvaluation) && $this->environment) { return $this->getIdentityFlagsFromDocument($identifier, $traits); } @@ -417,6 +445,9 @@ private function getEnvironmentFlagsFromApi(): Flags $this->defaultFlagHandler, ); } catch (FlagsmithAPIError $e) { + if (isset($this->offlineHandler)) { + return $this->getEnvironmentFlagsFromDocument(); + } if (isset($this->defaultFlagHandler)) { return (new Flags()) ->withDefaultFlagHandler($this->defaultFlagHandler); @@ -450,6 +481,9 @@ private function getIdentityFlagsFromApi(string $identifier, ?object $traits, ?b $this->defaultFlagHandler, ); } catch (FlagsmithAPIError $e) { + if (isset($this->offlineHandler)) { + return $this->getIdentityFlagsFromDocument($identifier, $traits); + } if (isset($this->defaultFlagHandler)) { return (new Flags()) ->withDefaultFlagHandler($this->defaultFlagHandler); @@ -485,7 +519,7 @@ private function getIdentityModel(string $identifier, ?object $traits): Identity if (is_null($identityModel)) { return (new IdentityModel()) ->withIdentifier($identifier) - ->withEnvironmentApiKey($this->apiKey) + ->withEnvironmentApiKey($this->environment->getApiKey()) ->withIdentityTraits(new IdentityTraitList($traitModels)); } diff --git a/src/Offline/IOfflineHandler.php b/src/Offline/IOfflineHandler.php new file mode 100644 index 0000000..8db194e --- /dev/null +++ b/src/Offline/IOfflineHandler.php @@ -0,0 +1,12 @@ +environmentModel = EnvironmentModel::build($environmentDict); + } + + public function getEnvironment(): ?EnvironmentModel + { + return $this->environmentModel; + } +} diff --git a/tests/FlagsmithClientTest.php b/tests/FlagsmithClientTest.php index fb5b0d2..ee63f1d 100644 --- a/tests/FlagsmithClientTest.php +++ b/tests/FlagsmithClientTest.php @@ -3,8 +3,8 @@ use Flagsmith\Exceptions\FlagsmithAPIError; use Flagsmith\Flagsmith; use Flagsmith\Models\DefaultFlag; -use Flagsmith\Utils\IdentitiesGenerator; use FlagsmithTest\ClientFixtures; +use FlagsmithTest\Offline\FakeOfflineHandler; use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\TestCase; use Psr\Http\Message\StreamFactoryInterface; @@ -322,4 +322,123 @@ public function testLocalEvaluationGetIdentityOverride() $this->assertEquals($flag->value, 'some-overridden-value'); } } + + public function testOfflineMode() + { + // Given + $offlineHandler = new FakeOfflineHandler(); + $flagsmith = new Flagsmith(offlineMode: true, offlineHandler: $offlineHandler); + + // When + $environmentFlags = $flagsmith->getEnvironmentFlags(); + $identityFlags = $flagsmith->getIdentityFlags('my-identity'); + + // Then + $this->assertEquals($environmentFlags->getFlag('some_feature')->enabled, true); + $this->assertEquals($environmentFlags->getFlag('some_feature')->value, 'some-value'); + + $this->assertEquals($identityFlags->getFlag('some_feature')->enabled, true); + $this->assertEquals($identityFlags->getFlag('some_feature')->value, 'some-value'); + } + + public function testFlagsmithUseOfflineHandlerIfSetAndNoApiResponse() + { + // Given + $handlerBuilder = ClientFixtures::getHandlerBuilder(); + $handlerBuilder->addRoute( + ClientFixtures::getRouteBuilder()->new() + ->withMethod('POST') + ->withPath('/api/v1/identities/') + ->withResponse(new Response(500)) + ->build() + ); + $handlerBuilder->addRoute( + ClientFixtures::getRouteBuilder()->new() + ->withMethod('GET') + ->withPath('/api/v1/flags/') + ->withResponse(new Response(500)) + ->build() + ); + + $flagsmith = (new Flagsmith(apiKey: 'some-key', offlineHandler: new FakeOfflineHandler())) + ->withClient(ClientFixtures::getMockClient($handlerBuilder, false)); + + // When + $environmentFlags = $flagsmith->getEnvironmentFlags(); + $identityFlags = $flagsmith->getIdentityFlags('my-identity'); + + // Then + $this->assertEquals($environmentFlags->getFlag('some_feature')->enabled, true); + $this->assertEquals($environmentFlags->getFlag('some_feature')->value, 'some-value'); + + $this->assertEquals($identityFlags->getFlag('some_feature')->enabled, true); + $this->assertEquals($identityFlags->getFlag('some_feature')->value, 'some-value'); + } + + public function testCannotUseOfflineModeWithoutOfflineHandler() + { + // Given + $this->expectException(ValueError::class); + $this->expectExceptionMessage('offlineHandler must be provided to use offline mode.'); + + // When + new Flagsmith(offlineMode:true, offlineHandler:null); + } + + public function testCannotUseDefaultHandlerAndOfflineHandler() + { + // Given + $defaultFlag = (new DefaultFlag()) + ->withEnabled(true) + ->withValue('some-default-value'); + + $defaultFlagHandler = function (string $featureName) use ($defaultFlag) { + return $defaultFlag; + }; + + $this->expectException(ValueError::class); + $this->expectExceptionMessage('Cannot use both defaultFlagHandler and offlineHandler.'); + + $offlineHandler = new FakeOfflineHandler(); + + // When + new Flagsmith(defaultFlagHandler:$defaultFlagHandler, offlineHandler:$offlineHandler); + } + + public function testCannotCreateFlagsmithClientInRemoteEvaluationWithoutApiKey() + { + // Given + $this->expectException(ValueError::class); + $this->expectExceptionMessage('apiKey is required'); + + // When + new Flagsmith(); + } + + public function testOfflineHandlerUsedAsFallbackForLocalEvaluation() + { + // Given + $handlerBuilder = ClientFixtures::getHandlerBuilder(); + $handlerBuilder->addRoute( + ClientFixtures::getRouteBuilder()->new() + ->withMethod('GET') + ->withPath('/api/v1/environment-document/') + ->withResponse(new Response(500)) + ->build() + ); + + $offlineHandler = new FakeOfflineHandler(); + $flagsmith = (new Flagsmith(apiKey: 'ser.some-key', environmentTtl: 3, offlineHandler: $offlineHandler)); + + // When + $environmentFlags = $flagsmith->getEnvironmentFlags(); + $identityFlags = $flagsmith->getIdentityFlags('my-identity'); + + // Then + $this->assertEquals($environmentFlags->getFlag('some_feature')->enabled, true); + $this->assertEquals($environmentFlags->getFlag('some_feature')->value, 'some-value'); + + $this->assertEquals($identityFlags->getFlag('some_feature')->enabled, true); + $this->assertEquals($identityFlags->getFlag('some_feature')->value, 'some-value'); + } } diff --git a/tests/Offline/FakeOfflineHandler.php b/tests/Offline/FakeOfflineHandler.php new file mode 100644 index 0000000..9ddfbc7 --- /dev/null +++ b/tests/Offline/FakeOfflineHandler.php @@ -0,0 +1,17 @@ +assertEquals($localFileHandler->getEnvironment(), $environmentModel); + } +}