diff --git a/CHANGELOG.md b/CHANGELOG.md index 8785f9d51..9766e60a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to the LaunchDarkly PHP SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [3.0.0] - 2018-02-21 +### Added +- Support for a new LaunchDarkly feature: reusable user segments. + ## [2.5.0] - 2018-02-13 ### Added - Adds support for a future LaunchDarkly feature, coming soon: semantic version user attributes. diff --git a/VERSION b/VERSION index 437459cd9..4a36342fc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.5.0 +3.0.0 diff --git a/src/LaunchDarkly/ApcLDDFeatureRequester.php b/src/LaunchDarkly/ApcLDDFeatureRequester.php index 811b8ce13..6d850cf5b 100644 --- a/src/LaunchDarkly/ApcLDDFeatureRequester.php +++ b/src/LaunchDarkly/ApcLDDFeatureRequester.php @@ -32,9 +32,9 @@ protected function fetch($key, &$success = null) return \apc_fetch($key, $success); } - protected function get_from_cache($key) + protected function get_from_cache($namespace, $key) { - $key = self::make_cache_key($key); + $key = self::make_cache_key($namespace, $key); $enabled = $this->fetch($key); if ($enabled === false) { return null; @@ -54,13 +54,13 @@ protected function add($key, $var, $ttl = 0) return \apc_add($key, $var, $ttl); } - protected function store_in_cache($key, $val) + protected function store_in_cache($namespace, $key, $val) { - $this->add($this->make_cache_key($key), $val, $this->_expiration); + $this->add($this->make_cache_key($namespace, $key), $val, $this->_expiration); } - private function make_cache_key($name) + private function make_cache_key($namespace, $name) { - return $this->_features_key.'.'.$name; + return $namespace.'.'.$name; } } diff --git a/src/LaunchDarkly/Clause.php b/src/LaunchDarkly/Clause.php index c0330b009..26c8e74c1 100644 --- a/src/LaunchDarkly/Clause.php +++ b/src/LaunchDarkly/Clause.php @@ -32,7 +32,28 @@ public static function getDecoder() * @param $user LDUser * @return bool */ - public function matchesUser($user) + public function matchesUser($user, $featureRequester) + { + if ($this->_op === 'segmentMatch') { + foreach ($this->_values as $value) { + $segment = $featureRequester->getSegment($value); + if ($segment) { + if ($segment->matchesUser($user)) { + return $this->_maybeNegate(true); + } + } + } + return $this->_maybeNegate(false); + } else { + return $this->matchesUserNoSegments($user); + } + } + + /** + * @param $user LDUser + * @return bool + */ + public function matchesUserNoSegments($user) { $userValue = $user->getValueForEvaluation($this->_attribute); if ($userValue === null) { diff --git a/src/LaunchDarkly/FeatureFlag.php b/src/LaunchDarkly/FeatureFlag.php index 9823e4a6e..0ac89d317 100644 --- a/src/LaunchDarkly/FeatureFlag.php +++ b/src/LaunchDarkly/FeatureFlag.php @@ -115,7 +115,7 @@ private function _evaluate($user, $featureRequester, &$events) foreach ($this->_prerequisites as $prereq) { try { $prereqEvalResult = null; - $prereqFeatureFlag = $featureRequester->get($prereq->getKey()); + $prereqFeatureFlag = $featureRequester->getFeature($prereq->getKey()); if ($prereqFeatureFlag == null) { return null; } elseif ($prereqFeatureFlag->isOn()) { @@ -134,7 +134,7 @@ private function _evaluate($user, $featureRequester, &$events) } } if ($prereqOk) { - return $this->getVariation($this->evaluateIndex($user)); + return $this->getVariation($this->evaluateIndex($user, $featureRequester)); } return null; } @@ -143,7 +143,7 @@ private function _evaluate($user, $featureRequester, &$events) * @param $user LDUser * @return int|null */ - private function evaluateIndex($user) + private function evaluateIndex($user, $featureRequester) { // Check to see if targets match if ($this->_targets != null) { @@ -158,7 +158,7 @@ private function evaluateIndex($user) // Now walk through the rules and see if any match if ($this->_rules != null) { foreach ($this->_rules as $rule) { - if ($rule->matchesUser($user)) { + if ($rule->matchesUser($user, $featureRequester)) { return $rule->variationIndexForUser($user, $this->_key, $this->_salt); } } diff --git a/src/LaunchDarkly/FeatureRequester.php b/src/LaunchDarkly/FeatureRequester.php index e8b436f7d..437f3d6fd 100644 --- a/src/LaunchDarkly/FeatureRequester.php +++ b/src/LaunchDarkly/FeatureRequester.php @@ -10,12 +10,20 @@ interface FeatureRequester * @param $key string feature key * @return FeatureFlag|null The decoded FeatureFlag, or null if missing */ - public function get($key); + public function getFeature($key); + + /** + * Gets segment data from a likely cached store + * + * @param $key string segment key + * @return Segment|null The decoded Segment, or null if missing + */ + public function getSegment($key); /** * Gets all features. * * @return array()|null The decoded FeatureFlags, or null if missing */ - public function getAll(); + public function getAllFeatures(); } diff --git a/src/LaunchDarkly/GuzzleFeatureRequester.php b/src/LaunchDarkly/GuzzleFeatureRequester.php index e435675a2..53dcd2c62 100644 --- a/src/LaunchDarkly/GuzzleFeatureRequester.php +++ b/src/LaunchDarkly/GuzzleFeatureRequester.php @@ -11,6 +11,7 @@ class GuzzleFeatureRequester implements FeatureRequester { const SDK_FLAGS = "/sdk/flags"; + const SDK_SEGMENTS = "/sdk/segments"; /** @var Client */ private $_client; /** @var string */ @@ -46,14 +47,13 @@ public function __construct($baseUri, $sdkKey, $options) $this->_client = new Client(['handler' => $stack, 'debug' => false]); } - /** * Gets feature data from a likely cached store * * @param $key string feature key * @return FeatureFlag|null The decoded FeatureFlag, or null if missing */ - public function get($key) + public function getFeature($key) { try { $uri = $this->_baseUri . self::SDK_FLAGS . "/" . $key; @@ -71,12 +71,36 @@ public function get($key) } } + /** + * Gets segment data from a likely cached store + * + * @param $key string segment key + * @return Segment|null The decoded Segment, or null if missing + */ + public function getSegment($key) + { + try { + $uri = $this->_baseUri . self::SDK_SEGMENTS . "/" . $key; + $response = $this->_client->get($uri, $this->_defaults); + $body = $response->getBody(); + return Segment::decode(json_decode($body, true)); + } catch (BadResponseException $e) { + $code = $e->getResponse()->getStatusCode(); + if ($code == 404) { + $this->_logger->warning("GuzzleFeatureRequester::get returned 404. Segment does not exist for key: " . $key); + } else { + $this->handleUnexpectedStatus($code, "GuzzleFeatureRequester::get"); + } + return null; + } + } + /** * Gets all features from a likely cached store * * @return array()|null The decoded FeatureFlags, or null if missing */ - public function getAll() + public function getAllFeatures() { try { $uri = $this->_baseUri . self::SDK_FLAGS; diff --git a/src/LaunchDarkly/LDClient.php b/src/LaunchDarkly/LDClient.php index 84bbe3354..fc21d080d 100644 --- a/src/LaunchDarkly/LDClient.php +++ b/src/LaunchDarkly/LDClient.php @@ -19,7 +19,7 @@ class LDClient { const DEFAULT_BASE_URI = 'https://app.launchdarkly.com'; const DEFAULT_EVENTS_URI = 'https://events.launchdarkly.com'; - const VERSION = '2.5.0'; + const VERSION = '3.0.0'; /** @var string */ protected $_sdkKey; @@ -157,7 +157,7 @@ public function variation($key, $user, $default = false) $this->_logger->warning("User key is blank. Flag evaluation will proceed, but the user will not be stored in LaunchDarkly."); } try { - $flag = $this->_featureRequester->get($key); + $flag = $this->_featureRequester->getFeature($key); } catch (InvalidSDKKeyException $e) { $this->handleInvalidSDKKey(); return $default; @@ -278,7 +278,7 @@ public function allFlags($user) return null; } try { - $flags = $this->_featureRequester->getAll(); + $flags = $this->_featureRequester->getAllFeatures(); } catch (InvalidSDKKeyException $e) { $this->handleInvalidSDKKey(); return null; diff --git a/src/LaunchDarkly/LDDFeatureRequester.php b/src/LaunchDarkly/LDDFeatureRequester.php index 88731c7e5..4ae4ecb44 100644 --- a/src/LaunchDarkly/LDDFeatureRequester.php +++ b/src/LaunchDarkly/LDDFeatureRequester.php @@ -10,6 +10,7 @@ class LDDFeatureRequester implements FeatureRequester protected $_sdkKey; protected $_options; protected $_features_key; + protected $_segments_key; /** @var LoggerInterface */ private $_logger; /** @var ClientInterface */ @@ -33,6 +34,7 @@ public function __construct($baseUri, $sdkKey, $options) $prefix = $options['redis_prefix']; } $this->_features_key = "$prefix:features"; + $this->_segments_key = "$prefix:segments"; $this->_logger = $options['logger']; if (isset($this->_options['predis_client']) && $this->_options['predis_client'] instanceof ClientInterface) { @@ -56,21 +58,20 @@ protected function get_connection() "port" => $this->_options['redis_port'])); } - /** * Gets feature data from a likely cached store * * @param $key string feature key * @return FeatureFlag|null The decoded JSON feature data, or null if missing */ - public function get($key) + public function getFeature($key) { - $raw = $this->get_from_cache($key); + $raw = $this->get_from_cache($this->_features_key, $key); if ($raw === null) { $redis = $this->get_connection(); $raw = $redis->hget($this->_features_key, $key); if ($raw) { - $this->store_in_cache($key, $raw); + $this->store_in_cache($this->_features_key, $key, $raw); } } if ($raw) { @@ -86,22 +87,53 @@ public function get($key) } } + /** + * Gets segment data from a likely cached store + * + * @param $key string segment key + * @return Segment|null The decoded JSON segment data, or null if missing + */ + public function getSegment($key) + { + $raw = $this->get_from_cache($this->_segments_key, $key); + if ($raw === null) { + $redis = $this->get_connection(); + $raw = $redis->hget($this->_features_key, $key); + if ($raw) { + $this->store_in_cache($this->_segments_key, $key, $raw); + } + } + if ($raw) { + $segment = Segment::decode(json_decode($raw, true)); + if ($segment->isDeleted()) { + $this->_logger->warning("LDDFeatureRequester: Attempted to get deleted segment with key: " . $key); + return null; + } + return $segment; + } else { + $this->_logger->warning("LDDFeatureRequester: Attempted to get missing segment with key: " . $key); + return null; + } + } + /** * Gets the value from local cache. No-op by default. - * @param $key string The feature key - * @return null|array The feature data or null if missing + * @param $namespace string that denotes features or segments + * @param $key string The feature or segment key + * @return null|array The feature or segment data or null if missing */ - protected function get_from_cache($key) + protected function get_from_cache($namespace, $key) { return null; } /** - * Stores the feature data into the local cache. No-op by default. - * @param $key string The feature key - * @param $val array The feature data + * Stores the feature or segment data into the local cache. No-op by default. + * @param $namespace string that denotes features or segments + * @param $key string The feature or segment key + * @param $val array The feature or segment data */ - protected function store_in_cache($key, $val) + protected function store_in_cache($namespace, $key, $val) { } @@ -110,7 +142,7 @@ protected function store_in_cache($key, $val) * * @return array()|null The decoded FeatureFlags, or null if missing */ - public function getAll() + public function getAllFeatures() { $redis = $this->get_connection(); $raw = $redis->hgetall($this->_features_key); diff --git a/src/LaunchDarkly/Rule.php b/src/LaunchDarkly/Rule.php index 0da0a8b2c..daf9c6e11 100644 --- a/src/LaunchDarkly/Rule.php +++ b/src/LaunchDarkly/Rule.php @@ -27,10 +27,10 @@ public static function getDecoder() * @param $user LDUser * @return bool */ - public function matchesUser($user) + public function matchesUser($user, $featureRequester) { foreach ($this->_clauses as $clause) { - if (!$clause->matchesUser($user)) { + if (!$clause->matchesUser($user, $featureRequester)) { return false; } } diff --git a/src/LaunchDarkly/Segment.php b/src/LaunchDarkly/Segment.php new file mode 100644 index 000000000..620f2a22a --- /dev/null +++ b/src/LaunchDarkly/Segment.php @@ -0,0 +1,165 @@ +_key = $key; + $this->_version = $version; + $this->_included = $included; + $this->_excluded = $excluded; + $this->_salt = $salt; + $this->_rules = $rules; + $this->_deleted = $deleted; + } + + public static function getDecoder() + { + return function ($v) { + return new Segment( + $v['key'], + $v['version'], + $v['included'] ?: [], + $v['excluded'] ?: [], + $v['salt'], + array_map(SegmentRule::getDecoder(), $v['rules'] ?: []), + $v['deleted']); + }; + } + + public static function decode($v) + { + return call_user_func(Segment::getDecoder(), $v); + } + + /** + * @param $user LDUser + * @return boolean + */ + public function matchesUser($user) + { + $key = $user->getKey(); + if (!$key) { + return false; + } + if (in_array($key, $this->_included, true)) { + return true; + } + if (in_array($key, $this->_excluded, true)) { + return false; + } + foreach ($this->_rules as $rule) { + if ($rule->matchesUser($user, $this->_key, $this->_salt)) { + return true; + } + } + return false; + } + + /** + * @return int + */ + public function getVersion() + { + return $this->_version; + } + + /** + * @return string + */ + public function getKey() + { + return $this->_key; + } + + /** + * @return boolean + */ + public function isDeleted() + { + return $this->_deleted; + } +} + +class SegmentRule +{ + /** @var Clause[] */ + private $_clauses = array(); + /** @var int */ + private $_weight = null; + /** @var string */ + private $_bucketBy = null; + + protected function __construct(array $clauses, $weight, $bucketBy) + { + $this->_clauses = $clauses; + $this->_weight = $weight; + $this->_bucketBy = $bucketBy; + } + + public static function getDecoder() + { + return function ($v) { + return new SegmentRule( + array_map(Clause::getDecoder(), $v['clauses'] ?: []), + isset($v['weight']) ? $v['weight'] : null, + isset($v['bucketBy']) ? $v['bucketBy'] : null); + }; + } + + public function matchesUser($user, $segmentKey, $segmentSalt) + { + foreach ($this->_clauses as $clause) { + if (!$clause->matchesUserNoSegments($user)) { + return false; + } + } + // If the weight is absent, this rule matches + if ($this->_weight === null) { + return true; + } + // All of the clauses are met. See if the user buckets in + $bucketBy = ($this->_bucketBy === null) ? "key" : bucketBy; + $bucket = VariationOrRollout::bucketUser($user, $segmentKey, $bucketBy, $segmentSalt); + $weight = $this->_weight / 100000.0; + return $bucket < $weight; + } + + /** + * @return Clause[] + */ + public function getClauses() + { + return $this->_clauses; + } + + /** + * @return string + */ + public function getBucketBy() + { + return $this->_bucketBy; + } +} diff --git a/src/LaunchDarkly/VariationOrRollout.php b/src/LaunchDarkly/VariationOrRollout.php index 2c53ce231..df9407ea8 100644 --- a/src/LaunchDarkly/VariationOrRollout.php +++ b/src/LaunchDarkly/VariationOrRollout.php @@ -54,7 +54,7 @@ public function variationIndexForUser($user, $_key, $_salt) return $this->_variation; } elseif ($this->_rollout !== null) { $bucketBy = $this->_rollout->getBucketBy() === null ? "key" : $this->_rollout->getBucketBy(); - $bucket = $this->bucketUser($user, $_key, $bucketBy, $_salt); + $bucket = self::bucketUser($user, $_key, $bucketBy, $_salt); $sum = 0.0; foreach ($this->_rollout->getVariations() as $wv) { $sum += $wv->getWeight() / 100000.0; @@ -73,7 +73,7 @@ public function variationIndexForUser($user, $_key, $_salt) * @param $_salt string * @return float */ - private function bucketUser($user, $_key, $attr, $_salt) + public static function bucketUser($user, $_key, $attr, $_salt) { $userValue = $user->getValueForEvaluation($attr); $idHash = null; diff --git a/tests/FeatureFlagTest.php b/tests/FeatureFlagTest.php index 93c98e35c..8b8dde742 100644 --- a/tests/FeatureFlagTest.php +++ b/tests/FeatureFlagTest.php @@ -2,10 +2,13 @@ namespace LaunchDarkly\Tests; use LaunchDarkly\FeatureFlag; +use LaunchDarkly\FeatureRequester; +use LaunchDarkly\LDUserBuilder; +use LaunchDarkly\Segment; + class FeatureFlagTest extends \PHPUnit_Framework_TestCase { - private static $json1 = "{ \"key\": \"integration.feature.0\", \"version\": 2210, @@ -189,5 +192,102 @@ public function testDecodeMulti(array $feature) self::assertInstanceOf(FeatureFlag::class, $featureFlag); } + + public function testSegmentMatchClauseRetrievesSegmentFromStore() + { + $segmentJson = array( + 'key' => 'segkey', + 'version' => 1, + 'deleted' => false, + 'included' => array('foo'), + 'excluded' => array(), + 'rules' => array(), + 'salt' => '' + ); + $segment = Segment::decode($segmentJson); + + $requester = new MockFeatureRequesterForSegment(); + $requester->key = 'segkey'; + $requester->val = $segment; + + $feature = $this->makeBooleanFeatureWithSegmentMatch('segkey'); + + $ub = new LDUserBuilder('foo'); + $user = $ub->build(); + + $result = $feature->evaluate($user, $requester); + + self::assertTrue($result->getValue()); + } + + public function testSegmentMatchClauseFallsThroughWithNoErrorsIfSegmentNotFound() + { + $requester = new MockFeatureRequesterForSegment(); + + $feature = $this->makeBooleanFeatureWithSegmentMatch('segkey'); + + $ub = new LDUserBuilder('foo'); + $user = $ub->build(); + + $result = $feature->evaluate($user, $requester); + + self::assertFalse($result->getValue()); + } + + private function makeBooleanFeatureWithSegmentMatch($segmentKey) + { + $featureJson = array( + 'key' => 'test', + 'version' => 1, + 'deleted' => false, + 'on' => true, + 'variations' => array(false, true), + 'fallthrough' => array('variation' => 0), + 'rules' => array( + array( + 'clauses' => array( + array( + 'attribute' => '', + 'op' => 'segmentMatch', + 'values' => array($segmentKey), + 'negate' => false + ) + ), + 'variation' => 1 + ) + ), + 'offVariation' => 0, + 'prerequisites' => array(), + 'targets' => array(), + 'salt' => '' + ); + return FeatureFlag::decode($featureJson); + } +} + + +class MockFeatureRequesterForSegment implements FeatureRequester +{ + public $key = null; + public $val = null; + + function __construct($baseurl = null, $key = null, $options = null) + { + } + + public function getFeature($key) + { + return null; + } + + public function getSegment($key) + { + return ($key == $this->key) ? $this->val : null; + } + + public function getAllFeatures() + { + return null; + } } diff --git a/tests/LDClientTest.php b/tests/LDClientTest.php index cdaae3958..2c938bca3 100644 --- a/tests/LDClientTest.php +++ b/tests/LDClientTest.php @@ -107,12 +107,17 @@ function __construct($baseurl, $key, $options) { } - public function get($key) + public function getFeature($key) { return self::$val; } - public function getAll() + public function getSegment($key) + { + return null; + } + + public function getAllFeatures() { return null; } diff --git a/tests/LDDFeatureRequesterTest.php b/tests/LDDFeatureRequesterTest.php index 2fb2d7d07..e4663b233 100644 --- a/tests/LDDFeatureRequesterTest.php +++ b/tests/LDDFeatureRequesterTest.php @@ -27,7 +27,7 @@ protected function setUp() ->getMockForAbstractClass(); } - public function testGet() + public function testGetFeature() { $sut = new LDDFeatureRequester('example.com', 'MySdkKey', [ 'logger' => $this->logger, @@ -64,7 +64,7 @@ public function testGet() 'deleted' => false, ])); - $featureFlag = $sut->get('foo'); + $featureFlag = $sut->getFeature('foo'); self::assertInstanceOf(FeatureFlag::class, $featureFlag); self::assertTrue($featureFlag->isOn()); diff --git a/tests/SegmentTest.php b/tests/SegmentTest.php new file mode 100644 index 000000000..5ffb1ffb9 --- /dev/null +++ b/tests/SegmentTest.php @@ -0,0 +1,179 @@ + 'test', + 'included' => array('foo'), + 'excluded' => array(), + 'rules' => array(), + 'salt' => 'salt', + 'version' => 1, + 'deleted' => false + ); + $segment = Segment::decode($json); + $ub = new LDUserBuilder('foo'); + $this->assertTrue($segment->matchesUser($ub->build())); + } + + public function testExplicitExcludeUser() { + $json = array( + 'key' => 'test', + 'included' => array(), + 'excluded' => array('foo'), + 'rules' => array(), + 'salt' => 'salt', + 'version' => 1, + 'deleted' => false + ); + $segment = Segment::decode($json); + $ub = new LDUserBuilder('foo'); + $this->assertFalse($segment->matchesUser($ub->build())); + } + + public function testExplicitIncludePasPrecedence() { + $json = array( + 'key' => 'test', + 'included' => array('foo'), + 'excluded' => array('foo'), + 'rules' => array(), + 'salt' => 'salt', + 'version' => 1, + 'deleted' => false + ); + $segment = Segment::decode($json); + $ub = new LDUserBuilder('foo'); + $this->assertTrue($segment->matchesUser($ub->build())); + } + + public function testMatchingRuleWithFullRollout() { + $json = array( + 'key' => 'test', + 'included' => array(), + 'excluded' => array(), + 'salt' => 'salt', + 'rules' => array( + array( + 'clauses' => array( + array( + 'attribute' => 'email', + 'op' => 'in', + 'values' => array('test@example.com'), + 'negate' => false + ) + ), + 'weight' => 100000 + ) + ), + 'version' => 1, + 'deleted' => false + ); + $segment = Segment::decode($json); + $ub = new LDUserBuilder('foo'); + $ub->email('test@example.com'); + $this->assertTrue($segment->matchesUser($ub->build())); + } + + public function testMatchingRuleWithZeroRollout() { + $json = array( + 'key' => 'test', + 'included' => array(), + 'excluded' => array(), + 'salt' => 'salt', + 'rules' => array( + array( + 'clauses' => array( + array( + 'attribute' => 'email', + 'op' => 'in', + 'values' => array('test@example.com'), + 'negate' => false + ) + ), + 'weight' => 0 + ) + ), + 'version' => 1, + 'deleted' => false + ); + $segment = Segment::decode($json); + $ub = new LDUserBuilder('foo'); + $ub->email('test@example.com'); + $this->assertFalse($segment->matchesUser($ub->build())); + } + + public function testMatchingRuleWithMultipleClauses() { + $json = array( + 'key' => 'test', + 'included' => array(), + 'excluded' => array(), + 'salt' => 'salt', + 'rules' => array( + array( + 'clauses' => array( + array( + 'attribute' => 'email', + 'op' => 'in', + 'values' => array('test@example.com'), + 'negate' => false + ), + array( + 'attribute' => 'name', + 'op' => 'in', + 'values' => array('bob'), + 'negate' => false + ) + ), + 'weight' => 100000 + ) + ), + 'version' => 1, + 'deleted' => false + ); + $segment = Segment::decode($json); + $ub = new LDUserBuilder('foo'); + $ub->email('test@example.com'); + $ub->name('bob'); + $this->assertTrue($segment->matchesUser($ub->build())); + } + + public function testNonMatchingRuleWithMultipleClauses() { + $json = array( + 'key' => 'test', + 'included' => array(), + 'excluded' => array(), + 'salt' => 'salt', + 'rules' => array( + array( + 'clauses' => array( + array( + 'attribute' => 'email', + 'op' => 'in', + 'values' => array('test@example.com'), + 'negate' => false + ), + array( + 'attribute' => 'name', + 'op' => 'in', + 'values' => array('bill'), + 'negate' => false + ) + ), + 'weight' => 100000 + ) + ), + 'version' => 1, + 'deleted' => false + ); + $segment = Segment::decode($json); + $ub = new LDUserBuilder('foo'); + $ub->email('test@example.com'); + $ub->name('bob'); + $this->assertFalse($segment->matchesUser($ub->build())); + } +}