Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 55 additions & 21 deletions src/Flagsmith.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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();
}
}

/**
Expand Down Expand Up @@ -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();
}

Expand All @@ -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);
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));
}

Expand Down
12 changes: 12 additions & 0 deletions src/Offline/IOfflineHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Flagsmith\Offline;

use Flagsmith\Engine\Environments\EnvironmentModel;

interface IOfflineHandler
{
public function getEnvironment(): ?EnvironmentModel;
}
29 changes: 29 additions & 0 deletions src/Offline/LocalFileHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Flagsmith\Offline;

use Flagsmith\Engine\Environments\EnvironmentModel;
use Flagsmith\Exceptions\FlagsmithClientError;

class LocalFileHandler implements IOfflineHandler
{
private ?EnvironmentModel $environmentModel = null;

public function __construct(string $filePath)
{
if (!file_exists($filePath)) {
throw new FlagsmithClientError('Unable to read environment from file '.$filePath);
}

$file = fopen($filePath, 'r');
$environmentDict = json_decode(fread($file, filesize($filePath)), false, 512, JSON_THROW_ON_ERROR);
$this->environmentModel = EnvironmentModel::build($environmentDict);
}

public function getEnvironment(): ?EnvironmentModel
{
return $this->environmentModel;
}
}
121 changes: 120 additions & 1 deletion tests/FlagsmithClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
}
}
17 changes: 17 additions & 0 deletions tests/Offline/FakeOfflineHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace FlagsmithTest\Offline;

use Flagsmith\Engine\Environments\EnvironmentModel;
use Flagsmith\Offline\IOfflineHandler;
use FlagsmithTest\ClientFixtures;

class FakeOfflineHandler implements IOfflineHandler
{
public function getEnvironment(): ?EnvironmentModel
{
return ClientFixtures::getEnvironmentModel();
}
}
20 changes: 20 additions & 0 deletions tests/Offline/LocalFileHandlerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

use Flagsmith\Offline\LocalFileHandler;
use FlagsmithTest\ClientFixtures;
use PHPUnit\Framework\TestCase;

class LocalFileHandlerTest extends TestCase
{
public function testLocalFileHandler()
{
// Given
$environmentModel = ClientFixtures::getEnvironmentModel();

// When
$localFileHandler = new LocalFileHandler(dirname(__FILE__).'/../Data/environment.json');

// Then
$this->assertEquals($localFileHandler->getEnvironment(), $environmentModel);
}
}