...
 
Commits (5)
......@@ -30,7 +30,7 @@
"require": {
"php": ">=7.3",
"guzzlehttp/guzzle": "^6.3@dev",
"monolog/monolog": "^2.0@dev",
"monolog/monolog": "1.25.1",
"psr/simple-cache": "^1.0@dev",
"zendframework/zend-cache": "^2.9@dev",
"zendframework/zend-serializer": "^2.9@dev",
......
......@@ -41,6 +41,11 @@ function main() : void
$enabled = $unleash->isEnabled('test_on_test', false);
$logger->info("'test_on_test' flag evaluates to {$enabled}");
$enabled = $unleash->isEnabled('non_existing_flag', false);
$logger->info("'non_existing_flag' flag evaluates to {$enabled}");
print_r($unleash->export());
}
main();
......@@ -51,24 +51,6 @@ class ClientSpec extends ObjectBehavior
$this->shouldHaveType(Client::class);
}
public function it_should_get_featureFlags(ResponseInterface $response)
{
$this->httpClient->get('client/features')
->shouldBeCalled()
->willReturn($response);
$response->getStatusCode()
->shouldBeCalled()
->willReturn(200);
$response->getBody()
->shouldBeCalled()
->willReturn($this->getMockData('features.json'));
$features = $this->getFeatureFlags();
$features->shouldHaveCount(2);
}
public function it_should_register(ResponseInterface $response)
{
$date = date("c");
......@@ -87,6 +69,24 @@ class ClientSpec extends ObjectBehavior
$this->register($date);
}
public function it_should_get_features(ResponseInterface $response)
{
$this->httpClient->get('client/features')
->shouldBeCalled()
->willReturn($response);
$response->getStatusCode()
->shouldBeCalled()
->willReturn(200);
$response->getBody()
->shouldBeCalled()
->willReturn($this->getMockData('features.json'));
$features = $this->getFeatures();
$features->shouldHaveCount(2);
}
private function getMockData($filename)
{
return file_get_contents(__DIR__."/MockData/${filename}");
......
<?php
namespace spec\Minds\UnleashClient;
use Minds\UnleashClient\Client;
use Minds\UnleashClient\Config;
use Minds\UnleashClient\Entities\Feature;
use Minds\UnleashClient\Factories\FeatureFactory;
use Minds\UnleashClient\Repository;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Psr\Log\LoggerInterface;
use Psr\SimpleCache\CacheInterface;
class RepositorySpec extends ObjectBehavior
{
/** @var Config */
protected $config;
/** @var LoggerInterface */
protected $logger;
/** @var CacheInterface */
protected $cache;
/** @var Client */
protected $client;
/** @var FeatureFactory */
protected $featureFactory;
public function let(
Config $config,
LoggerInterface $logger,
CacheInterface $cache,
Client $client,
FeatureFactory $featureFactory
) {
$this->config = $config;
$this->logger = $logger;
$this->cache = $cache;
$this->client = $client;
$this->featureFactory = $featureFactory;
$this->beConstructedWith(
$config,
$logger,
$cache,
$client,
$featureFactory
);
}
public function it_is_initializable()
{
$this->shouldHaveType(Repository::class);
}
public function it_should_get_list_using_cache(
Feature $feature1,
Feature $feature2
) {
$this->client->getId()
->shouldBeCalled()
->willReturn('test123');
$this->cache->get(Repository::UNLEASH_CACHE_PREFIX . 'test123')
->shouldBeCalled()
->willReturn([
'features' => [
[ 'name' => 'feature1' ],
[ 'name' => 'feature2' ],
],
'expires' => time() + 1000000
]);
$this->featureFactory->build([ 'name' => 'feature1' ])
->shouldBeCalled()
->willReturn($feature1);
$this->featureFactory->build([ 'name' => 'feature2' ])
->shouldBeCalled()
->willReturn($feature2);
$feature1->getName()
->shouldBeCalled()
->willReturn('feature1');
$feature2->getName()
->shouldBeCalled()
->willReturn('feature2');
$this
->getList()
->shouldReturn([
'feature1' => $feature1,
'feature2' => $feature2
]);
}
public function it_should_get_list_without_cache(
Feature $feature1,
Feature $feature2
) {
$this->client->getId()
->shouldBeCalled()
->willReturn('test123');
$this->cache->get(Repository::UNLEASH_CACHE_PREFIX . 'test123')
->shouldBeCalled()
->willReturn([
'features' => [],
'expires' => time() - 1000000
]);
$this->client->register()
->shouldBeCalled()
->willReturn(true);
$this->client->getFeatures()
->shouldBeCalled()
->willReturn([
[ 'name' => 'feature1' ],
[ 'name' => 'feature2' ],
]);
$this->config->getPollingIntervalSeconds()
->shouldBeCalled()
->willReturn(0);
$this->cache->set(Repository::UNLEASH_CACHE_PREFIX . 'test123', Argument::type('array'))
->shouldBeCalled()
->willReturn(true);
$this->featureFactory->build([ 'name' => 'feature1' ])
->shouldBeCalled()
->willReturn($feature1);
$this->featureFactory->build([ 'name' => 'feature2' ])
->shouldBeCalled()
->willReturn($feature2);
$feature1->getName()
->shouldBeCalled()
->willReturn('feature1');
$feature2->getName()
->shouldBeCalled()
->willReturn('feature2');
$this
->getList()
->shouldReturn([
'feature1' => $feature1,
'feature2' => $feature2
]);
}
}
<?php
namespace spec\Minds\UnleashClient;
use Minds\UnleashClient\Entities\Context;
use Minds\UnleashClient\Entities\Strategy;
use Minds\UnleashClient\Factories\StrategyAlgorithmFactory;
use Minds\UnleashClient\StrategyAlgorithms\StrategyAlgorithm;
use Minds\UnleashClient\StrategyResolver;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Psr\Log\LoggerInterface;
class StrategyResolverSpec extends ObjectBehavior
{
/** @var LoggerInterface */
protected $logger;
/** @var StrategyAlgorithmFactory */
protected $strategyAlgorithmFactory;
public function let(
LoggerInterface $logger,
StrategyAlgorithmFactory $strategyAlgorithmFactory
) {
$this->logger = $logger;
$this->strategyAlgorithmFactory = $strategyAlgorithmFactory;
$this->beConstructedWith(
$logger,
$strategyAlgorithmFactory
);
}
public function it_is_initializable()
{
$this->shouldHaveType(StrategyResolver::class);
}
public function it_should_return_if_is_not_enabled_when_empty(
Context $context
) {
$this
->isEnabled([], $context)
->shouldReturn(false);
}
public function it_should_return_if_is_not_enabled(
Context $context,
Strategy $strategy1,
Strategy $strategy2,
StrategyAlgorithm $strategyAlgorithm1,
StrategyAlgorithm $strategyAlgorithm2
) {
$this->strategyAlgorithmFactory->build($strategy1)
->shouldBeCalled()
->willReturn($strategyAlgorithm1);
$this->strategyAlgorithmFactory->build($strategy2)
->shouldBeCalled()
->willReturn($strategyAlgorithm2);
$strategyAlgorithm1->isEnabled($strategy1, $context)
->shouldBeCalled()
->willReturn(false);
$strategyAlgorithm2->isEnabled($strategy2, $context)
->shouldBeCalled()
->willReturn(false);
$this
->isEnabled([
$strategy1,
$strategy2,
], $context)
->shouldReturn(false);
}
public function it_should_return_if_is_enabled(
Context $context,
Strategy $strategy1,
Strategy $strategy2,
StrategyAlgorithm $strategyAlgorithm1,
StrategyAlgorithm $strategyAlgorithm2
) {
$this->strategyAlgorithmFactory->build($strategy1)
->shouldBeCalled()
->willReturn($strategyAlgorithm1);
$this->strategyAlgorithmFactory->build($strategy2)
->shouldNotBeCalled();
$strategyAlgorithm1->isEnabled($strategy1, $context)
->shouldBeCalled()
->willReturn(true);
$strategyAlgorithm2->isEnabled($strategy2, $context)
->shouldNotBeCalled();
$this
->isEnabled([
$strategy1,
$strategy2,
], $context)
->shouldReturn(true);
}
}
This diff is collapsed.
......@@ -7,8 +7,6 @@ use GuzzleHttp\Client as HttpClient;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\MessageFormatter;
use Minds\UnleashClient\Factories\FeatureFactory;
use Minds\UnleashClient\Entities\Feature;
use Psr\Log\LoggerInterface;
/**
......@@ -25,9 +23,6 @@ class Client
/** @var Logger */
protected $logger;
/** @var FeatureFactory */
protected $featureFactory;
/** @var string */
protected $id;
......@@ -36,18 +31,15 @@ class Client
* @param Config $config
* @param LoggerInterface|null $logger
* @param HttpClient|null $httpClient
* @param FeatureFactory|null $featureFactory
*/
public function __construct(
Config $config,
LoggerInterface $logger = null,
HttpClient $httpClient = null,
FeatureFactory $featureFactory = null
HttpClient $httpClient = null
) {
$this->config = $config;
$this->logger = $logger ?: new Logger();
$this->httpClient = $httpClient ?: $this->createHttpClient();
$this->featureFactory = $featureFactory ?: new FeatureFactory();
$this->logger->debug("Client configured. Base URL: {$this->config->getApiUrl()}");
}
......@@ -101,11 +93,11 @@ class Client
/**
* Calls the unleash api for getting feature flags.
* If HTTP 2xx, reconstitutes the feature flags and returns an array.
* If HTTP 2xx, reconstitutes the feature flags and returns the decoded JSON response.
* Else, logs and error and returns an empty array.
* @return Feature[]
* @return array
*/
public function getFeatureFlags(): array
public function getFeatures(): array
{
$this->logger->debug('Getting feature flags');
......@@ -117,11 +109,8 @@ class Client
$response->getStatusCode() >= 200 &&
$response->getStatusCode() < 300
) {
$data = json_decode((string) $response->getBody(), true);
return array_map(function ($feature) {
return $this->featureFactory->build($feature);
}, $data['features']);
$body = json_decode((string) $response->getBody(), true) ?? [];
return $body['features'] ?? [];
}
} catch (Exception $e) {
$this->logger->error($e);
......@@ -154,6 +143,9 @@ class Client
]);
}
/**
* Generates an ID based on the config values
*/
protected function generateId(): void
{
$this->id = substr(sha1(implode(':', [
......
<?php
/**
* Repository
*
* @author edgebal
*/
namespace Minds\UnleashClient;
use Exception;
use Minds\UnleashClient\Entities\Feature;
use Minds\UnleashClient\Factories\FeatureFactory;
use Psr\Log\LoggerInterface;
use Psr\SimpleCache\CacheInterface;
use Psr\SimpleCache\InvalidArgumentException;
/**
* Retrieve feature flag set from either the Unleash server (using a client) or from cache
* @package Minds\UnleashClient
*/
class Repository
{
/** @var string */
const UNLEASH_CACHE_PREFIX = '_unleash';
/** @var Config */
protected $config;
/** @var Logger|LoggerInterface */
protected $logger;
/** @var Cache\SimpleCache|CacheInterface */
protected $cache;
/** @var Client */
protected $client;
/** @var FeatureFactory */
protected $featureFactory;
/** @var bool */
protected $isClientRegistered = false;
/**
* Repository constructor.
* @param Config|null $config
* @param LoggerInterface|null $logger
* @param CacheInterface|null $cache
* @param Client|null $client
* @param FeatureFactory|null $featureFactory
*/
public function __construct(
$config = null,
$logger = null,
$cache = null,
$client = null,
$featureFactory = null
) {
$this->config = $config ?: new Config();
$this->logger = $logger ?: new Logger();
$this->cache = $cache ?: new Cache\SimpleCache($this->logger);
$this->client = $client ?: new Client($this->config, $this->logger);
$this->featureFactory = $featureFactory ?: new FeatureFactory();
}
/**
* Returns the complete list of features from the server as objects
* @return array
* @throws InvalidArgumentException
* @throws Exception
*/
public function getList(): array
{
$data = $this->cache->get($this->buildCacheKey());
if (!$data || $data['expires'] <= time()) {
if (!$this->isClientRegistered) {
$this->logger->debug('Client is not registered');
$this->isClientRegistered = $this->client->register();
if (!$this->isClientRegistered) {
throw new Exception('Could not register client');
}
}
$this->logger->debug('Fetching feature flags from server');
$features = $this->client->getFeatures();
$expires = time() + $this->config->getPollingIntervalSeconds();
$data = [
'features' => $features,
'expires' => $expires
];
$this->logger->debug('Cache will timeout at ' . date('c', $expires));
$this->cache->set($this->buildCacheKey(), $data);
}
$features = [];
foreach ($data['features'] as $featureDto) {
$feature = $this->featureFactory->build($featureDto);
$features[$feature->getName()] = $feature;
}
return $features;
}
/**
* Builds a config-aware cache key
* @return string
*/
protected function buildCacheKey(): string
{
return static::UNLEASH_CACHE_PREFIX . $this->client->getId();
}
}
......@@ -12,6 +12,10 @@ use Minds\UnleashClient\Factories\StrategyAlgorithmFactory;
use Minds\UnleashClient\StrategyAlgorithms\StrategyAlgorithm;
use Psr\Log\LoggerInterface;
/**
* Build and resolve feature strategies
* @package Minds\UnleashClient
*/
class StrategyResolver
{
/** @var LoggerInterface|Logger */
......@@ -23,19 +27,18 @@ class StrategyResolver
/**
* StrategyResolver constructor.
* @param LoggerInterface|null $logger
* @param StrategyAlgorithm|null $strategyAlgorithmFactory
* @param StrategyAlgorithmFactory|null $strategyAlgorithmFactory
*/
public function __construct(
LoggerInterface $logger = null,
StrategyAlgorithm $strategyAlgorithmFactory = null
StrategyAlgorithmFactory $strategyAlgorithmFactory = null
) {
$this->logger = $logger ?: new Logger();
$this->strategyAlgorithmFactory = $strategyAlgorithmFactory ?: new StrategyAlgorithmFactory($this->logger);
}
/**
* Instantiates a new strategy algorithm based on the passed strategy and run it against
* the context
* Instantiates a new strategy algorithm based on the passed strategy and run it against the context
* @param array $strategies
* @param Context $context
* @return bool
......
......@@ -2,35 +2,27 @@
namespace Minds\UnleashClient;
use Exception;
use Minds\UnleashClient\Entities\Context;
use Minds\UnleashClient\Entities\Feature;
use Psr\SimpleCache\CacheInterface;
use Psr\Log\LoggerInterface;
use Psr\SimpleCache\InvalidArgumentException;
/**
* Unleash integration with a context
* @package Minds\UnleashClient
*/
class Unleash
{
const UNLEASH_CLIENT_CACHE_PREFIX = '_unlsh';
/** @var string */
const UNLEASH_CLIENT_CACHE_TIMEOUT_KEY = 'cache_timeout';
/** @var Config */
protected $config;
/** @var Logger|LoggerInterface */
protected $logger;
/** @var Client */
protected $client;
/** @var Cache\SimpleCache|CacheInterface */
protected $cache;
/** @var StrategyResolver */
protected $strategyResolver;
/** @var bool */
protected $isClientRegistered = false;
/** @var Repository */
protected $repository;
/** @var Context */
protected $context;
......@@ -39,23 +31,30 @@ class Unleash
* Unleash constructor.
* @param Config|null $config
* @param LoggerInterface|null $logger
* @param Client|null $client
* @param CacheInterface|null $cache
* @param StrategyResolver|null $strategyResolver
* @param CacheInterface|null $cache
* @param Client|null $client
* @param Repository|null $repository
*/
public function __construct(
Config $config = null,
LoggerInterface $logger = null,
Client $client = null,
StrategyResolver $strategyResolver = null,
CacheInterface $cache = null,
StrategyResolver $strategyResolver = null
Client $client = null,
Repository $repository = null
) {
$this->config = $config ?: new Config();
$config = $config ?: new Config();
$this->logger = $logger ?: new Logger();
$this->client = $client ?: new Client($this->config, $this->logger);
$this->cache = $cache ?: new Cache\SimpleCache($this->logger);
$this->strategyResolver = $strategyResolver ?: new StrategyResolver($this->logger);
$repository = $repository ?: new Repository(
$config,
$this->logger,
$cache ?: new Cache\SimpleCache($this->logger),
$client ?: new Client($config, $this->logger)
);
$this->repository = $repository;
}
/**
......@@ -70,7 +69,7 @@ class Unleash
}
/**
* Checks the current collection of feature flags to determine if a flag is enabled for said context
* Resolves a feature flag for the current context
* @param string $featureName
* @param bool $default
* @return bool
......@@ -81,72 +80,58 @@ class Unleash
try {
$this->logger->debug("Checking for {$featureName}");
if ($this->isCacheInvalid()) {
$this->logger->debug('Cache timeout, fetching');
$this->fetch();
}
/** @var Entities\Feature|null $feature */
$feature = $this->cache->get($this->buildCacheKey($featureName), null);
$features = $this->repository
->getList();
if ($feature === null) {
if (!isset($features[$featureName])) {
$this->logger->debug("{$featureName} is not set, returning default");
return $default;
}
/** @var Feature $feature */
$feature = $features[$featureName];
return
$feature->isEnabled() &&
$this->strategyResolver->isEnabled(
$feature->getStrategies(),
$this->context
);
} catch (\Exception $e) {
$this->logger->error("Error checking feature flag {$featureName}");
} catch (Exception $e) {
$this->logger->error($e);
return false;
}
}
/**
* Checks if features cache is invalid
* @return bool
* Resolves and exports the whole set of feature flags for the current context
* @return array
* @throws InvalidArgumentException
*/
protected function isCacheInvalid()
public function export(): array
{
return $this->cache->get($this->buildCacheKey(static::UNLEASH_CLIENT_CACHE_TIMEOUT_KEY), -1) <= time();
}
/**
* Fetches the current collection of feature flags from server, and caches it
*/
protected function fetch(): void
{
if (!$this->isClientRegistered) {
$this->logger->debug('Client is not registered');
$this->isClientRegistered = $this->client->register();
}
$this->logger->debug('Fetching feature flags from server');
foreach ($this->client->getFeatureFlags() as $feature) {
$this->logger->debug("Storing {$feature->getName()}");
$this->cache->set($this->buildCacheKey($feature->getName()), $feature);
}
try {
$this->logger->debug("Exporting all features");
$timeout = time() + $this->config->getPollingIntervalSeconds();
$features = $this->repository
->getList();
$this->logger->debug('Cache will timeout at ' . date('c', $timeout));
$export = [];
$this->cache
->set($this->buildCacheKey(static::UNLEASH_CLIENT_CACHE_TIMEOUT_KEY), $timeout);
}
foreach ($features as $featureName => $feature) {
/** @var Feature $feature */
$export[$featureName] =
$feature->isEnabled() &&
$this->strategyResolver->isEnabled(
$feature->getStrategies(),
$this->context
);
}
/**
* Builds a config-aware cache key
* @param string $key
* @return string
*/
protected function buildCacheKey(string $key): string
{
return static::UNLEASH_CLIENT_CACHE_PREFIX . $this->client->getId() . '-' . $key;
return $export;
} catch (Exception $e) {
$this->logger->error($e);
return [];
}
}
}