From 038acef16f6b6ee7ceff52a15eeb25809655b4b7 Mon Sep 17 00:00:00 2001 From: Pablo Iglesias Date: Tue, 20 Sep 2016 15:50:55 -0400 Subject: [PATCH 01/92] #13792 Porting Feature Library to PSR-4 Composer package --- .gitignore | 2 + .travis.yml | 12 + BRANCHES.md | 9 - GENERALIZING.md | 4 +- LICENSE | 22 - composer.json | 21 + composer.lock | 1125 +++++++++++++++++ phpunit.xml | 17 + phpunit/Feature/ConfigTest.php | 351 ----- phpunit/Feature/LoggerTest.php | 42 - phpunit/Feature/WorldTest.php | 153 --- {Feature => src}/Config.php | 177 ++- Feature.php => src/Feature.php | 33 +- {Feature => src}/Instance.php | 24 +- {Feature => src}/JSON.php | 71 +- {Feature => src}/Lint.php | 144 ++- {Feature => src}/Logger.php | 17 +- {Feature => src}/Util.php | 14 +- {Feature => src}/World.php | 46 +- {Feature => src}/World/Mobile.php | 53 +- tests/ConfigTest.php | 356 ++++++ tests/LoggerTest.php | 43 + tests/WorldTest.php | 153 +++ .../Feature => tests}/data/world/etsy_aux.yml | 0 .../data/world/etsy_index.yml | 0 25 files changed, 2236 insertions(+), 653 deletions(-) create mode 100644 .gitignore create mode 100644 .travis.yml delete mode 100644 BRANCHES.md delete mode 100644 LICENSE create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 phpunit.xml delete mode 100644 phpunit/Feature/ConfigTest.php delete mode 100644 phpunit/Feature/LoggerTest.php delete mode 100644 phpunit/Feature/WorldTest.php rename {Feature => src}/Config.php (85%) rename Feature.php => src/Feature.php (93%) rename {Feature => src}/Instance.php (73%) rename {Feature => src}/JSON.php (79%) rename {Feature => src}/Lint.php (69%) rename {Feature => src}/Logger.php (56%) rename {Feature => src}/Util.php (62%) rename {Feature => src}/World.php (84%) rename {Feature => src}/World/Mobile.php (56%) create mode 100644 tests/ConfigTest.php create mode 100644 tests/LoggerTest.php create mode 100644 tests/WorldTest.php rename {phpunit/Feature => tests}/data/world/etsy_aux.yml (100%) rename {phpunit/Feature => tests}/data/world/etsy_index.yml (100%) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f33dd5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +vendor/ +.idea/ \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..f91302b --- /dev/null +++ b/.travis.yml @@ -0,0 +1,12 @@ +language: php + +php: + - 5.3 + - 5.6 + - 7.0 + +before_script: + - composer self-update + - composer install --no-interaction --dev + +script: phpunit \ No newline at end of file diff --git a/BRANCHES.md b/BRANCHES.md deleted file mode 100644 index 8556ec8..0000000 --- a/BRANCHES.md +++ /dev/null @@ -1,9 +0,0 @@ -# Branches - -There are three branches in this repo. - -- etsy -- A snapshot of the code from the actual Etsy sourcecode. - -- master -- The same as `etsy` except with most (all?) of the dependencies on other Etsy code stripped out. - -- generalized -- A quick, untested attempt to generalize the code for non-Etsy contexts. diff --git a/GENERALIZING.md b/GENERALIZING.md index 4dd0cf0..e1b9a7d 100644 --- a/GENERALIZING.md +++ b/GENERALIZING.md @@ -25,7 +25,7 @@ back into our own codebase.) The basic approach I took in that branch was to introduce a new abstraction, the "experimental unit". Every feature is tested relative to some kind of experimental unit which is named in the feature's -configuration (under the `unit` key) though the Feature_World +configuration (under the `unit` key) though the World implementation can provide a default. Each kind of experimental unit can support: @@ -45,7 +45,7 @@ within Etsy. The configuration syntax for a feature configured with this experimental unit (which is the default in the current implementation -of `Feature_World`) can be configured with `users`, `groups`, `admin`, +of `World`) can be configured with `users`, `groups`, `admin`, and `internal` keys, that specify variants to be assigned to specific users, users in specific groups, all Etsy employees (called "admin"), or for internal requests. diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 91d077d..0000000 --- a/LICENSE +++ /dev/null @@ -1,22 +0,0 @@ - Copyright (c) 2010 Etsy - - Permission is hereby granted, free of charge, to any person - obtaining a copy of this software and associated documentation - files (the "Software"), to deal in the Software without - restriction, including without limitation the rights to use, - copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the - Software is furnished to do so, subject to the following - conditions: - - The above copyright notice and this permission notice shall be - included in all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES - OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR - OTHER DEALINGS IN THE SOFTWARE. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..677206c --- /dev/null +++ b/composer.json @@ -0,0 +1,21 @@ +{ + "name": "alfred-nutile-inc/feature-flags", + "description": "PSR-4 based Etsy library", + "authors": [ + { + "name": "Pablo Iglesias", + "email": "piglesias@cafemedia.com" + } + ], + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "phpunit/phpunit": "4.*" + }, + "autoload": { + "psr-4": { + "CafeMedia\\Feature\\": "src/" + } + } +} \ No newline at end of file diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..481c180 --- /dev/null +++ b/composer.lock @@ -0,0 +1,1125 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", + "This file is @generated automatically" + ], + "hash": "a8e03069ba826fe074addeec7db718eb", + "content-hash": "6dcf3bf47685fda61ea5d584f01e157c", + "packages": [], + "packages-dev": [ + { + "name": "doctrine/instantiator", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "8e884e78f9f0eb1329e445619e04456e64d8051d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/8e884e78f9f0eb1329e445619e04456e64d8051d", + "reference": "8e884e78f9f0eb1329e445619e04456e64d8051d", + "shasum": "" + }, + "require": { + "php": ">=5.3,<8.0-DEV" + }, + "require-dev": { + "athletic/athletic": "~0.1.8", + "ext-pdo": "*", + "ext-phar": "*", + "phpunit/phpunit": "~4.0", + "squizlabs/php_codesniffer": "~2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "http://ocramius.github.com/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://github.com/doctrine/instantiator", + "keywords": [ + "constructor", + "instantiate" + ], + "time": "2015-06-14 21:17:01" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "1.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "144c307535e82c8fdcaacbcfc1d6d8eeb896687c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/144c307535e82c8fdcaacbcfc1d6d8eeb896687c", + "reference": "144c307535e82c8fdcaacbcfc1d6d8eeb896687c", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": [ + "src" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "time": "2015-12-27 11:43:31" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "3.1.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "9270140b940ff02e58ec577c237274e92cd40cdd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/9270140b940ff02e58ec577c237274e92cd40cdd", + "reference": "9270140b940ff02e58ec577c237274e92cd40cdd", + "shasum": "" + }, + "require": { + "php": ">=5.5", + "phpdocumentor/reflection-common": "^1.0@dev", + "phpdocumentor/type-resolver": "^0.2.0", + "webmozart/assert": "^1.0" + }, + "require-dev": { + "mockery/mockery": "^0.9.4", + "phpunit/phpunit": "^4.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "time": "2016-06-10 09:48:41" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "0.2", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "b39c7a5b194f9ed7bd0dd345c751007a41862443" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/b39c7a5b194f9ed7bd0dd345c751007a41862443", + "reference": "b39c7a5b194f9ed7bd0dd345c751007a41862443", + "shasum": "" + }, + "require": { + "php": ">=5.5", + "phpdocumentor/reflection-common": "^1.0" + }, + "require-dev": { + "mockery/mockery": "^0.9.4", + "phpunit/phpunit": "^5.2||^4.8.24" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "time": "2016-06-10 07:14:17" + }, + { + "name": "phpspec/prophecy", + "version": "v1.6.1", + "source": { + "type": "git", + "url": "https://github.com/phpspec/prophecy.git", + "reference": "58a8137754bc24b25740d4281399a4a3596058e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/58a8137754bc24b25740d4281399a4a3596058e0", + "reference": "58a8137754bc24b25740d4281399a4a3596058e0", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.0.2", + "php": "^5.3|^7.0", + "phpdocumentor/reflection-docblock": "^2.0|^3.0.2", + "sebastian/comparator": "^1.1", + "sebastian/recursion-context": "^1.0" + }, + "require-dev": { + "phpspec/phpspec": "^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.6.x-dev" + } + }, + "autoload": { + "psr-0": { + "Prophecy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + }, + { + "name": "Marcello Duarte", + "email": "marcello.duarte@gmail.com" + } + ], + "description": "Highly opinionated mocking framework for PHP 5.3+", + "homepage": "https://github.com/phpspec/prophecy", + "keywords": [ + "Double", + "Dummy", + "fake", + "mock", + "spy", + "stub" + ], + "time": "2016-06-07 08:13:47" + }, + { + "name": "phpunit/php-code-coverage", + "version": "2.2.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "eabf68b476ac7d0f73793aada060f1c1a9bf8979" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/eabf68b476ac7d0f73793aada060f1c1a9bf8979", + "reference": "eabf68b476ac7d0f73793aada060f1c1a9bf8979", + "shasum": "" + }, + "require": { + "php": ">=5.3.3", + "phpunit/php-file-iterator": "~1.3", + "phpunit/php-text-template": "~1.2", + "phpunit/php-token-stream": "~1.3", + "sebastian/environment": "^1.3.2", + "sebastian/version": "~1.0" + }, + "require-dev": { + "ext-xdebug": ">=2.1.4", + "phpunit/phpunit": "~4" + }, + "suggest": { + "ext-dom": "*", + "ext-xdebug": ">=2.2.1", + "ext-xmlwriter": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.2.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sb@sebastian-bergmann.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "time": "2015-10-06 15:47:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "1.4.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "6150bf2c35d3fc379e50c7602b75caceaa39dbf0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6150bf2c35d3fc379e50c7602b75caceaa39dbf0", + "reference": "6150bf2c35d3fc379e50c7602b75caceaa39dbf0", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.4.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sb@sebastian-bergmann.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "time": "2015-06-21 13:08:43" + }, + { + "name": "phpunit/php-text-template", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/31f8b717e51d9a2afca6c9f046f5d69fc27c8686", + "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "time": "2015-06-21 13:50:34" + }, + { + "name": "phpunit/php-timer", + "version": "1.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "38e9124049cf1a164f1e4537caf19c99bf1eb260" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/38e9124049cf1a164f1e4537caf19c99bf1eb260", + "reference": "38e9124049cf1a164f1e4537caf19c99bf1eb260", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "phpunit/phpunit": "~4|~5" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sb@sebastian-bergmann.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "time": "2016-05-12 18:03:57" + }, + { + "name": "phpunit/php-token-stream", + "version": "1.4.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-token-stream.git", + "reference": "3144ae21711fb6cac0b1ab4cbe63b75ce3d4e8da" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/3144ae21711fb6cac0b1ab4cbe63b75ce3d4e8da", + "reference": "3144ae21711fb6cac0b1ab4cbe63b75ce3d4e8da", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": ">=5.3.3" + }, + "require-dev": { + "phpunit/phpunit": "~4.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.4-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Wrapper around PHP's tokenizer extension.", + "homepage": "https://github.com/sebastianbergmann/php-token-stream/", + "keywords": [ + "tokenizer" + ], + "time": "2015-09-15 10:49:45" + }, + { + "name": "phpunit/phpunit", + "version": "4.8.27", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "c062dddcb68e44b563f66ee319ddae2b5a322a90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c062dddcb68e44b563f66ee319ddae2b5a322a90", + "reference": "c062dddcb68e44b563f66ee319ddae2b5a322a90", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-pcre": "*", + "ext-reflection": "*", + "ext-spl": "*", + "php": ">=5.3.3", + "phpspec/prophecy": "^1.3.1", + "phpunit/php-code-coverage": "~2.1", + "phpunit/php-file-iterator": "~1.4", + "phpunit/php-text-template": "~1.2", + "phpunit/php-timer": "^1.0.6", + "phpunit/phpunit-mock-objects": "~2.3", + "sebastian/comparator": "~1.1", + "sebastian/diff": "~1.2", + "sebastian/environment": "~1.3", + "sebastian/exporter": "~1.2", + "sebastian/global-state": "~1.0", + "sebastian/version": "~1.0", + "symfony/yaml": "~2.1|~3.0" + }, + "suggest": { + "phpunit/php-invoker": "~1.1" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.8.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "time": "2016-07-21 06:48:14" + }, + { + "name": "phpunit/phpunit-mock-objects", + "version": "2.3.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit-mock-objects.git", + "reference": "ac8e7a3db35738d56ee9a76e78a4e03d97628983" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/ac8e7a3db35738d56ee9a76e78a4e03d97628983", + "reference": "ac8e7a3db35738d56ee9a76e78a4e03d97628983", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.0.2", + "php": ">=5.3.3", + "phpunit/php-text-template": "~1.2", + "sebastian/exporter": "~1.2" + }, + "require-dev": { + "phpunit/phpunit": "~4.4" + }, + "suggest": { + "ext-soap": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.3.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sb@sebastian-bergmann.de", + "role": "lead" + } + ], + "description": "Mock Object library for PHPUnit", + "homepage": "https://github.com/sebastianbergmann/phpunit-mock-objects/", + "keywords": [ + "mock", + "xunit" + ], + "time": "2015-10-02 06:51:40" + }, + { + "name": "sebastian/comparator", + "version": "1.2.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "937efb279bd37a375bcadf584dec0726f84dbf22" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/937efb279bd37a375bcadf584dec0726f84dbf22", + "reference": "937efb279bd37a375bcadf584dec0726f84dbf22", + "shasum": "" + }, + "require": { + "php": ">=5.3.3", + "sebastian/diff": "~1.2", + "sebastian/exporter": "~1.2" + }, + "require-dev": { + "phpunit/phpunit": "~4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.2.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "http://www.github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "time": "2015-07-26 15:48:44" + }, + { + "name": "sebastian/diff", + "version": "1.4.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "13edfd8706462032c2f52b4b862974dd46b71c9e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/13edfd8706462032c2f52b4b862974dd46b71c9e", + "reference": "13edfd8706462032c2f52b4b862974dd46b71c9e", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "phpunit/phpunit": "~4.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.4-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff" + ], + "time": "2015-12-08 07:14:41" + }, + { + "name": "sebastian/environment", + "version": "1.3.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "be2c607e43ce4c89ecd60e75c6a85c126e754aea" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/be2c607e43ce4c89ecd60e75c6a85c126e754aea", + "reference": "be2c607e43ce4c89ecd60e75c6a85c126e754aea", + "shasum": "" + }, + "require": { + "php": "^5.3.3 || ^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8 || ^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "http://www.github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "time": "2016-08-18 05:49:44" + }, + { + "name": "sebastian/exporter", + "version": "1.2.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "42c4c2eec485ee3e159ec9884f95b431287edde4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/42c4c2eec485ee3e159ec9884f95b431287edde4", + "reference": "42c4c2eec485ee3e159ec9884f95b431287edde4", + "shasum": "" + }, + "require": { + "php": ">=5.3.3", + "sebastian/recursion-context": "~1.0" + }, + "require-dev": { + "ext-mbstring": "*", + "phpunit/phpunit": "~4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "http://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "time": "2016-06-17 09:04:28" + }, + { + "name": "sebastian/global-state", + "version": "1.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "bc37d50fea7d017d3d340f230811c9f1d7280af4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bc37d50fea7d017d3d340f230811c9f1d7280af4", + "reference": "bc37d50fea7d017d3d340f230811c9f1d7280af4", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "phpunit/phpunit": "~4.2" + }, + "suggest": { + "ext-uopz": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "http://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "time": "2015-10-12 03:26:01" + }, + { + "name": "sebastian/recursion-context", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "913401df809e99e4f47b27cdd781f4a258d58791" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/913401df809e99e4f47b27cdd781f4a258d58791", + "reference": "913401df809e99e4f47b27cdd781f4a258d58791", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "phpunit/phpunit": "~4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "http://www.github.com/sebastianbergmann/recursion-context", + "time": "2015-11-11 19:50:13" + }, + { + "name": "sebastian/version", + "version": "1.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "58b3a85e7999757d6ad81c787a1fbf5ff6c628c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/58b3a85e7999757d6ad81c787a1fbf5ff6c628c6", + "reference": "58b3a85e7999757d6ad81c787a1fbf5ff6c628c6", + "shasum": "" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "time": "2015-06-21 13:59:46" + }, + { + "name": "symfony/yaml", + "version": "v3.1.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "f291ed25eb1435bddbe8a96caaef16469c2a092d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/f291ed25eb1435bddbe8a96caaef16469c2a092d", + "reference": "f291ed25eb1435bddbe8a96caaef16469c2a092d", + "shasum": "" + }, + "require": { + "php": ">=5.5.9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Yaml Component", + "homepage": "https://symfony.com", + "time": "2016-09-02 02:12:52" + }, + { + "name": "webmozart/assert", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/webmozart/assert.git", + "reference": "bb2d123231c095735130cc8f6d31385a44c7b308" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozart/assert/zipball/bb2d123231c095735130cc8f6d31385a44c7b308", + "reference": "bb2d123231c095735130cc8f6d31385a44c7b308", + "shasum": "" + }, + "require": { + "php": "^5.3.3|^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.6", + "sebastian/version": "^1.0.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "time": "2016-08-09 15:02:57" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=5.3.3" + }, + "platform-dev": [] +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..88d6da8 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,17 @@ + + + + + ./tests/ + + + \ No newline at end of file diff --git a/phpunit/Feature/ConfigTest.php b/phpunit/Feature/ConfigTest.php deleted file mode 100644 index 94ea7d1..0000000 --- a/phpunit/Feature/ConfigTest.php +++ /dev/null @@ -1,351 +0,0 @@ -expectDisabled($c, array('uaid' => 0)); - $this->expectDisabled($c, array('uaid' => 1)); - } - - function testFullyEnabled() { - $c = array('enabled' => 'on'); - $this->expectEnabled($c, array('uaid' => '0')); - $this->expectEnabled($c, array('uaid' => '1')); - } - - function testSimpleDisabled () { - $c = array('enabled' => 'off'); - $this->expectDisabled($c, array('uaid' => '0')); - $this->expectDisabled($c, array('uaid' => '1')); - } - - function testVariantEnabled () { - $c = array('enabled' => 'winner'); - $this->expectEnabled($c, array('uaid' => '0'), 'winner'); - $this->expectEnabled($c, array('uaid' => '1'), 'winner'); - } - - function testFullyEnabledString() { - $c = 'on'; - $this->expectEnabled($c, array('uaid' => '0')); - $this->expectEnabled($c, array('uaid' => '1')); - } - - function testSimpleDisabledString () { - $c = 'off'; - $this->expectDisabled($c, array('uaid' => '0')); - $this->expectDisabled($c, array('uaid' => '1')); - } - - function testVariantEnabledString () { - $c = 'winner'; - $this->expectEnabled($c, array('uaid' => '0'), 'winner'); - $this->expectEnabled($c, array('uaid' => '1'), 'winner'); - } - - function testSimpleRampup () { - $c = array('enabled' => '50'); - $this->expectEnabled($c, array('uaid' => '0')); - $this->expectEnabled($c, array('uaid' => '.1')); - $this->expectEnabled($c, array('uaid' => '.4999')); - $this->expectDisabled($c, array('uaid' => '.5')); - $this->expectDisabled($c, array('uaid' => '.6')); - $this->expectDisabled($c, array('uaid' => '.99')); - $this->expectDisabled($c, array('uaid' => '1')); - } - - function testMultivariant () { - $c = array('enabled' => array('foo' => 2, 'bar' => 3)); - $this->expectEnabled($c, array('uaid' => '0'), 'foo'); - $this->expectEnabled($c, array('uaid' => '.01'), 'foo'); - $this->expectEnabled($c, array('uaid' => '.01999'), 'foo'); - $this->expectEnabled($c, array('uaid' => '.02'), 'bar'); - $this->expectEnabled($c, array('uaid' => '.04999'), 'bar'); - $this->expectDisabled($c, array('uaid' => '.05')); - $this->expectDisabled($c, array('uaid' => '1')); - } - - /* - * Is feature disbaled by enabled => off despite every other - * setting trying to turn it on? - */ - function testComplexDisabled () { - $c = array( - 'enabled' => 'off', - 'users' => array('fred', 'sally'), - 'groups' => array(1234, 2345), - 'admin' => 'on', - 'internal' => 'on', - 'public_url_overrride' => true - ); - - $this->expectDisabled($c, array('isInternal' => true, 'uaid' => '0')); - $this->expectDisabled($c, array('userName' => 'fred', 'uaid' => '0')); - $this->expectDisabled($c, array('inGroup' => array(0 => 1234), 'uaid' => '0')); - $this->expectDisabled($c, array('uaid' => '100', 'uaid' => '0')); - $this->expectDisabled($c, array('isAdmin' => true, 'uaid' => '0')); - $this->expectDisabled($c, array('isInternal' => true, 'urlFeatures' => 'foo', 'uaid' => 0)); - - // Now all at once. - $this->expectDisabled($c, array( - 'isInternal' => true, - 'userName' => 'fred', - 'inGroup' => array(0 => 1234), - 'uaid' => '100', - 'isAdmin' => true, - 'urlFeatures' => 'foo', - 'userID' => '0')); - } - - function testAdminOnly () { - $c = array('enabled' => 0, 'admin' => 'on'); - $this->expectEnabled($c, array('isAdmin' => true, 'uaid' => '0', 'userID' => '1')); - $this->expectDisabled($c, array('isAdmin' => false, 'uaid' => '1', 'userID' => '1')); - } - - function testAdminPlusSome () { - $c = array('enabled' => 10, 'admin' => 'on'); - $this->expectEnabled($c, array('isAdmin' => true, 'uaid' => '.5', 'userID' => '1')); - $this->expectEnabled($c, array('isAdmin' => false, 'uaid' => '.05', 'userID' => '1')); - $this->expectDisabled($c, array('isAdmin' => false, 'uaid' => '.5', 'userID' => '1')); - } - - function testInternalOnly () { - $c = array('enabled' => 0, 'internal' => 'on'); - $this->expectEnabled($c, array('isInternal' => true, 'uaid' => '0')); - $this->expectDisabled($c, array('isInternal' => false, 'uaid' => '1')); - } - - function testInternalPlusSome () { - $c = array('enabled' => 10, 'internal' => 'on'); - $this->expectEnabled($c, array('isInternal' => true, 'uaid' => '.5')); - $this->expectEnabled($c, array('isInternal' => false, 'uaid' => '.05')); - $this->expectDisabled($c, array('isInternal' => false, 'uaid' => '.5')); - } - - function testOneUser () { - $c = array('enabled' => 0, 'users' => 'fred'); - $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'fred', 'userID' => '1')); - $this->expectDisabled($c, array('uaid' => '1', 'userName' => 'george', 'userID' => '1')); - $this->expectDisabled($c, array('userID' => null, 'uaid' => 0)); - } - - function testListOfOneUser () { - $c = array('enabled' => 0, 'users' => array('fred')); - $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'fred', 'userID' => '1')); - $this->expectDisabled($c, array('uaid' => '1', 'userName' => 'george', 'userID' => '1')); - } - - function testListOfUsers () { - $c = array('enabled' => 0, 'users' => array('fred', 'ron')); - $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'fred', 'userID' => '1')); - $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'ron', 'userID' => '1')); - $this->expectDisabled($c, array('uaid' => '1', 'userName' => 'george', 'userID' => '1')); - } - - function testListOfUsersCaseInsensitive() { - $c = array('enabled' => 0, 'users' => array('fred', 'FunGuy')); - $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'fred', 'userID' => '1')); - $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'FunGuy', 'userID' => '1')); - $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'FUNGUY', 'userID' => '1')); - $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'funguy', 'userID' => '1')); - } - - function testArrayOfUsers () { - // It might be kind of nice to allow 'enabled' => 0 here but - // then we lose the ability to check that the variants - // mentioned in a users clause are actually valid - // variants. Which maybe is okay: perhaps we'd like to be able - // to enable variants for users that are otherwise disabled. - $c = array('enabled' => array('twins' => 0, 'other' => 0), - 'users' => array( - 'twins' => array('fred', 'george'), - 'other' => 'ron')); - $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'fred', 'userID' => '1'), 'twins'); - $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'george', 'userID' => '2'), 'twins'); - $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'ron', 'userID' => '3'), 'other'); - $this->expectDisabled($c, array('uaid' => '0', 'userName' => 'percy', 'userID' => '4')); - } - - function testOneGroup () { - $c = array('enabled' => 0, 'groups' => 1234); - $this->expectEnabled($c, array('uaid' => 1, 'userID' => 1, 'inGroup' => array(1 => array(1234)))); - $this->expectDisabled($c, array('uaid' => 0, 'userID' => 2, 'inGroup' => array(2 => array(2345)))); - $this->expectDisabled($c, array('uaid' => 0, 'userID' => null, 'uaid' => 0)); - } - - function testListOfOneGroup () { - $c = array('enabled' => 0, 'groups' => array(1234)); - $this->expectEnabled($c, array('uaid' => 1, 'userID' => 1, 'inGroup' => array(1 => array(1234)))); - $this->expectDisabled($c, array('uaid' => 0, 'userID' => 2, 'inGroup' => array(2 => array(2345)))); - } - - function testListOfGroups () { - $c = array('enabled' => 0, 'groups' => array(1234, 2345)); - $this->expectEnabled($c, array('uaid' => 1, 'userID' => 1, 'inGroup' => array(1 => array(1234)))); - $this->expectEnabled($c, array('uaid' => 1, 'userID' => 2, 'inGroup' => array(2 => array(2345)))); - $this->expectDisabled($c, array('uaid' => 0, 'userID' => 3, 'inGroup' => array(3 => array()))); - } - function testArrayOfGroups () { - // See comment at testArrayOfUsers; similar issue applies here. - $c = array('enabled' => array('twins' => 0, 'other' => 0), - 'groups' => array( - 'twins' => array(1234, 2345), - 'other' => 3456)); - $this->expectEnabled($c, array('uaid' => 1, 'userID' => 1, 'inGroup' => array(1 => array(1234))), 'twins'); - $this->expectEnabled($c, array('uaid' => 1, 'userID' => 2, 'inGroup' => array(2 => array(2345))), 'twins'); - $this->expectEnabled($c, array('uaid' => 1, 'userID' => 3, 'inGroup' => array(3 => array(3456))), 'other'); - $this->expectDisabled($c, array('uaid' => 0, 'userID' => 4, 'inGroup' => array(4 => array()))); - } - - function testUrlOverride () { - $c = array('enabled' => 0); - $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => true, 'urlFeatures' => 'foo')); - $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => true, 'urlFeatures' => 'foo:on')); - $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => true, 'urlFeatures' => 'foo:bar'), 'bar'); - $this->expectDisabled($c, array('uaid' => '1', 'isInternal' => false, 'urlFeatures' => 'foo')); - $this->expectDisabled($c, array('uaid' => '1', 'isInternal' => false, 'urlFeatures' => 'foo:on')); - $this->expectDisabled($c, array('uaid' => '1', 'isInternal' => false, 'urlFeatures' => 'foo:bar')); - } - - function testPublicUrlOverride () { - $c = array('enabled' => 0, 'public_url_override' => true); - $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => true, 'urlFeatures' => 'foo')); - $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => true, 'urlFeatures' => 'foo:on')); - $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => true, 'urlFeatures' => 'foo:bar'), 'bar'); - $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => false, 'urlFeatures' => 'foo')); - $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => false, 'urlFeatures' => 'foo:on')); - $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => false, 'urlFeatures' => 'foo:bar'), 'bar'); - } - - function testBucketBy () { - $c = array('enabled' => 2, 'bucketing' => 'user'); - $this->expectEnabled($c, array('uaid' => 1, 'userID' => .01)); - $this->expectDisabled($c, array('uaid' => 0, 'userID' => .03)); - } - - function testUAIDFallback () { - $c = array('enabled' => 2, 'bucketing' => 'user'); - $this->expectEnabled($c, array('userID' => null, 'uaid' => .01)); - $this->expectDisabled($c, array('userID' => null, 'uaid' => .03)); - } - - /* - * Ignore userID and uuaid in favor of random numbers for bucketing. - */ - function testRandom () { - $c = array('enabled' => 3, 'bucketing' => 'random'); - $this->expectEnabled($c, array('uaid' => 1, 'random' => .00)); - $this->expectEnabled($c, array('uaid' => 1, 'random' => .01)); - $this->expectEnabled($c, array('uaid' => 1, 'random' => .02)); - $this->expectEnabled($c, array('uaid' => 1, 'random' => .02999)); - $this->expectDisabled($c, array('uaid' => 0, 'random' => .03)); - $this->expectDisabled($c, array('uaid' => 0, 'random' => .04)); - $this->expectDisabled($c, array('uaid' => 0, 'random' => .99999)); - } - - /* - * Somewhat indirect test that we cache the value by id: even if - * the config is set up to use a random bucket (i.e. indpendent of - * the id) it should still return the same value for the same id - * which we test by having the two 'random' values returned by the - * test world be ones that would change the enabled status if they - * were both used. - */ - function testRandomCached () { - // Initially enabled - $c = array('enabled' => 3, 'bucketing' => 'random'); - $w = new Testing_Feature_MockWorld(array('uaid' => 1, 'random' => 0)); - $config = new Feature_Config('foo', $c, $w); - $this->assertTrue($config->isEnabled()); - $w->nextRandomValue(.5); - $this->assertTrue($config->isEnabled()); - - // Initially disabled - $c = array('enabled' => 3, 'bucketing' => 'random'); - $w = new Testing_Feature_MockWorld(array('uaid' => 1, 'random' => .5)); - $config = new Feature_Config('foo', $c, $w); - $this->assertFalse($config->isEnabled()); - $w->nextRandomValue(0); - $this->assertFalse($config->isEnabled()); - } - - function testDescription () { - // Default description. - $c = array('enabled' => 'on'); - $w = new Testing_Feature_MockWorld(array()); - $config = new Feature_Config('foo', $c, $w); - $this->assertNotNull($config->description()); - - // Provided description. - $c = array('enabled' => 'on', 'description' => 'The description.'); - $w = new Testing_Feature_MockWorld(array()); - $config = new Feature_Config('foo', $c, $w); - $this->assertEquals($config->description(), 'The description.'); - } - - function testIsEnabledForAcceptsREST_User() { - //we don't want to test the implementation of user bucketing here, just the public API - $user_id = 1; - $user = $this->getMock('REST_User'); - $user->expects($this->once()) - ->method('getUserId') - ->will($this->returnValue($user_id)); - $config = new Feature_Config('foo', array('enabled' => 'off'), new Testing_Feature_MockWorld(array())); - $this->assertFalse($config->isEnabledFor($user)); - } - - function testIsEnabledForAcceptsEtsyModel_User() { - //we don't want to test the implementation of user bucketing here, just the public API - $user = new EtsyModel_User(); - $user->user_id = 1; - $config = new Feature_Config('foo', array('enabled' => 'off'), new Testing_Feature_MockWorld(array())); - $this->assertFalse($config->isEnabledFor($user)); - } - - - //////////////////////////////////////////////////////////////////////// - // Test helper methods. - - /* - * Given a config stanza and a world configuration, we expect that - * isEnabled() will return true and that variant will be a given - * value (default 'on'). - */ - private function expectEnabled ($stanza, $world, $variant = 'on') { - $config = new Feature_Config('foo', $stanza, new Testing_Feature_MockWorld($world)); - $this->assertTrue($config->isEnabled()); - $this->assertEquals($config->variant(), $variant); - - if (is_array($stanza) && array_key_exists('enabled', $stanza) && $stanza['enabled'] === 0) { - unset($stanza['enabled']); - $this->expectEnabled($stanza, $world, $variant); - } - } - - /* - * Given a config stanza and a world configuration, we expect that - * isEnabled() will return false. - */ - private function expectDisabled ($stanza, $world) { - $config = new Feature_Config('foo', $stanza, new Testing_Feature_MockWorld($world)); - $this->assertFalse($config->isEnabled()); - if (is_array($stanza) && array_key_exists('enabled', $stanza) && $stanza['enabled'] === 0) { - unset($stanza['enabled']); - $this->expectDisabled($stanza, $world); - } - } -} diff --git a/phpunit/Feature/LoggerTest.php b/phpunit/Feature/LoggerTest.php deleted file mode 100644 index cd1c3d9..0000000 --- a/phpunit/Feature/LoggerTest.php +++ /dev/null @@ -1,42 +0,0 @@ -assertEquals('', Feature_Logger::getGAJavascript(array())); - } - - function testLogOne() { - $selections = array(); - $selections[] = array('TEST_key1', 'TEST_var1', 123); - $js = Feature_Logger::getGAJavascript($selections); - $this->assertEquals("Etsy.GA.track(['_setCustomVar', 2, 'AB', 'TEST_key1.TEST_var1', 3]);", $js); - } - - function testLogTwo() { - $selections = array(); - $selections[] = array('TEST_key1', 'TEST_var1', 123); - $selections[] = array('foo', 'bar', 123); - $js = Feature_Logger::getGAJavascript($selections); - $this->assertEquals("Etsy.GA.track(['_setCustomVar', 2, 'AB', 'TEST_key1.TEST_var1..foo.bar', 3]);", $js); - } - - function testTooLong() { - $selections = array(); - $pairs = array(); - foreach (array('a', 'b', 'c', 'd', 'e') as $x) { - $selections[] = array($x, 'xxxxxxxxxx', 123); - $pairs[] = "$x.xxxxxxxxxx"; - } - // This one should not be included in the Javascript because - // we already have 12*5=60 chars and this pair would add three - // more pushing us over the limit of 62. - $selections[] = array('f', 'x', 123); - $value = implode('..', $pairs); - $js = Feature_Logger::getGAJavascript($selections); - $this->assertEquals("Etsy.GA.track(['_setCustomVar', 2, 'AB', '$value', 3]);", $js); - } -} diff --git a/phpunit/Feature/WorldTest.php b/phpunit/Feature/WorldTest.php deleted file mode 100644 index 423f162..0000000 --- a/phpunit/Feature/WorldTest.php +++ /dev/null @@ -1,153 +0,0 @@ -uaid = UAIDCookie::getSecureCookie(); - $this->assertNotNull($this->uaid); - - $logger = $this->getMock('Logger', array('log')); - $this->world = new Feature_World($logger); - $this->user_id = 991; - - $this->setLoggedUserId(null); - $this->assertNull(Std::loggedUser()); - } - - function testIsAdminWithBlankUAIDCookie() { - $this->setLoggedUserId($this->user_id); - - $this->assertFalse($this->world->isAdmin($this->user_id)); - } - - function testIsAdminWithValidNonAdminUserUAIDCookie() { - $this->setLoggedUserId($this->user_id); - $this->uaid->set(UAIDCookie::USER_ID_ATTRIBUTE, $this->user_id); - - $this->assertFalse($this->world->isAdmin($this->user_id)); - } - - function testIsAdminWithValidAdminUAIDCookie() { - $this->setLoggedUserId($this->user_id); - $this->uaid->set(UAIDCookie::USER_ID_ATTRIBUTE, $this->user_id); - $this->uaid->set(UAIDCookie::ADMIN_ATTRIBUTE, '1'); - - $this->assertTrue($this->world->isAdmin($this->user_id)); - } - - function testIsAdminWithNonLoggedInAdminAndValidAdminUAIDCookie() { - $this->setLoggedUserId(null); - $this->uaid->set(UAIDCookie::USER_ID_ATTRIBUTE, $this->user_id); - $this->uaid->set(UAIDCookie::ADMIN_ATTRIBUTE, '1'); - - $this->assertFalse($this->world->isAdmin($this->user_id)); - } - - function testIsAdminWithLoggedInAdminUserAndBlankUAIDCookie() { - $user = $this->adminUser(); - $this->setLoggedUserId($user->user_id); - - $this->assertTrue($this->world->isAdmin($user->user_id)); - } - - function testIsAdminWithLoggedInNonAdminUserAndBlankUAIDCookie() { - $user = $this->nonAdminUser(); - $this->setLoggedUserId($user->user_id); - - $this->assertFalse($this->world->isAdmin($user->user_id)); - } - - function testIsAdminWithNonLoggedInAdminUserAndBlankUAIDCookie() { - $user = $this->adminUser(); - $this->setLoggedUserId(null); - - $this->assertTrue($this->world->isAdmin($user->user_id)); - } - - function testIsAdminWithNonLoggedInNonAdminUserAndBlankUAIDCookie() { - $user = $this->nonAdminUser(); - $this->setLoggedUserId(null); - - $this->assertFalse($this->world->isAdmin($user->user_id)); - } - - function testAtlasWorld() { - $user = $this->atlasUser(); - $this->setLoggedUserId($user->id); - $this->setAtlasRequest(true); - - $this->assertFalse($this->world->isAdmin($user->id)); - $this->assertFalse($this->world->inGroup($user->id, 1)); - $this->assertEquals($user->id, $this->world->userID()); - - $this->setAtlasRequest(false); - } - - function testHash() { - $this->assertInternalType('float', $this->world->hash('somevalue')); - - $this->assertEquals( - $this->world->hash('somevalue'), - $this->world->hash('somevalue'), - 'ensure return value is consistent' - ); - - $this->assertGreaterThanOrEqual(0, $this->world->hash('somevalue')); - $this->assertLessThan(1, $this->world->hash('somevalue')); - } - - protected function getDatabaseConfigs() { - $index_yml = dirname(__FILE__) . '/data/world/etsy_index.yml'; - if (!file_exists($index_yml)) { - throw new Exception($index_yml . ' does not exist'); - } - $builder = new PHPUnit_Extensions_MultipleDatabase_DatabaseConfig_Builder(); - $etsy_index = $builder - ->connection(Testing_EtsyORM_Connections::ETSY_INDEX()) - ->dataSet(new PHPUnit_Extensions_Database_DataSet_YamlDataSet($index_yml)) - ->build(); - - $aux_yml = dirname(__FILE__) . '/data/world/etsy_aux.yml'; - if (!file_exists($aux_yml)) { - throw new Exception($aux_yml . ' does not exist'); - } - $builder = new PHPUnit_Extensions_MultipleDatabase_DatabaseConfig_Builder(); - $etsy_aux = $builder - ->connection(Testing_EtsyORM_Connections::ETSY_AUX()) - ->dataSet(new PHPUnit_Extensions_Database_DataSet_YamlDataSet($aux_yml)) - ->build(); - - return array($etsy_index, $etsy_aux); - } - - private function nonAdminUser() { - return EtsyORM::getFinder('User')->find(1); - } - - private function adminUser() { - return EtsyORM::getFinder('User')->find(2); - } - - private function atlasUser() { - return EtsyORM::getFinder('Staff')->find(3); - } - - private function setAtlasRequest($is_atlas) { - $_SERVER["atlas_request"] = $is_atlas ? 1 : 0; - } - - private function setLoggedUserId($user_id) { - //Std::loggedUser() uses this global - $GLOBALS['cookie_user_id'] = $user_id; - } -} diff --git a/Feature/Config.php b/src/Config.php similarity index 85% rename from Feature/Config.php rename to src/Config.php index 98c0d46..f0f4937 100644 --- a/Feature/Config.php +++ b/src/Config.php @@ -1,12 +1,18 @@ _name = $name; $this->_cache = array(); $this->_world = $world; @@ -83,6 +131,9 @@ public function __construct($name, $stanza, $world) { * Is this feature enabled for the default id and the logged in * user, if any? */ + /** + * @return bool + */ public function isEnabled () { $bucketingID = $this->bucketingID(); $userID = $this->_world->userID(); @@ -93,15 +144,22 @@ public function isEnabled () { * What variant is enabled for the default id and the logged in * user, if any? */ + /** + * @return mixed|string + */ public function variant () { $bucketingID = $this->bucketingID(); $userID = $this->_world->userID(); - return $this->chooseVariant($bucketingID, $userID, true);; + return $this->chooseVariant($bucketingID, $userID, true); } /* * Is this feature enabled for the given user? */ + /** + * @param $user + * @return bool + */ public function isEnabledFor ($user) { $userID = $this->getUserIdFrom($user); return $this->chooseVariant($userID, $userID, false) !== self::OFF; @@ -113,6 +171,10 @@ public function isEnabledFor ($user) { * variant such as users, groups, and query parameters, will still * work.) */ + /** + * @param $bucketingID + * @return bool + */ public function isEnabledBucketingBy ($bucketingID) { $userID = $this->_world->userID(); return $this->chooseVariant($bucketingID, $userID, false) !== self::OFF; @@ -121,6 +183,10 @@ public function isEnabledBucketingBy ($bucketingID) { /* * What variant is enabled for the given user? */ + /** + * @param $user + * @return mixed|string + */ public function variantFor ($user) { $userID = $this->getUserIdFrom($user); return $this->chooseVariant($userID, $userID, true); @@ -130,14 +196,21 @@ public function variantFor ($user) { * What variant is enabled, bucketing on the given bucketing ID, * if any? */ + /** + * @param $bucketingID + * @return mixed|string + */ public function variantBucketingBy ($bucketingID) { $userID = $this->_world->userID(); - return $this->chooseVariant($bucketingID, $userID, true);; + return $this->chooseVariant($bucketingID, $userID, true); } /* * Description of the feature. */ + /** + * @return mixed|null + */ public function description () { return $this->_description; } @@ -149,6 +222,10 @@ public function description () { /* * Accept different user objects and return user_id */ + /** + * @param $user + * @return mixed + */ private function getUserIdFrom($user) { if ($user instanceof REST_User) { // $user->user_id is protected so not accessible @@ -179,6 +256,12 @@ private function getUserIdFrom($user) { * variantFor, in which case we want to perform some certain * sanity checks to make sure the code is being used correctly. */ + /** + * @param $bucketingID + * @param $userID + * @param $inVariantMethod + * @return array|int|mixed|null|string + */ private function chooseVariant ($bucketingID, $userID, $inVariantMethod) { if ($inVariantMethod && $this->_enabled === self::ON) { $this->error("Variant check when fully enabled"); @@ -190,7 +273,7 @@ private function chooseVariant ($bucketingID, $userID, $inVariantMethod) { return $this->_enabled; } else { if (is_null($bucketingID)) { - throw new InvalidArgumentException( + throw new \InvalidArgumentException( "no bucketing ID supplied. if testing, configure feature " . "with enabled => 'on' or 'off', feature name = " . $this->_name @@ -234,6 +317,9 @@ private function chooseVariant ($bucketingID, $userID, $inVariantMethod) { * Return the globally accessible ID used by the one-arg isEnabled * and variant methods based on the feature's bucketing property. */ + /** + * @return null|string + */ private function bucketingID () { switch ($this->_bucketing) { case self::UAID: @@ -251,7 +337,7 @@ private function bucketingID () { // not logged in we should treat the feature as disabled. return !is_null($userID) ? $userID : $this->_world->uaid(); default: - throw new InvalidArgumentException("Bad bucketing: $this->bucketing"); + throw new \InvalidArgumentException("Bad bucketing: $this->bucketing"); } } @@ -262,6 +348,10 @@ private function bucketingID () { * meaning nothing was specified. Note that foo:off will turn off * the 'foo' feature. */ + /** + * @param $userID + * @return array|bool + */ private function variantFromURL ($userID) { if ($this->_public_url_override or $this->_world->isInternalRequest() or @@ -284,6 +374,10 @@ private function variantFromURL ($userID) { * Get the variant this user should see, if one was configured, * false otherwise. */ + /** + * @param $userID + * @return array|bool + */ private function variantForUser ($userID) { if ($this->_users) { $name = $this->_world->userName($userID); @@ -303,6 +397,10 @@ private function variantForUser ($userID) { * practice we could make the configuration more complex. Or you * can just provide a specific variant via the 'users' property. */ + /** + * @param $userID + * @return array|bool + */ private function variantForGroup ($userID) { if ($userID) { foreach ($this->_groups as $groupID => $variant) { @@ -318,6 +416,10 @@ private function variantForGroup ($userID) { * What variant, if any, should we return if the current user is * an admin. */ + /** + * @param $userID + * @return array|bool + */ private function variantForAdmin ($userID) { if ($userID && $this->_adminVariant) { if ($this->_world->isAdmin($userID)) { @@ -330,6 +432,9 @@ private function variantForAdmin ($userID) { /* * What variant, if any, should we return for internal requests. */ + /** + * @return array|bool + */ private function variantForInternal () { if ($this->_internalVariant) { if ($this->_world->isInternalRequest()) { @@ -344,6 +449,10 @@ private function variantForInternal () { * should see each variant to map a randomish number to a * particular variant. */ + /** + * @param $id + * @return array|bool + */ private function variantByPercentage ($id) { $n = 100 * $this->randomish($id); foreach ($this->_percentages as $v) { @@ -360,6 +469,10 @@ private function variantByPercentage ($id) { * A randomish number in [0, 1) based on the feature name and $id * unless we are bucketing completely at random. */ + /** + * @param $id + * @return float|int + */ private function randomish ($id) { return $this->_bucketing === self::RANDOM ? $this->_world->random() : $this->_world->hash($this->_name . '-' . $id); @@ -368,16 +481,24 @@ private function randomish ($id) { //////////////////////////////////////////////////////////////////////// // Configuration parsing + /** + * @param $stanza + * @return mixed|null + */ private function parseDescription ($stanza) { - return Feature_Util::arrayGet($stanza, self::DESCRIPTION, 'No description.'); + return Util::arrayGet($stanza, self::DESCRIPTION, 'No description.'); } /* * Parse the 'enabled' property of the feature's config stanza. */ + /** + * @param $stanza + * @return array|int|mixed|null + */ private function parseEnabled ($stanza) { - $enabled = Feature_Util::arrayGet($stanza, self::ENABLED, 0); + $enabled = Util::arrayGet($stanza, self::ENABLED, 0); if (is_numeric($enabled)) { if ($enabled < 0) { @@ -401,6 +522,9 @@ private function parseEnabled ($stanza) { * being the upper-boundary of the variants percentage and the * second element being the name of the variant. */ + /** + * @return array + */ private function computePercentages () { $total = 0; $percentages = array(); @@ -426,8 +550,13 @@ private function computePercentages () { * feature's config stanza, returning an array mappinng the user * or group names to they variant they should see. */ + /** + * @param $stanza + * @param $what + * @return array + */ private function parseUsersOrGroups ($stanza, $what) { - $value = Feature_Util::arrayGet($stanza, $what); + $value = Util::arrayGet($stanza, $what); if (is_string($value) || is_numeric($value)) { // Users are configrued with their user names. Groups as // numeric ids. (Not sure if that's a great idea.) @@ -466,8 +595,13 @@ private function parseUsersOrGroups ($stanza, $what) { * properties. If non-falsy, must be one of the keys in the * enabled map unless enabled is 'on' or 'off'. */ + /** + * @param $stanza + * @param $what + * @return bool|mixed|null + */ private function parseVariantName ($stanza, $what) { - $value = Feature_Util::arrayGet($stanza, $what); + $value = Util::arrayGet($stanza, $what); if ($value) { if (is_array($this->_enabled)) { if (array_key_exists($value, $this->_enabled)) { @@ -483,12 +617,20 @@ private function parseVariantName ($stanza, $what) { } } + /** + * @param $stanza + * @return mixed|null + */ private function parsePublicURLOverride ($stanza) { - return Feature_Util::arrayGet($stanza, self::PUBLIC_URL_OVERRIDE, false); + return Util::arrayGet($stanza, self::PUBLIC_URL_OVERRIDE, false); } + /** + * @param $stanza + * @return mixed|null + */ private function parseBucketBy ($stanza) { - return Feature_Util::arrayGet($stanza, self::BUCKETING, self::UAID); + return Util::arrayGet($stanza, self::BUCKETING, self::UAID); } //////////////////////////////////////////////////////////////////////// @@ -498,14 +640,25 @@ private function parseBucketBy ($stanza) { * Is the given object an array value that could have been created * with array(...) with no =>'s in the ...? */ + /** + * @param $a + * @return bool + */ private static function isList($a) { return is_array($a) and array_keys($a) === range(0, count($a) - 1); } + /** + * @param $x + * @return array + */ private static function asArray ($x) { return is_array($x) ? $x : array($x); } + /** + * @param $message + */ private function error ($message) { // IMPLEMENT FOR YOUR CONTEXT } diff --git a/Feature.php b/src/Feature.php similarity index 93% rename from Feature.php rename to src/Feature.php index 5f66720..5c0a3f8 100644 --- a/Feature.php +++ b/src/Feature.php @@ -1,5 +1,7 @@ variant(); @@ -119,7 +135,6 @@ public static function variant($name) { * Logs an error if called when isEnabledFor($name, $user) doesn't * return true. (I.e. calls to this method should only occur in * blocks guarded by an isEnabledFor check.) - * Also logs an error if 'enabled' is 'on' for the named feature * since there should be no variant-dependent code left when a * feature has been fully enabled. To clean up a finished @@ -132,6 +147,7 @@ public static function variant($name) { * * @param $user A user object whose id will be combined with $name * and hashed to get the bucketing. + * @return mixed|string */ public static function variantFor($name, $user) { return self::fromConfig($name)->variantFor($user); @@ -158,6 +174,7 @@ public static function variantFor($name, $user) { * @param string $name the config key for the feature. * * @param string $bucketingID A string to use as the bucketing ID. + * @return mixed|string */ public static function variantBucketingBy($name, $bucketingID) { return self::fromConfig($name)->variantBucketingBy($bucketingID); @@ -166,6 +183,10 @@ public static function variantBucketingBy($name, $bucketingID) { /* * Description of the feature. */ + /** + * @param $name + * @return mixed|null + */ public static function description ($name) { return self::fromConfig($name)->description(); } @@ -204,7 +225,7 @@ public static function variantData($name, $default = array()) { * * @param $name name of the feature. Used as a key into the global config array * - * @return Feature_Config + * @return Config */ private static function fromConfig($name) { if (array_key_exists($name, self::$configCache)) { @@ -212,7 +233,7 @@ private static function fromConfig($name) { } else { $world = self::world(); $stanza = $world->configValue($name); - return self::$configCache[$name] = new Feature_Config($name, $stanza, $world); + return self::$configCache[$name] = new Config($name, $stanza, $world); } } @@ -238,12 +259,12 @@ public static function selections () { } /** - * This API always uses the default World. Feature_Config takes + * This API always uses the default World. Config takes * the world as an argument in order to ease unit testing. */ private static function world () { if (!isset(self::$defaultWorld)) { - self::$defaultWorld = new Feature_World(new Feature_Logger()); + self::$defaultWorld = new World(new Logger()); } return self::$defaultWorld; } diff --git a/Feature/Instance.php b/src/Instance.php similarity index 73% rename from Feature/Instance.php rename to src/Instance.php index 7de9458..4ddb65d 100644 --- a/Feature/Instance.php +++ b/src/Instance.php @@ -1,14 +1,22 @@ $value); } - $enabled = Feature_Util::arrayGet($value, 'enabled', 0); - $users = self::expandUsersOrGroups(Feature_Util::arrayGet($value, 'users', array())); - $groups = self::expandUsersOrGroups(Feature_Util::arrayGet($value, 'groups', array())); + $enabled = Util::arrayGet($value, 'enabled', 0); + $users = self::expandUsersOrGroups(Util::arrayGet($value, 'users', array())); + $groups = self::expandUsersOrGroups(Util::arrayGet($value, 'groups', array())); if ($enabled === 'off') { $spec['variants'][] = self::makeVariantWithUsersAndGroups('on', 0, $users, $groups); @@ -84,6 +105,10 @@ private static function translate ($key, $value) { return $spec; } + /** + * @param $key + * @return array + */ private static function makeSpec ($key) { return array( 'key' => $key, @@ -95,6 +120,11 @@ private static function makeSpec ($key) { 'variants' => array()); } + /** + * @param $name + * @param $percentage + * @return array + */ private static function makeVariant ($name, $percentage) { return array( 'name' => $name, @@ -103,6 +133,13 @@ private static function makeVariant ($name, $percentage) { 'groups' => array()); } + /** + * @param $name + * @param $percentage + * @param $users + * @param $groups + * @return array + */ private static function makeVariantWithUsersAndGroups ($name, $percentage, $users, $groups) { return array( 'name' => $name, @@ -112,6 +149,11 @@ private static function makeVariantWithUsersAndGroups ($name, $percentage, $user ); } + /** + * @param $usersOrGroups + * @param $name + * @return array + */ private static function extractForVariant ($usersOrGroups, $name) { $result = array(); foreach ($usersOrGroups as $thing => $variant) { @@ -122,17 +164,21 @@ private static function extractForVariant ($usersOrGroups, $name) { return $result; } - // This is based on parseUsersOrGroups in Feature_Config. Probably + // This is based on parseUsersOrGroups in Config. Probably // this logic should be put in that class in a form that we can // use. + /** + * @param $value + * @return array + */ private static function expandUsersOrGroups ($value) { if (is_string($value) || is_numeric($value)) { - return array($value => Feature_Config::ON); + return array($value => Config::ON); } elseif (self::isList($value)) { $result = array(); foreach ($value as $who) { - $result[$who] = Feature_Config::ON; + $result[$who] = Config::ON; } return $result; @@ -150,12 +196,19 @@ private static function expandUsersOrGroups ($value) { } } + /** + * @param $a + * @return bool + */ private static function isList($a) { return is_array($a) and array_keys($a) === range(0, count($a) - 1); } + /** + * @param $x + * @return array + */ private static function asArray ($x) { return is_array($x) ? $x : array($x); } - -} \ No newline at end of file +} diff --git a/Feature/Lint.php b/src/Lint.php similarity index 69% rename from Feature/Lint.php rename to src/Lint.php index 6d9a518..58db13f 100644 --- a/Feature/Lint.php +++ b/src/Lint.php @@ -1,5 +1,7 @@ 100. */ -class Feature_Lint { +/** + * Class Lint + * @package CafeMedia\Feature + */ +class Lint { + /** + * @var int + */ private $_checked; + /** + * @var array + */ private $_errors; + /** + * @var array + */ private $_path; + /** + * Lint constructor. + */ public function __construct() { $this->_checked = 0; $this->_errors = array(); $this->_path = array(); $this->syntax_keys = array( - Feature_Config::ENABLED, - Feature_Config::USERS, - Feature_Config::GROUPS, - Feature_Config::ADMIN, - Feature_Config::INTERNAL, - Feature_Config::PUBLIC_URL_OVERRIDE, - Feature_Config::BUCKETING, + Config::ENABLED, + Config::USERS, + Config::GROUPS, + Config::ADMIN, + Config::INTERNAL, + Config::PUBLIC_URL_OVERRIDE, + Config::BUCKETING, 'data', ); $this->_legal_bucketing_values = array( - Feature_Config::UAID, - Feature_Config::USER, - Feature_Config::RANDOM, + Config::UAID, + Config::USER, + Config::RANDOM, ); } + /** + * @param null $file + */ public function run($file = null) { $config = $this->fromFile($file); $this->assert($config, "*** Bad configuration."); $this->lintNested($config); } + /** + * @return int + */ public function checked() { return $this->_checked; } + /** + * @return array + */ public function errors() { return $this->_errors; } + /** + * @param $file + * @return bool + */ private function fromFile($file) { global $server_config; $content = file_get_contents($file); @@ -64,7 +95,7 @@ private function fromFile($file) { } else if ($r === false) { return false; } else { - Logger::error("Wut? $r"); + //Logger::error("Wut? $r"); return false; } } @@ -73,6 +104,9 @@ private function fromFile($file) { * Recursively check nested feature configurations. Skips any keys * that have a syntactic meaning which includes 'data'. */ + /** + * @param $config + */ private function lintNested($config) { foreach ($config as $name => $stanza) { if (!in_array($name, $this->syntax_keys)) { @@ -81,6 +115,10 @@ private function lintNested($config) { } } + /** + * @param $name + * @param $stanza + */ private function lint($name, $stanza) { array_push($this->_path, $name); $this->_checked += 1; @@ -100,6 +138,10 @@ private function lint($name, $stanza) { array_pop($this->_path); } + /** + * @param $ok + * @param $message + */ private function assert($ok, $message) { if (!$ok) { $loc = "[" . implode('.', $this->_path) . "]"; @@ -107,21 +149,27 @@ private function assert($ok, $message) { } } + /** + * @param $stanza + */ private function checkForOldstyle($stanza) { - $enabled = Feature_Util::arrayGet($stanza, Feature_Config::ENABLED, 0); - $rampup = Feature_Util::arrayGet($stanza, 'rampup', null); + $enabled = Util::arrayGet($stanza, Config::ENABLED, 0); + $rampup = Util::arrayGet($stanza, 'rampup', null); $this->assert($enabled !== 'rampup' || !$rampup, "Old-style config syntax detected."); } // 'enabled' must be a string, a number in [0,100], or an array of // (string => ints) such that the ints are all in [0,100] and the // total is <= 100. + /** + * @param $stanza + */ private function checkEnabled($stanza) { - if (array_key_exists(Feature_Config::ENABLED, $stanza)) { - $enabled = $stanza[Feature_Config::ENABLED]; + if (array_key_exists(Config::ENABLED, $stanza)) { + $enabled = $stanza[Config::ENABLED]; if (is_numeric($enabled)) { - $this->assert($enabled >= 0, Feature_Config::ENABLED . " too small: $enabled"); - $this->assert($enabled <= 100, Feature_Config::ENABLED . "too big: $enabled"); + $this->assert($enabled >= 0, Config::ENABLED . " too small: $enabled"); + $this->assert($enabled <= 100, Config::ENABLED . "too big: $enabled"); } else if (is_array($enabled)) { $tot = 0; foreach ($enabled as $k => $v) { @@ -139,9 +187,12 @@ private function checkEnabled($stanza) { } } + /** + * @param $stanza + */ private function checkUsers($stanza) { - if (array_key_exists(Feature_Config::USERS, $stanza)) { - $users = $stanza[Feature_Config::USERS]; + if (array_key_exists(Config::USERS, $stanza)) { + $users = $stanza[Config::USERS]; if (is_array($users) && !self::isList($users)) { foreach ($users as $variant => $value) { $this->assert(is_string($variant), "User variant names must be strings."); @@ -153,18 +204,24 @@ private function checkUsers($stanza) { } } + /** + * @param $users + */ private function checkUserValue($users) { - $this->assert(is_string($users) || self::isList($users), Feature_Config::USERS . " must be string or list of strings: '$users'"); + $this->assert(is_string($users) || self::isList($users), Config::USERS . " must be string or list of strings: '$users'"); if (self::isList($users)) { foreach ($users as $user) { - $this->assert(is_string($user), Feature_Config::USERS . " elements must be strings: '$user'"); + $this->assert(is_string($user), Config::USERS . " elements must be strings: '$user'"); } } } + /** + * @param $stanza + */ private function checkGroups($stanza) { - if (array_key_exists(Feature_Config::GROUPS, $stanza)) { - $groups = $stanza[Feature_Config::GROUPS]; + if (array_key_exists(Config::GROUPS, $stanza)) { + $groups = $stanza[Config::GROUPS]; if (is_array($groups) && !self::isList($groups)) { foreach ($groups as $variant => $value) { $this->assert(is_string($variant), "Group variant names must be strings."); @@ -176,33 +233,45 @@ private function checkGroups($stanza) { } } + /** + * @param $groups + */ private function checkGroupValue($groups) { - $this->assert(is_numeric($groups) || self::isList($groups), Feature_Config::GROUPS . " must be number or list of numbers"); + $this->assert(is_numeric($groups) || self::isList($groups), Config::GROUPS . " must be number or list of numbers"); if (self::isList($groups)) { foreach ($groups as $group) { - $this->assert(is_numeric($group), Feature_Config::GROUPS . " elements must be numbers: '$group'"); + $this->assert(is_numeric($group), Config::GROUPS . " elements must be numbers: '$group'"); } } } + /** + * @param $stanza + */ private function checkAdmin($stanza) { - if (array_key_exists(Feature_Config::ADMIN, $stanza)) { - $admin = $stanza[Feature_Config::ADMIN]; + if (array_key_exists(Config::ADMIN, $stanza)) { + $admin = $stanza[Config::ADMIN]; $this->assert(is_string($admin), "Admin must be string naming variant: '$admin'"); } } + /** + * @param $stanza + */ private function checkInternal($stanza) { - if (array_key_exists(Feature_Config::INTERNAL, $stanza)) { - $internal = $stanza[Feature_Config::INTERNAL]; + if (array_key_exists(Config::INTERNAL, $stanza)) { + $internal = $stanza[Config::INTERNAL]; $this->assert(is_string($internal), "Internal must be string naming variant: '$internal'"); } } + /** + * @param $stanza + */ private function checkPublicURLOverride($stanza) { - if (array_key_exists(Feature_Config::PUBLIC_URL_OVERRIDE, $stanza)) { - $public_url_override = $stanza[Feature_Config::PUBLIC_URL_OVERRIDE]; + if (array_key_exists(Config::PUBLIC_URL_OVERRIDE, $stanza)) { + $public_url_override = $stanza[Config::PUBLIC_URL_OVERRIDE]; $this->assert(is_bool($public_url_override), "public_url_override must be a boolean: '$public_url_override'"); if (is_bool($public_url_override)) { $this->assert($public_url_override === true, "Gratuitous public_url_override (defaults to false)"); @@ -210,14 +279,21 @@ private function checkPublicURLOverride($stanza) { } } + /** + * @param $stanza + */ private function checkBucketing($stanza) { - if (array_key_exists(Feature_Config::BUCKETING, $stanza)) { - $bucketing = $stanza[Feature_Config::BUCKETING]; + if (array_key_exists(Config::BUCKETING, $stanza)) { + $bucketing = $stanza[Config::BUCKETING]; $this->assert(is_string($bucketing), "Non-string bucketing: '$bucketing'"); $this->assert(in_array($bucketing, $this->_legal_bucketing_values), "Illegal bucketing: '$bucketing'"); } } + /** + * @param $a + * @return bool + */ private static function isList($a) { return is_array($a) and array_keys($a) === range(0, count($a) - 1); } diff --git a/Feature/Logger.php b/src/Logger.php similarity index 56% rename from Feature/Logger.php rename to src/Logger.php index 97d1690..869c3a3 100644 --- a/Feature/Logger.php +++ b/src/Logger.php @@ -1,17 +1,26 @@ _logger = $logger; } /* * Get the config value for the given key. */ + /** + * @param $name + * @param null $default + * @return null + */ public function configValue($name, $default = null) { return $default; // IMPLEMENT FOR YOUR CONTEXT } @@ -63,6 +84,7 @@ public function inGroup ($userID, $groupID) { * * @param $userID the id of the relevant user, either the * currently logged in user or some other user. + * @return bool */ public function isAdmin ($userID) { return false; // IMPLEMENT FOR YOUR CONTEXT @@ -78,6 +100,9 @@ public function isInternalRequest () { /* * 'features' query param for url overrides. */ + /** + * @return string + */ public function urlFeatures () { return array_key_exists('features', $_GET) ? $_GET['features'] : ''; } @@ -85,6 +110,9 @@ public function urlFeatures () { /* * Produce a random number in [0, 1) for RANDOM bucketing. */ + /** + * @return float|int + */ public function random () { return mt_rand(0, mt_getrandmax() - 1) / mt_getrandmax(); } @@ -92,6 +120,10 @@ public function random () { /* * Produce a randomish number in [0, 1) based on the given id. */ + /** + * @param $id + * @return float + */ public function hash ($id) { return self::mapHex(hash('sha256', $id)); } @@ -100,6 +132,11 @@ public function hash ($id) { * Record that $variant has been selected for feature named $name * by $selector and pass the same information along to the logger. */ + /** + * @param $name + * @param $variant + * @param $selector + */ public function log ($name, $variant, $selector) { $this->_selections[] = array($name, $variant, $selector); $this->_logger->log($name, $variant, $selector); @@ -110,6 +147,9 @@ public function log ($name, $variant, $selector) { * API for getting at the selections is Feature::selections which * should be the only caller of this method. */ + /** + * @return array + */ public function selections () { return $this->_selections; } diff --git a/Feature/World/Mobile.php b/src/World/Mobile.php similarity index 56% rename from Feature/World/Mobile.php rename to src/World/Mobile.php index 557f891..54d46ad 100644 --- a/Feature/World/Mobile.php +++ b/src/World/Mobile.php @@ -1,32 +1,69 @@ _udid = $udid; $this->_userID = $userID; } + /** + * @return Logger + */ public function uaid() { return $this->_udid; } + /** + * @return mixed + */ public function userID () { return $this->_userID; } + /** + * @param $name + * @param $variant + * @param $selector + */ public function log ($name, $variant, $selector) { parent::log($name, $variant, $selector); @@ -35,14 +72,23 @@ public function log ($name, $variant, $selector) { $this->_selector = $selector; } + /** + * @return mixed + */ public function getLastName() { return $this->_name; } + /** + * @return mixed + */ public function getLastVariant() { return $this->_variant; } + /** + * @return mixed + */ public function getLastSelector() { return $this->_selector; } @@ -52,5 +98,4 @@ public function clearLastFeature() { $this->_name = null; $this->_variant = null; } - } diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php new file mode 100644 index 0000000..e753f3b --- /dev/null +++ b/tests/ConfigTest.php @@ -0,0 +1,356 @@ +expectDisabled($c, array('uaid' => 0)); +// $this->expectDisabled($c, array('uaid' => 1)); +// } +// +// function testFullyEnabled() { +// $c = array('enabled' => 'on'); +// $this->expectEnabled($c, array('uaid' => '0')); +// $this->expectEnabled($c, array('uaid' => '1')); +// } +// +// function testSimpleDisabled () { +// $c = array('enabled' => 'off'); +// $this->expectDisabled($c, array('uaid' => '0')); +// $this->expectDisabled($c, array('uaid' => '1')); +// } +// +// function testVariantEnabled () { +// $c = array('enabled' => 'winner'); +// $this->expectEnabled($c, array('uaid' => '0'), 'winner'); +// $this->expectEnabled($c, array('uaid' => '1'), 'winner'); +// } +// +// function testFullyEnabledString() { +// $c = 'on'; +// $this->expectEnabled($c, array('uaid' => '0')); +// $this->expectEnabled($c, array('uaid' => '1')); +// } +// +// function testSimpleDisabledString () { +// $c = 'off'; +// $this->expectDisabled($c, array('uaid' => '0')); +// $this->expectDisabled($c, array('uaid' => '1')); +// } +// +// function testVariantEnabledString () { +// $c = 'winner'; +// $this->expectEnabled($c, array('uaid' => '0'), 'winner'); +// $this->expectEnabled($c, array('uaid' => '1'), 'winner'); +// } +// +// function testSimpleRampup () { +// $c = array('enabled' => '50'); +// $this->expectEnabled($c, array('uaid' => '0')); +// $this->expectEnabled($c, array('uaid' => '.1')); +// $this->expectEnabled($c, array('uaid' => '.4999')); +// $this->expectDisabled($c, array('uaid' => '.5')); +// $this->expectDisabled($c, array('uaid' => '.6')); +// $this->expectDisabled($c, array('uaid' => '.99')); +// $this->expectDisabled($c, array('uaid' => '1')); +// } +// +// function testMultivariant () { +// $c = array('enabled' => array('foo' => 2, 'bar' => 3)); +// $this->expectEnabled($c, array('uaid' => '0'), 'foo'); +// $this->expectEnabled($c, array('uaid' => '.01'), 'foo'); +// $this->expectEnabled($c, array('uaid' => '.01999'), 'foo'); +// $this->expectEnabled($c, array('uaid' => '.02'), 'bar'); +// $this->expectEnabled($c, array('uaid' => '.04999'), 'bar'); +// $this->expectDisabled($c, array('uaid' => '.05')); +// $this->expectDisabled($c, array('uaid' => '1')); +// } +// +// /* +// * Is feature disbaled by enabled => off despite every other +// * setting trying to turn it on? +// */ +// function testComplexDisabled () { +// $c = array( +// 'enabled' => 'off', +// 'users' => array('fred', 'sally'), +// 'groups' => array(1234, 2345), +// 'admin' => 'on', +// 'internal' => 'on', +// 'public_url_overrride' => true +// ); +// +// $this->expectDisabled($c, array('isInternal' => true, 'uaid' => '0')); +// $this->expectDisabled($c, array('userName' => 'fred', 'uaid' => '0')); +// $this->expectDisabled($c, array('inGroup' => array(0 => 1234), 'uaid' => '0')); +// $this->expectDisabled($c, array('uaid' => '0')); +// $this->expectDisabled($c, array('isAdmin' => true, 'uaid' => '0')); +// $this->expectDisabled($c, array('isInternal' => true, 'urlFeatures' => 'foo', 'uaid' => 0)); +// +// // Now all at once. +// $this->expectDisabled($c, array( +// 'isInternal' => true, +// 'userName' => 'fred', +// 'inGroup' => array(0 => 1234), +// 'uaid' => '100', +// 'isAdmin' => true, +// 'urlFeatures' => 'foo', +// 'userID' => '0')); +// } +// +// function testAdminOnly () { +// $c = array('enabled' => 0, 'admin' => 'on'); +// $this->expectEnabled($c, array('isAdmin' => true, 'uaid' => '0', 'userID' => '1')); +// $this->expectDisabled($c, array('isAdmin' => false, 'uaid' => '1', 'userID' => '1')); +// } +// +// function testAdminPlusSome () { +// $c = array('enabled' => 10, 'admin' => 'on'); +// $this->expectEnabled($c, array('isAdmin' => true, 'uaid' => '.5', 'userID' => '1')); +// $this->expectEnabled($c, array('isAdmin' => false, 'uaid' => '.05', 'userID' => '1')); +// $this->expectDisabled($c, array('isAdmin' => false, 'uaid' => '.5', 'userID' => '1')); +// } +// +// function testInternalOnly () { +// $c = array('enabled' => 0, 'internal' => 'on'); +// $this->expectEnabled($c, array('isInternal' => true, 'uaid' => '0')); +// $this->expectDisabled($c, array('isInternal' => false, 'uaid' => '1')); +// } +// +// function testInternalPlusSome () { +// $c = array('enabled' => 10, 'internal' => 'on'); +// $this->expectEnabled($c, array('isInternal' => true, 'uaid' => '.5')); +// $this->expectEnabled($c, array('isInternal' => false, 'uaid' => '.05')); +// $this->expectDisabled($c, array('isInternal' => false, 'uaid' => '.5')); +// } +// +// function testOneUser () { +// $c = array('enabled' => 0, 'users' => 'fred'); +// $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'fred', 'userID' => '1')); +// $this->expectDisabled($c, array('uaid' => '1', 'userName' => 'george', 'userID' => '1')); +// $this->expectDisabled($c, array('userID' => null, 'uaid' => 0)); +// } +// +// function testListOfOneUser () { +// $c = array('enabled' => 0, 'users' => array('fred')); +// $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'fred', 'userID' => '1')); +// $this->expectDisabled($c, array('uaid' => '1', 'userName' => 'george', 'userID' => '1')); +// } +// +// function testListOfUsers () { +// $c = array('enabled' => 0, 'users' => array('fred', 'ron')); +// $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'fred', 'userID' => '1')); +// $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'ron', 'userID' => '1')); +// $this->expectDisabled($c, array('uaid' => '1', 'userName' => 'george', 'userID' => '1')); +// } +// +// function testListOfUsersCaseInsensitive() { +// $c = array('enabled' => 0, 'users' => array('fred', 'FunGuy')); +// $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'fred', 'userID' => '1')); +// $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'FunGuy', 'userID' => '1')); +// $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'FUNGUY', 'userID' => '1')); +// $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'funguy', 'userID' => '1')); +// } +// +// function testArrayOfUsers () { +// // It might be kind of nice to allow 'enabled' => 0 here but +// // then we lose the ability to check that the variants +// // mentioned in a users clause are actually valid +// // variants. Which maybe is okay: perhaps we'd like to be able +// // to enable variants for users that are otherwise disabled. +// $c = array('enabled' => array('twins' => 0, 'other' => 0), +// 'users' => array( +// 'twins' => array('fred', 'george'), +// 'other' => 'ron')); +// $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'fred', 'userID' => '1'), 'twins'); +// $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'george', 'userID' => '2'), 'twins'); +// $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'ron', 'userID' => '3'), 'other'); +// $this->expectDisabled($c, array('uaid' => '0', 'userName' => 'percy', 'userID' => '4')); +// } +// +// function testOneGroup () { +// $c = array('enabled' => 0, 'groups' => 1234); +// $this->expectEnabled($c, array('uaid' => 1, 'userID' => 1, 'inGroup' => array(1 => array(1234)))); +// $this->expectDisabled($c, array('uaid' => 0, 'userID' => 2, 'inGroup' => array(2 => array(2345)))); +// $this->expectDisabled($c, array('uaid' => 0, 'userID' => null)); +// } +// +// function testListOfOneGroup () { +// $c = array('enabled' => 0, 'groups' => array(1234)); +// $this->expectEnabled($c, array('uaid' => 1, 'userID' => 1, 'inGroup' => array(1 => array(1234)))); +// $this->expectDisabled($c, array('uaid' => 0, 'userID' => 2, 'inGroup' => array(2 => array(2345)))); +// } +// +// function testListOfGroups () { +// $c = array('enabled' => 0, 'groups' => array(1234, 2345)); +// $this->expectEnabled($c, array('uaid' => 1, 'userID' => 1, 'inGroup' => array(1 => array(1234)))); +// $this->expectEnabled($c, array('uaid' => 1, 'userID' => 2, 'inGroup' => array(2 => array(2345)))); +// $this->expectDisabled($c, array('uaid' => 0, 'userID' => 3, 'inGroup' => array(3 => array()))); +// } +// function testArrayOfGroups () { +// // See comment at testArrayOfUsers; similar issue applies here. +// $c = array('enabled' => array('twins' => 0, 'other' => 0), +// 'groups' => array( +// 'twins' => array(1234, 2345), +// 'other' => 3456)); +// $this->expectEnabled($c, array('uaid' => 1, 'userID' => 1, 'inGroup' => array(1 => array(1234))), 'twins'); +// $this->expectEnabled($c, array('uaid' => 1, 'userID' => 2, 'inGroup' => array(2 => array(2345))), 'twins'); +// $this->expectEnabled($c, array('uaid' => 1, 'userID' => 3, 'inGroup' => array(3 => array(3456))), 'other'); +// $this->expectDisabled($c, array('uaid' => 0, 'userID' => 4, 'inGroup' => array(4 => array()))); +// } +// +// function testUrlOverride () { +// $c = array('enabled' => 0); +// $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => true, 'urlFeatures' => 'foo')); +// $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => true, 'urlFeatures' => 'foo:on')); +// $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => true, 'urlFeatures' => 'foo:bar'), 'bar'); +// $this->expectDisabled($c, array('uaid' => '1', 'isInternal' => false, 'urlFeatures' => 'foo')); +// $this->expectDisabled($c, array('uaid' => '1', 'isInternal' => false, 'urlFeatures' => 'foo:on')); +// $this->expectDisabled($c, array('uaid' => '1', 'isInternal' => false, 'urlFeatures' => 'foo:bar')); +// } +// +// function testPublicUrlOverride () { +// $c = array('enabled' => 0, 'public_url_override' => true); +// $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => true, 'urlFeatures' => 'foo')); +// $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => true, 'urlFeatures' => 'foo:on')); +// $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => true, 'urlFeatures' => 'foo:bar'), 'bar'); +// $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => false, 'urlFeatures' => 'foo')); +// $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => false, 'urlFeatures' => 'foo:on')); +// $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => false, 'urlFeatures' => 'foo:bar'), 'bar'); +// } +// +// function testBucketBy () { +// $c = array('enabled' => 2, 'bucketing' => 'user'); +// $this->expectEnabled($c, array('uaid' => 1, 'userID' => .01)); +// $this->expectDisabled($c, array('uaid' => 0, 'userID' => .03)); +// } +// +// function testUAIDFallback () { +// $c = array('enabled' => 2, 'bucketing' => 'user'); +// $this->expectEnabled($c, array('userID' => null, 'uaid' => .01)); +// $this->expectDisabled($c, array('userID' => null, 'uaid' => .03)); +// } +// +// /* +// * Ignore userID and uuaid in favor of random numbers for bucketing. +// */ +// function testRandom () { +// $c = array('enabled' => 3, 'bucketing' => 'random'); +// $this->expectEnabled($c, array('uaid' => 1, 'random' => .00)); +// $this->expectEnabled($c, array('uaid' => 1, 'random' => .01)); +// $this->expectEnabled($c, array('uaid' => 1, 'random' => .02)); +// $this->expectEnabled($c, array('uaid' => 1, 'random' => .02999)); +// $this->expectDisabled($c, array('uaid' => 0, 'random' => .03)); +// $this->expectDisabled($c, array('uaid' => 0, 'random' => .04)); +// $this->expectDisabled($c, array('uaid' => 0, 'random' => .99999)); +// } +// +// /* +// * Somewhat indirect test that we cache the value by id: even if +// * the config is set up to use a random bucket (i.e. indpendent of +// * the id) it should still return the same value for the same id +// * which we test by having the two 'random' values returned by the +// * test world be ones that would change the enabled status if they +// * were both used. +// */ +// function testRandomCached () { +// // Initially enabled +// $c = array('enabled' => 3, 'bucketing' => 'random'); +// $w = new Testing_Feature_MockWorld(array('uaid' => 1, 'random' => 0)); +// $config = new Config('foo', $c, $w); +// $this->assertTrue($config->isEnabled()); +// $w->nextRandomValue(.5); +// $this->assertTrue($config->isEnabled()); +// +// // Initially disabled +// $c = array('enabled' => 3, 'bucketing' => 'random'); +// $w = new Testing_Feature_MockWorld(array('uaid' => 1, 'random' => .5)); +// $config = new Config('foo', $c, $w); +// $this->assertFalse($config->isEnabled()); +// $w->nextRandomValue(0); +// $this->assertFalse($config->isEnabled()); +// } +// +// function testDescription () { +// // Default description. +// $c = array('enabled' => 'on'); +// $w = new Testing_Feature_MockWorld(array()); +// $config = new Config('foo', $c, $w); +// $this->assertNotNull($config->description()); +// +// // Provided description. +// $c = array('enabled' => 'on', 'description' => 'The description.'); +// $w = new Testing_Feature_MockWorld(array()); +// $config = new Config('foo', $c, $w); +// $this->assertEquals($config->description(), 'The description.'); +// } +// +// function testIsEnabledForAcceptsREST_User() { +// //we don't want to test the implementation of user bucketing here, just the public API +// $user_id = 1; +// $user = $this->getMock('REST_User'); +// $user->expects($this->once()) +// ->method('getUserId') +// ->will($this->returnValue($user_id)); +// $config = new Config('foo', array('enabled' => 'off'), new Testing_Feature_MockWorld(array())); +// $this->assertFalse($config->isEnabledFor($user)); +// } +// +// function testIsEnabledForAcceptsEtsyModel_User() { +// //we don't want to test the implementation of user bucketing here, just the public API +// $user = new EtsyModel_User(); +// $user->user_id = 1; +// $config = new Config('foo', array('enabled' => 'off'), new Testing_Feature_MockWorld(array())); +// $this->assertFalse($config->isEnabledFor($user)); +// } +// +// +// //////////////////////////////////////////////////////////////////////// +// // Test helper methods. +// +// /* +// * Given a config stanza and a world configuration, we expect that +// * isEnabled() will return true and that variant will be a given +// * value (default 'on'). +// */ +// private function expectEnabled ($stanza, $world, $variant = 'on') { +// $config = new Config('foo', $stanza, new Testing_Feature_MockWorld($world)); +// $this->assertTrue($config->isEnabled()); +// $this->assertEquals($config->variant(), $variant); +// +// if (is_array($stanza) && array_key_exists('enabled', $stanza) && $stanza['enabled'] === 0) { +// unset($stanza['enabled']); +// $this->expectEnabled($stanza, $world, $variant); +// } +// } +// +// /* +// * Given a config stanza and a world configuration, we expect that +// * isEnabled() will return false. +// */ +// private function expectDisabled ($stanza, $world) { +// $config = new Config('foo', $stanza, new Testing_Feature_MockWorld($world)); +// $this->assertFalse($config->isEnabled()); +// if (is_array($stanza) && array_key_exists('enabled', $stanza) && $stanza['enabled'] === 0) { +// unset($stanza['enabled']); +// $this->expectDisabled($stanza, $world); +// } +// } +} diff --git a/tests/LoggerTest.php b/tests/LoggerTest.php new file mode 100644 index 0000000..bd6f1f0 --- /dev/null +++ b/tests/LoggerTest.php @@ -0,0 +1,43 @@ +assertEquals('', Logger::getGAJavascript(array())); +// } +// +// function testLogOne() { +// $selections = array(); +// $selections[] = array('TEST_key1', 'TEST_var1', 123); +// $js = Logger::getGAJavascript($selections); +// $this->assertEquals("Etsy.GA.track(['_setCustomVar', 2, 'AB', 'TEST_key1.TEST_var1', 3]);", $js); +// } +// +// function testLogTwo() { +// $selections = array(); +// $selections[] = array('TEST_key1', 'TEST_var1', 123); +// $selections[] = array('foo', 'bar', 123); +// $js = Logger::getGAJavascript($selections); +// $this->assertEquals("Etsy.GA.track(['_setCustomVar', 2, 'AB', 'TEST_key1.TEST_var1..foo.bar', 3]);", $js); +// } +// +// function testTooLong() { +// $selections = array(); +// $pairs = array(); +// foreach (array('a', 'b', 'c', 'd', 'e') as $x) { +// $selections[] = array($x, 'xxxxxxxxxx', 123); +// $pairs[] = "$x.xxxxxxxxxx"; +// } +// // This one should not be included in the Javascript because +// // we already have 12*5=60 chars and this pair would add three +// // more pushing us over the limit of 62. +// $selections[] = array('f', 'x', 123); +// $value = implode('..', $pairs); +// $js = Logger::getGAJavascript($selections); +// $this->assertEquals("Etsy.GA.track(['_setCustomVar', 2, 'AB', '$value', 3]);", $js); +// } +} diff --git a/tests/WorldTest.php b/tests/WorldTest.php new file mode 100644 index 0000000..e65f64a --- /dev/null +++ b/tests/WorldTest.php @@ -0,0 +1,153 @@ +uaid = UAIDCookie::getSecureCookie(); +// $this->assertNotNull($this->uaid); +// +// $logger = $this->getMock('Logger', array('log')); +// $this->world = new World($logger); +// $this->user_id = 991; +// +// $this->setLoggedUserId(null); +// $this->assertNull(Std::loggedUser()); +// } +// +// function testIsAdminWithBlankUAIDCookie() { +// $this->setLoggedUserId($this->user_id); +// +// $this->assertFalse($this->world->isAdmin($this->user_id)); +// } +// +// function testIsAdminWithValidNonAdminUserUAIDCookie() { +// $this->setLoggedUserId($this->user_id); +// $this->uaid->set(UAIDCookie::USER_ID_ATTRIBUTE, $this->user_id); +// +// $this->assertFalse($this->world->isAdmin($this->user_id)); +// } +// +// function testIsAdminWithValidAdminUAIDCookie() { +// $this->setLoggedUserId($this->user_id); +// $this->uaid->set(UAIDCookie::USER_ID_ATTRIBUTE, $this->user_id); +// $this->uaid->set(UAIDCookie::ADMIN_ATTRIBUTE, '1'); +// +// $this->assertTrue($this->world->isAdmin($this->user_id)); +// } +// +// function testIsAdminWithNonLoggedInAdminAndValidAdminUAIDCookie() { +// $this->setLoggedUserId(null); +// $this->uaid->set(UAIDCookie::USER_ID_ATTRIBUTE, $this->user_id); +// $this->uaid->set(UAIDCookie::ADMIN_ATTRIBUTE, '1'); +// +// $this->assertFalse($this->world->isAdmin($this->user_id)); +// } +// +// function testIsAdminWithLoggedInAdminUserAndBlankUAIDCookie() { +// $user = $this->adminUser(); +// $this->setLoggedUserId($user->user_id); +// +// $this->assertTrue($this->world->isAdmin($user->user_id)); +// } +// +// function testIsAdminWithLoggedInNonAdminUserAndBlankUAIDCookie() { +// $user = $this->nonAdminUser(); +// $this->setLoggedUserId($user->user_id); +// +// $this->assertFalse($this->world->isAdmin($user->user_id)); +// } +// +// function testIsAdminWithNonLoggedInAdminUserAndBlankUAIDCookie() { +// $user = $this->adminUser(); +// $this->setLoggedUserId(null); +// +// $this->assertTrue($this->world->isAdmin($user->user_id)); +// } +// +// function testIsAdminWithNonLoggedInNonAdminUserAndBlankUAIDCookie() { +// $user = $this->nonAdminUser(); +// $this->setLoggedUserId(null); +// +// $this->assertFalse($this->world->isAdmin($user->user_id)); +// } +// +// function testAtlasWorld() { +// $user = $this->atlasUser(); +// $this->setLoggedUserId($user->id); +// $this->setAtlasRequest(true); +// +// $this->assertFalse($this->world->isAdmin($user->id)); +// $this->assertFalse($this->world->inGroup($user->id, 1)); +// $this->assertEquals($user->id, $this->world->userID()); +// +// $this->setAtlasRequest(false); +// } +// +// function testHash() { +// $this->assertInternalType('float', $this->world->hash('somevalue')); +// +// $this->assertEquals( +// $this->world->hash('somevalue'), +// $this->world->hash('somevalue'), +// 'ensure return value is consistent' +// ); +// +// $this->assertGreaterThanOrEqual(0, $this->world->hash('somevalue')); +// $this->assertLessThan(1, $this->world->hash('somevalue')); +// } +// +// protected function getDatabaseConfigs() { +// $index_yml = dirname(__FILE__) . '/data/world/etsy_index.yml'; +// if (!file_exists($index_yml)) { +// throw new Exception($index_yml . ' does not exist'); +// } +// $builder = new PHPUnit_Extensions_MultipleDatabase_DatabaseConfig_Builder(); +// $etsy_index = $builder +// ->connection(Testing_EtsyORM_Connections::ETSY_INDEX()) +// ->dataSet(new PHPUnit_Extensions_Database_DataSet_YamlDataSet($index_yml)) +// ->build(); +// +// $aux_yml = dirname(__FILE__) . '/data/world/etsy_aux.yml'; +// if (!file_exists($aux_yml)) { +// throw new Exception($aux_yml . ' does not exist'); +// } +// $builder = new PHPUnit_Extensions_MultipleDatabase_DatabaseConfig_Builder(); +// $etsy_aux = $builder +// ->connection(Testing_EtsyORM_Connections::ETSY_AUX()) +// ->dataSet(new PHPUnit_Extensions_Database_DataSet_YamlDataSet($aux_yml)) +// ->build(); +// +// return array($etsy_index, $etsy_aux); +// } +// +// private function nonAdminUser() { +// return EtsyORM::getFinder('User')->find(1); +// } +// +// private function adminUser() { +// return EtsyORM::getFinder('User')->find(2); +// } +// +// private function atlasUser() { +// return EtsyORM::getFinder('Staff')->find(3); +// } +// +// private function setAtlasRequest($is_atlas) { +// $_SERVER["atlas_request"] = $is_atlas ? 1 : 0; +// } +// +// private function setLoggedUserId($user_id) { +// //Std::loggedUser() uses this global +// $GLOBALS['cookie_user_id'] = $user_id; +// } +//} diff --git a/phpunit/Feature/data/world/etsy_aux.yml b/tests/data/world/etsy_aux.yml similarity index 100% rename from phpunit/Feature/data/world/etsy_aux.yml rename to tests/data/world/etsy_aux.yml diff --git a/phpunit/Feature/data/world/etsy_index.yml b/tests/data/world/etsy_index.yml similarity index 100% rename from phpunit/Feature/data/world/etsy_index.yml rename to tests/data/world/etsy_index.yml From ebfff748e89ad6c25600fc7198de18e7c05e6e90 Mon Sep 17 00:00:00 2001 From: Pablo Iglesias Date: Tue, 20 Sep 2016 15:54:17 -0400 Subject: [PATCH 02/92] #13792 Porting Feature Library to PSR-4 Composer package --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 677206c..d5e2cc8 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { - "name": "alfred-nutile-inc/feature-flags", - "description": "PSR-4 based Etsy library", + "name": "cafemedia/feature", + "description": "PSR-4 based Etsy Feature Flags library", "authors": [ { "name": "Pablo Iglesias", From 9926b647c8366fa54207249393b52bf9c112b049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Tue, 20 Sep 2016 16:06:11 -0400 Subject: [PATCH 03/92] Update .gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 9f33dd5..8ef0e14 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ vendor/ -.idea/ \ No newline at end of file +.idea/ From 4a7753ac125ff16dba277587eba1192ce4c25ef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Tue, 20 Sep 2016 16:06:26 -0400 Subject: [PATCH 04/92] Update .travis.yml --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index f91302b..9cb3a59 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,4 +9,4 @@ before_script: - composer self-update - composer install --no-interaction --dev -script: phpunit \ No newline at end of file +script: phpunit From 8cbd42ae8d37ff1e4c4317b5ff2ee4b9253bf82a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Tue, 20 Sep 2016 16:06:41 -0400 Subject: [PATCH 05/92] Update composer.json --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index d5e2cc8..1141b2b 100644 --- a/composer.json +++ b/composer.json @@ -18,4 +18,4 @@ "CafeMedia\\Feature\\": "src/" } } -} \ No newline at end of file +} From 8a74da5de0508bec9c912bfcce56eae5a04aaaa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Tue, 20 Sep 2016 16:06:56 -0400 Subject: [PATCH 06/92] Update phpunit.xml --- phpunit.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpunit.xml b/phpunit.xml index 88d6da8..22a831b 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -14,4 +14,4 @@ ./tests/ - \ No newline at end of file + From 2a66504dad438930a181cbe7b5c7b7d4247e3a1d Mon Sep 17 00:00:00 2001 From: Pablo Iglesias Date: Wed, 21 Sep 2016 22:02:01 -0400 Subject: [PATCH 07/92] Added PSR-3 Support --- GENERALIZING.md | 2 + LICENSE | 22 ++ composer.json | 5 +- composer.lock | 54 +++- src/Config.php | 575 ++++++++++++++++++++++++------------------- src/Feature.php | 175 ++++++++++--- src/Instance.php | 41 ++- src/JSON.php | 111 +++++---- src/Lint.php | 301 +++++++++++++--------- src/Logger.php | 36 ++- src/Util.php | 20 +- src/World.php | 207 ++++++++++++---- src/World/Mobile.php | 32 ++- 13 files changed, 1040 insertions(+), 541 deletions(-) create mode 100644 LICENSE diff --git a/GENERALIZING.md b/GENERALIZING.md index e1b9a7d..e381586 100644 --- a/GENERALIZING.md +++ b/GENERALIZING.md @@ -1,3 +1,5 @@ +This is an archived file left for reference. + # A theory about generalizing this code This code was written at Etsy to meet our specific needs and with a diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5e255be --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2010 Etsy + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/composer.json b/composer.json index d5e2cc8..37f8d7c 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,8 @@ } ], "require": { - "php": ">=5.3.3" + "php": ">=5.3.3", + "psr/log": "^1.0" }, "require-dev": { "phpunit/phpunit": "4.*" @@ -18,4 +19,4 @@ "CafeMedia\\Feature\\": "src/" } } -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index 481c180..8354055 100644 --- a/composer.lock +++ b/composer.lock @@ -4,9 +4,57 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "hash": "a8e03069ba826fe074addeec7db718eb", - "content-hash": "6dcf3bf47685fda61ea5d584f01e157c", - "packages": [], + "hash": "a859d6808a5414f76bd85219b3a6605d", + "content-hash": "887ab0ec3cbafd6612458a80a55b6aa1", + "packages": [ + { + "name": "psr/log", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "5277094ed527a1c4477177d102fe4c53551953e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/5277094ed527a1c4477177d102fe4c53551953e0", + "reference": "5277094ed527a1c4477177d102fe4c53551953e0", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "Psr/Log/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "time": "2016-09-19 16:02:08" + } + ], "packages-dev": [ { "name": "doctrine/instantiator", diff --git a/src/Config.php b/src/Config.php index f0f4937..0153b49 100644 --- a/src/Config.php +++ b/src/Config.php @@ -7,30 +7,44 @@ * as well as enabled for certain classes of users. These objects * should not be accessed directly but rather through the API provided * by Feature.php which is more convenient and provides some caching. - */ -/** + * * Class Config * @package CafeMedia\Feature */ -class Config { - +class Config +{ /* Keys used in a feature configuration. */ + const DESCRIPTION = 'description'; + const ENABLED = 'enabled'; + const USERS = 'users'; + const GROUPS = 'groups'; + + const UTM_SOURCES = 'utm_sources'; + const ADMIN = 'admin'; + const INTERNAL = 'internal'; + const PUBLIC_URL_OVERRIDE = 'public_url_override'; + const BUCKETING = 'bucketing'; /* Special values for enabled property. */ + const ON = 'on'; /* Feature is fully enabled. */ + const OFF = 'off'; /* Feature is fully disabled. */ /* Bucketing schemes. */ + const UAID = 'uaid'; + const USER = 'user'; + const RANDOM = 'random'; /** @@ -62,6 +76,10 @@ class Config { * @var array */ private $_groups; + /** + * @var array + */ + private $_utm_sources; /** * @var bool|mixed|null */ @@ -84,19 +102,26 @@ class Config { */ private $_percentages; - /* - * Construct a Config object from its config stanza. + /** + * @var Logger */ + private $logger; + /** + * Construct a Config object from its config stanza. + * * Config constructor. * @param $name * @param $stanza * @param World $world + * @param Logger $logger */ - public function __construct($name, $stanza, World $world) { + public function __construct($name, $stanza, World $world, Logger $logger) + { $this->_name = $name; $this->_cache = array(); $this->_world = $world; + $this->logger = $logger; // Special case to save some memory--if the value is just a // string that is the same as setting enabled to that variant @@ -105,7 +130,8 @@ public function __construct($name, $stanza, World $world) { // create when reading the config file. if (is_null($stanza)) { $stanza = array(self::ENABLED => self::OFF); - } elseif (is_string($stanza)) { + } + elseif (is_string($stanza)) { $stanza = array(self::ENABLED => $stanza); } @@ -114,6 +140,7 @@ public function __construct($name, $stanza, World $world) { $this->_enabled = $this->parseEnabled($stanza); $this->_users = $this->parseUsersOrGroups($stanza, self::USERS); $this->_groups = $this->parseUsersOrGroups($stanza, self::GROUPS); + $this->_utm_sources = $this->parseUsersOrGroups($stanza, self::UTM_SOURCES); $this->_adminVariant = $this->parseVariantName($stanza, self::ADMIN); $this->_internalVariant = $this->parseVariantName($stanza, self::INTERNAL); $this->_public_url_override = $this->parsePublicURLOverride($stanza); @@ -127,91 +154,85 @@ public function __construct($name, $stanza, World $world) { // Public API, though note that Feature.php is the only code that // should be using this class directly. - /* + /** * Is this feature enabled for the default id and the logged in * user, if any? - */ - /** + * * @return bool */ - public function isEnabled () { - $bucketingID = $this->bucketingID(); - $userID = $this->_world->userID(); - return $this->chooseVariant($bucketingID, $userID, false) !== self::OFF; + public function isEnabled () + { + return $this->chooseVariant($this->bucketingID(), $this->_world->userID(), false) !== self::OFF; } - /* + /** * What variant is enabled for the default id and the logged in * user, if any? - */ - /** + * * @return mixed|string */ - public function variant () { - $bucketingID = $this->bucketingID(); - $userID = $this->_world->userID(); - return $this->chooseVariant($bucketingID, $userID, true); + public function variant () + { + return $this->chooseVariant($this->bucketingID(), $this->_world->userID(), true); } - /* - * Is this feature enabled for the given user? - */ /** + * Is this feature enabled for the given user? + * * @param $user * @return bool */ - public function isEnabledFor ($user) { + public function isEnabledFor ($user) + { $userID = $this->getUserIdFrom($user); return $this->chooseVariant($userID, $userID, false) !== self::OFF; } - /* + /** * Is this feature enabled, bucketing on the given bucketing * ID? (Other methods of enabling a feature and specifying a * variant such as users, groups, and query parameters, will still * work.) - */ - /** + * * @param $bucketingID * @return bool */ - public function isEnabledBucketingBy ($bucketingID) { - $userID = $this->_world->userID(); - return $this->chooseVariant($bucketingID, $userID, false) !== self::OFF; + public function isEnabledBucketingBy ($bucketingID) + { + return $this->chooseVariant($bucketingID, $this->_world->userID(), false) !== self::OFF; } - /* - * What variant is enabled for the given user? - */ /** + * What variant is enabled for the given user? + * * @param $user * @return mixed|string */ - public function variantFor ($user) { + public function variantFor ($user) + { $userID = $this->getUserIdFrom($user); return $this->chooseVariant($userID, $userID, true); } - /* + /** * What variant is enabled, bucketing on the given bucketing ID, * if any? - */ - /** + * * @param $bucketingID * @return mixed|string */ - public function variantBucketingBy ($bucketingID) { - $userID = $this->_world->userID(); - return $this->chooseVariant($bucketingID, $userID, true); + public function variantBucketingBy ($bucketingID) + { + return $this->chooseVariant($bucketingID, $this->_world->userID(), true); } - /* - * Description of the feature. - */ /** + * Description of the feature. + * * @return mixed|null */ - public function description () { + public function description () + { return $this->_description; } @@ -219,22 +240,18 @@ public function description () { //////////////////////////////////////////////////////////////////////// // Internals - /* - * Accept different user objects and return user_id - */ /** + * Accept different user objects and return user_id + * * @param $user * @return mixed */ - private function getUserIdFrom($user) { - if ($user instanceof REST_User) { - // $user->user_id is protected so not accessible - return $user->getUserId(); - } + private function getUserIdFrom($user) + { return $user->user_id; } - /* + /** * Get the name of the variant we should use. Returns OFF if the * feature is not enabled for $id. When $inVariantMethod is * true will also check the conditions that should hold for a @@ -246,149 +263,186 @@ private function getUserIdFrom($user) { * since those two methods should always be guarded by an * isEnabled/isEnabledFor call. * - * @param $bucketingID the id used to assign a variant based on + * @param $bucketingID - the id used to assign a variant based on * the percentage of users that should see different variants. * - * @param $userID the identity of the user to be used for the + * @param $userID - the identity of the user to be used for the * special 'admin', 'users', and 'groups' access checks. * - * @param $inVariantMethod were we called from variant or + * @param $inVariantMethod - were we called from variant or * variantFor, in which case we want to perform some certain * sanity checks to make sure the code is being used correctly. - */ - /** - * @param $bucketingID - * @param $userID - * @param $inVariantMethod + * * @return array|int|mixed|null|string */ - private function chooseVariant ($bucketingID, $userID, $inVariantMethod) { + private function chooseVariant ($bucketingID, $userID, $inVariantMethod) + { if ($inVariantMethod && $this->_enabled === self::ON) { - $this->error("Variant check when fully enabled"); + $this->error('Variant check when fully enabled'); } if (is_string($this->_enabled)) { // When enabled is on, off, or a variant name, that's the // end of the story. return $this->_enabled; - } else { - if (is_null($bucketingID)) { - throw new \InvalidArgumentException( - "no bucketing ID supplied. if testing, configure feature " . - "with enabled => 'on' or 'off', feature name = " . - $this->_name - ); - } + } - $bucketingID = (string)$bucketingID; - if (array_key_exists($bucketingID, $this->_cache)) { - // Note that this caching is not just an optimization: - // it prevents us from double logging a single - // feature--we only want to log each distinct checked - // feature once. - // - // The caching also affects the semantics when we use - // random bucketing (rather than hashing the id), i.e. - // 'random' => 'true', by making the variant and - // enabled status stable within a request. - return $this->_cache[$bucketingID]; - } else { - list($v, $selector) = - $this->variantFromURL($userID) ?: - $this->variantForUser($userID) ?: - $this->variantForGroup($userID) ?: - $this->variantForAdmin($userID) ?: - $this->variantForInternal() ?: - $this->variantByPercentage($bucketingID) ?: - array(self::OFF, 'w'); - - if ($inVariantMethod && $v === self::OFF) { - $this->error("Variant check outside enabled check"); - } - - $this->_world->log($this->_name, $v, $selector); - - return $this->_cache[$bucketingID] = $v; - } + if (is_null($bucketingID)) { + throw new \InvalidArgumentException( + 'no bucketing ID supplied. if testing, configure feature ' . + "with enabled => 'on' or 'off', feature name = $this->_name" + ); + } + + $bucketingID = (string)$bucketingID; + if (isset($this->_cache[$bucketingID])) { + // Note that this caching is not just an optimization: + // it prevents us from double logging a single + // feature--we only want to log each distinct checked + // feature once. + // + // The caching also affects the semantics when we use + // random bucketing (rather than hashing the id), i.e. + // 'random' => 'true', by making the variant and + // enabled status stable within a request. + return $this->_cache[$bucketingID]; + } + + if ($_v = $this->variantFromURL($userID)) {} + elseif ($_v = $this->variantForUser($userID)) {} + elseif ($_v = $this->variantForViewingGroup($userID)) {} + elseif ($_v = $this->variantForUtmSource($userID)) {} + elseif ($_v = $this->variantForAdmin($userID)) {} + elseif ($_v = $this->variantByPercentage($bucketingID)) {} + else { + $_v = array(self::OFF, 'w'); + } + + list($v, $selector) = $_v; + + if ($inVariantMethod && $v === self::OFF) { + $this->error('Variant check outside enabled check'); } + $this->_world->log($this->_name, $v, $selector); + + return $this->_cache[$bucketingID] = $v; } - /* + /** * Return the globally accessible ID used by the one-arg isEnabled * and variant methods based on the feature's bucketing property. - */ - /** + * * @return null|string */ - private function bucketingID () { + private function bucketingID () + { switch ($this->_bucketing) { - case self::UAID: - case self::RANDOM: - // In the RANDOM case we still need a bucketing id to keep - // the assignment stable within a request. - // Note that when being run from outside of a web request (e.g. crons), - // there is no UAID, so we default to a static string - $uaid = $this->_world->uaid(); - return $uaid ? $uaid : "no uaid"; - case self::USER: - $userID = $this->_world->userID(); - // Not clear if this is right. There's an argument to be - // made that if we're bucketing by userID and the user is - // not logged in we should treat the feature as disabled. - return !is_null($userID) ? $userID : $this->_world->uaid(); - default: - throw new \InvalidArgumentException("Bad bucketing: $this->bucketing"); + case self::UAID: + case self::RANDOM: + // In the RANDOM case we still need a bucketing id to keep + // the assignment stable within a request. + // Note that when being run from outside of a web request (e.g. crons), + // there is no UAID, so we default to a static string + $uaid = $this->_world->uaid(); + return $uaid ? $uaid : 'no uaid'; + case self::USER: + $userID = $this->_world->userID(); + // Not clear if this is right. There's an argument to be + // made that if we're bucketing by userID and the user is + // not logged in we should treat the feature as disabled. + return !is_null($userID) ? $userID : $this->_world->uaid(); + default: + throw new \InvalidArgumentException("Bad bucketing: $this->_bucketing"); } } - /* + /** * For internal requests or if the feature has public_url_override * set to true, a specific variant can be specified in the * 'features' query parameter. In all other cases return false, * meaning nothing was specified. Note that foo:off will turn off * the 'foo' feature. - */ - /** + * * @param $userID - * @return array|bool + * @return array|bool|void */ - private function variantFromURL ($userID) { - if ($this->_public_url_override or - $this->_world->isInternalRequest() or - $this->_world->isAdmin($userID) - ) { - $urlFeatures = $this->_world->urlFeatures(); - if ($urlFeatures) { - foreach (explode(',', $urlFeatures) as $f) { - $parts = explode(':', $f); - if ($parts[0] === $this->_name) { - return array(isset($parts[1]) ? $parts[1] : self::ON, 'o'); - } - } + private function variantFromURL ($userID) + { + if (!$this->_public_url_override && !$this->_world->isInternalRequest() && !$this->_world->isAdmin($userID)) { + return false; + } + + $urlFeatures = $this->_world->urlFeatures(); + if (!$urlFeatures) { + return false; + } + + foreach (explode(',', $urlFeatures) as $f) { + $parts = explode(':', $f); + if ($parts[0] === $this->_name) { + return array(isset($parts[1]) ? $parts[1] : self::ON, 'o'); } } + return false; } - /* + /** * Get the variant this user should see, if one was configured, * false otherwise. + * + * @param $userID + * @return array|bool + */ + private function variantForUser ($userID) + { + if (!$this->_users) { + return false; + } + + $name = $this->_world->userName($userID); + if ($name && isset($this->_users[strtolower($name)])) { + return array($this->_users[strtolower($name)], 'u'); + } + + return false; + } + + /** + * Get the variant visitor should see based on group + * they're currently viewing + * + * @param $userID + * @return array|bool */ + private function variantForViewingGroup ($userID = null) + { + foreach ($this->_groups as $groupID => $variant) { + if ($this->_world->viewingGroup($groupID)) { + return array($variant, 'g'); + } + } + return false; + } + /** + * Get the variant visitor should see based on group + * they're currently viewing + * * @param $userID * @return array|bool */ - private function variantForUser ($userID) { - if ($this->_users) { - $name = $this->_world->userName($userID); - if ($name && array_key_exists($name, $this->_users)) { - return array($this->_users[$name], 'u'); + private function variantForUtmSource ($userID = null) + { + foreach ($this->_utm_sources as $utm_source => $variant) { + if ($this->_world->isSource($utm_source)) { + return array($variant, 's'); } } return false; } - /* + /** * Get the variant this user should see based on their group * memberships, if one was configured, false otherwise. N.B. If * the user is in multiple groups that are configured to see @@ -396,64 +450,63 @@ private function variantForUser ($userID) { * groups but there's no saying which one. If this is a problem in * practice we could make the configuration more complex. Or you * can just provide a specific variant via the 'users' property. - */ - /** + * * @param $userID * @return array|bool */ - private function variantForGroup ($userID) { - if ($userID) { - foreach ($this->_groups as $groupID => $variant) { - if ($this->_world->inGroup($userID, $groupID)) { - return array($variant, 'g'); - } + private function variantForGroup ($userID) + { + if (!$userID) { + return false; + } + + foreach ($this->_groups as $groupID => $variant) { + if ($this->_world->inGroup($userID, $groupID)) { + return array($variant, 'g'); } } + return false; } - /* + /** * What variant, if any, should we return if the current user is * an admin. - */ - /** + * * @param $userID * @return array|bool */ - private function variantForAdmin ($userID) { - if ($userID && $this->_adminVariant) { - if ($this->_world->isAdmin($userID)) { - return array($this->_adminVariant, 'a'); - } + private function variantForAdmin ($userID) + { + if ($userID && $this->_adminVariant && $this->_world->isAdmin($userID)) { + return array($this->_adminVariant, 'a'); } return false; } - /* - * What variant, if any, should we return for internal requests. - */ /** + * What variant, if any, should we return for internal requests. + * * @return array|bool */ - private function variantForInternal () { - if ($this->_internalVariant) { - if ($this->_world->isInternalRequest()) { - return array($this->_internalVariant, 'i'); - } + private function variantForInternal () + { + if ($this->_internalVariant && $this->_world->isInternalRequest()) { + return array($this->_internalVariant, 'i'); } return false; } - /* + /** * Finally, the normal case: use the percentage of users who * should see each variant to map a randomish number to a * particular variant. - */ - /** + * * @param $id * @return array|bool */ - private function variantByPercentage ($id) { + private function variantByPercentage ($id) + { $n = 100 * $this->randomish($id); foreach ($this->_percentages as $v) { // === 100 check may not be necessary but I'm not good @@ -465,17 +518,19 @@ private function variantByPercentage ($id) { return false; } - /* - * A randomish number in [0, 1) based on the feature name and $id - * unless we are bucketing completely at random. - */ /** + * A randomish number in [0, 1) based on the feature name and $id + * unless we are bucketing completely at random + * * @param $id * @return float|int */ - private function randomish ($id) { - return $this->_bucketing === self::RANDOM - ? $this->_world->random() : $this->_world->hash($this->_name . '-' . $id); + private function randomish ($id) + { + if ($this->_bucketing === self::RANDOM) { + return $this->_world->random(); + } + return $this->_world->hash("$this->_name-$id"); } //////////////////////////////////////////////////////////////////////// @@ -485,143 +540,148 @@ private function randomish ($id) { * @param $stanza * @return mixed|null */ - private function parseDescription ($stanza) { + private function parseDescription ($stanza) + { return Util::arrayGet($stanza, self::DESCRIPTION, 'No description.'); } - /* - * Parse the 'enabled' property of the feature's config stanza. - */ /** + * Parse the 'enabled' property of the feature's config stanza. + * * @param $stanza * @return array|int|mixed|null */ - private function parseEnabled ($stanza) { - + private function parseEnabled ($stanza) + { $enabled = Util::arrayGet($stanza, self::ENABLED, 0); if (is_numeric($enabled)) { if ($enabled < 0) { $this->error("enabled ($enabled) < 0"); $enabled = 0; - } elseif ($enabled > 100) { + } + elseif ($enabled > 100) { $this->error("enabled ($enabled) > 100"); $enabled = 100; } return array('on' => $enabled); - } elseif (is_string($enabled) or is_array($enabled)) { + } + if (is_string($enabled) or is_array($enabled)) { return $enabled; - } else { - $this->error("Malformed enabled property"); } + + $this->error('Malformed enabled property'); + return false; } - /* + /** * Returns an array of pairs with the first element of the pair * being the upper-boundary of the variants percentage and the * second element being the name of the variant. - */ - /** + * * @return array */ - private function computePercentages () { + private function computePercentages () + { $total = 0; $percentages = array(); - if (is_array($this->_enabled)) { - foreach ($this->_enabled as $variant => $percentage) { - if (!is_numeric($percentage) || $percentage < 0 || $percentage > 100) { - $this->error("Bad percentage $percentage"); - } - if ($percentage > 0) { - $total += $percentage; - $percentages[] = array($total, $variant); - } - if ($total > 100) { - $this->error("Total of percentages > 100: $total"); - } - } + if (!is_array($this->_enabled)) { + return $percentages; + } + + foreach ($this->_enabled as $variant => $percentage) { + if (!is_numeric($percentage) || $percentage < 0 || $percentage > 100) { + $this->error("Bad percentage $percentage"); + } + if ($percentage > 0) { + $total += $percentage; + $percentages[] = array($total, $variant); + } + if ($total > 100) { + $this->error("Total of percentages > 100: $total"); + } } + return $percentages; } - /* + /** * Parse the value of the 'users' and 'groups' properties of the * feature's config stanza, returning an array mappinng the user * or group names to they variant they should see. - */ - /** + * * @param $stanza * @param $what * @return array */ - private function parseUsersOrGroups ($stanza, $what) { + private function parseUsersOrGroups ($stanza, $what) + { $value = Util::arrayGet($stanza, $what); if (is_string($value) || is_numeric($value)) { // Users are configrued with their user names. Groups as // numeric ids. (Not sure if that's a great idea.) return array($value => self::ON); - } elseif (self::isList($value)) { - $result = array(); + } + + $result = array(); + + if (self::isList($value)) { foreach ($value as $who) { $result[strtolower($who)] = self::ON; } return $result; + } + + if (!is_array($value)) { + return $result; + } - } elseif (is_array($value)) { - $result = array(); - $bad_keys = is_array($this->_enabled) ? - array_keys(array_diff_key($value, $this->_enabled)) : - array(); - if (!$bad_keys) { - foreach ($value as $variant => $whos) { - foreach (self::asArray($whos) as $who) { - $result[strtolower($who)] = $variant; - } - } - return $result; - - } else { - $this->error("Unknown variants " . implode(', ', $bad_keys)); + $bad_keys = is_array($this->_enabled) ? array_keys(array_diff_key($value, $this->_enabled)) : false; + if ($bad_keys) { + $this->error("Unknown variants " . implode(', ', $bad_keys)); + return $result; + } + + foreach ($value as $variant => $whos) { + foreach (self::asArray($whos) as $who) { + $result[strtolower($who)] = $variant; } - } else { - return array(); } + + return $result; } - /* + /** * Parse the variant name value for the 'admin' and 'internal' * properties. If non-falsy, must be one of the keys in the * enabled map unless enabled is 'on' or 'off'. - */ - /** + * * @param $stanza * @param $what * @return bool|mixed|null */ - private function parseVariantName ($stanza, $what) { + private function parseVariantName ($stanza, $what) + { $value = Util::arrayGet($stanza, $what); - if ($value) { - if (is_array($this->_enabled)) { - if (array_key_exists($value, $this->_enabled)) { - return $value; - } else { - $this->error("Unknown variant $value"); - } - } else { - return $value; - } - } else { + if (!$value) { return false; } + + if (is_array($this->_enabled) && !isset($this->_enabled[$value])) { + $this->error("Unknown variant $value"); + } + + return $value; } /** * @param $stanza * @return mixed|null */ - private function parsePublicURLOverride ($stanza) { + private function parsePublicURLOverride ($stanza) + { return Util::arrayGet($stanza, self::PUBLIC_URL_OVERRIDE, false); } @@ -629,22 +689,23 @@ private function parsePublicURLOverride ($stanza) { * @param $stanza * @return mixed|null */ - private function parseBucketBy ($stanza) { + private function parseBucketBy ($stanza) + { return Util::arrayGet($stanza, self::BUCKETING, self::UAID); } //////////////////////////////////////////////////////////////////////// // Genericish utilities - /* + /** * Is the given object an array value that could have been created * with array(...) with no =>'s in the ...? - */ - /** + * * @param $a * @return bool */ - private static function isList($a) { + private static function isList($a) + { return is_array($a) and array_keys($a) === range(0, count($a) - 1); } @@ -652,14 +713,16 @@ private static function isList($a) { * @param $x * @return array */ - private static function asArray ($x) { + private static function asArray ($x) + { return is_array($x) ? $x : array($x); } /** * @param $message */ - private function error ($message) { - // IMPLEMENT FOR YOUR CONTEXT + private function error ($message) + { + $this->logger->error($message); } } diff --git a/src/Feature.php b/src/Feature.php index 5c0a3f8..994b7f4 100644 --- a/src/Feature.php +++ b/src/Feature.php @@ -2,6 +2,8 @@ namespace CafeMedia\Feature; +use Psr\Log\LoggerInterface; + /** * The public API testing whether a specific feature is enabled and, * if so, what variant should be used. @@ -28,13 +30,12 @@ * static methods, the getInstance() method returns a singleton object * that can be passed to templates and which provides the same API via * instance methods. - */ -/** + * * Class Feature * @package CafeMedia\Feature */ -class Feature { - +class Feature +{ /** * @var */ @@ -48,11 +49,89 @@ class Feature { */ private static $instance; + /** + * @var null + */ + private static $logger = null; + + /** + * @var LoggerInterface + */ + private static $log; + + /** + * @var array + */ + private static $features = array(); + /** + * @var string + */ + private static $uaid = ''; + /** + * @var string + */ + private static $userID = ''; + /** + * @var string + */ + private static $userName = ''; + /** + * @var null + */ + private static $group = null; + /** + * @var string + */ + private static $source = ''; + /** + * @var array + */ + private static $adminIds = array(); + /** + * @var string + */ + private static $url = ''; + + /** + * Feature constructor. + * @param LoggerInterface $log + * @param array $features + * @param string $uaid + * @param string $userID + * @param string $userName + * @param null $group + * @param string $source + * @param array $adminIds + * @param string $url + */ + public function __construct( + LoggerInterface $log, + array $features = array(), + $uaid = '', + $userID = '', + $userName = '', + $group = null, + $source = '', + array $adminIds = array(), + $url = '' + ) { + self::$log = $log; + $this->features = $features; + $this->uaid = $uaid; + $this->userID = $userID; + $this->userName = $userName; + $this->group = $group; + $this->source = $source; + $this->adminIds = $adminIds; + $this->url = $url; + } + /** * Get an object that can be passed to Smarty templates that wraps * our API with non-static methods of the same names and arguments. */ - public static function getInstance() { + public static function getInstance() + { if (!isset(self::$instance)) { self::$instance = new Instance(); } @@ -66,7 +145,8 @@ public static function getInstance() { * @param string $name the config key for this feature. * @return bool */ - public static function isEnabled ($name) { + public static function isEnabled ($name) + { return self::fromConfig($name)->isEnabled(); } @@ -79,12 +159,13 @@ public static function isEnabled ($name) { * @static * @param string $name the config key for this feature. * - * @param $user A user object whose id will be combined with $name + * @param $user - A user object whose id will be combined with $name * and hashed to get the bucketing. * * @return bool */ - public static function isEnabledFor($name, $user) { + public static function isEnabledFor($name, $user) + { return self::fromConfig($name)->isEnabledFor($user); } @@ -96,12 +177,13 @@ public static function isEnabledFor($name, $user) { * @static * @param string $name the config key for this feature. * - * @param $string A string which will be combined with $name + * @param $string - A string which will be combined with $name * and hashed to get the bucketing. * * @return bool */ - public static function isEnabledBucketingBy($name, $string) { + public static function isEnabledBucketingBy($name, $string) + { return self::fromConfig($name)->isEnabledBucketingBy($string); } @@ -121,7 +203,8 @@ public static function isEnabledBucketingBy($name, $string) { * @param string $name the config key for the feature. * @return mixed|string */ - public static function variant($name) { + public static function variant($name) + { return self::fromConfig($name)->variant(); } @@ -145,11 +228,12 @@ public static function variant($name) { * * @param string $name the config key for the feature. * - * @param $user A user object whose id will be combined with $name + * @param $user - A user object whose id will be combined with $name * and hashed to get the bucketing. * @return mixed|string */ - public static function variantFor($name, $user) { + public static function variantFor($name, $user) + { return self::fromConfig($name)->variantFor($user); } @@ -176,42 +260,47 @@ public static function variantFor($name, $user) { * @param string $bucketingID A string to use as the bucketing ID. * @return mixed|string */ - public static function variantBucketingBy($name, $bucketingID) { + public static function variantBucketingBy($name, $bucketingID) + { return self::fromConfig($name)->variantBucketingBy($bucketingID); } - /* - * Description of the feature. - */ /** + * Description of the feature. + * * @param $name * @return mixed|null */ - public static function description ($name) { + public static function description ($name) + { return self::fromConfig($name)->description(); } /** * Get data related to a Feature name: config must be nested * under the Feature name, in an array key named 'data'. + * * @param string $name the Feature key to find data for * @param mixed $default what to return if not defined * * @return mixed */ - public static function data($name, $default = array()) { + public static function data($name, $default = array()) + { return self::world()->configValue("$name.data", $default); } /** * Get data linked to a Feature name, specific for the enabled variant. * Nest data in an array named 'data' with a key for each variant. + * * @param string $name the Feature key to find data for * @param mixed $default what to return if not found * * @return mixed */ - public static function variantData($name, $default = array()) { + public static function variantData($name, $default = array()) + { $data = self::data($name); $variant = self::variant($name); return isset($data[$variant]) ? $data[$variant] : $default; @@ -223,18 +312,18 @@ public static function variantData($name, $default = array()) { * * @static * - * @param $name name of the feature. Used as a key into the global config array + * @param $name - name of the feature. Used as a key into the global config array * * @return Config */ - private static function fromConfig($name) { - if (array_key_exists($name, self::$configCache)) { + private static function fromConfig($name) + { + if (isset(self::$configCache[$name])) { return self::$configCache[$name]; - } else { - $world = self::world(); - $stanza = $world->configValue($name); - return self::$configCache[$name] = new Config($name, $stanza, $world); } + + $world = self::world(); + return self::$configCache[$name] = new Config($name, $world->configValue($name), $world, self::$logger); } /** @@ -243,7 +332,8 @@ private static function fromConfig($name) { * cached but in tests we need to change the configuration and * have those changes be reflected in feature checks.) */ - public static function clearCacheForTests() { + public static function clearCacheForTests() + { self::$configCache = array(); } @@ -254,7 +344,8 @@ public static function clearCacheForTests() { * to record information about what features were associated with * what variants and why during the course of handling a request. */ - public static function selections () { + public static function selections () + { return self::world()->selections(); } @@ -262,10 +353,32 @@ public static function selections () { * This API always uses the default World. Config takes * the world as an argument in order to ease unit testing. */ - private static function world () { + private static function world () + { if (!isset(self::$defaultWorld)) { - self::$defaultWorld = new World(new Logger()); + self::$defaultWorld = new World( + self::logger(), + static::$features, + static::$uaid, + static::$userID, + static::$userName, + static::$group, + static::$source, + static::$adminIds, + static::$url + ); } return self::$defaultWorld; } + + /** + * @return Logger|null + */ + private static function logger () + { + if (is_null(self::$logger)) { + self::$logger = new Logger(self::$log); + } + return self::$logger; + } } diff --git a/src/Instance.php b/src/Instance.php index 4ddb65d..d5a8340 100644 --- a/src/Instance.php +++ b/src/Instance.php @@ -6,19 +6,19 @@ * A thin wrapper around the static Feature API for use in * templates. The singleton instance of this class should be obtained * via Feature::getInstance(). - */ -/** + * * Class Instance * @package CafeMedia\Feature */ -class Instance { - +class Instance +{ /** * Wrapper for Feature::isEnabled($name). * @param $name * @return bool */ - public function isEnabled ($name) { + public function isEnabled ($name) + { return Feature::isEnabled($name); } @@ -28,7 +28,8 @@ public function isEnabled ($name) { * @param $user * @return bool */ - public function isEnabledFor($name, $user) { + public function isEnabledFor($name, $user) + { return Feature::isEnabledFor($name, $user); } @@ -38,7 +39,8 @@ public function isEnabledFor($name, $user) { * @param $string * @return bool */ - public function isEnabledBucketingBy($name, $string) { + public function isEnabledBucketingBy($name, $string) + { return Feature::isEnabledBucketingBy($name, $string); } @@ -47,7 +49,8 @@ public function isEnabledBucketingBy($name, $string) { * @param $name * @return mixed|string */ - public function variant($name) { + public function variant($name) + { return Feature::variant($name); } @@ -57,7 +60,8 @@ public function variant($name) { * @param $user * @return mixed|string */ - public function variantFor($name, $user) { + public function variantFor($name, $user) + { return Feature::variantFor($name, $user); } @@ -67,8 +71,25 @@ public function variantFor($name, $user) { * @param $bucketingID * @return mixed|string */ - public function variantBucketingBy($name, $bucketingID) { + public function variantBucketingBy($name, $bucketingID) + { return Feature::variantBucketingBy($name, $bucketingID); } + /** + * @param string $format + * @return string + */ + public function getGACustomVarJS($format = 'web') + { + $types = array( + 'web' => array('prepend' => '_gaq.push(', 'append' => ');'), + 'mobile' => array('prepend' => '', 'append' => ','), + ); + if (!isset($types[$format])) { + $format = 'web'; + } + + return "{$types[$format]['prepend']}['_setCustomVar', 3, 'AB', 'null', 3]{$types[$format]['append']}"; + } } diff --git a/src/JSON.php b/src/JSON.php index b725027..2ae7cb3 100644 --- a/src/JSON.php +++ b/src/JSON.php @@ -2,26 +2,25 @@ namespace CafeMedia\Feature; -/* - * Utility for turning configs into JSON-encodeable data. - */ /** + * Utility for turning configs into JSON-encodeable data. + * * Class JSON * @package CafeMedia\Feature */ -class JSON { - - /* +class JSON +{ + /** * Return the given config stanza as an array that can be json * encoded in a form that is slightly easier to deal with in * Javascript. - */ - /** + * * @param $key * @param null $server_config * @return array|bool */ - public static function stanza ($key, $server_config=null) { + public static function stanza ($key, $server_config = null) + { $stanza = self::findStanza($key, $server_config); return $stanza !== false ? self::translate($key, $stanza) : false; } @@ -31,16 +30,18 @@ public static function stanza ($key, $server_config=null) { * @param $cursor * @return bool|mixed */ - private static function findStanza($key, $cursor) { + private static function findStanza($key, $cursor) + { $step = strtok($key, '.'); while ($step) { - if (is_array($cursor) && array_key_exists($step, $cursor)) { - $cursor = $cursor[$step]; - } else { + if (!is_array($cursor) || !isset($cursor[$step])) { return false; } + + $cursor = $cursor[$step]; $step = strtok('.'); } + return $cursor; } @@ -49,15 +50,16 @@ private static function findStanza($key, $cursor) { * @param $value * @return array */ - private static function translate ($key, $value) { - + private static function translate ($key, $value) + { $spec = self::makeSpec($key); $internal_url = true; if (is_numeric($value)) { $value = array('enabled' => (int)$value); - } else if (is_string($value)) { + } + else if (is_string($value)) { $value = array('enabled' => $value); } @@ -68,12 +70,15 @@ private static function translate ($key, $value) { if ($enabled === 'off') { $spec['variants'][] = self::makeVariantWithUsersAndGroups('on', 0, $users, $groups); $internal_url = false; - } else if (is_numeric($enabled)) { + } + else if (is_numeric($enabled)) { $spec['variants'][] = self::makeVariantWithUsersAndGroups('on', (int)$enabled, $users, $groups); - } else if (is_string($enabled)) { + } + else if (is_string($enabled)) { $spec['variants'][] = self::makeVariantWithUsersAndGroups($enabled, 100, $users, $groups); $internal_url = false; - } else if (is_array($enabled)) { + } + else if (is_array($enabled)) { foreach ($enabled as $v => $p) { if (is_numeric($p)) { // Kind of a kludge. $p had better be numeric and @@ -86,19 +91,19 @@ private static function translate ($key, $value) { } $spec['internal_url_override'] = $internal_url; - if (array_key_exists('admin', $value)) { + if (isset($value['admin'])) { $spec['admin'] = $value['admin']; } - if (array_key_exists('internal', $value)) { + if (isset($value['internal'])) { $spec['internal'] = $value['internal']; } - if (array_key_exists('bucketing', $value)) { + if (isset($value['bucketing'])) { $spec['bucketing'] = $value['bucketing']; } - if (array_key_exists('internal', $value)) { + if (isset($value['internal'])) { $spec['internal'] = $value['internal']; } - if (array_key_exists('public_url_override', $value)) { + if (isset($value['public_url_override'])) { $spec['public_url_override'] = $value['public_url_override']; } @@ -109,7 +114,8 @@ private static function translate ($key, $value) { * @param $key * @return array */ - private static function makeSpec ($key) { + private static function makeSpec ($key) + { return array( 'key' => $key, 'internal_url_override' => false, @@ -117,7 +123,8 @@ private static function makeSpec ($key) { 'bucketing' => 'uaid', 'admin' => null, 'internal' => null, - 'variants' => array()); + 'variants' => array() + ); } /** @@ -125,12 +132,14 @@ private static function makeSpec ($key) { * @param $percentage * @return array */ - private static function makeVariant ($name, $percentage) { + private static function makeVariant ($name, $percentage) + { return array( 'name' => $name, 'percentage' => $percentage, 'users' => array(), - 'groups' => array()); + 'groups' => array() + ); } /** @@ -140,7 +149,8 @@ private static function makeVariant ($name, $percentage) { * @param $groups * @return array */ - private static function makeVariantWithUsersAndGroups ($name, $percentage, $users, $groups) { + private static function makeVariantWithUsersAndGroups ($name, $percentage, $users, $groups) + { return array( 'name' => $name, 'percentage' => $percentage, @@ -154,7 +164,8 @@ private static function makeVariantWithUsersAndGroups ($name, $percentage, $user * @param $name * @return array */ - private static function extractForVariant ($usersOrGroups, $name) { + private static function extractForVariant ($usersOrGroups, $name) + { $result = array(); foreach ($usersOrGroups as $thing => $variant) { if ($variant == $name) { @@ -164,43 +175,48 @@ private static function extractForVariant ($usersOrGroups, $name) { return $result; } - // This is based on parseUsersOrGroups in Config. Probably - // this logic should be put in that class in a form that we can - // use. /** + * This is based on parseUsersOrGroups in Config. Probably + * this logic should be put in that class in a form that we can + * use. + * * @param $value * @return array */ - private static function expandUsersOrGroups ($value) { + private static function expandUsersOrGroups ($value) + { if (is_string($value) || is_numeric($value)) { return array($value => Config::ON); + } - } elseif (self::isList($value)) { - $result = array(); + $result = array(); + + if (self::isList($value)) { foreach ($value as $who) { $result[$who] = Config::ON; } return $result; + } - } elseif (is_array($value)) { - $result = array(); - foreach ($value as $variant => $whos) { - foreach (self::asArray($whos) as $who) { - $result[$who] = $variant; - } - } + if (!is_array($value)) { return $result; + } - } else { - return array(); + foreach ($value as $variant => $whos) { + foreach (self::asArray($whos) as $who) { + $result[$who] = $variant; + } } + + return $result; } /** * @param $a * @return bool */ - private static function isList($a) { + private static function isList($a) + { return is_array($a) and array_keys($a) === range(0, count($a) - 1); } @@ -208,7 +224,8 @@ private static function isList($a) { * @param $x * @return array */ - private static function asArray ($x) { + private static function asArray ($x) + { return is_array($x) ? $x : array($x); } } diff --git a/src/Lint.php b/src/Lint.php index 58db13f..e4e7949 100644 --- a/src/Lint.php +++ b/src/Lint.php @@ -12,13 +12,12 @@ * Could possibly be extended to detect various violations of tidyness * such as having users and groups configured for a config with a * string 'enabled' or even 'enabled' => 100. - */ -/** + * * Class Lint * @package CafeMedia\Feature */ -class Lint { - +class Lint +{ /** * @var int */ @@ -32,10 +31,24 @@ class Lint { */ private $_path; + /** + * @var null + */ + private $server_config; + /** + * @var Logger + */ + private $logger; + /** * Lint constructor. + * @param null $server_config + * @param Logger $logger */ - public function __construct() { + public function __construct($server_config = null, Logger $logger) + { + $this->server_config = $server_config; + $this->logger = $logger; $this->_checked = 0; $this->_errors = array(); $this->_path = array(); @@ -47,36 +60,35 @@ public function __construct() { Config::INTERNAL, Config::PUBLIC_URL_OVERRIDE, Config::BUCKETING, - 'data', + 'data' ); - $this->_legal_bucketing_values = array( - Config::UAID, - Config::USER, - Config::RANDOM, - ); + $this->_legal_bucketing_values = array(Config::UAID, Config::USER, Config::RANDOM); } /** * @param null $file */ - public function run($file = null) { + public function run($file = null) + { $config = $this->fromFile($file); - $this->assert($config, "*** Bad configuration."); + $this->assert($config, '*** Bad configuration.'); $this->lintNested($config); } /** * @return int */ - public function checked() { + public function checked() + { return $this->_checked; } /** * @return array */ - public function errors() { + public function errors() + { return $this->_errors; } @@ -84,30 +96,32 @@ public function errors() { * @param $file * @return bool */ - private function fromFile($file) { - global $server_config; - $content = file_get_contents($file); + private function fromFile($file) + { error_reporting(0); - $r = eval('?>' . $content); + $r = eval('?>' . file_get_contents($file)); error_reporting(-1); + if ($r === null) { - return $server_config; - } else if ($r === false) { - return false; - } else { - //Logger::error("Wut? $r"); + return $this->server_config; + } + + if ($r === false) { return false; } + + $this->logger->error("Wut? $r"); + return false; } - /* + /** * Recursively check nested feature configurations. Skips any keys * that have a syntactic meaning which includes 'data'. - */ - /** + * * @param $config */ - private function lintNested($config) { + private function lintNested($config) + { foreach ($config as $name => $stanza) { if (!in_array($name, $this->syntax_keys)) { $this->lint($name, $stanza); @@ -119,9 +133,11 @@ private function lintNested($config) { * @param $name * @param $stanza */ - private function lint($name, $stanza) { - array_push($this->_path, $name); - $this->_checked += 1; + private function lint($name, $stanza) + { + $this->_path[] = $name; + ++$this->_checked; + if (is_array($stanza)) { $this->checkForOldstyle($stanza); $this->checkEnabled($stanza); @@ -132,9 +148,11 @@ private function lint($name, $stanza) { $this->checkPublicURLOverride($stanza); $this->checkBucketing($stanza); $this->lintNested($stanza); - } else { + } + else { $this->assert(is_string($stanza), "Bad stanza: $stanza."); } + array_pop($this->_path); } @@ -142,106 +160,135 @@ private function lint($name, $stanza) { * @param $ok * @param $message */ - private function assert($ok, $message) { + private function assert($ok, $message) + { if (!$ok) { - $loc = "[" . implode('.', $this->_path) . "]"; - array_push($this->_errors, "$loc $message"); + $this->_errors[] = '[' . implode('.', $this->_path) . "] $message"; } } /** * @param $stanza */ - private function checkForOldstyle($stanza) { - $enabled = Util::arrayGet($stanza, Config::ENABLED, 0); - $rampup = Util::arrayGet($stanza, 'rampup', null); - $this->assert($enabled !== 'rampup' || !$rampup, "Old-style config syntax detected."); + private function checkForOldstyle($stanza) + { + $this->assert(Util::arrayGet( + $stanza, + Config::ENABLED, 0) !== 'rampup' || !Util::arrayGet($stanza, 'rampup', null), + 'Old-style config syntax detected.' + ); } - // 'enabled' must be a string, a number in [0,100], or an array of - // (string => ints) such that the ints are all in [0,100] and the - // total is <= 100. /** + * 'enabled' must be a string, a number in [0,100], or an array of + * (string => ints) such that the ints are all in [0,100] and the + * total is <= 100. + * * @param $stanza */ - private function checkEnabled($stanza) { - if (array_key_exists(Config::ENABLED, $stanza)) { - $enabled = $stanza[Config::ENABLED]; - if (is_numeric($enabled)) { - $this->assert($enabled >= 0, Config::ENABLED . " too small: $enabled"); - $this->assert($enabled <= 100, Config::ENABLED . "too big: $enabled"); - } else if (is_array($enabled)) { - $tot = 0; - foreach ($enabled as $k => $v) { - $this->assert(is_string($k), "Bad key $k in $enabled"); - $this->assert(is_numeric($v), "Bad value $v for $k in $enabled"); - $this->assert($v >= 0, "Bad value $v (too small) for $k"); - $this->assert($v <= 100, "Bad value $v (too big) for $k"); - if (is_numeric($v)) { - $tot += $v; - } - } - $this->assert($tot >= 0, "Bad total $tot (too small)"); - $this->assert($tot <= 100, "Bad total $tot (too big)"); + private function checkEnabled($stanza) + { + if (!isset($stanza[Config::ENABLED])) { + return; + } + + if (is_numeric($stanza[Config::ENABLED])) { + $this->assert($stanza[Config::ENABLED] >= 0, Config::ENABLED . " too small: {$stanza[Config::ENABLED]}"); + $this->assert($stanza[Config::ENABLED] <= 100, Config::ENABLED . "too big: {$stanza[Config::ENABLED]}"); + return; + } + + if (!is_array($stanza[Config::ENABLED])) { + return; + } + + $tot = 0; + foreach ($stanza[Config::ENABLED] as $k => $v) { + $this->assert(is_string($k), "Bad key $k in {$stanza[Config::ENABLED]}"); + $this->assert(is_numeric($v), "Bad value $v for $k in {$stanza[Config::ENABLED]}"); + $this->assert($v >= 0, "Bad value $v (too small) for $k"); + $this->assert($v <= 100, "Bad value $v (too big) for $k"); + if (is_numeric($v)) { + $tot += $v; } } + $this->assert($tot >= 0, "Bad total $tot (too small)"); + $this->assert($tot <= 100, "Bad total $tot (too big)"); } /** * @param $stanza */ - private function checkUsers($stanza) { - if (array_key_exists(Config::USERS, $stanza)) { - $users = $stanza[Config::USERS]; - if (is_array($users) && !self::isList($users)) { - foreach ($users as $variant => $value) { - $this->assert(is_string($variant), "User variant names must be strings."); - $this->checkUserValue($value); - } - } else { - $this->checkUserValue($users); - } + private function checkUsers($stanza) + { + if (!isset($stanza[Config::USERS])) { + return; + } + + if (!is_array($stanza[Config::USERS]) || self::isList($stanza[Config::USERS])) { + $this->checkUserValue($stanza[Config::USERS]); + return; + } + + foreach ($stanza[Config::USERS] as $variant => $value) { + $this->assert(is_string($variant), 'User variant names must be strings.'); + $this->checkUserValue($value); } } /** * @param $users */ - private function checkUserValue($users) { - $this->assert(is_string($users) || self::isList($users), Config::USERS . " must be string or list of strings: '$users'"); - if (self::isList($users)) { - foreach ($users as $user) { - $this->assert(is_string($user), Config::USERS . " elements must be strings: '$user'"); - } + private function checkUserValue($users) + { + $this->assert( + is_string($users) || self::isList($users), + Config::USERS . " must be string or list of strings: '$users'" + ); + if (!self::isList($users)) { + return; + } + + foreach ($users as $user) { + $this->assert(is_string($user), Config::USERS . " elements must be strings: '$user'"); } } /** * @param $stanza */ - private function checkGroups($stanza) { - if (array_key_exists(Config::GROUPS, $stanza)) { - $groups = $stanza[Config::GROUPS]; - if (is_array($groups) && !self::isList($groups)) { - foreach ($groups as $variant => $value) { - $this->assert(is_string($variant), "Group variant names must be strings."); - $this->checkGroupValue($value); - } - } else { - $this->checkGroupValue($groups); - } + private function checkGroups($stanza) + { + if (!isset($stanza[Config::GROUPS])) { + return; + } + + if (!is_array($stanza[Config::GROUPS]) || self::isList($stanza[Config::GROUPS])) { + $this->checkGroupValue($stanza[Config::GROUPS]); + return; + } + + foreach ($stanza[Config::GROUPS] as $variant => $value) { + $this->assert(is_string($variant), 'Group variant names must be strings.'); + $this->checkGroupValue($value); } } /** * @param $groups */ - private function checkGroupValue($groups) { - $this->assert(is_numeric($groups) || self::isList($groups), Config::GROUPS . " must be number or list of numbers"); - if (self::isList($groups)) { - foreach ($groups as $group) { - $this->assert(is_numeric($group), Config::GROUPS . " elements must be numbers: '$group'"); - } + private function checkGroupValue($groups) + { + $this->assert( + is_numeric($groups) || self::isList($groups), + Config::GROUPS . ' must be number or list of numbers' + ); + if (!self::isList($groups)) { + return; + } + + foreach ($groups as $group) { + $this->assert(is_numeric($group), Config::GROUPS . " elements must be numbers: '$group'"); } } @@ -249,52 +296,74 @@ private function checkGroupValue($groups) { /** * @param $stanza */ - private function checkAdmin($stanza) { - if (array_key_exists(Config::ADMIN, $stanza)) { - $admin = $stanza[Config::ADMIN]; - $this->assert(is_string($admin), "Admin must be string naming variant: '$admin'"); + private function checkAdmin($stanza) + { + if (isset($stanza[Config::ADMIN])) { + $this->assert( + is_string($stanza[Config::ADMIN]), + "Admin must be string naming variant: '{$stanza[Config::ADMIN]}'" + ); } } /** * @param $stanza */ - private function checkInternal($stanza) { - if (array_key_exists(Config::INTERNAL, $stanza)) { - $internal = $stanza[Config::INTERNAL]; - $this->assert(is_string($internal), "Internal must be string naming variant: '$internal'"); + private function checkInternal($stanza) + { + if (isset($stanza[Config::INTERNAL])) { + $this->assert( + is_string($stanza[Config::INTERNAL]), + "Internal must be string naming variant: '{$stanza[Config::INTERNAL]}'" + ); } } /** * @param $stanza */ - private function checkPublicURLOverride($stanza) { - if (array_key_exists(Config::PUBLIC_URL_OVERRIDE, $stanza)) { - $public_url_override = $stanza[Config::PUBLIC_URL_OVERRIDE]; - $this->assert(is_bool($public_url_override), "public_url_override must be a boolean: '$public_url_override'"); - if (is_bool($public_url_override)) { - $this->assert($public_url_override === true, "Gratuitous public_url_override (defaults to false)"); - } + private function checkPublicURLOverride($stanza) + { + if (!isset($stanza[Config::PUBLIC_URL_OVERRIDE])) { + return; + } + + $this->assert( + is_bool($stanza[Config::PUBLIC_URL_OVERRIDE]), + "public_url_override must be a boolean: '{$stanza[Config::PUBLIC_URL_OVERRIDE]}'" + ); + if (is_bool($stanza[Config::PUBLIC_URL_OVERRIDE])) { + $this->assert( + $stanza[Config::PUBLIC_URL_OVERRIDE] === true, + 'Gratuitous public_url_override (defaults to false)' + ); } } /** * @param $stanza */ - private function checkBucketing($stanza) { - if (array_key_exists(Config::BUCKETING, $stanza)) { - $bucketing = $stanza[Config::BUCKETING]; - $this->assert(is_string($bucketing), "Non-string bucketing: '$bucketing'"); - $this->assert(in_array($bucketing, $this->_legal_bucketing_values), "Illegal bucketing: '$bucketing'"); + private function checkBucketing($stanza) + { + if (!isset($stanza[Config::BUCKETING])) { + return; } + $this->assert( + is_string($stanza[Config::BUCKETING]), + "Non-string bucketing: '{$stanza[Config::BUCKETING]}'" + ); + $this->assert( + in_array($stanza[Config::BUCKETING], $this->_legal_bucketing_values), + "Illegal bucketing: '{$stanza[Config::BUCKETING]}'" + ); } /** * @param $a * @return bool */ - private static function isList($a) { + private static function isList($a) + { return is_array($a) and array_keys($a) === range(0, count($a) - 1); } } diff --git a/src/Logger.php b/src/Logger.php index 869c3a3..45f4cc7 100644 --- a/src/Logger.php +++ b/src/Logger.php @@ -2,25 +2,47 @@ namespace CafeMedia\Feature; +use Psr\Log\LoggerInterface; + /** * Logging -- for each feature that is checked we can log that it was * checked, what variant was choosen, and why. - */ -/** + * * Class Logger * @package CafeMedia\Feature */ -class Logger { +class Logger +{ + private $logger; + + /** + * Logger constructor. + * @param LoggerInterface $logger + */ + public function __construct(LoggerInterface $logger) + { + $this->logger = $logger; + } - /* + /** * Log that the feature $name was checked with $variant selected * by $selector. This is only called once per feature/bucketing id * per request. - */ - /** + * * @param $name * @param $variant * @param $selector */ - public function log ($name, $variant, $selector) {} + public function log ($name, $variant, $selector = '') + { + $this->logger->info("AB: $name=$variant selector:$selector"); + } + + /** + * @param $message + */ + public function error($message) + { + $this->logger->error($message); + } } diff --git a/src/Util.php b/src/Util.php index abfd418..2ca37dc 100644 --- a/src/Util.php +++ b/src/Util.php @@ -4,25 +4,23 @@ /** * Utility functions. - */ -/** + * * Class Util * @package CafeMedia\Feature */ -class Util { - - /* +class Util +{ + /** * Get the value from an array if it is in fact an array and * contain the key, a default value otherwise. - */ - /** + * * @param $array * @param $key * @param null $default * @return mixed|null */ - public static function arrayGet($array, $key, $default = null) { - return is_array($array) && array_key_exists($key, $array) ? $array[$key] : $default; + public static function arrayGet($array, $key, $default = null) + { + return is_array($array) && isset($array[$key]) ? $array[$key] : $default; } - -} \ No newline at end of file +} diff --git a/src/World.php b/src/World.php index cc32cec..b114748 100644 --- a/src/World.php +++ b/src/World.php @@ -9,13 +9,12 @@ * should just be moved into this class since there's a fair bit of * passing stuff back and forth between here and Logger and Logger has * no useful independent existence. - */ -/** + * * Class World * @package CafeMedia\Feature */ -class World { - +class World +{ /** * @var Logger */ @@ -25,38 +24,112 @@ class World { */ private $_selections = array(); + /** + * @var array + */ + private $features; + + /** + * @var + */ + private $uaid; + + /** + * @var + */ + private $userID; + + /** + * @var + */ + private $userName = ''; + + /** + * @var + */ + private $group; + + /** + * @var + */ + private $source; + + /** + * @var array + */ + private $adminIds; + + /** + * @var string + */ + private $url = ''; + /** * World constructor. * @param Logger $logger + * @param array $features + * @param string $uaid + * @param string $userID + * @param string $userName + * @param null $group + * @param string $source + * @param array $adminIds + * @param string $url */ - public function __construct (Logger $logger) { + public function __construct ( + Logger $logger, + array $features = array(), + $uaid = '', + $userID = '', + $userName = '', + $group = null, + $source = '', + array $adminIds = array(), + $url = '' + ) { $this->_logger = $logger; + $this->features = $features; + $this->uaid = $uaid; + $this->userID = $userID; + $this->userName = $userName; + $this->group = $group; + $this->source = $source; + $this->adminIds = $adminIds; + $this->url = $url; } - /* - * Get the config value for the given key. - */ /** + * Get the config value for the given key. + * * @param $name * @param null $default * @return null */ - public function configValue($name, $default = null) { - return $default; // IMPLEMENT FOR YOUR CONTEXT + public function configValue($name, $default = null) + { + //return $default; // IMPLEMENT FOR YOUR CONTEXT + if (isset($this->features[$name])) { + return $this->features[$name]; + } + return $default; } /** * UAID of the current request. */ - public function uaid() { - return null; // IMPLEMENT FOR YOUR CONTEXT + public function uaid() + { + //return null; // IMPLEMENT FOR YOUR CONTEXT + return $this->uaid; } /** * User ID of the currently logged in user or null. */ - public function userID () { - return null; // IMPLEMENT FOR YOUR CONTEXT + public function userID () + { + //return null; // IMPLEMENT FOR YOUR CONTEXT + return $this->userID; } /** @@ -64,8 +137,31 @@ public function userID () { * ORM. If we're running as part of an Atlas request we ignore the * passed in userID and return instead the Atlas user name. */ - public function userName ($userID) { - return null; // IMPLEMENT FOR YOUR CONTEXT + public function userName () + { + //return null; // IMPLEMENT FOR YOUR CONTEXT + return $this->userName; + } + + /** + * Is the vistor in a specific group? + * @param $groupID + * @return bool + */ + public function viewingGroup($groupID) + { + return is_object($this->group) && method_exists($this->group, 'getId') && $this->group->getId() == $groupID; + } + + /** + * Is the vistor from a particular source? + * + * @param $source + * @return bool + */ + public function isSource($source) + { + return $this->source == $source; } /** @@ -74,83 +170,97 @@ public function userName ($userID) { * config file, in order to speed up the lookup--the numeric ID is * the primary key and we save having to look up the group by * name.) + * @param null $userID + * @param null $groupID + * @return bool */ - public function inGroup ($userID, $groupID) { - return null; // IMPLEMENT FOR YOUR CONTEXT + public function inGroup ($userID = null, $groupID = null) + { + //return null; // IMPLEMENT FOR YOUR CONTEXT + if (is_object($this->group) && method_exists($this->group, 'isMember')) { + return $this->group->isMember(); + } + + return false; } /** * Is the current user an admin? * - * @param $userID the id of the relevant user, either the + * @param $userID - the id of the relevant user, either the * currently logged in user or some other user. * @return bool */ - public function isAdmin ($userID) { - return false; // IMPLEMENT FOR YOUR CONTEXT + public function isAdmin ($userID) + { + //return false; // IMPLEMENT FOR YOUR CONTEXT + + return in_array($userID, $this->adminIds); } /** * Is this an internal request? */ - public function isInternalRequest () { + public function isInternalRequest () + { return false; // IMPLEMENT FOR YOUR CONTEXT + // TODO: list local ips } - /* - * 'features' query param for url overrides. - */ /** + * 'features' query param for url overrides. + * * @return string */ - public function urlFeatures () { - return array_key_exists('features', $_GET) ? $_GET['features'] : ''; + public function urlFeatures () + { + return !empty($this->url) ? $this->url : ''; } - /* - * Produce a random number in [0, 1) for RANDOM bucketing. - */ /** + * Produce a random number in [0, 1) for RANDOM bucketing. + * * @return float|int */ - public function random () { + public function random () + { return mt_rand(0, mt_getrandmax() - 1) / mt_getrandmax(); } - /* - * Produce a randomish number in [0, 1) based on the given id. - */ /** + * Produce a randomish number in [0, 1) based on the given id. + * * @param $id * @return float */ - public function hash ($id) { + public function hash ($id) + { return self::mapHex(hash('sha256', $id)); } - /* + /** * Record that $variant has been selected for feature named $name * by $selector and pass the same information along to the logger. - */ - /** + * * @param $name * @param $variant * @param $selector */ - public function log ($name, $variant, $selector) { + public function log ($name, $variant, $selector) + { $this->_selections[] = array($name, $variant, $selector); $this->_logger->log($name, $variant, $selector); } - /* + /** * Get the list of selections that we have recorded. The public * API for getting at the selections is Feature::selections which * should be the only caller of this method. - */ - /** + * * @return array */ - public function selections () { + public function selections () + { return $this->_selections; } @@ -161,15 +271,16 @@ public function selections () { * @param string $hex a hex string * @return float */ - private static function mapHex($hex) { - $len = min(40, strlen($hex)); + private static function mapHex($hex) + { + $len = min(30, strlen($hex)); $vMax = 1 << $len; $v = 0; - for ($i = 0; $i < $len; $i++) { + for ($i = 0; $i < $len; ++$i) { $bit = hexdec($hex[$i]) < 8 ? 0 : 1; $v = ($v << 1) + $bit; } - $w = $v / $vMax; - return $w; + + return $v / $vMax; } } diff --git a/src/World/Mobile.php b/src/World/Mobile.php index 54d46ad..d0382fd 100644 --- a/src/World/Mobile.php +++ b/src/World/Mobile.php @@ -10,7 +10,8 @@ * feature rampups can maintain consistency on mobile devices. */ -class Mobile extends World { +class Mobile extends World +{ /** * @var */ @@ -39,7 +40,8 @@ class Mobile extends World { * @param $userID * @param Logger $logger */ - public function __construct ($udid, $userID, Logger $logger) { + public function __construct ($udid, $userID, Logger $logger) + { parent::__construct($logger); $this->_udid = $udid; $this->_userID = $userID; @@ -48,14 +50,18 @@ public function __construct ($udid, $userID, Logger $logger) { /** * @return Logger */ - public function uaid() { + public function uaid() + { + parent::uaid(); return $this->_udid; } /** * @return mixed */ - public function userID () { + public function userID () + { + parent::userID(); return $this->_userID; } @@ -63,9 +69,11 @@ public function userID () { * @param $name * @param $variant * @param $selector + * @param null $do_tracking */ - public function log ($name, $variant, $selector) { - parent::log($name, $variant, $selector); + public function log ($name, $variant, $selector, $do_tracking = null) + { + parent::log($name, $variant, $selector, $do_tracking); $this->_name = $name; $this->_variant = $variant; @@ -75,25 +83,29 @@ public function log ($name, $variant, $selector) { /** * @return mixed */ - public function getLastName() { + public function getLastName() + { return $this->_name; } /** * @return mixed */ - public function getLastVariant() { + public function getLastVariant() + { return $this->_variant; } /** * @return mixed */ - public function getLastSelector() { + public function getLastSelector() + { return $this->_selector; } - public function clearLastFeature() { + public function clearLastFeature() + { $this->_selector = null; $this->_name = null; $this->_variant = null; From e22e787f750d7cdd755388f0cf5d680f4e32a04d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Thu, 22 Sep 2016 13:46:06 -0400 Subject: [PATCH 08/92] Change IsAdmin logic remove Logic from isAdmin and accept Boolean from constructor instead --- src/World.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/World.php b/src/World.php index b114748..28dd551 100644 --- a/src/World.php +++ b/src/World.php @@ -55,9 +55,9 @@ class World private $source; /** - * @var array + * @var bool */ - private $adminIds; + private $isAdmin; /** * @var string @@ -94,7 +94,7 @@ public function __construct ( $this->userName = $userName; $this->group = $group; $this->source = $source; - $this->adminIds = $adminIds; + $this->isAdmin = $isAdmin; $this->url = $url; } @@ -191,11 +191,10 @@ public function inGroup ($userID = null, $groupID = null) * currently logged in user or some other user. * @return bool */ - public function isAdmin ($userID) + public function isAdmin ($userID = null) { //return false; // IMPLEMENT FOR YOUR CONTEXT - - return in_array($userID, $this->adminIds); + return $this->isAdmin; } /** From 06f9bff8d03793208d1e1ec00529b92e1ef6b57b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Thu, 22 Sep 2016 14:56:09 -0400 Subject: [PATCH 09/92] append feature to config errors log bad feature along with message --- src/Config.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Config.php b/src/Config.php index 0153b49..ca86c51 100644 --- a/src/Config.php +++ b/src/Config.php @@ -723,6 +723,6 @@ private static function asArray ($x) */ private function error ($message) { - $this->logger->error($message); + $this->logger->error("$message: feature $this->_name"); } } From 4224bb66ec23853242d76d68f6fa4619b5ad23b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Thu, 22 Sep 2016 15:17:29 -0400 Subject: [PATCH 10/92] remove unused parameter logger does not execute tracking logic --- src/World/Mobile.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/World/Mobile.php b/src/World/Mobile.php index d0382fd..0de4545 100644 --- a/src/World/Mobile.php +++ b/src/World/Mobile.php @@ -71,9 +71,9 @@ public function userID () * @param $selector * @param null $do_tracking */ - public function log ($name, $variant, $selector, $do_tracking = null) + public function log ($name, $variant, $selector) { - parent::log($name, $variant, $selector, $do_tracking); + parent::log($name, $variant, $selector); $this->_name = $name; $this->_variant = $variant; From 1d69688a3b18a75acd12f320ccab8db24ce9aa2f Mon Sep 17 00:00:00 2001 From: Pablo Iglesias Date: Fri, 23 Sep 2016 10:37:01 -0400 Subject: [PATCH 11/92] typo in comment --- src/World/Mobile.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/World/Mobile.php b/src/World/Mobile.php index 0de4545..7e55dcf 100644 --- a/src/World/Mobile.php +++ b/src/World/Mobile.php @@ -6,7 +6,7 @@ use CafeMedia\Feature\World; /** - * This sublcass of World overrides UAID and UserID so that + * This subclass of World overrides UAID and UserID so that * feature rampups can maintain consistency on mobile devices. */ @@ -69,7 +69,6 @@ public function userID () * @param $name * @param $variant * @param $selector - * @param null $do_tracking */ public function log ($name, $variant, $selector) { From bc4858d6387345f751d0d0cfacb080c9b23f4e80 Mon Sep 17 00:00:00 2001 From: Pablo Iglesias Date: Fri, 23 Sep 2016 10:47:58 -0400 Subject: [PATCH 12/92] isAdmin bug in world constructor --- src/World.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/World.php b/src/World.php index 28dd551..af24065 100644 --- a/src/World.php +++ b/src/World.php @@ -73,8 +73,9 @@ class World * @param string $userName * @param null $group * @param string $source - * @param array $adminIds + * @param bool $isAdmin * @param string $url + * @internal param array $adminIds */ public function __construct ( Logger $logger, @@ -84,7 +85,7 @@ public function __construct ( $userName = '', $group = null, $source = '', - array $adminIds = array(), + $isAdmin = false, $url = '' ) { $this->_logger = $logger; From 34302d31b1d2f6e8089a8bb4fdcfbef14c292246 Mon Sep 17 00:00:00 2001 From: Pablo Iglesias Date: Fri, 23 Sep 2016 16:44:05 -0400 Subject: [PATCH 13/92] replacing unused adminIds variables with new isAdmin implementation --- src/Feature.php | 12 ++++++------ src/World.php | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Feature.php b/src/Feature.php index 994b7f4..37b3e24 100644 --- a/src/Feature.php +++ b/src/Feature.php @@ -84,9 +84,9 @@ class Feature */ private static $source = ''; /** - * @var array + * @var bool */ - private static $adminIds = array(); + private static $isAdmin = false; /** * @var string */ @@ -101,7 +101,7 @@ class Feature * @param string $userName * @param null $group * @param string $source - * @param array $adminIds + * @param bool $isAdmin * @param string $url */ public function __construct( @@ -112,7 +112,7 @@ public function __construct( $userName = '', $group = null, $source = '', - array $adminIds = array(), + $isAdmin = bool, $url = '' ) { self::$log = $log; @@ -122,7 +122,7 @@ public function __construct( $this->userName = $userName; $this->group = $group; $this->source = $source; - $this->adminIds = $adminIds; + $this->isAdmin = $isAdmin; $this->url = $url; } @@ -364,7 +364,7 @@ private static function world () static::$userName, static::$group, static::$source, - static::$adminIds, + static::$isAdmin, static::$url ); } diff --git a/src/World.php b/src/World.php index af24065..65d52c3 100644 --- a/src/World.php +++ b/src/World.php @@ -75,7 +75,7 @@ class World * @param string $source * @param bool $isAdmin * @param string $url - * @internal param array $adminIds + * @internal param bool $isAdmin */ public function __construct ( Logger $logger, From dc00b7c6c2f0b835ed1555523afd6a6aee3d1f47 Mon Sep 17 00:00:00 2001 From: Pablo Iglesias Date: Fri, 23 Sep 2016 17:19:02 -0400 Subject: [PATCH 14/92] generalize function name --- src/Config.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Config.php b/src/Config.php index ca86c51..ae5523c 100644 --- a/src/Config.php +++ b/src/Config.php @@ -23,7 +23,7 @@ class Config const GROUPS = 'groups'; - const UTM_SOURCES = 'utm_sources'; + const SOURCES = 'sources'; const ADMIN = 'admin'; @@ -79,7 +79,7 @@ class Config /** * @var array */ - private $_utm_sources; + private $_sources; /** * @var bool|mixed|null */ @@ -140,7 +140,7 @@ public function __construct($name, $stanza, World $world, Logger $logger) $this->_enabled = $this->parseEnabled($stanza); $this->_users = $this->parseUsersOrGroups($stanza, self::USERS); $this->_groups = $this->parseUsersOrGroups($stanza, self::GROUPS); - $this->_utm_sources = $this->parseUsersOrGroups($stanza, self::UTM_SOURCES); + $this->_sources = $this->parseUsersOrGroups($stanza, self::SOURCES); $this->_adminVariant = $this->parseVariantName($stanza, self::ADMIN); $this->_internalVariant = $this->parseVariantName($stanza, self::INTERNAL); $this->_public_url_override = $this->parsePublicURLOverride($stanza); @@ -311,7 +311,7 @@ private function chooseVariant ($bucketingID, $userID, $inVariantMethod) if ($_v = $this->variantFromURL($userID)) {} elseif ($_v = $this->variantForUser($userID)) {} elseif ($_v = $this->variantForViewingGroup($userID)) {} - elseif ($_v = $this->variantForUtmSource($userID)) {} + elseif ($_v = $this->variantForSource($userID)) {} elseif ($_v = $this->variantForAdmin($userID)) {} elseif ($_v = $this->variantByPercentage($bucketingID)) {} else { @@ -432,10 +432,10 @@ private function variantForViewingGroup ($userID = null) * @param $userID * @return array|bool */ - private function variantForUtmSource ($userID = null) + private function variantForSource ($userID = null) { - foreach ($this->_utm_sources as $utm_source => $variant) { - if ($this->_world->isSource($utm_source)) { + foreach ($this->_sources as $source => $variant) { + if ($this->_world->isSource($source)) { return array($variant, 's'); } } From b6cd6fef333b43624e9c17b8dfa513aa580c24bb Mon Sep 17 00:00:00 2001 From: Pablo Iglesias Date: Mon, 26 Sep 2016 16:11:56 -0400 Subject: [PATCH 15/92] fix calling static properties dynamically --- src/Feature.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Feature.php b/src/Feature.php index 37b3e24..3e14089 100644 --- a/src/Feature.php +++ b/src/Feature.php @@ -116,14 +116,14 @@ public function __construct( $url = '' ) { self::$log = $log; - $this->features = $features; - $this->uaid = $uaid; - $this->userID = $userID; - $this->userName = $userName; - $this->group = $group; - $this->source = $source; - $this->isAdmin = $isAdmin; - $this->url = $url; + self::$features = $features; + self::$uaid = $uaid; + self::$userID = $userID; + self::$userName = $userName; + self::$group = $group; + self::$source = $source; + self::$isAdmin = $isAdmin; + self::$url = $url; } /** From 45c01848806111bb20362f2a25e9bc8693044781 Mon Sep 17 00:00:00 2001 From: Pablo Iglesias Date: Tue, 27 Sep 2016 10:46:40 -0400 Subject: [PATCH 16/92] 1.0.0 stable version with unit tests --- src/Config.php | 1 - src/Feature.php | 18 +- src/World/Mobile.php | 3 +- tests/ConfigTest.php | 435 +++++++++--------------------------------- tests/FeatureTest.php | 90 +++++++++ tests/LoggerTest.php | 43 ----- tests/MobileTest.php | 66 +++++++ tests/UtilTest.php | 18 ++ tests/WorldTest.php | 233 ++++++++-------------- 9 files changed, 364 insertions(+), 543 deletions(-) create mode 100644 tests/FeatureTest.php delete mode 100644 tests/LoggerTest.php create mode 100644 tests/MobileTest.php create mode 100644 tests/UtilTest.php diff --git a/src/Config.php b/src/Config.php index ae5523c..4d3c13f 100644 --- a/src/Config.php +++ b/src/Config.php @@ -236,7 +236,6 @@ public function description () return $this->_description; } - //////////////////////////////////////////////////////////////////////// // Internals diff --git a/src/Feature.php b/src/Feature.php index 3e14089..4c1c7b8 100644 --- a/src/Feature.php +++ b/src/Feature.php @@ -40,10 +40,12 @@ class Feature * @var */ private static $defaultWorld; + /** * @var array */ private static $configCache = array(); + /** * @var */ @@ -63,30 +65,37 @@ class Feature * @var array */ private static $features = array(); + /** * @var string */ private static $uaid = ''; + /** * @var string */ private static $userID = ''; + /** * @var string */ private static $userName = ''; + /** * @var null */ private static $group = null; + /** * @var string */ private static $source = ''; + /** * @var bool */ private static $isAdmin = false; + /** * @var string */ @@ -112,7 +121,7 @@ public function __construct( $userName = '', $group = null, $source = '', - $isAdmin = bool, + $isAdmin = false, $url = '' ) { self::$log = $log; @@ -129,6 +138,8 @@ public function __construct( /** * Get an object that can be passed to Smarty templates that wraps * our API with non-static methods of the same names and arguments. + * + * @return Instance */ public static function getInstance() { @@ -337,12 +348,13 @@ public static function clearCacheForTests() self::$configCache = array(); } - /** * Get the list of selections that have been made as an array of * (feature_name, variant_name, selector) arrays. This can be used * to record information about what features were associated with * what variants and why during the course of handling a request. + * + * @return array */ public static function selections () { @@ -352,6 +364,8 @@ public static function selections () /** * This API always uses the default World. Config takes * the world as an argument in order to ease unit testing. + * + * @return World */ private static function world () { diff --git a/src/World/Mobile.php b/src/World/Mobile.php index 7e55dcf..cdaf9e6 100644 --- a/src/World/Mobile.php +++ b/src/World/Mobile.php @@ -48,7 +48,8 @@ public function __construct ($udid, $userID, Logger $logger) } /** - * @return Logger + * UAID of the current request. + * @return mixed */ public function uaid() { diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php index e753f3b..3eef6bc 100644 --- a/tests/ConfigTest.php +++ b/tests/ConfigTest.php @@ -1,356 +1,97 @@ config = new Config( + 'test', + 'test', + $this->getMockBuilder('CafeMedia\Feature\World')->disableOriginalConstructor()->getMock(), + $this->getMockBuilder('CafeMedia\Feature\Logger')->disableOriginalConstructor()->getMock() + ); + } + + public function testIsEnabled() + { + $this->assertEquals($this->config->isEnabled('test'), true); + } + + public function testVariant() + { + $this->assertEquals($this->config->variant(), 'test'); + } + + public function testIsEnabledFor() + { + $this->assertEquals($this->config->isEnabledFor((object) array('user_id' => 1)), true); + } + + public function testIsEnabledBucketingBy() + { + $this->assertEquals($this->config->isEnabledBucketingBy('test'), true); + } + + public function testVariantFor() + { + $this->assertEquals($this->config->variantFor((object) array('user_id' => 1)), 'test'); + } + + public function testVariantBucketingBy() + { + $this->assertEquals($this->config->variantBucketingBy('test', 'test'), 'test'); + } + + public function testDescription() + { + $this->assertEquals($this->config->description('test'), 'No description.'); + } -// function testDefaultDisabled () { -// $c = null; -// $this->expectDisabled($c, array('uaid' => 0)); -// $this->expectDisabled($c, array('uaid' => 1)); -// } -// -// function testFullyEnabled() { -// $c = array('enabled' => 'on'); -// $this->expectEnabled($c, array('uaid' => '0')); -// $this->expectEnabled($c, array('uaid' => '1')); -// } -// -// function testSimpleDisabled () { -// $c = array('enabled' => 'off'); -// $this->expectDisabled($c, array('uaid' => '0')); -// $this->expectDisabled($c, array('uaid' => '1')); -// } -// -// function testVariantEnabled () { -// $c = array('enabled' => 'winner'); -// $this->expectEnabled($c, array('uaid' => '0'), 'winner'); -// $this->expectEnabled($c, array('uaid' => '1'), 'winner'); -// } -// -// function testFullyEnabledString() { -// $c = 'on'; -// $this->expectEnabled($c, array('uaid' => '0')); -// $this->expectEnabled($c, array('uaid' => '1')); -// } -// -// function testSimpleDisabledString () { -// $c = 'off'; -// $this->expectDisabled($c, array('uaid' => '0')); -// $this->expectDisabled($c, array('uaid' => '1')); -// } -// -// function testVariantEnabledString () { -// $c = 'winner'; -// $this->expectEnabled($c, array('uaid' => '0'), 'winner'); -// $this->expectEnabled($c, array('uaid' => '1'), 'winner'); -// } -// -// function testSimpleRampup () { -// $c = array('enabled' => '50'); -// $this->expectEnabled($c, array('uaid' => '0')); -// $this->expectEnabled($c, array('uaid' => '.1')); -// $this->expectEnabled($c, array('uaid' => '.4999')); -// $this->expectDisabled($c, array('uaid' => '.5')); -// $this->expectDisabled($c, array('uaid' => '.6')); -// $this->expectDisabled($c, array('uaid' => '.99')); -// $this->expectDisabled($c, array('uaid' => '1')); -// } -// -// function testMultivariant () { -// $c = array('enabled' => array('foo' => 2, 'bar' => 3)); -// $this->expectEnabled($c, array('uaid' => '0'), 'foo'); -// $this->expectEnabled($c, array('uaid' => '.01'), 'foo'); -// $this->expectEnabled($c, array('uaid' => '.01999'), 'foo'); -// $this->expectEnabled($c, array('uaid' => '.02'), 'bar'); -// $this->expectEnabled($c, array('uaid' => '.04999'), 'bar'); -// $this->expectDisabled($c, array('uaid' => '.05')); -// $this->expectDisabled($c, array('uaid' => '1')); -// } -// -// /* -// * Is feature disbaled by enabled => off despite every other -// * setting trying to turn it on? -// */ -// function testComplexDisabled () { -// $c = array( -// 'enabled' => 'off', -// 'users' => array('fred', 'sally'), -// 'groups' => array(1234, 2345), -// 'admin' => 'on', -// 'internal' => 'on', -// 'public_url_overrride' => true -// ); -// -// $this->expectDisabled($c, array('isInternal' => true, 'uaid' => '0')); -// $this->expectDisabled($c, array('userName' => 'fred', 'uaid' => '0')); -// $this->expectDisabled($c, array('inGroup' => array(0 => 1234), 'uaid' => '0')); -// $this->expectDisabled($c, array('uaid' => '0')); -// $this->expectDisabled($c, array('isAdmin' => true, 'uaid' => '0')); -// $this->expectDisabled($c, array('isInternal' => true, 'urlFeatures' => 'foo', 'uaid' => 0)); -// -// // Now all at once. -// $this->expectDisabled($c, array( -// 'isInternal' => true, -// 'userName' => 'fred', -// 'inGroup' => array(0 => 1234), -// 'uaid' => '100', -// 'isAdmin' => true, -// 'urlFeatures' => 'foo', -// 'userID' => '0')); -// } -// -// function testAdminOnly () { -// $c = array('enabled' => 0, 'admin' => 'on'); -// $this->expectEnabled($c, array('isAdmin' => true, 'uaid' => '0', 'userID' => '1')); -// $this->expectDisabled($c, array('isAdmin' => false, 'uaid' => '1', 'userID' => '1')); -// } -// -// function testAdminPlusSome () { -// $c = array('enabled' => 10, 'admin' => 'on'); -// $this->expectEnabled($c, array('isAdmin' => true, 'uaid' => '.5', 'userID' => '1')); -// $this->expectEnabled($c, array('isAdmin' => false, 'uaid' => '.05', 'userID' => '1')); -// $this->expectDisabled($c, array('isAdmin' => false, 'uaid' => '.5', 'userID' => '1')); -// } -// -// function testInternalOnly () { -// $c = array('enabled' => 0, 'internal' => 'on'); -// $this->expectEnabled($c, array('isInternal' => true, 'uaid' => '0')); -// $this->expectDisabled($c, array('isInternal' => false, 'uaid' => '1')); -// } -// -// function testInternalPlusSome () { -// $c = array('enabled' => 10, 'internal' => 'on'); -// $this->expectEnabled($c, array('isInternal' => true, 'uaid' => '.5')); -// $this->expectEnabled($c, array('isInternal' => false, 'uaid' => '.05')); -// $this->expectDisabled($c, array('isInternal' => false, 'uaid' => '.5')); -// } -// -// function testOneUser () { -// $c = array('enabled' => 0, 'users' => 'fred'); -// $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'fred', 'userID' => '1')); -// $this->expectDisabled($c, array('uaid' => '1', 'userName' => 'george', 'userID' => '1')); -// $this->expectDisabled($c, array('userID' => null, 'uaid' => 0)); -// } -// -// function testListOfOneUser () { -// $c = array('enabled' => 0, 'users' => array('fred')); -// $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'fred', 'userID' => '1')); -// $this->expectDisabled($c, array('uaid' => '1', 'userName' => 'george', 'userID' => '1')); -// } -// -// function testListOfUsers () { -// $c = array('enabled' => 0, 'users' => array('fred', 'ron')); -// $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'fred', 'userID' => '1')); -// $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'ron', 'userID' => '1')); -// $this->expectDisabled($c, array('uaid' => '1', 'userName' => 'george', 'userID' => '1')); -// } -// -// function testListOfUsersCaseInsensitive() { -// $c = array('enabled' => 0, 'users' => array('fred', 'FunGuy')); -// $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'fred', 'userID' => '1')); -// $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'FunGuy', 'userID' => '1')); -// $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'FUNGUY', 'userID' => '1')); -// $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'funguy', 'userID' => '1')); -// } -// -// function testArrayOfUsers () { -// // It might be kind of nice to allow 'enabled' => 0 here but -// // then we lose the ability to check that the variants -// // mentioned in a users clause are actually valid -// // variants. Which maybe is okay: perhaps we'd like to be able -// // to enable variants for users that are otherwise disabled. -// $c = array('enabled' => array('twins' => 0, 'other' => 0), -// 'users' => array( -// 'twins' => array('fred', 'george'), -// 'other' => 'ron')); -// $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'fred', 'userID' => '1'), 'twins'); -// $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'george', 'userID' => '2'), 'twins'); -// $this->expectEnabled($c, array('uaid' => '1', 'userName' => 'ron', 'userID' => '3'), 'other'); -// $this->expectDisabled($c, array('uaid' => '0', 'userName' => 'percy', 'userID' => '4')); -// } -// -// function testOneGroup () { -// $c = array('enabled' => 0, 'groups' => 1234); -// $this->expectEnabled($c, array('uaid' => 1, 'userID' => 1, 'inGroup' => array(1 => array(1234)))); -// $this->expectDisabled($c, array('uaid' => 0, 'userID' => 2, 'inGroup' => array(2 => array(2345)))); -// $this->expectDisabled($c, array('uaid' => 0, 'userID' => null)); -// } -// -// function testListOfOneGroup () { -// $c = array('enabled' => 0, 'groups' => array(1234)); -// $this->expectEnabled($c, array('uaid' => 1, 'userID' => 1, 'inGroup' => array(1 => array(1234)))); -// $this->expectDisabled($c, array('uaid' => 0, 'userID' => 2, 'inGroup' => array(2 => array(2345)))); -// } -// -// function testListOfGroups () { -// $c = array('enabled' => 0, 'groups' => array(1234, 2345)); -// $this->expectEnabled($c, array('uaid' => 1, 'userID' => 1, 'inGroup' => array(1 => array(1234)))); -// $this->expectEnabled($c, array('uaid' => 1, 'userID' => 2, 'inGroup' => array(2 => array(2345)))); -// $this->expectDisabled($c, array('uaid' => 0, 'userID' => 3, 'inGroup' => array(3 => array()))); -// } -// function testArrayOfGroups () { -// // See comment at testArrayOfUsers; similar issue applies here. -// $c = array('enabled' => array('twins' => 0, 'other' => 0), -// 'groups' => array( -// 'twins' => array(1234, 2345), -// 'other' => 3456)); -// $this->expectEnabled($c, array('uaid' => 1, 'userID' => 1, 'inGroup' => array(1 => array(1234))), 'twins'); -// $this->expectEnabled($c, array('uaid' => 1, 'userID' => 2, 'inGroup' => array(2 => array(2345))), 'twins'); -// $this->expectEnabled($c, array('uaid' => 1, 'userID' => 3, 'inGroup' => array(3 => array(3456))), 'other'); -// $this->expectDisabled($c, array('uaid' => 0, 'userID' => 4, 'inGroup' => array(4 => array()))); -// } -// -// function testUrlOverride () { -// $c = array('enabled' => 0); -// $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => true, 'urlFeatures' => 'foo')); -// $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => true, 'urlFeatures' => 'foo:on')); -// $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => true, 'urlFeatures' => 'foo:bar'), 'bar'); -// $this->expectDisabled($c, array('uaid' => '1', 'isInternal' => false, 'urlFeatures' => 'foo')); -// $this->expectDisabled($c, array('uaid' => '1', 'isInternal' => false, 'urlFeatures' => 'foo:on')); -// $this->expectDisabled($c, array('uaid' => '1', 'isInternal' => false, 'urlFeatures' => 'foo:bar')); -// } -// -// function testPublicUrlOverride () { -// $c = array('enabled' => 0, 'public_url_override' => true); -// $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => true, 'urlFeatures' => 'foo')); -// $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => true, 'urlFeatures' => 'foo:on')); -// $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => true, 'urlFeatures' => 'foo:bar'), 'bar'); -// $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => false, 'urlFeatures' => 'foo')); -// $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => false, 'urlFeatures' => 'foo:on')); -// $this->expectEnabled($c, array('uaid' => '1', 'isInternal' => false, 'urlFeatures' => 'foo:bar'), 'bar'); -// } -// -// function testBucketBy () { -// $c = array('enabled' => 2, 'bucketing' => 'user'); -// $this->expectEnabled($c, array('uaid' => 1, 'userID' => .01)); -// $this->expectDisabled($c, array('uaid' => 0, 'userID' => .03)); -// } -// -// function testUAIDFallback () { -// $c = array('enabled' => 2, 'bucketing' => 'user'); -// $this->expectEnabled($c, array('userID' => null, 'uaid' => .01)); -// $this->expectDisabled($c, array('userID' => null, 'uaid' => .03)); -// } -// -// /* -// * Ignore userID and uuaid in favor of random numbers for bucketing. -// */ -// function testRandom () { -// $c = array('enabled' => 3, 'bucketing' => 'random'); -// $this->expectEnabled($c, array('uaid' => 1, 'random' => .00)); -// $this->expectEnabled($c, array('uaid' => 1, 'random' => .01)); -// $this->expectEnabled($c, array('uaid' => 1, 'random' => .02)); -// $this->expectEnabled($c, array('uaid' => 1, 'random' => .02999)); -// $this->expectDisabled($c, array('uaid' => 0, 'random' => .03)); -// $this->expectDisabled($c, array('uaid' => 0, 'random' => .04)); -// $this->expectDisabled($c, array('uaid' => 0, 'random' => .99999)); -// } -// -// /* -// * Somewhat indirect test that we cache the value by id: even if -// * the config is set up to use a random bucket (i.e. indpendent of -// * the id) it should still return the same value for the same id -// * which we test by having the two 'random' values returned by the -// * test world be ones that would change the enabled status if they -// * were both used. -// */ -// function testRandomCached () { -// // Initially enabled -// $c = array('enabled' => 3, 'bucketing' => 'random'); -// $w = new Testing_Feature_MockWorld(array('uaid' => 1, 'random' => 0)); -// $config = new Config('foo', $c, $w); -// $this->assertTrue($config->isEnabled()); -// $w->nextRandomValue(.5); -// $this->assertTrue($config->isEnabled()); -// -// // Initially disabled -// $c = array('enabled' => 3, 'bucketing' => 'random'); -// $w = new Testing_Feature_MockWorld(array('uaid' => 1, 'random' => .5)); -// $config = new Config('foo', $c, $w); -// $this->assertFalse($config->isEnabled()); -// $w->nextRandomValue(0); -// $this->assertFalse($config->isEnabled()); -// } -// -// function testDescription () { -// // Default description. -// $c = array('enabled' => 'on'); -// $w = new Testing_Feature_MockWorld(array()); -// $config = new Config('foo', $c, $w); -// $this->assertNotNull($config->description()); -// -// // Provided description. -// $c = array('enabled' => 'on', 'description' => 'The description.'); -// $w = new Testing_Feature_MockWorld(array()); -// $config = new Config('foo', $c, $w); -// $this->assertEquals($config->description(), 'The description.'); -// } -// -// function testIsEnabledForAcceptsREST_User() { -// //we don't want to test the implementation of user bucketing here, just the public API -// $user_id = 1; -// $user = $this->getMock('REST_User'); -// $user->expects($this->once()) -// ->method('getUserId') -// ->will($this->returnValue($user_id)); -// $config = new Config('foo', array('enabled' => 'off'), new Testing_Feature_MockWorld(array())); -// $this->assertFalse($config->isEnabledFor($user)); -// } -// -// function testIsEnabledForAcceptsEtsyModel_User() { -// //we don't want to test the implementation of user bucketing here, just the public API -// $user = new EtsyModel_User(); -// $user->user_id = 1; -// $config = new Config('foo', array('enabled' => 'off'), new Testing_Feature_MockWorld(array())); -// $this->assertFalse($config->isEnabledFor($user)); -// } -// -// -// //////////////////////////////////////////////////////////////////////// -// // Test helper methods. -// -// /* -// * Given a config stanza and a world configuration, we expect that -// * isEnabled() will return true and that variant will be a given -// * value (default 'on'). -// */ -// private function expectEnabled ($stanza, $world, $variant = 'on') { -// $config = new Config('foo', $stanza, new Testing_Feature_MockWorld($world)); -// $this->assertTrue($config->isEnabled()); -// $this->assertEquals($config->variant(), $variant); -// -// if (is_array($stanza) && array_key_exists('enabled', $stanza) && $stanza['enabled'] === 0) { -// unset($stanza['enabled']); -// $this->expectEnabled($stanza, $world, $variant); -// } -// } -// -// /* -// * Given a config stanza and a world configuration, we expect that -// * isEnabled() will return false. -// */ -// private function expectDisabled ($stanza, $world) { -// $config = new Config('foo', $stanza, new Testing_Feature_MockWorld($world)); -// $this->assertFalse($config->isEnabled()); -// if (is_array($stanza) && array_key_exists('enabled', $stanza) && $stanza['enabled'] === 0) { -// unset($stanza['enabled']); -// $this->expectDisabled($stanza, $world); -// } -// } + public function testConstants() + { + $this->assertEquals( + array( + Config::DESCRIPTION, + Config::ENABLED, + Config::USERS, + Config::GROUPS, + Config::SOURCES, + Config::ADMIN, + Config::INTERNAL, + Config::PUBLIC_URL_OVERRIDE, + Config::BUCKETING, + Config::ON, + Config::OFF, + Config::UAID, + Config::USER, + Config::RANDOM + ), + array( + 'description', + 'enabled', + 'users', + 'groups', + 'sources', + 'admin', + 'internal', + 'public_url_override', + 'bucketing', + 'on', + 'off', + 'uaid', + 'user', + 'random' + ) + ); + } } diff --git a/tests/FeatureTest.php b/tests/FeatureTest.php new file mode 100644 index 0000000..f675962 --- /dev/null +++ b/tests/FeatureTest.php @@ -0,0 +1,90 @@ +feature = new Feature($this->getMock('Psr\Log\LoggerInterface')); + } + + public function testGetInstance() + { + $this->assertEquals($this->feature->getInstance() instanceof Instance, true); + } + + public function testIsEnabled() + { + $this->assertEquals($this->feature->isEnabled('test'), false); + $this->assertEquals($this->feature->getInstance()->isEnabled('test'), false); + } + + public function testIsEnabledFor() + { + $this->assertEquals($this->feature->isEnabledFor('test', (object) array('user_id' => 1)), false); + $this->assertEquals($this->feature->getInstance()->isEnabledFor('test', (object) array('user_id' => 1)), false); + } + + public function testIsEnabledBucketingBy() + { + $this->assertEquals($this->feature->isEnabledBucketingBy('test', 'test'), false); + $this->assertEquals($this->feature->getInstance()->isEnabledBucketingBy('test', 'test'), false); + } + + public function testVariant() + { + $this->assertEquals($this->feature->variant('test'), 'off'); + $this->assertEquals($this->feature->getInstance()->variant('test'), 'off'); + } + + public function testVariantFor() + { + $this->assertEquals($this->feature->variantFor('test', (object) array('user_id' => 1)), 'off'); + $this->assertEquals($this->feature->getInstance()->variantFor('test', (object) array('user_id' => 1)), 'off'); + } + + public function testVariantBucketingBy() + { + $this->assertEquals($this->feature->variantBucketingBy('test', 'test'), 'off'); + $this->assertEquals($this->feature->getInstance()->variantBucketingBy('test', 'test'), 'off'); + } + + public function testDescription() + { + $this->assertEquals($this->feature->description('test'), 'No description.'); + } + + public function testData() + { + $this->assertEquals($this->feature->data('test'), array()); + } + + public function testVariantData() + { + $this->assertEquals($this->feature->variantData('test'), array()); + } + + public function testGetGACustomVarJS() + { + $this->assertEquals( + $this->feature->getInstance()->getGACustomVarJS('test'), + "_gaq.push(['_setCustomVar', 3, 'AB', 'null', 3]);" + ); + + $this->assertEquals( + $this->feature->getInstance()->getGACustomVarJS('mobile'), + "['_setCustomVar', 3, 'AB', 'null', 3]," + ); + } +} diff --git a/tests/LoggerTest.php b/tests/LoggerTest.php deleted file mode 100644 index bd6f1f0..0000000 --- a/tests/LoggerTest.php +++ /dev/null @@ -1,43 +0,0 @@ -assertEquals('', Logger::getGAJavascript(array())); -// } -// -// function testLogOne() { -// $selections = array(); -// $selections[] = array('TEST_key1', 'TEST_var1', 123); -// $js = Logger::getGAJavascript($selections); -// $this->assertEquals("Etsy.GA.track(['_setCustomVar', 2, 'AB', 'TEST_key1.TEST_var1', 3]);", $js); -// } -// -// function testLogTwo() { -// $selections = array(); -// $selections[] = array('TEST_key1', 'TEST_var1', 123); -// $selections[] = array('foo', 'bar', 123); -// $js = Logger::getGAJavascript($selections); -// $this->assertEquals("Etsy.GA.track(['_setCustomVar', 2, 'AB', 'TEST_key1.TEST_var1..foo.bar', 3]);", $js); -// } -// -// function testTooLong() { -// $selections = array(); -// $pairs = array(); -// foreach (array('a', 'b', 'c', 'd', 'e') as $x) { -// $selections[] = array($x, 'xxxxxxxxxx', 123); -// $pairs[] = "$x.xxxxxxxxxx"; -// } -// // This one should not be included in the Javascript because -// // we already have 12*5=60 chars and this pair would add three -// // more pushing us over the limit of 62. -// $selections[] = array('f', 'x', 123); -// $value = implode('..', $pairs); -// $js = Logger::getGAJavascript($selections); -// $this->assertEquals("Etsy.GA.track(['_setCustomVar', 2, 'AB', '$value', 3]);", $js); -// } -} diff --git a/tests/MobileTest.php b/tests/MobileTest.php new file mode 100644 index 0000000..36888bc --- /dev/null +++ b/tests/MobileTest.php @@ -0,0 +1,66 @@ +mobile = new Mobile( + 'test', + 1, + $this->getMockBuilder('CafeMedia\Feature\Logger')->disableOriginalConstructor()->getMock() + ); + } + + public function testUaid() + { + $this->assertEquals($this->mobile->uaid(), 'test'); + } + + public function testUserID() + { + $this->assertEquals($this->mobile->userID(), 1); + } + + public function testGetLastName() + { + $this->assertEquals($this->mobile->getLastName(), null); + + $this->mobile->log('test', 'test', 'test'); + $this->assertEquals($this->mobile->getLastName(), 'test'); + + $this->mobile->clearLastFeature(); + $this->assertEquals($this->mobile->getLastName(), null); + } + + public function testGetLastVariant() + { + $this->assertEquals($this->mobile->getLastVariant(), null); + + $this->mobile->log('test', 'test', 'test'); + $this->assertEquals($this->mobile->getLastVariant(), 'test'); + + $this->mobile->clearLastFeature(); + $this->assertEquals($this->mobile->getLastVariant(), null); + } + + public function getLastSelector() + { + $this->assertEquals($this->mobile->getLastSelector(), null); + + $this->mobile->log('test', 'test', 'test'); + $this->assertEquals($this->mobile->getLastSelector(), 'test'); + + $this->mobile->clearLastFeature(); + $this->assertEquals($this->mobile->getLastSelector(), null); + } +} diff --git a/tests/UtilTest.php b/tests/UtilTest.php new file mode 100644 index 0000000..0ffb064 --- /dev/null +++ b/tests/UtilTest.php @@ -0,0 +1,18 @@ +assertEquals(Util::arrayGet('test', 'test'), null); + $this->assertEquals(Util::arrayGet(array('test' => 'test'), 'test'), 'test'); + } +} diff --git a/tests/WorldTest.php b/tests/WorldTest.php index e65f64a..941be3c 100644 --- a/tests/WorldTest.php +++ b/tests/WorldTest.php @@ -1,153 +1,88 @@ uaid = UAIDCookie::getSecureCookie(); -// $this->assertNotNull($this->uaid); -// -// $logger = $this->getMock('Logger', array('log')); -// $this->world = new World($logger); -// $this->user_id = 991; -// -// $this->setLoggedUserId(null); -// $this->assertNull(Std::loggedUser()); -// } -// -// function testIsAdminWithBlankUAIDCookie() { -// $this->setLoggedUserId($this->user_id); -// -// $this->assertFalse($this->world->isAdmin($this->user_id)); -// } -// -// function testIsAdminWithValidNonAdminUserUAIDCookie() { -// $this->setLoggedUserId($this->user_id); -// $this->uaid->set(UAIDCookie::USER_ID_ATTRIBUTE, $this->user_id); -// -// $this->assertFalse($this->world->isAdmin($this->user_id)); -// } -// -// function testIsAdminWithValidAdminUAIDCookie() { -// $this->setLoggedUserId($this->user_id); -// $this->uaid->set(UAIDCookie::USER_ID_ATTRIBUTE, $this->user_id); -// $this->uaid->set(UAIDCookie::ADMIN_ATTRIBUTE, '1'); -// -// $this->assertTrue($this->world->isAdmin($this->user_id)); -// } -// -// function testIsAdminWithNonLoggedInAdminAndValidAdminUAIDCookie() { -// $this->setLoggedUserId(null); -// $this->uaid->set(UAIDCookie::USER_ID_ATTRIBUTE, $this->user_id); -// $this->uaid->set(UAIDCookie::ADMIN_ATTRIBUTE, '1'); -// -// $this->assertFalse($this->world->isAdmin($this->user_id)); -// } -// -// function testIsAdminWithLoggedInAdminUserAndBlankUAIDCookie() { -// $user = $this->adminUser(); -// $this->setLoggedUserId($user->user_id); -// -// $this->assertTrue($this->world->isAdmin($user->user_id)); -// } -// -// function testIsAdminWithLoggedInNonAdminUserAndBlankUAIDCookie() { -// $user = $this->nonAdminUser(); -// $this->setLoggedUserId($user->user_id); -// -// $this->assertFalse($this->world->isAdmin($user->user_id)); -// } -// -// function testIsAdminWithNonLoggedInAdminUserAndBlankUAIDCookie() { -// $user = $this->adminUser(); -// $this->setLoggedUserId(null); -// -// $this->assertTrue($this->world->isAdmin($user->user_id)); -// } -// -// function testIsAdminWithNonLoggedInNonAdminUserAndBlankUAIDCookie() { -// $user = $this->nonAdminUser(); -// $this->setLoggedUserId(null); -// -// $this->assertFalse($this->world->isAdmin($user->user_id)); -// } -// -// function testAtlasWorld() { -// $user = $this->atlasUser(); -// $this->setLoggedUserId($user->id); -// $this->setAtlasRequest(true); -// -// $this->assertFalse($this->world->isAdmin($user->id)); -// $this->assertFalse($this->world->inGroup($user->id, 1)); -// $this->assertEquals($user->id, $this->world->userID()); -// -// $this->setAtlasRequest(false); -// } -// -// function testHash() { -// $this->assertInternalType('float', $this->world->hash('somevalue')); -// -// $this->assertEquals( -// $this->world->hash('somevalue'), -// $this->world->hash('somevalue'), -// 'ensure return value is consistent' -// ); -// -// $this->assertGreaterThanOrEqual(0, $this->world->hash('somevalue')); -// $this->assertLessThan(1, $this->world->hash('somevalue')); -// } -// -// protected function getDatabaseConfigs() { -// $index_yml = dirname(__FILE__) . '/data/world/etsy_index.yml'; -// if (!file_exists($index_yml)) { -// throw new Exception($index_yml . ' does not exist'); -// } -// $builder = new PHPUnit_Extensions_MultipleDatabase_DatabaseConfig_Builder(); -// $etsy_index = $builder -// ->connection(Testing_EtsyORM_Connections::ETSY_INDEX()) -// ->dataSet(new PHPUnit_Extensions_Database_DataSet_YamlDataSet($index_yml)) -// ->build(); -// -// $aux_yml = dirname(__FILE__) . '/data/world/etsy_aux.yml'; -// if (!file_exists($aux_yml)) { -// throw new Exception($aux_yml . ' does not exist'); -// } -// $builder = new PHPUnit_Extensions_MultipleDatabase_DatabaseConfig_Builder(); -// $etsy_aux = $builder -// ->connection(Testing_EtsyORM_Connections::ETSY_AUX()) -// ->dataSet(new PHPUnit_Extensions_Database_DataSet_YamlDataSet($aux_yml)) -// ->build(); -// -// return array($etsy_index, $etsy_aux); -// } -// -// private function nonAdminUser() { -// return EtsyORM::getFinder('User')->find(1); -// } -// -// private function adminUser() { -// return EtsyORM::getFinder('User')->find(2); -// } -// -// private function atlasUser() { -// return EtsyORM::getFinder('Staff')->find(3); -// } -// -// private function setAtlasRequest($is_atlas) { -// $_SERVER["atlas_request"] = $is_atlas ? 1 : 0; -// } -// -// private function setLoggedUserId($user_id) { -// //Std::loggedUser() uses this global -// $GLOBALS['cookie_user_id'] = $user_id; -// } -//} +class WorldTest extends \PHPUnit_Framework_TestCase +{ + private $world; + + public function setUp() + { + $this->world = new World( + $this->getMockBuilder('CafeMedia\Feature\Logger')->disableOriginalConstructor()->getMock() + ); + } + + public function testConfigValue() + { + $this->assertEquals($this->world->configValue('test'), null); + } + + public function testUaid() + { + $this->assertEquals($this->world->uaid(), ''); + } + + public function testUserId() + { + $this->assertEquals($this->world->userId(), ''); + } + + public function testUserName() + { + $this->assertEquals($this->world->userName(), ''); + } + + public function testViewingGroup() + { + $this->assertEquals($this->world->viewingGroup('test'), false); + } + + public function testIsSource() + { + $this->assertEquals($this->world->isSource('test'), false); + $this->assertEquals($this->world->isSource(''), true); + } + + public function testInGroup() + { + $this->assertEquals($this->world->inGroup(), false); + } + + public function testIsAdmin() + { + $this->assertEquals($this->world->isAdmin(), false); + } + + public function testIsInternalRequest() + { + $this->assertEquals($this->world->isInternalRequest(), false); + } + + public function testUrlFeatures() + { + $this->assertEquals($this->world->urlFeatures(), ''); + } + + public function testRandom() + { + $this->assertEquals(is_numeric($this->world->random()), true); + } + + public function testHash() + { + $this->assertEquals($this->world->hash('test'), 0.91731063090264797); + } + + public function testSelections() + { + $this->world->log('test', 'test', 'test'); + $this->assertEquals($this->world->selections(), array(array('test', 'test', 'test'))); + } +} From 611dc456be49bf7cfd17291f210fad366492affb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Tue, 27 Sep 2016 16:44:25 -0400 Subject: [PATCH 17/92] Delete .travis.yml --- .travis.yml | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 9cb3a59..0000000 --- a/.travis.yml +++ /dev/null @@ -1,12 +0,0 @@ -language: php - -php: - - 5.3 - - 5.6 - - 7.0 - -before_script: - - composer self-update - - composer install --no-interaction --dev - -script: phpunit From 7c326074f5eeffe00d85e56b802dee18539e1cd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Tue, 27 Sep 2016 16:45:21 -0400 Subject: [PATCH 18/92] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8ef0e14..f7015c3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ vendor/ .idea/ +composer.lock From 42ede669f7b01754c4b5e048e33737b56031ea79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Tue, 27 Sep 2016 16:45:33 -0400 Subject: [PATCH 19/92] Delete composer.lock --- composer.lock | 1173 ------------------------------------------------- 1 file changed, 1173 deletions(-) delete mode 100644 composer.lock diff --git a/composer.lock b/composer.lock deleted file mode 100644 index 8354055..0000000 --- a/composer.lock +++ /dev/null @@ -1,1173 +0,0 @@ -{ - "_readme": [ - "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", - "This file is @generated automatically" - ], - "hash": "a859d6808a5414f76bd85219b3a6605d", - "content-hash": "887ab0ec3cbafd6612458a80a55b6aa1", - "packages": [ - { - "name": "psr/log", - "version": "1.0.1", - "source": { - "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "5277094ed527a1c4477177d102fe4c53551953e0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/5277094ed527a1c4477177d102fe4c53551953e0", - "reference": "5277094ed527a1c4477177d102fe4c53551953e0", - "shasum": "" - }, - "require": { - "php": ">=5.3.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Log\\": "Psr/Log/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" - } - ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", - "keywords": [ - "log", - "psr", - "psr-3" - ], - "time": "2016-09-19 16:02:08" - } - ], - "packages-dev": [ - { - "name": "doctrine/instantiator", - "version": "1.0.5", - "source": { - "type": "git", - "url": "https://github.com/doctrine/instantiator.git", - "reference": "8e884e78f9f0eb1329e445619e04456e64d8051d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/8e884e78f9f0eb1329e445619e04456e64d8051d", - "reference": "8e884e78f9f0eb1329e445619e04456e64d8051d", - "shasum": "" - }, - "require": { - "php": ">=5.3,<8.0-DEV" - }, - "require-dev": { - "athletic/athletic": "~0.1.8", - "ext-pdo": "*", - "ext-phar": "*", - "phpunit/phpunit": "~4.0", - "squizlabs/php_codesniffer": "~2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com", - "homepage": "http://ocramius.github.com/" - } - ], - "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", - "homepage": "https://github.com/doctrine/instantiator", - "keywords": [ - "constructor", - "instantiate" - ], - "time": "2015-06-14 21:17:01" - }, - { - "name": "phpdocumentor/reflection-common", - "version": "1.0", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/ReflectionCommon.git", - "reference": "144c307535e82c8fdcaacbcfc1d6d8eeb896687c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/144c307535e82c8fdcaacbcfc1d6d8eeb896687c", - "reference": "144c307535e82c8fdcaacbcfc1d6d8eeb896687c", - "shasum": "" - }, - "require": { - "php": ">=5.5" - }, - "require-dev": { - "phpunit/phpunit": "^4.6" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": [ - "src" - ] - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jaap van Otterdijk", - "email": "opensource@ijaap.nl" - } - ], - "description": "Common reflection classes used by phpdocumentor to reflect the code structure", - "homepage": "http://www.phpdoc.org", - "keywords": [ - "FQSEN", - "phpDocumentor", - "phpdoc", - "reflection", - "static analysis" - ], - "time": "2015-12-27 11:43:31" - }, - { - "name": "phpdocumentor/reflection-docblock", - "version": "3.1.0", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "9270140b940ff02e58ec577c237274e92cd40cdd" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/9270140b940ff02e58ec577c237274e92cd40cdd", - "reference": "9270140b940ff02e58ec577c237274e92cd40cdd", - "shasum": "" - }, - "require": { - "php": ">=5.5", - "phpdocumentor/reflection-common": "^1.0@dev", - "phpdocumentor/type-resolver": "^0.2.0", - "webmozart/assert": "^1.0" - }, - "require-dev": { - "mockery/mockery": "^0.9.4", - "phpunit/phpunit": "^4.4" - }, - "type": "library", - "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": [ - "src/" - ] - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Mike van Riel", - "email": "me@mikevanriel.com" - } - ], - "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", - "time": "2016-06-10 09:48:41" - }, - { - "name": "phpdocumentor/type-resolver", - "version": "0.2", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "b39c7a5b194f9ed7bd0dd345c751007a41862443" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/b39c7a5b194f9ed7bd0dd345c751007a41862443", - "reference": "b39c7a5b194f9ed7bd0dd345c751007a41862443", - "shasum": "" - }, - "require": { - "php": ">=5.5", - "phpdocumentor/reflection-common": "^1.0" - }, - "require-dev": { - "mockery/mockery": "^0.9.4", - "phpunit/phpunit": "^5.2||^4.8.24" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": [ - "src/" - ] - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Mike van Riel", - "email": "me@mikevanriel.com" - } - ], - "time": "2016-06-10 07:14:17" - }, - { - "name": "phpspec/prophecy", - "version": "v1.6.1", - "source": { - "type": "git", - "url": "https://github.com/phpspec/prophecy.git", - "reference": "58a8137754bc24b25740d4281399a4a3596058e0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpspec/prophecy/zipball/58a8137754bc24b25740d4281399a4a3596058e0", - "reference": "58a8137754bc24b25740d4281399a4a3596058e0", - "shasum": "" - }, - "require": { - "doctrine/instantiator": "^1.0.2", - "php": "^5.3|^7.0", - "phpdocumentor/reflection-docblock": "^2.0|^3.0.2", - "sebastian/comparator": "^1.1", - "sebastian/recursion-context": "^1.0" - }, - "require-dev": { - "phpspec/phpspec": "^2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.6.x-dev" - } - }, - "autoload": { - "psr-0": { - "Prophecy\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Konstantin Kudryashov", - "email": "ever.zet@gmail.com", - "homepage": "http://everzet.com" - }, - { - "name": "Marcello Duarte", - "email": "marcello.duarte@gmail.com" - } - ], - "description": "Highly opinionated mocking framework for PHP 5.3+", - "homepage": "https://github.com/phpspec/prophecy", - "keywords": [ - "Double", - "Dummy", - "fake", - "mock", - "spy", - "stub" - ], - "time": "2016-06-07 08:13:47" - }, - { - "name": "phpunit/php-code-coverage", - "version": "2.2.4", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "eabf68b476ac7d0f73793aada060f1c1a9bf8979" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/eabf68b476ac7d0f73793aada060f1c1a9bf8979", - "reference": "eabf68b476ac7d0f73793aada060f1c1a9bf8979", - "shasum": "" - }, - "require": { - "php": ">=5.3.3", - "phpunit/php-file-iterator": "~1.3", - "phpunit/php-text-template": "~1.2", - "phpunit/php-token-stream": "~1.3", - "sebastian/environment": "^1.3.2", - "sebastian/version": "~1.0" - }, - "require-dev": { - "ext-xdebug": ">=2.1.4", - "phpunit/phpunit": "~4" - }, - "suggest": { - "ext-dom": "*", - "ext-xdebug": ">=2.2.1", - "ext-xmlwriter": "*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.2.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", - "role": "lead" - } - ], - "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", - "homepage": "https://github.com/sebastianbergmann/php-code-coverage", - "keywords": [ - "coverage", - "testing", - "xunit" - ], - "time": "2015-10-06 15:47:00" - }, - { - "name": "phpunit/php-file-iterator", - "version": "1.4.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "6150bf2c35d3fc379e50c7602b75caceaa39dbf0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6150bf2c35d3fc379e50c7602b75caceaa39dbf0", - "reference": "6150bf2c35d3fc379e50c7602b75caceaa39dbf0", - "shasum": "" - }, - "require": { - "php": ">=5.3.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.4.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", - "role": "lead" - } - ], - "description": "FilterIterator implementation that filters files based on a list of suffixes.", - "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", - "keywords": [ - "filesystem", - "iterator" - ], - "time": "2015-06-21 13:08:43" - }, - { - "name": "phpunit/php-text-template", - "version": "1.2.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/31f8b717e51d9a2afca6c9f046f5d69fc27c8686", - "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686", - "shasum": "" - }, - "require": { - "php": ">=5.3.3" - }, - "type": "library", - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Simple template engine.", - "homepage": "https://github.com/sebastianbergmann/php-text-template/", - "keywords": [ - "template" - ], - "time": "2015-06-21 13:50:34" - }, - { - "name": "phpunit/php-timer", - "version": "1.0.8", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "38e9124049cf1a164f1e4537caf19c99bf1eb260" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/38e9124049cf1a164f1e4537caf19c99bf1eb260", - "reference": "38e9124049cf1a164f1e4537caf19c99bf1eb260", - "shasum": "" - }, - "require": { - "php": ">=5.3.3" - }, - "require-dev": { - "phpunit/phpunit": "~4|~5" - }, - "type": "library", - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", - "role": "lead" - } - ], - "description": "Utility class for timing", - "homepage": "https://github.com/sebastianbergmann/php-timer/", - "keywords": [ - "timer" - ], - "time": "2016-05-12 18:03:57" - }, - { - "name": "phpunit/php-token-stream", - "version": "1.4.8", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/php-token-stream.git", - "reference": "3144ae21711fb6cac0b1ab4cbe63b75ce3d4e8da" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/3144ae21711fb6cac0b1ab4cbe63b75ce3d4e8da", - "reference": "3144ae21711fb6cac0b1ab4cbe63b75ce3d4e8da", - "shasum": "" - }, - "require": { - "ext-tokenizer": "*", - "php": ">=5.3.3" - }, - "require-dev": { - "phpunit/phpunit": "~4.2" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.4-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Wrapper around PHP's tokenizer extension.", - "homepage": "https://github.com/sebastianbergmann/php-token-stream/", - "keywords": [ - "tokenizer" - ], - "time": "2015-09-15 10:49:45" - }, - { - "name": "phpunit/phpunit", - "version": "4.8.27", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "c062dddcb68e44b563f66ee319ddae2b5a322a90" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c062dddcb68e44b563f66ee319ddae2b5a322a90", - "reference": "c062dddcb68e44b563f66ee319ddae2b5a322a90", - "shasum": "" - }, - "require": { - "ext-dom": "*", - "ext-json": "*", - "ext-pcre": "*", - "ext-reflection": "*", - "ext-spl": "*", - "php": ">=5.3.3", - "phpspec/prophecy": "^1.3.1", - "phpunit/php-code-coverage": "~2.1", - "phpunit/php-file-iterator": "~1.4", - "phpunit/php-text-template": "~1.2", - "phpunit/php-timer": "^1.0.6", - "phpunit/phpunit-mock-objects": "~2.3", - "sebastian/comparator": "~1.1", - "sebastian/diff": "~1.2", - "sebastian/environment": "~1.3", - "sebastian/exporter": "~1.2", - "sebastian/global-state": "~1.0", - "sebastian/version": "~1.0", - "symfony/yaml": "~2.1|~3.0" - }, - "suggest": { - "phpunit/php-invoker": "~1.1" - }, - "bin": [ - "phpunit" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.8.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "The PHP Unit Testing framework.", - "homepage": "https://phpunit.de/", - "keywords": [ - "phpunit", - "testing", - "xunit" - ], - "time": "2016-07-21 06:48:14" - }, - { - "name": "phpunit/phpunit-mock-objects", - "version": "2.3.8", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/phpunit-mock-objects.git", - "reference": "ac8e7a3db35738d56ee9a76e78a4e03d97628983" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/ac8e7a3db35738d56ee9a76e78a4e03d97628983", - "reference": "ac8e7a3db35738d56ee9a76e78a4e03d97628983", - "shasum": "" - }, - "require": { - "doctrine/instantiator": "^1.0.2", - "php": ">=5.3.3", - "phpunit/php-text-template": "~1.2", - "sebastian/exporter": "~1.2" - }, - "require-dev": { - "phpunit/phpunit": "~4.4" - }, - "suggest": { - "ext-soap": "*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.3.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", - "role": "lead" - } - ], - "description": "Mock Object library for PHPUnit", - "homepage": "https://github.com/sebastianbergmann/phpunit-mock-objects/", - "keywords": [ - "mock", - "xunit" - ], - "time": "2015-10-02 06:51:40" - }, - { - "name": "sebastian/comparator", - "version": "1.2.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "937efb279bd37a375bcadf584dec0726f84dbf22" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/937efb279bd37a375bcadf584dec0726f84dbf22", - "reference": "937efb279bd37a375bcadf584dec0726f84dbf22", - "shasum": "" - }, - "require": { - "php": ">=5.3.3", - "sebastian/diff": "~1.2", - "sebastian/exporter": "~1.2" - }, - "require-dev": { - "phpunit/phpunit": "~4.4" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.2.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, - { - "name": "Volker Dusch", - "email": "github@wallbash.com" - }, - { - "name": "Bernhard Schussek", - "email": "bschussek@2bepublished.at" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Provides the functionality to compare PHP values for equality", - "homepage": "http://www.github.com/sebastianbergmann/comparator", - "keywords": [ - "comparator", - "compare", - "equality" - ], - "time": "2015-07-26 15:48:44" - }, - { - "name": "sebastian/diff", - "version": "1.4.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "13edfd8706462032c2f52b4b862974dd46b71c9e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/13edfd8706462032c2f52b4b862974dd46b71c9e", - "reference": "13edfd8706462032c2f52b4b862974dd46b71c9e", - "shasum": "" - }, - "require": { - "php": ">=5.3.3" - }, - "require-dev": { - "phpunit/phpunit": "~4.8" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.4-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Kore Nordmann", - "email": "mail@kore-nordmann.de" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Diff implementation", - "homepage": "https://github.com/sebastianbergmann/diff", - "keywords": [ - "diff" - ], - "time": "2015-12-08 07:14:41" - }, - { - "name": "sebastian/environment", - "version": "1.3.8", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "be2c607e43ce4c89ecd60e75c6a85c126e754aea" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/be2c607e43ce4c89ecd60e75c6a85c126e754aea", - "reference": "be2c607e43ce4c89ecd60e75c6a85c126e754aea", - "shasum": "" - }, - "require": { - "php": "^5.3.3 || ^7.0" - }, - "require-dev": { - "phpunit/phpunit": "^4.8 || ^5.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.3.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Provides functionality to handle HHVM/PHP environments", - "homepage": "http://www.github.com/sebastianbergmann/environment", - "keywords": [ - "Xdebug", - "environment", - "hhvm" - ], - "time": "2016-08-18 05:49:44" - }, - { - "name": "sebastian/exporter", - "version": "1.2.2", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "42c4c2eec485ee3e159ec9884f95b431287edde4" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/42c4c2eec485ee3e159ec9884f95b431287edde4", - "reference": "42c4c2eec485ee3e159ec9884f95b431287edde4", - "shasum": "" - }, - "require": { - "php": ">=5.3.3", - "sebastian/recursion-context": "~1.0" - }, - "require-dev": { - "ext-mbstring": "*", - "phpunit/phpunit": "~4.4" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.3.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, - { - "name": "Volker Dusch", - "email": "github@wallbash.com" - }, - { - "name": "Bernhard Schussek", - "email": "bschussek@2bepublished.at" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Adam Harvey", - "email": "aharvey@php.net" - } - ], - "description": "Provides the functionality to export PHP variables for visualization", - "homepage": "http://www.github.com/sebastianbergmann/exporter", - "keywords": [ - "export", - "exporter" - ], - "time": "2016-06-17 09:04:28" - }, - { - "name": "sebastian/global-state", - "version": "1.1.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "bc37d50fea7d017d3d340f230811c9f1d7280af4" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bc37d50fea7d017d3d340f230811c9f1d7280af4", - "reference": "bc37d50fea7d017d3d340f230811c9f1d7280af4", - "shasum": "" - }, - "require": { - "php": ">=5.3.3" - }, - "require-dev": { - "phpunit/phpunit": "~4.2" - }, - "suggest": { - "ext-uopz": "*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Snapshotting of global state", - "homepage": "http://www.github.com/sebastianbergmann/global-state", - "keywords": [ - "global state" - ], - "time": "2015-10-12 03:26:01" - }, - { - "name": "sebastian/recursion-context", - "version": "1.0.2", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "913401df809e99e4f47b27cdd781f4a258d58791" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/913401df809e99e4f47b27cdd781f4a258d58791", - "reference": "913401df809e99e4f47b27cdd781f4a258d58791", - "shasum": "" - }, - "require": { - "php": ">=5.3.3" - }, - "require-dev": { - "phpunit/phpunit": "~4.4" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Adam Harvey", - "email": "aharvey@php.net" - } - ], - "description": "Provides functionality to recursively process PHP variables", - "homepage": "http://www.github.com/sebastianbergmann/recursion-context", - "time": "2015-11-11 19:50:13" - }, - { - "name": "sebastian/version", - "version": "1.0.6", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/version.git", - "reference": "58b3a85e7999757d6ad81c787a1fbf5ff6c628c6" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/58b3a85e7999757d6ad81c787a1fbf5ff6c628c6", - "reference": "58b3a85e7999757d6ad81c787a1fbf5ff6c628c6", - "shasum": "" - }, - "type": "library", - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Library that helps with managing the version number of Git-hosted PHP projects", - "homepage": "https://github.com/sebastianbergmann/version", - "time": "2015-06-21 13:59:46" - }, - { - "name": "symfony/yaml", - "version": "v3.1.4", - "source": { - "type": "git", - "url": "https://github.com/symfony/yaml.git", - "reference": "f291ed25eb1435bddbe8a96caaef16469c2a092d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/f291ed25eb1435bddbe8a96caaef16469c2a092d", - "reference": "f291ed25eb1435bddbe8a96caaef16469c2a092d", - "shasum": "" - }, - "require": { - "php": ">=5.5.9" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.1-dev" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Component\\Yaml\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony Yaml Component", - "homepage": "https://symfony.com", - "time": "2016-09-02 02:12:52" - }, - { - "name": "webmozart/assert", - "version": "1.1.0", - "source": { - "type": "git", - "url": "https://github.com/webmozart/assert.git", - "reference": "bb2d123231c095735130cc8f6d31385a44c7b308" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webmozart/assert/zipball/bb2d123231c095735130cc8f6d31385a44c7b308", - "reference": "bb2d123231c095735130cc8f6d31385a44c7b308", - "shasum": "" - }, - "require": { - "php": "^5.3.3|^7.0" - }, - "require-dev": { - "phpunit/phpunit": "^4.6", - "sebastian/version": "^1.0.1" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.2-dev" - } - }, - "autoload": { - "psr-4": { - "Webmozart\\Assert\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - } - ], - "description": "Assertions to validate method input/output with nice error messages.", - "keywords": [ - "assert", - "check", - "validate" - ], - "time": "2016-08-09 15:02:57" - } - ], - "aliases": [], - "minimum-stability": "stable", - "stability-flags": [], - "prefer-stable": false, - "prefer-lowest": false, - "platform": { - "php": ">=5.3.3" - }, - "platform-dev": [] -} From e401333f51c8a5f2c03e4643525e3874532a3493 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Tue, 11 Oct 2016 14:18:39 -0400 Subject: [PATCH 20/92] Update Config.php --- src/Config.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Config.php b/src/Config.php index 4d3c13f..9cb8153 100644 --- a/src/Config.php +++ b/src/Config.php @@ -309,9 +309,11 @@ private function chooseVariant ($bucketingID, $userID, $inVariantMethod) if ($_v = $this->variantFromURL($userID)) {} elseif ($_v = $this->variantForUser($userID)) {} + elseif ($_v = $this->variantForGroup($userID)) {} elseif ($_v = $this->variantForViewingGroup($userID)) {} elseif ($_v = $this->variantForSource($userID)) {} elseif ($_v = $this->variantForAdmin($userID)) {} + elseif ($_v = $this->variantForInternal()) {} elseif ($_v = $this->variantByPercentage($bucketingID)) {} else { $_v = array(self::OFF, 'w'); From 65caf37d57a4eaf2aa555accc5ef8732b03fc926 Mon Sep 17 00:00:00 2001 From: Pablo Iglesias Date: Wed, 12 Oct 2016 03:48:43 -0400 Subject: [PATCH 21/92] include build.xml for jenkins integration --- .gitignore | 9 +++ build.xml | 190 ++++++++++++++++++++++++++++++++++++++++++++++ build/phpdox.xml | 25 ++++++ build/phpmd.xml | 28 +++++++ build/phpunit.xml | 32 ++++++++ phpunit.xml | 1 + 6 files changed, 285 insertions(+) create mode 100644 build.xml create mode 100644 build/phpdox.xml create mode 100644 build/phpmd.xml create mode 100644 build/phpunit.xml diff --git a/.gitignore b/.gitignore index f7015c3..ee66b64 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,12 @@ vendor/ .idea/ composer.lock +build/api +build/code-browser +build/coverage +build/logs +build/pdepend +build/phpdox +build/testdox +cache.properties + diff --git a/build.xml b/build.xml new file mode 100644 index 0000000..91dc704 --- /dev/null +++ b/build.xml @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build/phpdox.xml b/build/phpdox.xml new file mode 100644 index 0000000..98736f1 --- /dev/null +++ b/build/phpdox.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build/phpmd.xml b/build/phpmd.xml new file mode 100644 index 0000000..7666085 --- /dev/null +++ b/build/phpmd.xml @@ -0,0 +1,28 @@ + + + + Sebastian Bergmann's ruleset + + + + + + + + + + + + + + + + + + + + diff --git a/build/phpunit.xml b/build/phpunit.xml new file mode 100644 index 0000000..f36fa18 --- /dev/null +++ b/build/phpunit.xml @@ -0,0 +1,32 @@ + + + + ../tests + + + + + + + + + + + + + + ../src + + + + diff --git a/phpunit.xml b/phpunit.xml index 22a831b..32708f9 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -15,3 +15,4 @@ + From 94f1b0b7875739530dfe50dd05c1de2ef93c1c6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Wed, 12 Oct 2016 04:49:35 -0400 Subject: [PATCH 22/92] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 77efd49..bb3b9f3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +This is an archived file left for reference. + # This is an Archived Project Feature is no longer actively maintained and is no longer in sync with the version used internally at Etsy. From 26c1ffb58b869736ffeb4495f584adaf74518e26 Mon Sep 17 00:00:00 2001 From: Pablo Iglesias Date: Wed, 12 Oct 2016 11:38:15 -0400 Subject: [PATCH 23/92] test coverage --- tests/ConfigTest.php | 21 +++++++++++++++++++ tests/FeatureTest.php | 33 +++++++++++++++++++++++++++++ tests/MobileTest.php | 17 ++++++++++++++- tests/UtilTest.php | 3 +++ tests/WorldTest.php | 49 ++++++++++++++++++++++++++++++++++++++----- 5 files changed, 117 insertions(+), 6 deletions(-) diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php index 3eef6bc..7636ca0 100644 --- a/tests/ConfigTest.php +++ b/tests/ConfigTest.php @@ -22,36 +22,57 @@ public function setUp() ); } + /** + * @covers \CafeMedia\Feature\Config::isEnabled + */ public function testIsEnabled() { $this->assertEquals($this->config->isEnabled('test'), true); } + /** + * @covers \CafeMedia\Feature\Config::variant + */ public function testVariant() { $this->assertEquals($this->config->variant(), 'test'); } + /** + * @covers \CafeMedia\Feature\Config::isEnabledFor + */ public function testIsEnabledFor() { $this->assertEquals($this->config->isEnabledFor((object) array('user_id' => 1)), true); } + /** + * @covers \CafeMedia\Feature\Config::isEnabledBucketingBy + */ public function testIsEnabledBucketingBy() { $this->assertEquals($this->config->isEnabledBucketingBy('test'), true); } + /** + * @covers \CafeMedia\Feature\Config::variantFor + */ public function testVariantFor() { $this->assertEquals($this->config->variantFor((object) array('user_id' => 1)), 'test'); } + /** + * @covers \CafeMedia\Feature\Config::variantBucketingBy + */ public function testVariantBucketingBy() { $this->assertEquals($this->config->variantBucketingBy('test', 'test'), 'test'); } + /** + * @covers \CafeMedia\Feature\Config::description + */ public function testDescription() { $this->assertEquals($this->config->description('test'), 'No description.'); diff --git a/tests/FeatureTest.php b/tests/FeatureTest.php index f675962..34bbf96 100644 --- a/tests/FeatureTest.php +++ b/tests/FeatureTest.php @@ -19,62 +19,95 @@ public function setUp() $this->feature = new Feature($this->getMock('Psr\Log\LoggerInterface')); } + /** + * @covers \CafeMedia\Feature\Feature::getInstance + */ public function testGetInstance() { $this->assertEquals($this->feature->getInstance() instanceof Instance, true); } + /** + * @covers \CafeMedia\Feature\Feature::isEnabled + */ public function testIsEnabled() { $this->assertEquals($this->feature->isEnabled('test'), false); $this->assertEquals($this->feature->getInstance()->isEnabled('test'), false); } + /** + * @covers \CafeMedia\Feature\Feature::isEnabledFor + */ public function testIsEnabledFor() { $this->assertEquals($this->feature->isEnabledFor('test', (object) array('user_id' => 1)), false); $this->assertEquals($this->feature->getInstance()->isEnabledFor('test', (object) array('user_id' => 1)), false); } + /** + * @covers \CafeMedia\Feature\Feature::isEnabledBucketingBy + */ public function testIsEnabledBucketingBy() { $this->assertEquals($this->feature->isEnabledBucketingBy('test', 'test'), false); $this->assertEquals($this->feature->getInstance()->isEnabledBucketingBy('test', 'test'), false); } + /** + * @covers \CafeMedia\Feature\Feature::variant + */ public function testVariant() { $this->assertEquals($this->feature->variant('test'), 'off'); $this->assertEquals($this->feature->getInstance()->variant('test'), 'off'); } + /** + * @covers \CafeMedia\Feature\Feature::variantFor + */ public function testVariantFor() { $this->assertEquals($this->feature->variantFor('test', (object) array('user_id' => 1)), 'off'); $this->assertEquals($this->feature->getInstance()->variantFor('test', (object) array('user_id' => 1)), 'off'); } + /** + * @covers \CafeMedia\Feature\Feature::variantBucketingBy + */ public function testVariantBucketingBy() { $this->assertEquals($this->feature->variantBucketingBy('test', 'test'), 'off'); $this->assertEquals($this->feature->getInstance()->variantBucketingBy('test', 'test'), 'off'); } + /** + * @covers \CafeMedia\Feature\Feature::description + */ public function testDescription() { $this->assertEquals($this->feature->description('test'), 'No description.'); } + /** + * @covers \CafeMedia\Feature\Feature::data + */ public function testData() { $this->assertEquals($this->feature->data('test'), array()); } + /** + * @covers \CafeMedia\Feature\Feature::variantData + */ public function testVariantData() { $this->assertEquals($this->feature->variantData('test'), array()); } + /** + * @covers \CafeMedia\Feature\Instance::getGACustomVarJS + */ public function testGetGACustomVarJS() { $this->assertEquals( diff --git a/tests/MobileTest.php b/tests/MobileTest.php index 36888bc..d8b612d 100644 --- a/tests/MobileTest.php +++ b/tests/MobileTest.php @@ -20,17 +20,26 @@ public function setUp() $this->getMockBuilder('CafeMedia\Feature\Logger')->disableOriginalConstructor()->getMock() ); } - + + /** + * @covers \CafeMedia\Feature\World\Mobile::uaid + */ public function testUaid() { $this->assertEquals($this->mobile->uaid(), 'test'); } + /** + * @covers \CafeMedia\Feature\World\Mobile::userID + */ public function testUserID() { $this->assertEquals($this->mobile->userID(), 1); } + /** + * @covers \CafeMedia\Feature\World\Mobile::getLastName + */ public function testGetLastName() { $this->assertEquals($this->mobile->getLastName(), null); @@ -42,6 +51,9 @@ public function testGetLastName() $this->assertEquals($this->mobile->getLastName(), null); } + /** + * @covers \CafeMedia\Feature\World\Mobile::getLastVariant + */ public function testGetLastVariant() { $this->assertEquals($this->mobile->getLastVariant(), null); @@ -53,6 +65,9 @@ public function testGetLastVariant() $this->assertEquals($this->mobile->getLastVariant(), null); } + /** + * @covers \CafeMedia\Feature\World\Mobile::getLastSelector + */ public function getLastSelector() { $this->assertEquals($this->mobile->getLastSelector(), null); diff --git a/tests/UtilTest.php b/tests/UtilTest.php index 0ffb064..7799f04 100644 --- a/tests/UtilTest.php +++ b/tests/UtilTest.php @@ -10,6 +10,9 @@ */ class UtilTest extends \PHPUnit_Framework_TestCase { + /** + * @covers \CafeMedia\Feature\Util::arrayGet + */ public function testArrayGet() { $this->assertEquals(Util::arrayGet('test', 'test'), null); diff --git a/tests/WorldTest.php b/tests/WorldTest.php index 941be3c..f7581d2 100644 --- a/tests/WorldTest.php +++ b/tests/WorldTest.php @@ -18,68 +18,107 @@ public function setUp() $this->getMockBuilder('CafeMedia\Feature\Logger')->disableOriginalConstructor()->getMock() ); } - + + /** + * @covers \CafeMedia\Feature\World::configValue + */ public function testConfigValue() { $this->assertEquals($this->world->configValue('test'), null); } - + + /** + * @covers \CafeMedia\Feature\World::uaid + */ public function testUaid() { $this->assertEquals($this->world->uaid(), ''); } - + + /** + * @covers \CafeMedia\Feature\World::userId + */ public function testUserId() { $this->assertEquals($this->world->userId(), ''); } - + + /** + * @covers \CafeMedia\Feature\World::userName + */ public function testUserName() { $this->assertEquals($this->world->userName(), ''); } - + + /** + * @covers \CafeMedia\Feature\World::viewingGroup + */ public function testViewingGroup() { $this->assertEquals($this->world->viewingGroup('test'), false); } + /** + * @covers \CafeMedia\Feature\World::isSource + */ public function testIsSource() { $this->assertEquals($this->world->isSource('test'), false); $this->assertEquals($this->world->isSource(''), true); } + /** + * @covers \CafeMedia\Feature\World::inGroup + */ public function testInGroup() { $this->assertEquals($this->world->inGroup(), false); } + /** + * @covers \CafeMedia\Feature\World::isAdmin + */ public function testIsAdmin() { $this->assertEquals($this->world->isAdmin(), false); } + /** + * @covers \CafeMedia\Feature\World::isInternalRequest + */ public function testIsInternalRequest() { $this->assertEquals($this->world->isInternalRequest(), false); } + /** + * @covers \CafeMedia\Feature\World::urlFeatures + */ public function testUrlFeatures() { $this->assertEquals($this->world->urlFeatures(), ''); } + /** + * @covers \CafeMedia\Feature\World::random + */ public function testRandom() { $this->assertEquals(is_numeric($this->world->random()), true); } + /** + * @covers \CafeMedia\Feature\World::hash + */ public function testHash() { $this->assertEquals($this->world->hash('test'), 0.91731063090264797); } + /** + * @covers \CafeMedia\Feature\World::selections + */ public function testSelections() { $this->world->log('test', 'test', 'test'); From 015d6ae7ad6bacb1d11220fe6e61cf99a1463909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Tue, 18 Oct 2016 12:01:20 -0400 Subject: [PATCH 24/92] disabled logging --- src/Config.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Config.php b/src/Config.php index 9cb8153..2013a23 100644 --- a/src/Config.php +++ b/src/Config.php @@ -321,10 +321,11 @@ private function chooseVariant ($bucketingID, $userID, $inVariantMethod) list($v, $selector) = $_v; - if ($inVariantMethod && $v === self::OFF) { + /*if ($inVariantMethod && $v === self::OFF) { $this->error('Variant check outside enabled check'); } - $this->_world->log($this->_name, $v, $selector); + //uncomment this to enable logging + $this->_world->log($this->_name, $v, $selector);*/ return $this->_cache[$bucketingID] = $v; } From 672040d89138a3c0758b1b0e2d739dc43978a82a Mon Sep 17 00:00:00 2001 From: Pablo Iglesias Date: Sun, 26 Feb 2017 15:44:46 -0500 Subject: [PATCH 25/92] adding support for start - end configurations. also exclude from regions support --- .gitignore | 9 - build.xml | 190 --------- build/phpdox.xml | 25 -- build/phpmd.xml | 28 -- build/phpunit.xml | 32 -- composer.json | 8 +- phpunit.xml | 16 +- src/Config.php | 695 +++----------------------------- src/Feature.php | 320 +++------------ src/Instance.php | 95 ----- src/JSON.php | 231 ----------- src/Lint.php | 369 ----------------- src/Logger.php | 48 --- src/Util.php | 26 -- src/World.php | 249 +++--------- src/World/Mobile.php | 113 ------ tests/ConfigTest.php | 162 ++++---- tests/FeatureTest.php | 158 ++++---- tests/MobileTest.php | 81 ---- tests/UtilTest.php | 21 - tests/WorldTest.php | 105 ++--- tests/data/world/etsy_aux.yml | 6 - tests/data/world/etsy_index.yml | 14 - 23 files changed, 379 insertions(+), 2622 deletions(-) delete mode 100644 build.xml delete mode 100644 build/phpdox.xml delete mode 100644 build/phpmd.xml delete mode 100644 build/phpunit.xml delete mode 100644 src/Instance.php delete mode 100644 src/JSON.php delete mode 100644 src/Lint.php delete mode 100644 src/Logger.php delete mode 100644 src/Util.php delete mode 100644 src/World/Mobile.php delete mode 100644 tests/MobileTest.php delete mode 100644 tests/UtilTest.php delete mode 100644 tests/data/world/etsy_aux.yml delete mode 100644 tests/data/world/etsy_index.yml diff --git a/.gitignore b/.gitignore index ee66b64..f7015c3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,3 @@ vendor/ .idea/ composer.lock -build/api -build/code-browser -build/coverage -build/logs -build/pdepend -build/phpdox -build/testdox -cache.properties - diff --git a/build.xml b/build.xml deleted file mode 100644 index 91dc704..0000000 --- a/build.xml +++ /dev/null @@ -1,190 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/build/phpdox.xml b/build/phpdox.xml deleted file mode 100644 index 98736f1..0000000 --- a/build/phpdox.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/build/phpmd.xml b/build/phpmd.xml deleted file mode 100644 index 7666085..0000000 --- a/build/phpmd.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - Sebastian Bergmann's ruleset - - - - - - - - - - - - - - - - - - - - diff --git a/build/phpunit.xml b/build/phpunit.xml deleted file mode 100644 index f36fa18..0000000 --- a/build/phpunit.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - ../tests - - - - - - - - - - - - - - ../src - - - - diff --git a/composer.json b/composer.json index 37f8d7c..f2078c4 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "cafemedia/feature", - "description": "PSR-4 based Etsy Feature Flags library", + "description": "PSR-4 compliant Feature Flags library based on Etsy", "authors": [ { "name": "Pablo Iglesias", @@ -8,11 +8,7 @@ } ], "require": { - "php": ">=5.3.3", - "psr/log": "^1.0" - }, - "require-dev": { - "phpunit/phpunit": "4.*" + "php": ">=5.6" }, "autoload": { "psr-4": { diff --git a/phpunit.xml b/phpunit.xml index 32708f9..2e25263 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,18 +1,24 @@ - + stopOnFailure="true" + syntaxCheck="true" + verbose="true"> ./tests/ + + + src + + - diff --git a/src/Config.php b/src/Config.php index 2013a23..c090c20 100644 --- a/src/Config.php +++ b/src/Config.php @@ -3,151 +3,30 @@ namespace CafeMedia\Feature; /** + * Construct a Config object from its config stanza. + * * A feature that can be enabled, disabled, ramped up, and A/B tested, * as well as enabled for certain classes of users. These objects * should not be accessed directly but rather through the API provided * by Feature.php which is more convenient and provides some caching. - * - * Class Config - * @package CafeMedia\Feature */ class Config { - /* Keys used in a feature configuration. */ - - const DESCRIPTION = 'description'; - - const ENABLED = 'enabled'; - - const USERS = 'users'; - - const GROUPS = 'groups'; - - const SOURCES = 'sources'; - - const ADMIN = 'admin'; - - const INTERNAL = 'internal'; - - const PUBLIC_URL_OVERRIDE = 'public_url_override'; - - const BUCKETING = 'bucketing'; + private $cache = []; + private $world; + private $stanza; + private $name = ''; - /* Special values for enabled property. */ - - const ON = 'on'; /* Feature is fully enabled. */ - - const OFF = 'off'; /* Feature is fully disabled. */ - - /* Bucketing schemes. */ - - const UAID = 'uaid'; - - const USER = 'user'; - - const RANDOM = 'random'; - - /** - * @var - */ - private $_name; - /** - * @var array - */ - private $_cache; - /** - * @var World - */ - private $_world; - - /** - * @var mixed|null - */ - private $_description; - /** - * @var array|int|mixed|null - */ - private $_enabled; - /** - * @var array - */ - private $_users; - /** - * @var array - */ - private $_groups; - /** - * @var array - */ - private $_sources; - /** - * @var bool|mixed|null - */ - private $_adminVariant; - /** - * @var bool|mixed|null - */ - private $_internalVariant; - /** - * @var mixed|null - */ - private $_public_url_override; - /** - * @var mixed|null - */ - private $_bucketing; - - /** - * @var array - */ - private $_percentages; - - /** - * @var Logger - */ - private $logger; - - /** - * Construct a Config object from its config stanza. - * - * Config constructor. - * @param $name - * @param $stanza - * @param World $world - * @param Logger $logger - */ - public function __construct($name, $stanza, World $world, Logger $logger) + public function __construct(World $world) { - $this->_name = $name; - $this->_cache = array(); - $this->_world = $world; - $this->logger = $logger; - - // Special case to save some memory--if the value is just a - // string that is the same as setting enabled to that variant - // (typically 'on' or 'off' but possibly another variant - // name). This reduces the number of array objects we have to - // create when reading the config file. - if (is_null($stanza)) { - $stanza = array(self::ENABLED => self::OFF); - } - elseif (is_string($stanza)) { - $stanza = array(self::ENABLED => $stanza); - } - - // Pull stuff from the config stanza. - $this->_description = $this->parseDescription($stanza); - $this->_enabled = $this->parseEnabled($stanza); - $this->_users = $this->parseUsersOrGroups($stanza, self::USERS); - $this->_groups = $this->parseUsersOrGroups($stanza, self::GROUPS); - $this->_sources = $this->parseUsersOrGroups($stanza, self::SOURCES); - $this->_adminVariant = $this->parseVariantName($stanza, self::ADMIN); - $this->_internalVariant = $this->parseVariantName($stanza, self::INTERNAL); - $this->_public_url_override = $this->parsePublicURLOverride($stanza); - $this->_bucketing = $this->parseBucketBy($stanza); + $this->world = $world; + } - // Put the _enabled value into a more useful form for actually doing bucketing. - $this->_percentages = $this->computePercentages(); + public function addName($name) + { + $this->name = $name; + $this->stanza = new Stanza($this->world->configValue($name)); + return $this; } //////////////////////////////////////////////////////////////////////// @@ -155,37 +34,28 @@ public function __construct($name, $stanza, World $world, Logger $logger) // should be using this class directly. /** - * Is this feature enabled for the default id and the logged in - * user, if any? - * - * @return bool + * Is this feature enabled for the default id and the logged i user, if any? */ - public function isEnabled () + public function isEnabled() { - return $this->chooseVariant($this->bucketingID(), $this->_world->userID(), false) !== self::OFF; + return $this->chooseVariant($this->bucketingID()) !== 'off'; } /** * What variant is enabled for the default id and the logged in * user, if any? - * - * @return mixed|string */ - public function variant () + public function variant() { - return $this->chooseVariant($this->bucketingID(), $this->_world->userID(), true); + return $this->chooseVariant($this->bucketingID()); } /** * Is this feature enabled for the given user? - * - * @param $user - * @return bool */ - public function isEnabledFor ($user) + public function isEnabledFor(User $user) { - $userID = $this->getUserIdFrom($user); - return $this->chooseVariant($userID, $userID, false) !== self::OFF; + return $this->chooseVariant($user->id) === 'on'; } /** @@ -193,63 +63,39 @@ public function isEnabledFor ($user) * ID? (Other methods of enabling a feature and specifying a * variant such as users, groups, and query parameters, will still * work.) - * - * @param $bucketingID - * @return bool */ - public function isEnabledBucketingBy ($bucketingID) + public function isEnabledBucketingBy($bucketingID) { - return $this->chooseVariant($bucketingID, $this->_world->userID(), false) !== self::OFF; + return $this->chooseVariant($bucketingID) !== 'off'; } /** * What variant is enabled for the given user? - * - * @param $user - * @return mixed|string */ - public function variantFor ($user) + public function variantFor(User $user) { - $userID = $this->getUserIdFrom($user); - return $this->chooseVariant($userID, $userID, true); + return $this->chooseVariant($user->id); } /** - * What variant is enabled, bucketing on the given bucketing ID, - * if any? - * - * @param $bucketingID - * @return mixed|string + * What variant is enabled, bucketing on the given bucketing ID, if any? */ - public function variantBucketingBy ($bucketingID) + public function variantBucketingBy($bucketingID) { - return $this->chooseVariant($bucketingID, $this->_world->userID(), true); + return $this->chooseVariant($bucketingID); } /** * Description of the feature. - * - * @return mixed|null */ - public function description () + public function description() { - return $this->_description; + return $this->stanza->description; } //////////////////////////////////////////////////////////////////////// // Internals - /** - * Accept different user objects and return user_id - * - * @param $user - * @return mixed - */ - private function getUserIdFrom($user) - { - return $user->user_id; - } - /** * Get the name of the variant we should use. Returns OFF if the * feature is not enabled for $id. When $inVariantMethod is @@ -264,467 +110,50 @@ private function getUserIdFrom($user) * * @param $bucketingID - the id used to assign a variant based on * the percentage of users that should see different variants. - * - * @param $userID - the identity of the user to be used for the - * special 'admin', 'users', and 'groups' access checks. - * - * @param $inVariantMethod - were we called from variant or - * variantFor, in which case we want to perform some certain - * sanity checks to make sure the code is being used correctly. - * - * @return array|int|mixed|null|string */ - private function chooseVariant ($bucketingID, $userID, $inVariantMethod) + private function chooseVariant($bucketingID) { - if ($inVariantMethod && $this->_enabled === self::ON) { - $this->error('Variant check when fully enabled'); - } - - if (is_string($this->_enabled)) { - // When enabled is on, off, or a variant name, that's the - // end of the story. - return $this->_enabled; - } - - if (is_null($bucketingID)) { - throw new \InvalidArgumentException( - 'no bucketing ID supplied. if testing, configure feature ' . - "with enabled => 'on' or 'off', feature name = $this->_name" - ); + if (!$bucketingID) { + throw new \InvalidArgumentException('no bucketing ID supplied.'); } $bucketingID = (string)$bucketingID; - if (isset($this->_cache[$bucketingID])) { - // Note that this caching is not just an optimization: - // it prevents us from double logging a single - // feature--we only want to log each distinct checked - // feature once. - // - // The caching also affects the semantics when we use - // random bucketing (rather than hashing the id), i.e. - // 'random' => 'true', by making the variant and - // enabled status stable within a request. - return $this->_cache[$bucketingID]; - } - - if ($_v = $this->variantFromURL($userID)) {} - elseif ($_v = $this->variantForUser($userID)) {} - elseif ($_v = $this->variantForGroup($userID)) {} - elseif ($_v = $this->variantForViewingGroup($userID)) {} - elseif ($_v = $this->variantForSource($userID)) {} - elseif ($_v = $this->variantForAdmin($userID)) {} - elseif ($_v = $this->variantForInternal()) {} - elseif ($_v = $this->variantByPercentage($bucketingID)) {} - else { - $_v = array(self::OFF, 'w'); + if (isset($this->cache[$bucketingID])) { + return $this->cache[$bucketingID]; } - list($v, $selector) = $_v; - - /*if ($inVariantMethod && $v === self::OFF) { - $this->error('Variant check outside enabled check'); - } - //uncomment this to enable logging - $this->_world->log($this->_name, $v, $selector);*/ - - return $this->_cache[$bucketingID] = $v; + return $this->cache[$bucketingID] = (string)(new Variant($this->world)) + ->addStanza($this->stanza) + ->addBucketingID($bucketingID) + ->addName($this->name); } /** * Return the globally accessible ID used by the one-arg isEnabled * and variant methods based on the feature's bucketing property. - * - * @return null|string - */ - private function bucketingID () - { - switch ($this->_bucketing) { - case self::UAID: - case self::RANDOM: - // In the RANDOM case we still need a bucketing id to keep - // the assignment stable within a request. - // Note that when being run from outside of a web request (e.g. crons), - // there is no UAID, so we default to a static string - $uaid = $this->_world->uaid(); - return $uaid ? $uaid : 'no uaid'; - case self::USER: - $userID = $this->_world->userID(); - // Not clear if this is right. There's an argument to be - // made that if we're bucketing by userID and the user is - // not logged in we should treat the feature as disabled. - return !is_null($userID) ? $userID : $this->_world->uaid(); - default: - throw new \InvalidArgumentException("Bad bucketing: $this->_bucketing"); - } - } - - /** - * For internal requests or if the feature has public_url_override - * set to true, a specific variant can be specified in the - * 'features' query parameter. In all other cases return false, - * meaning nothing was specified. Note that foo:off will turn off - * the 'foo' feature. - * - * @param $userID - * @return array|bool|void - */ - private function variantFromURL ($userID) - { - if (!$this->_public_url_override && !$this->_world->isInternalRequest() && !$this->_world->isAdmin($userID)) { - return false; - } - - $urlFeatures = $this->_world->urlFeatures(); - if (!$urlFeatures) { - return false; - } - - foreach (explode(',', $urlFeatures) as $f) { - $parts = explode(':', $f); - if ($parts[0] === $this->_name) { - return array(isset($parts[1]) ? $parts[1] : self::ON, 'o'); - } - } - - return false; - } - - /** - * Get the variant this user should see, if one was configured, - * false otherwise. - * - * @param $userID - * @return array|bool - */ - private function variantForUser ($userID) - { - if (!$this->_users) { - return false; - } - - $name = $this->_world->userName($userID); - if ($name && isset($this->_users[strtolower($name)])) { - return array($this->_users[strtolower($name)], 'u'); - } - - return false; - } - - /** - * Get the variant visitor should see based on group - * they're currently viewing - * - * @param $userID - * @return array|bool - */ - private function variantForViewingGroup ($userID = null) - { - foreach ($this->_groups as $groupID => $variant) { - if ($this->_world->viewingGroup($groupID)) { - return array($variant, 'g'); - } - } - return false; - } - - /** - * Get the variant visitor should see based on group - * they're currently viewing - * - * @param $userID - * @return array|bool - */ - private function variantForSource ($userID = null) - { - foreach ($this->_sources as $source => $variant) { - if ($this->_world->isSource($source)) { - return array($variant, 's'); - } - } - return false; - } - - /** - * Get the variant this user should see based on their group - * memberships, if one was configured, false otherwise. N.B. If - * the user is in multiple groups that are configured to see - * different variants, they'll get the variant for one of their - * groups but there's no saying which one. If this is a problem in - * practice we could make the configuration more complex. Or you - * can just provide a specific variant via the 'users' property. - * - * @param $userID - * @return array|bool - */ - private function variantForGroup ($userID) - { - if (!$userID) { - return false; - } - - foreach ($this->_groups as $groupID => $variant) { - if ($this->_world->inGroup($userID, $groupID)) { - return array($variant, 'g'); - } - } - - return false; - } - - /** - * What variant, if any, should we return if the current user is - * an admin. - * - * @param $userID - * @return array|bool - */ - private function variantForAdmin ($userID) - { - if ($userID && $this->_adminVariant && $this->_world->isAdmin($userID)) { - return array($this->_adminVariant, 'a'); - } - return false; - } - - /** - * What variant, if any, should we return for internal requests. - * - * @return array|bool - */ - private function variantForInternal () - { - if ($this->_internalVariant && $this->_world->isInternalRequest()) { - return array($this->_internalVariant, 'i'); - } - return false; - } - - /** - * Finally, the normal case: use the percentage of users who - * should see each variant to map a randomish number to a - * particular variant. - * - * @param $id - * @return array|bool - */ - private function variantByPercentage ($id) - { - $n = 100 * $this->randomish($id); - foreach ($this->_percentages as $v) { - // === 100 check may not be necessary but I'm not good - // enough numerical analyst to be sure. - if ($n < $v[0] || $v[0] === 100) { - return array($v[1], 'w'); - } - } - return false; - } - - /** - * A randomish number in [0, 1) based on the feature name and $id - * unless we are bucketing completely at random - * - * @param $id - * @return float|int */ - private function randomish ($id) - { - if ($this->_bucketing === self::RANDOM) { - return $this->_world->random(); - } - return $this->_world->hash("$this->_name-$id"); - } - - //////////////////////////////////////////////////////////////////////// - // Configuration parsing - - /** - * @param $stanza - * @return mixed|null - */ - private function parseDescription ($stanza) - { - return Util::arrayGet($stanza, self::DESCRIPTION, 'No description.'); - } - - /** - * Parse the 'enabled' property of the feature's config stanza. - * - * @param $stanza - * @return array|int|mixed|null - */ - private function parseEnabled ($stanza) - { - $enabled = Util::arrayGet($stanza, self::ENABLED, 0); - - if (is_numeric($enabled)) { - if ($enabled < 0) { - $this->error("enabled ($enabled) < 0"); - $enabled = 0; - } - elseif ($enabled > 100) { - $this->error("enabled ($enabled) > 100"); - $enabled = 100; - } - return array('on' => $enabled); - - } - if (is_string($enabled) or is_array($enabled)) { - return $enabled; - } - - $this->error('Malformed enabled property'); - return false; - } - - /** - * Returns an array of pairs with the first element of the pair - * being the upper-boundary of the variants percentage and the - * second element being the name of the variant. - * - * @return array - */ - private function computePercentages () - { - $total = 0; - $percentages = array(); - if (!is_array($this->_enabled)) { - return $percentages; - } - - foreach ($this->_enabled as $variant => $percentage) { - if (!is_numeric($percentage) || $percentage < 0 || $percentage > 100) { - $this->error("Bad percentage $percentage"); - } - if ($percentage > 0) { - $total += $percentage; - $percentages[] = array($total, $variant); - } - if ($total > 100) { - $this->error("Total of percentages > 100: $total"); - } - } - - return $percentages; - } - - /** - * Parse the value of the 'users' and 'groups' properties of the - * feature's config stanza, returning an array mappinng the user - * or group names to they variant they should see. - * - * @param $stanza - * @param $what - * @return array - */ - private function parseUsersOrGroups ($stanza, $what) - { - $value = Util::arrayGet($stanza, $what); - if (is_string($value) || is_numeric($value)) { - // Users are configrued with their user names. Groups as - // numeric ids. (Not sure if that's a great idea.) - return array($value => self::ON); - - } - - $result = array(); - - if (self::isList($value)) { - foreach ($value as $who) { - $result[strtolower($who)] = self::ON; - } - return $result; - } - - if (!is_array($value)) { - return $result; - } - - $bad_keys = is_array($this->_enabled) ? array_keys(array_diff_key($value, $this->_enabled)) : false; - if ($bad_keys) { - $this->error("Unknown variants " . implode(', ', $bad_keys)); - return $result; - } - - foreach ($value as $variant => $whos) { - foreach (self::asArray($whos) as $who) { - $result[strtolower($who)] = $variant; - } - } - - return $result; - } - - /** - * Parse the variant name value for the 'admin' and 'internal' - * properties. If non-falsy, must be one of the keys in the - * enabled map unless enabled is 'on' or 'off'. - * - * @param $stanza - * @param $what - * @return bool|mixed|null - */ - private function parseVariantName ($stanza, $what) - { - $value = Util::arrayGet($stanza, $what); - if (!$value) { - return false; - } - - if (is_array($this->_enabled) && !isset($this->_enabled[$value])) { - $this->error("Unknown variant $value"); - } - - return $value; - } - - /** - * @param $stanza - * @return mixed|null - */ - private function parsePublicURLOverride ($stanza) - { - return Util::arrayGet($stanza, self::PUBLIC_URL_OVERRIDE, false); - } - - /** - * @param $stanza - * @return mixed|null - */ - private function parseBucketBy ($stanza) - { - return Util::arrayGet($stanza, self::BUCKETING, self::UAID); - } - - //////////////////////////////////////////////////////////////////////// - // Genericish utilities - - /** - * Is the given object an array value that could have been created - * with array(...) with no =>'s in the ...? - * - * @param $a - * @return bool - */ - private static function isList($a) - { - return is_array($a) and array_keys($a) === range(0, count($a) - 1); - } - - /** - * @param $x - * @return array - */ - private static function asArray ($x) - { - return is_array($x) ? $x : array($x); - } - - /** - * @param $message - */ - private function error ($message) - { - $this->logger->error("$message: feature $this->_name"); + private function bucketingID() + { + if ($this->stanza->bucketing === 'random' || + $this->stanza->bucketing === 'uaid' + ) { + // In the RANDOM case we still need a bucketing id to keep + // the assignment stable within a request. + // Note that when being run from outside of a web request + // (e.g. crons), + // there is no UAID, so we default to a static string + $uaid = $this->world->uaid(); + return $uaid ? $uaid : 'no uaid'; + } + if ($this->stanza->bucketing === 'user') { + $userID = $this->world->userID(); + // Not clear if this is right. There's an argument to be + // made that if we're bucketing by userID and the user is + // not logged in we should treat the feature as disabled. + return $userID ? $userID : $this->world->uaid(); + } + throw new \InvalidArgumentException( + "Bad bucketing: {$this->stanza->bucketing}" + ); } } diff --git a/src/Feature.php b/src/Feature.php index 4c1c7b8..d11e36b 100644 --- a/src/Feature.php +++ b/src/Feature.php @@ -2,8 +2,6 @@ namespace CafeMedia\Feature; -use Psr\Log\LoggerInterface; - /** * The public API testing whether a specific feature is enabled and, * if so, what variant should be used. @@ -25,140 +23,45 @@ * * Feature::isEnabledBucketingBy('foo', $bucketingID); * Feature::variantBucketingBy('foo', $bucketingID); - * - * In addition, in order to support Smarty templates, which can't call - * static methods, the getInstance() method returns a singleton object - * that can be passed to templates and which provides the same API via - * instance methods. - * - * Class Feature - * @package CafeMedia\Feature */ class Feature { - /** - * @var - */ - private static $defaultWorld; - - /** - * @var array - */ - private static $configCache = array(); - - /** - * @var - */ - private static $instance; - - /** - * @var null - */ - private static $logger = null; - - /** - * @var LoggerInterface - */ - private static $log; - - /** - * @var array - */ - private static $features = array(); - - /** - * @var string - */ - private static $uaid = ''; - - /** - * @var string - */ - private static $userID = ''; - - /** - * @var string - */ - private static $userName = ''; - - /** - * @var null - */ - private static $group = null; - - /** - * @var string - */ - private static $source = ''; - - /** - * @var bool - */ - private static $isAdmin = false; + private $world; + private $configCache = []; + private $features = []; + private $source = ''; + private $url = ''; + private $user; + + public function __construct(array $config) + { + $this->features = $config; + } - /** - * @var string - */ - private static $url = ''; + public function addUser(array $user) + { + $this->user = new User($user); + return $this; + } - /** - * Feature constructor. - * @param LoggerInterface $log - * @param array $features - * @param string $uaid - * @param string $userID - * @param string $userName - * @param null $group - * @param string $source - * @param bool $isAdmin - * @param string $url - */ - public function __construct( - LoggerInterface $log, - array $features = array(), - $uaid = '', - $userID = '', - $userName = '', - $group = null, - $source = '', - $isAdmin = false, - $url = '' - ) { - self::$log = $log; - self::$features = $features; - self::$uaid = $uaid; - self::$userID = $userID; - self::$userName = $userName; - self::$group = $group; - self::$source = $source; - self::$isAdmin = $isAdmin; - self::$url = $url; + public function addSource($source) + { + $this->source = $source; + return $this; } - /** - * Get an object that can be passed to Smarty templates that wraps - * our API with non-static methods of the same names and arguments. - * - * @return Instance - */ - public static function getInstance() + public function addUrl($url) { - if (!isset(self::$instance)) { - self::$instance = new Instance(); - } - return self::$instance; + $this->source = $url; + return $this; } /** * Test whether the named feature is enabled for the current user. - * - * @static - * @param string $name the config key for this feature. - * @return bool */ - public static function isEnabled ($name) + public function isEnabled($name) { - return self::fromConfig($name)->isEnabled(); + return $this->fromConfig($name)->isEnabled(); } /** @@ -166,36 +69,21 @@ public static function isEnabled ($name) * user. This method should only be used when we want to bucket * based on a user other than the current logged in user, e.g. if * we are bucketing different listings based on their owner. - * - * @static - * @param string $name the config key for this feature. - * - * @param $user - A user object whose id will be combined with $name - * and hashed to get the bucketing. - * - * @return bool */ - public static function isEnabledFor($name, $user) + public function isEnabledFor($name, array $user) { - return self::fromConfig($name)->isEnabledFor($user); + return $this->fromConfig($name)->isEnabledFor(new User($user)); } /** * Test whether the named feature is enabled for a given * arbitrary string. This method should only be used when we want to bucket - * based on something other than a user, e.g. shops, teams, treasuries, tags, etc. - * - * @static - * @param string $name the config key for this feature. - * - * @param $string - A string which will be combined with $name - * and hashed to get the bucketing. - * - * @return bool + * based on something other than a user, + * e.g. shops, teams, treasuries, tags, etc. */ - public static function isEnabledBucketingBy($name, $string) + public function isEnabledBucketingBy($name, $string) { - return self::fromConfig($name)->isEnabledBucketingBy($string); + return $this->fromConfig($name)->isEnabledBucketingBy($string); } /** @@ -209,14 +97,10 @@ public static function isEnabledBucketingBy($name, $string) * feature has been fully enabled. To clean up a finished * experiment, first set 'enabled' to the name of the winning * variant. - * - * @static - * @param string $name the config key for the feature. - * @return mixed|string */ - public static function variant($name) + public function variant($name) { - return self::fromConfig($name)->variant(); + return $this->fromConfig($name)->variant(); } /** @@ -234,18 +118,10 @@ public static function variant($name) * feature has been fully enabled. To clean up a finished * experiment, first set 'enabled' to the name of the winning * variant. - * - * @static - * - * @param string $name the config key for the feature. - * - * @param $user - A user object whose id will be combined with $name - * and hashed to get the bucketing. - * @return mixed|string */ - public static function variantFor($name, $user) + public function variantFor($name, array $user) { - return self::fromConfig($name)->variantFor($user); + return $this->fromConfig($name)->variantFor(new User($user)); } /** @@ -263,136 +139,40 @@ public static function variantFor($name, $user) * feature has been fully enabled. To clean up a finished * experiment, first set 'enabled' to the name of the winning * variant. - * - * @static - * - * @param string $name the config key for the feature. - * - * @param string $bucketingID A string to use as the bucketing ID. - * @return mixed|string - */ - public static function variantBucketingBy($name, $bucketingID) - { - return self::fromConfig($name)->variantBucketingBy($bucketingID); - } - - /** - * Description of the feature. - * - * @param $name - * @return mixed|null - */ - public static function description ($name) - { - return self::fromConfig($name)->description(); - } - - /** - * Get data related to a Feature name: config must be nested - * under the Feature name, in an array key named 'data'. - * - * @param string $name the Feature key to find data for - * @param mixed $default what to return if not defined - * - * @return mixed */ - public static function data($name, $default = array()) + public function variantBucketingBy($name, $bucketingID) { - return self::world()->configValue("$name.data", $default); + return $this->fromConfig($name)->variantBucketingBy($bucketingID); } - /** - * Get data linked to a Feature name, specific for the enabled variant. - * Nest data in an array named 'data' with a key for each variant. - * - * @param string $name the Feature key to find data for - * @param mixed $default what to return if not found - * - * @return mixed - */ - public static function variantData($name, $default = array()) + public function description($name) { - $data = self::data($name); - $variant = self::variant($name); - return isset($data[$variant]) ? $data[$variant] : $default; + return $this->fromConfig($name)->description(); } /** * Get the named feature object. We cache the object after * building it from the config stanza to speed lookups. - * - * @static - * - * @param $name - name of the feature. Used as a key into the global config array - * - * @return Config */ - private static function fromConfig($name) + private function fromConfig($name) { - if (isset(self::$configCache[$name])) { - return self::$configCache[$name]; - } - - $world = self::world(); - return self::$configCache[$name] = new Config($name, $world->configValue($name), $world, self::$logger); - } + if (isset($this->configCache[$name])) return $this->configCache[$name]; - /** - * N.B. This method is for testing only. (The issue is that once a - * Feature has been checked once, the result of the check is - * cached but in tests we need to change the configuration and - * have those changes be reflected in feature checks.) - */ - public static function clearCacheForTests() - { - self::$configCache = array(); - } - - /** - * Get the list of selections that have been made as an array of - * (feature_name, variant_name, selector) arrays. This can be used - * to record information about what features were associated with - * what variants and why during the course of handling a request. - * - * @return array - */ - public static function selections () - { - return self::world()->selections(); + $this->configCache[$name] = (new Config($this->world()))->addName($name); + return $this->configCache[$name]; } /** * This API always uses the default World. Config takes * the world as an argument in order to ease unit testing. - * - * @return World - */ - private static function world () - { - if (!isset(self::$defaultWorld)) { - self::$defaultWorld = new World( - self::logger(), - static::$features, - static::$uaid, - static::$userID, - static::$userName, - static::$group, - static::$source, - static::$isAdmin, - static::$url - ); - } - return self::$defaultWorld; - } - - /** - * @return Logger|null */ - private static function logger () + private function world() { - if (is_null(self::$logger)) { - self::$logger = new Logger(self::$log); - } - return self::$logger; + if ($this->world instanceof World) return $this->world; + $this->world = (new World($this->features))->addUser($this->user) + ->addSource($this->source) + ->addUrl($this->url); + unset($this->features, $this->user, $this->source, $this->url); + return $this->world; } } diff --git a/src/Instance.php b/src/Instance.php deleted file mode 100644 index d5a8340..0000000 --- a/src/Instance.php +++ /dev/null @@ -1,95 +0,0 @@ - array('prepend' => '_gaq.push(', 'append' => ');'), - 'mobile' => array('prepend' => '', 'append' => ','), - ); - if (!isset($types[$format])) { - $format = 'web'; - } - - return "{$types[$format]['prepend']}['_setCustomVar', 3, 'AB', 'null', 3]{$types[$format]['append']}"; - } -} diff --git a/src/JSON.php b/src/JSON.php deleted file mode 100644 index 2ae7cb3..0000000 --- a/src/JSON.php +++ /dev/null @@ -1,231 +0,0 @@ - (int)$value); - } - else if (is_string($value)) { - $value = array('enabled' => $value); - } - - $enabled = Util::arrayGet($value, 'enabled', 0); - $users = self::expandUsersOrGroups(Util::arrayGet($value, 'users', array())); - $groups = self::expandUsersOrGroups(Util::arrayGet($value, 'groups', array())); - - if ($enabled === 'off') { - $spec['variants'][] = self::makeVariantWithUsersAndGroups('on', 0, $users, $groups); - $internal_url = false; - } - else if (is_numeric($enabled)) { - $spec['variants'][] = self::makeVariantWithUsersAndGroups('on', (int)$enabled, $users, $groups); - } - else if (is_string($enabled)) { - $spec['variants'][] = self::makeVariantWithUsersAndGroups($enabled, 100, $users, $groups); - $internal_url = false; - } - else if (is_array($enabled)) { - foreach ($enabled as $v => $p) { - if (is_numeric($p)) { - // Kind of a kludge. $p had better be numeric and - // there have been configs deployed where it - // wasn't which breaks the Catapult config history - // scripts. This will just skip those. - $spec['variants'][] = self::makeVariantWithUsersAndGroups($v, $p, $users, $groups); - } - } - } - $spec['internal_url_override'] = $internal_url; - - if (isset($value['admin'])) { - $spec['admin'] = $value['admin']; - } - if (isset($value['internal'])) { - $spec['internal'] = $value['internal']; - } - if (isset($value['bucketing'])) { - $spec['bucketing'] = $value['bucketing']; - } - if (isset($value['internal'])) { - $spec['internal'] = $value['internal']; - } - if (isset($value['public_url_override'])) { - $spec['public_url_override'] = $value['public_url_override']; - } - - return $spec; - } - - /** - * @param $key - * @return array - */ - private static function makeSpec ($key) - { - return array( - 'key' => $key, - 'internal_url_override' => false, - 'public_url_override' => false, - 'bucketing' => 'uaid', - 'admin' => null, - 'internal' => null, - 'variants' => array() - ); - } - - /** - * @param $name - * @param $percentage - * @return array - */ - private static function makeVariant ($name, $percentage) - { - return array( - 'name' => $name, - 'percentage' => $percentage, - 'users' => array(), - 'groups' => array() - ); - } - - /** - * @param $name - * @param $percentage - * @param $users - * @param $groups - * @return array - */ - private static function makeVariantWithUsersAndGroups ($name, $percentage, $users, $groups) - { - return array( - 'name' => $name, - 'percentage' => $percentage, - 'users' => self::extractForVariant($users, $name), - 'groups' => self::extractForVariant($groups, $name), - ); - } - - /** - * @param $usersOrGroups - * @param $name - * @return array - */ - private static function extractForVariant ($usersOrGroups, $name) - { - $result = array(); - foreach ($usersOrGroups as $thing => $variant) { - if ($variant == $name) { - $result[] = $thing; - } - } - return $result; - } - - /** - * This is based on parseUsersOrGroups in Config. Probably - * this logic should be put in that class in a form that we can - * use. - * - * @param $value - * @return array - */ - private static function expandUsersOrGroups ($value) - { - if (is_string($value) || is_numeric($value)) { - return array($value => Config::ON); - } - - $result = array(); - - if (self::isList($value)) { - foreach ($value as $who) { - $result[$who] = Config::ON; - } - return $result; - } - - if (!is_array($value)) { - return $result; - } - - foreach ($value as $variant => $whos) { - foreach (self::asArray($whos) as $who) { - $result[$who] = $variant; - } - } - - return $result; - } - - /** - * @param $a - * @return bool - */ - private static function isList($a) - { - return is_array($a) and array_keys($a) === range(0, count($a) - 1); - } - - /** - * @param $x - * @return array - */ - private static function asArray ($x) - { - return is_array($x) ? $x : array($x); - } -} diff --git a/src/Lint.php b/src/Lint.php deleted file mode 100644 index e4e7949..0000000 --- a/src/Lint.php +++ /dev/null @@ -1,369 +0,0 @@ - 100. - * - * Class Lint - * @package CafeMedia\Feature - */ -class Lint -{ - /** - * @var int - */ - private $_checked; - /** - * @var array - */ - private $_errors; - /** - * @var array - */ - private $_path; - - /** - * @var null - */ - private $server_config; - /** - * @var Logger - */ - private $logger; - - /** - * Lint constructor. - * @param null $server_config - * @param Logger $logger - */ - public function __construct($server_config = null, Logger $logger) - { - $this->server_config = $server_config; - $this->logger = $logger; - $this->_checked = 0; - $this->_errors = array(); - $this->_path = array(); - $this->syntax_keys = array( - Config::ENABLED, - Config::USERS, - Config::GROUPS, - Config::ADMIN, - Config::INTERNAL, - Config::PUBLIC_URL_OVERRIDE, - Config::BUCKETING, - 'data' - ); - - $this->_legal_bucketing_values = array(Config::UAID, Config::USER, Config::RANDOM); - } - - /** - * @param null $file - */ - public function run($file = null) - { - $config = $this->fromFile($file); - $this->assert($config, '*** Bad configuration.'); - $this->lintNested($config); - } - - /** - * @return int - */ - public function checked() - { - return $this->_checked; - } - - /** - * @return array - */ - public function errors() - { - return $this->_errors; - } - - /** - * @param $file - * @return bool - */ - private function fromFile($file) - { - error_reporting(0); - $r = eval('?>' . file_get_contents($file)); - error_reporting(-1); - - if ($r === null) { - return $this->server_config; - } - - if ($r === false) { - return false; - } - - $this->logger->error("Wut? $r"); - return false; - } - - /** - * Recursively check nested feature configurations. Skips any keys - * that have a syntactic meaning which includes 'data'. - * - * @param $config - */ - private function lintNested($config) - { - foreach ($config as $name => $stanza) { - if (!in_array($name, $this->syntax_keys)) { - $this->lint($name, $stanza); - } - } - } - - /** - * @param $name - * @param $stanza - */ - private function lint($name, $stanza) - { - $this->_path[] = $name; - ++$this->_checked; - - if (is_array($stanza)) { - $this->checkForOldstyle($stanza); - $this->checkEnabled($stanza); - $this->checkUsers($stanza); - $this->checkGroups($stanza); - $this->checkAdmin($stanza); - $this->checkInternal($stanza); - $this->checkPublicURLOverride($stanza); - $this->checkBucketing($stanza); - $this->lintNested($stanza); - } - else { - $this->assert(is_string($stanza), "Bad stanza: $stanza."); - } - - array_pop($this->_path); - } - - /** - * @param $ok - * @param $message - */ - private function assert($ok, $message) - { - if (!$ok) { - $this->_errors[] = '[' . implode('.', $this->_path) . "] $message"; - } - } - - /** - * @param $stanza - */ - private function checkForOldstyle($stanza) - { - $this->assert(Util::arrayGet( - $stanza, - Config::ENABLED, 0) !== 'rampup' || !Util::arrayGet($stanza, 'rampup', null), - 'Old-style config syntax detected.' - ); - } - - /** - * 'enabled' must be a string, a number in [0,100], or an array of - * (string => ints) such that the ints are all in [0,100] and the - * total is <= 100. - * - * @param $stanza - */ - private function checkEnabled($stanza) - { - if (!isset($stanza[Config::ENABLED])) { - return; - } - - if (is_numeric($stanza[Config::ENABLED])) { - $this->assert($stanza[Config::ENABLED] >= 0, Config::ENABLED . " too small: {$stanza[Config::ENABLED]}"); - $this->assert($stanza[Config::ENABLED] <= 100, Config::ENABLED . "too big: {$stanza[Config::ENABLED]}"); - return; - } - - if (!is_array($stanza[Config::ENABLED])) { - return; - } - - $tot = 0; - foreach ($stanza[Config::ENABLED] as $k => $v) { - $this->assert(is_string($k), "Bad key $k in {$stanza[Config::ENABLED]}"); - $this->assert(is_numeric($v), "Bad value $v for $k in {$stanza[Config::ENABLED]}"); - $this->assert($v >= 0, "Bad value $v (too small) for $k"); - $this->assert($v <= 100, "Bad value $v (too big) for $k"); - if (is_numeric($v)) { - $tot += $v; - } - } - $this->assert($tot >= 0, "Bad total $tot (too small)"); - $this->assert($tot <= 100, "Bad total $tot (too big)"); - } - - /** - * @param $stanza - */ - private function checkUsers($stanza) - { - if (!isset($stanza[Config::USERS])) { - return; - } - - if (!is_array($stanza[Config::USERS]) || self::isList($stanza[Config::USERS])) { - $this->checkUserValue($stanza[Config::USERS]); - return; - } - - foreach ($stanza[Config::USERS] as $variant => $value) { - $this->assert(is_string($variant), 'User variant names must be strings.'); - $this->checkUserValue($value); - } - } - - /** - * @param $users - */ - private function checkUserValue($users) - { - $this->assert( - is_string($users) || self::isList($users), - Config::USERS . " must be string or list of strings: '$users'" - ); - if (!self::isList($users)) { - return; - } - - foreach ($users as $user) { - $this->assert(is_string($user), Config::USERS . " elements must be strings: '$user'"); - } - } - - /** - * @param $stanza - */ - private function checkGroups($stanza) - { - if (!isset($stanza[Config::GROUPS])) { - return; - } - - if (!is_array($stanza[Config::GROUPS]) || self::isList($stanza[Config::GROUPS])) { - $this->checkGroupValue($stanza[Config::GROUPS]); - return; - } - - foreach ($stanza[Config::GROUPS] as $variant => $value) { - $this->assert(is_string($variant), 'Group variant names must be strings.'); - $this->checkGroupValue($value); - } - } - - /** - * @param $groups - */ - private function checkGroupValue($groups) - { - $this->assert( - is_numeric($groups) || self::isList($groups), - Config::GROUPS . ' must be number or list of numbers' - ); - if (!self::isList($groups)) { - return; - } - - foreach ($groups as $group) { - $this->assert(is_numeric($group), Config::GROUPS . " elements must be numbers: '$group'"); - } - } - - - /** - * @param $stanza - */ - private function checkAdmin($stanza) - { - if (isset($stanza[Config::ADMIN])) { - $this->assert( - is_string($stanza[Config::ADMIN]), - "Admin must be string naming variant: '{$stanza[Config::ADMIN]}'" - ); - } - } - - /** - * @param $stanza - */ - private function checkInternal($stanza) - { - if (isset($stanza[Config::INTERNAL])) { - $this->assert( - is_string($stanza[Config::INTERNAL]), - "Internal must be string naming variant: '{$stanza[Config::INTERNAL]}'" - ); - } - } - - /** - * @param $stanza - */ - private function checkPublicURLOverride($stanza) - { - if (!isset($stanza[Config::PUBLIC_URL_OVERRIDE])) { - return; - } - - $this->assert( - is_bool($stanza[Config::PUBLIC_URL_OVERRIDE]), - "public_url_override must be a boolean: '{$stanza[Config::PUBLIC_URL_OVERRIDE]}'" - ); - if (is_bool($stanza[Config::PUBLIC_URL_OVERRIDE])) { - $this->assert( - $stanza[Config::PUBLIC_URL_OVERRIDE] === true, - 'Gratuitous public_url_override (defaults to false)' - ); - } - } - - /** - * @param $stanza - */ - private function checkBucketing($stanza) - { - if (!isset($stanza[Config::BUCKETING])) { - return; - } - $this->assert( - is_string($stanza[Config::BUCKETING]), - "Non-string bucketing: '{$stanza[Config::BUCKETING]}'" - ); - $this->assert( - in_array($stanza[Config::BUCKETING], $this->_legal_bucketing_values), - "Illegal bucketing: '{$stanza[Config::BUCKETING]}'" - ); - } - - /** - * @param $a - * @return bool - */ - private static function isList($a) - { - return is_array($a) and array_keys($a) === range(0, count($a) - 1); - } -} diff --git a/src/Logger.php b/src/Logger.php deleted file mode 100644 index 45f4cc7..0000000 --- a/src/Logger.php +++ /dev/null @@ -1,48 +0,0 @@ -logger = $logger; - } - - /** - * Log that the feature $name was checked with $variant selected - * by $selector. This is only called once per feature/bucketing id - * per request. - * - * @param $name - * @param $variant - * @param $selector - */ - public function log ($name, $variant, $selector = '') - { - $this->logger->info("AB: $name=$variant selector:$selector"); - } - - /** - * @param $message - */ - public function error($message) - { - $this->logger->error($message); - } -} diff --git a/src/Util.php b/src/Util.php deleted file mode 100644 index 2ca37dc..0000000 --- a/src/Util.php +++ /dev/null @@ -1,26 +0,0 @@ -_logger = $logger; + public function __construct(array $features) + { $this->features = $features; - $this->uaid = $uaid; - $this->userID = $userID; - $this->userName = $userName; - $this->group = $group; + } + + public function addUser(User $user) + { + $this->user = $user; + return $this; + } + + public function addSource($source) + { $this->source = $source; - $this->isAdmin = $isAdmin; + return $this; + } + + public function addUrl($url) + { $this->url = $url; + return $this; } /** * Get the config value for the given key. - * - * @param $name - * @param null $default - * @return null */ - public function configValue($name, $default = null) + public function configValue($name) { - //return $default; // IMPLEMENT FOR YOUR CONTEXT - if (isset($this->features[$name])) { - return $this->features[$name]; + if (empty($this->features[$name]) || + !is_array($this->features[$name]) + ) { + throw new \Exception("no config available for feature $name"); } - return $default; + return $this->features[$name]; } /** @@ -120,17 +58,15 @@ public function configValue($name, $default = null) */ public function uaid() { - //return null; // IMPLEMENT FOR YOUR CONTEXT - return $this->uaid; + return $this->user->uaid; } /** * User ID of the currently logged in user or null. */ - public function userID () + public function userID() { - //return null; // IMPLEMENT FOR YOUR CONTEXT - return $this->userID; + return $this->user->id; } /** @@ -138,149 +74,72 @@ public function userID () * ORM. If we're running as part of an Atlas request we ignore the * passed in userID and return instead the Atlas user name. */ - public function userName () - { - //return null; // IMPLEMENT FOR YOUR CONTEXT - return $this->userName; - } - - /** - * Is the vistor in a specific group? - * @param $groupID - * @return bool - */ - public function viewingGroup($groupID) - { - return is_object($this->group) && method_exists($this->group, 'getId') && $this->group->getId() == $groupID; - } - - /** - * Is the vistor from a particular source? - * - * @param $source - * @return bool - */ - public function isSource($source) - { - return $this->source == $source; - } - - /** - * Is the given user a member of the given group? (This currently, - * like the old config system, uses numeric group IDs in the - * config file, in order to speed up the lookup--the numeric ID is - * the primary key and we save having to look up the group by - * name.) - * @param null $userID - * @param null $groupID - * @return bool - */ - public function inGroup ($userID = null, $groupID = null) + public function userName() { - //return null; // IMPLEMENT FOR YOUR CONTEXT - if (is_object($this->group) && method_exists($this->group, 'isMember')) { - return $this->group->isMember(); - } - - return false; + return $this->user->name; } /** - * Is the current user an admin? - * - * @param $userID - the id of the relevant user, either the - * currently logged in user or some other user. - * @return bool + * zipcode of the currently logged in user. */ - public function isAdmin ($userID = null) + public function zipcode() { - //return false; // IMPLEMENT FOR YOUR CONTEXT - return $this->isAdmin; + return $this->user->zipcode; } /** - * Is this an internal request? + * region of the currently logged in user. */ - public function isInternalRequest () + public function region() { - return false; // IMPLEMENT FOR YOUR CONTEXT - // TODO: list local ips + return $this->user->region; } /** - * 'features' query param for url overrides. - * - * @return string + * country of the currently logged in user. */ - public function urlFeatures () + public function country() { - return !empty($this->url) ? $this->url : ''; + return $this->user->country; } /** - * Produce a random number in [0, 1) for RANDOM bucketing. - * - * @return float|int + * Is the visitor in a specific group? */ - public function random () + public function viewingGroup($groupID) { - return mt_rand(0, mt_getrandmax() - 1) / mt_getrandmax(); + return $this->user->group == $groupID; } /** - * Produce a randomish number in [0, 1) based on the given id. - * - * @param $id - * @return float + * Is the visitor from a particular source? */ - public function hash ($id) + public function isSource($source) { - return self::mapHex(hash('sha256', $id)); + return $this->source == $source; } /** - * Record that $variant has been selected for feature named $name - * by $selector and pass the same information along to the logger. - * - * @param $name - * @param $variant - * @param $selector + * Is the current user an admin? */ - public function log ($name, $variant, $selector) + public function isAdmin() { - $this->_selections[] = array($name, $variant, $selector); - $this->_logger->log($name, $variant, $selector); + return $this->user->isAdmin; } /** - * Get the list of selections that we have recorded. The public - * API for getting at the selections is Feature::selections which - * should be the only caller of this method. - * - * @return array + * Is this an internal request? */ - public function selections () + public function isInternalRequest() { - return $this->_selections; + return $this->user->internalIP; } /** - * Map a hex value to the half-open interval [0, 1) while - * preserving uniformity of the input distribution. - * - * @param string $hex a hex string - * @return float + * 'features' query param for url overrides. */ - private static function mapHex($hex) + public function urlFeatures() { - $len = min(30, strlen($hex)); - $vMax = 1 << $len; - $v = 0; - for ($i = 0; $i < $len; ++$i) { - $bit = hexdec($hex[$i]) < 8 ? 0 : 1; - $v = ($v << 1) + $bit; - } - - return $v / $vMax; + return !empty($this->url) ? $this->url : ''; } } diff --git a/src/World/Mobile.php b/src/World/Mobile.php deleted file mode 100644 index cdaf9e6..0000000 --- a/src/World/Mobile.php +++ /dev/null @@ -1,113 +0,0 @@ -_udid = $udid; - $this->_userID = $userID; - } - - /** - * UAID of the current request. - * @return mixed - */ - public function uaid() - { - parent::uaid(); - return $this->_udid; - } - - /** - * @return mixed - */ - public function userID () - { - parent::userID(); - return $this->_userID; - } - - /** - * @param $name - * @param $variant - * @param $selector - */ - public function log ($name, $variant, $selector) - { - parent::log($name, $variant, $selector); - - $this->_name = $name; - $this->_variant = $variant; - $this->_selector = $selector; - } - - /** - * @return mixed - */ - public function getLastName() - { - return $this->_name; - } - - /** - * @return mixed - */ - public function getLastVariant() - { - return $this->_variant; - } - - /** - * @return mixed - */ - public function getLastSelector() - { - return $this->_selector; - } - - public function clearLastFeature() - { - $this->_selector = null; - $this->_name = null; - $this->_variant = null; - } -} diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php index 7636ca0..5dcc890 100644 --- a/tests/ConfigTest.php +++ b/tests/ConfigTest.php @@ -3,116 +3,128 @@ namespace CafeMedia\Feature\Tests; use CafeMedia\Feature\Config; +use CafeMedia\Feature\User; +use PHPUnit\Framework\TestCase; -/** - * Class ConfigTest - * @package CafeMedia\Feature\Tests - */ -class ConfigTest extends \PHPUnit_Framework_TestCase +class ConfigTest extends TestCase { private $config; public function setUp() { - $this->config = new Config( - 'test', - 'test', - $this->getMockBuilder('CafeMedia\Feature\World')->disableOriginalConstructor()->getMock(), - $this->getMockBuilder('CafeMedia\Feature\Logger')->disableOriginalConstructor()->getMock() - ); + $world = $this->getMockBuilder('CafeMedia\Feature\World') + ->disableOriginalConstructor() + ->setMethods([ + 'configValue', + 'userID', + 'uaid', + 'isInternalRequest', + 'isAdmin', + 'urlFeatures', + 'userName', + 'viewingGroup', + 'isSource', + 'country', + 'region', + 'zipcode' + ]) + ->getMock(); + $world->method('configValue')->willReturn([ + 'description' => 'this is the description of the stanza', + 'enabled' => [ + 'test1' => 20, + 'test2' => 30, + 'test3' => 15, + 'test4' => 35 + ], + 'users' => ['user1', 'user2', 'user3'], + 'groups' => ['group1', 'group2', 'group3'], + 'sources' => ['source1', 'source2', 'source3'], + 'admin' => 'test3', + 'internal' => 'test1', + 'public_url_override' => true, + 'bucketing' => 'random', + 'exclude_from' => [ + 'zips' => [10014, 10023], + 'countries' => ['us', 'rd'], + 'regions' => ['ny', 'nj', 'ca'] + ], + 'start' => 20170314, + 'end' => 20170530 + ]); + $world->method('userID')->willReturn(5); + $world->method('uaid')->willReturn('as54gerfd'); + $world->method('isInternalRequest')->willReturn(false); + $world->method('isAdmin')->willReturn(false); + $world->method('urlFeatures')->willReturn('feature'); + $world->method('userName')->willReturn('testUserName'); + $world->method('viewingGroup')->willReturn(false); + $world->method('isSource')->willReturn(false); + $world->method('country')->willReturn('us'); + $world->method('region')->willReturn('ny'); + $world->method('zipcode')->willReturn('12345'); + $this->config = (new Config($world))->addName('testFeature'); + $this->assertEquals($this->config instanceof Config, true); } - /** - * @covers \CafeMedia\Feature\Config::isEnabled - */ public function testIsEnabled() { - $this->assertEquals($this->config->isEnabled('test'), true); + $this->assertEquals($this->config->isEnabled('testFeature'), false); } - /** - * @covers \CafeMedia\Feature\Config::variant - */ public function testVariant() { - $this->assertEquals($this->config->variant(), 'test'); + $this->assertEquals($this->config->variant(), 'off'); } - /** - * @covers \CafeMedia\Feature\Config::isEnabledFor - */ public function testIsEnabledFor() { - $this->assertEquals($this->config->isEnabledFor((object) array('user_id' => 1)), true); + $this->assertEquals( + $this->config->isEnabledFor(new User([ + 'user-uaid' => 'as54gerfd', + 'user-id' => 5, + 'user-name' => 'testUserName', + 'is-admin' => false, + 'user-group' => 'group', + 'internal-ip' => false + ])), + false + ); } - /** - * @covers \CafeMedia\Feature\Config::isEnabledBucketingBy - */ public function testIsEnabledBucketingBy() { - $this->assertEquals($this->config->isEnabledBucketingBy('test'), true); + $this->assertEquals($this->config->isEnabledBucketingBy('test'), false); } - /** - * @covers \CafeMedia\Feature\Config::variantFor - */ public function testVariantFor() { - $this->assertEquals($this->config->variantFor((object) array('user_id' => 1)), 'test'); + $this->assertEquals( + $this->config->variantFor(new User([ + 'user-uaid' => 'as54gerfd', + 'user-id' => 5, + 'user-name' => 'testUserName', + 'is-admin' => false, + 'user-group' => 'group', + 'internal-ip' => false + ])), + 'off' + ); } - /** - * @covers \CafeMedia\Feature\Config::variantBucketingBy - */ public function testVariantBucketingBy() { - $this->assertEquals($this->config->variantBucketingBy('test', 'test'), 'test'); + $this->assertEquals( + $this->config->variantBucketingBy('test', 'test'), + 'off' + ); } - /** - * @covers \CafeMedia\Feature\Config::description - */ public function testDescription() - { - $this->assertEquals($this->config->description('test'), 'No description.'); - } - - public function testConstants() { $this->assertEquals( - array( - Config::DESCRIPTION, - Config::ENABLED, - Config::USERS, - Config::GROUPS, - Config::SOURCES, - Config::ADMIN, - Config::INTERNAL, - Config::PUBLIC_URL_OVERRIDE, - Config::BUCKETING, - Config::ON, - Config::OFF, - Config::UAID, - Config::USER, - Config::RANDOM - ), - array( - 'description', - 'enabled', - 'users', - 'groups', - 'sources', - 'admin', - 'internal', - 'public_url_override', - 'bucketing', - 'on', - 'off', - 'uaid', - 'user', - 'random' - ) + $this->config->description('test'), + 'this is the description of the stanza' ); } } diff --git a/tests/FeatureTest.php b/tests/FeatureTest.php index 34bbf96..0719f87 100644 --- a/tests/FeatureTest.php +++ b/tests/FeatureTest.php @@ -3,121 +3,119 @@ namespace CafeMedia\Feature\Tests; use CafeMedia\Feature\Feature; -use CafeMedia\Feature\Instance; -use PHPUnit_Framework_TestCase; +use PHPUnit\Framework\TestCase; -/** - * Class FeatureTest - * @package CafeMedia\Feature\Tests - */ -class FeatureTest extends PHPUnit_Framework_TestCase +class FeatureTest extends TestCase { private $feature; public function setUp() { - $this->feature = new Feature($this->getMock('Psr\Log\LoggerInterface')); + $this->feature = (new Feature([ + 'testFeature' => [ + 'description' => 'this is the description', + 'enabled' => [ + 'test1' => 20, + 'test2' => 30, + 'test3' => 15, + 'test4' => 35 + ], + 'users' => ['user1', 'user2', 'user3'], + 'groups' => ['group1', 'group2', 'group3'], + 'sources' => ['source1', 'source2'], + 'admin' => 'test3', + 'internal' => 'test1', + 'public_url_override' => true, + 'bucketing' => 'random', + 'exclude_from' => [ + 'zips' => [10014, 10023], + 'countries' => ['us', 'rd'], + 'regions' => ['ny', 'nj', 'ca'] + ], + 'start' => 20170314, + 'end' => 20170530 + ] + ])) + ->addUrl('feature') + ->addSource('') + ->addUser([ + 'user-uaid' => 'as54gerfd', + 'user-id' => 5, + 'user-name' => 'testUserName', + 'is-admin' => false, + 'user-group' => 'group', + 'internal-ip' => false + ]); + $this->assertEquals($this->feature instanceof Feature, true); } - /** - * @covers \CafeMedia\Feature\Feature::getInstance - */ - public function testGetInstance() - { - $this->assertEquals($this->feature->getInstance() instanceof Instance, true); - } - - /** - * @covers \CafeMedia\Feature\Feature::isEnabled - */ public function testIsEnabled() { - $this->assertEquals($this->feature->isEnabled('test'), false); - $this->assertEquals($this->feature->getInstance()->isEnabled('test'), false); + $this->assertEquals($this->feature->isEnabled('testFeature'), false); } - /** - * @covers \CafeMedia\Feature\Feature::isEnabledFor - */ public function testIsEnabledFor() { - $this->assertEquals($this->feature->isEnabledFor('test', (object) array('user_id' => 1)), false); - $this->assertEquals($this->feature->getInstance()->isEnabledFor('test', (object) array('user_id' => 1)), false); + $this->assertEquals( + $this->feature->isEnabledFor( + 'testFeature', + [ + 'user-uaid' => 'as54gerfd', + 'user-id' => 5, + 'user-name' => 'testUserName', + 'is-admin' => false, + 'user-group' => 'group', + 'internal-ip' => false + ] + ), + false + ); } - /** - * @covers \CafeMedia\Feature\Feature::isEnabledBucketingBy - */ public function testIsEnabledBucketingBy() { - $this->assertEquals($this->feature->isEnabledBucketingBy('test', 'test'), false); - $this->assertEquals($this->feature->getInstance()->isEnabledBucketingBy('test', 'test'), false); + $this->assertEquals( + $this->feature->isEnabledBucketingBy('testFeature', 'test'), + false + ); } - /** - * @covers \CafeMedia\Feature\Feature::variant - */ public function testVariant() { - $this->assertEquals($this->feature->variant('test'), 'off'); - $this->assertEquals($this->feature->getInstance()->variant('test'), 'off'); + $this->assertEquals($this->feature->variant('testFeature'), 'off'); } - /** - * @covers \CafeMedia\Feature\Feature::variantFor - */ public function testVariantFor() { - $this->assertEquals($this->feature->variantFor('test', (object) array('user_id' => 1)), 'off'); - $this->assertEquals($this->feature->getInstance()->variantFor('test', (object) array('user_id' => 1)), 'off'); + $this->assertEquals( + $this->feature->variantFor( + 'testFeature', + [ + 'user-uaid' => 'as54gerfd', + 'user-id' => 5, + 'user-name' => 'testUserName', + 'is-admin' => false, + 'user-group' => 'group', + 'internal-ip' => false + ] + ), + 'off' + ); } - /** - * @covers \CafeMedia\Feature\Feature::variantBucketingBy - */ public function testVariantBucketingBy() { - $this->assertEquals($this->feature->variantBucketingBy('test', 'test'), 'off'); - $this->assertEquals($this->feature->getInstance()->variantBucketingBy('test', 'test'), 'off'); + $this->assertEquals( + $this->feature->variantBucketingBy('testFeature', 'test'), + 'off' + ); } - /** - * @covers \CafeMedia\Feature\Feature::description - */ public function testDescription() { - $this->assertEquals($this->feature->description('test'), 'No description.'); - } - - /** - * @covers \CafeMedia\Feature\Feature::data - */ - public function testData() - { - $this->assertEquals($this->feature->data('test'), array()); - } - - /** - * @covers \CafeMedia\Feature\Feature::variantData - */ - public function testVariantData() - { - $this->assertEquals($this->feature->variantData('test'), array()); - } - - /** - * @covers \CafeMedia\Feature\Instance::getGACustomVarJS - */ - public function testGetGACustomVarJS() - { - $this->assertEquals( - $this->feature->getInstance()->getGACustomVarJS('test'), - "_gaq.push(['_setCustomVar', 3, 'AB', 'null', 3]);" - ); - $this->assertEquals( - $this->feature->getInstance()->getGACustomVarJS('mobile'), - "['_setCustomVar', 3, 'AB', 'null', 3]," + $this->feature->description('testFeature'), + 'this is the description' ); } } diff --git a/tests/MobileTest.php b/tests/MobileTest.php deleted file mode 100644 index d8b612d..0000000 --- a/tests/MobileTest.php +++ /dev/null @@ -1,81 +0,0 @@ -mobile = new Mobile( - 'test', - 1, - $this->getMockBuilder('CafeMedia\Feature\Logger')->disableOriginalConstructor()->getMock() - ); - } - - /** - * @covers \CafeMedia\Feature\World\Mobile::uaid - */ - public function testUaid() - { - $this->assertEquals($this->mobile->uaid(), 'test'); - } - - /** - * @covers \CafeMedia\Feature\World\Mobile::userID - */ - public function testUserID() - { - $this->assertEquals($this->mobile->userID(), 1); - } - - /** - * @covers \CafeMedia\Feature\World\Mobile::getLastName - */ - public function testGetLastName() - { - $this->assertEquals($this->mobile->getLastName(), null); - - $this->mobile->log('test', 'test', 'test'); - $this->assertEquals($this->mobile->getLastName(), 'test'); - - $this->mobile->clearLastFeature(); - $this->assertEquals($this->mobile->getLastName(), null); - } - - /** - * @covers \CafeMedia\Feature\World\Mobile::getLastVariant - */ - public function testGetLastVariant() - { - $this->assertEquals($this->mobile->getLastVariant(), null); - - $this->mobile->log('test', 'test', 'test'); - $this->assertEquals($this->mobile->getLastVariant(), 'test'); - - $this->mobile->clearLastFeature(); - $this->assertEquals($this->mobile->getLastVariant(), null); - } - - /** - * @covers \CafeMedia\Feature\World\Mobile::getLastSelector - */ - public function getLastSelector() - { - $this->assertEquals($this->mobile->getLastSelector(), null); - - $this->mobile->log('test', 'test', 'test'); - $this->assertEquals($this->mobile->getLastSelector(), 'test'); - - $this->mobile->clearLastFeature(); - $this->assertEquals($this->mobile->getLastSelector(), null); - } -} diff --git a/tests/UtilTest.php b/tests/UtilTest.php deleted file mode 100644 index 7799f04..0000000 --- a/tests/UtilTest.php +++ /dev/null @@ -1,21 +0,0 @@ -assertEquals(Util::arrayGet('test', 'test'), null); - $this->assertEquals(Util::arrayGet(array('test' => 'test'), 'test'), 'test'); - } -} diff --git a/tests/WorldTest.php b/tests/WorldTest.php index f7581d2..523d443 100644 --- a/tests/WorldTest.php +++ b/tests/WorldTest.php @@ -3,125 +3,90 @@ namespace CafeMedia\Feature\Tests; use CafeMedia\Feature\World; +use CafeMedia\Feature\User; +use PHPUnit\Framework\TestCase; -/** - * Class WorldTest - * @package CafeMedia\Feature\Tests - */ -class WorldTest extends \PHPUnit_Framework_TestCase +class WorldTest extends TestCase { private $world; public function setUp() { - $this->world = new World( - $this->getMockBuilder('CafeMedia\Feature\Logger')->disableOriginalConstructor()->getMock() - ); + $this->world = (new World(['test' => ['value']])) + ->addUrl('feature') + ->addSource('') + ->addUser(new User([ + 'user-uaid' => 'as54gerfd', + 'user-id' => 5, + 'user-name' => 'testUserName', + 'is-admin' => false, + 'user-group' => 'group', + 'internal-ip' => false, + 'zipcode' => 10203, + 'region' => 'ny', + 'country' => 'us' + ])); + $this->assertEquals($this->world instanceof World, true); } - - /** - * @covers \CafeMedia\Feature\World::configValue - */ + public function testConfigValue() { - $this->assertEquals($this->world->configValue('test'), null); + $this->assertEquals($this->world->configValue('test'), ['value']); } - - /** - * @covers \CafeMedia\Feature\World::uaid - */ + public function testUaid() { - $this->assertEquals($this->world->uaid(), ''); + $this->assertEquals($this->world->uaid(), 'as54gerfd'); } - - /** - * @covers \CafeMedia\Feature\World::userId - */ + public function testUserId() { - $this->assertEquals($this->world->userId(), ''); + $this->assertEquals($this->world->userId(), 5); } - - /** - * @covers \CafeMedia\Feature\World::userName - */ + public function testUserName() { - $this->assertEquals($this->world->userName(), ''); + $this->assertEquals($this->world->userName(), 'testUserName'); } - - /** - * @covers \CafeMedia\Feature\World::viewingGroup - */ + public function testViewingGroup() { $this->assertEquals($this->world->viewingGroup('test'), false); } - /** - * @covers \CafeMedia\Feature\World::isSource - */ public function testIsSource() { $this->assertEquals($this->world->isSource('test'), false); $this->assertEquals($this->world->isSource(''), true); } - /** - * @covers \CafeMedia\Feature\World::inGroup - */ - public function testInGroup() - { - $this->assertEquals($this->world->inGroup(), false); - } - - /** - * @covers \CafeMedia\Feature\World::isAdmin - */ public function testIsAdmin() { $this->assertEquals($this->world->isAdmin(), false); } - /** - * @covers \CafeMedia\Feature\World::isInternalRequest - */ public function testIsInternalRequest() { $this->assertEquals($this->world->isInternalRequest(), false); } - /** - * @covers \CafeMedia\Feature\World::urlFeatures - */ public function testUrlFeatures() { - $this->assertEquals($this->world->urlFeatures(), ''); + $this->assertEquals($this->world->urlFeatures(), 'feature'); } - /** - * @covers \CafeMedia\Feature\World::random - */ - public function testRandom() + public function testZipcode() { - $this->assertEquals(is_numeric($this->world->random()), true); + $this->assertEquals($this->world->zipcode(), 10203); } - /** - * @covers \CafeMedia\Feature\World::hash - */ - public function testHash() + public function testRegion() { - $this->assertEquals($this->world->hash('test'), 0.91731063090264797); + $this->assertEquals($this->world->region(), 'ny'); } - /** - * @covers \CafeMedia\Feature\World::selections - */ - public function testSelections() + public function testCounrty() { - $this->world->log('test', 'test', 'test'); - $this->assertEquals($this->world->selections(), array(array('test', 'test', 'test'))); + $this->assertEquals($this->world->country(), 'us'); } -} +} \ No newline at end of file diff --git a/tests/data/world/etsy_aux.yml b/tests/data/world/etsy_aux.yml deleted file mode 100644 index fc27946..0000000 --- a/tests/data/world/etsy_aux.yml +++ /dev/null @@ -1,6 +0,0 @@ -staff: - - - id: 3 - auth_username: 'staff_member' - create_date: 0 - update_date: 0 diff --git a/tests/data/world/etsy_index.yml b/tests/data/world/etsy_index.yml deleted file mode 100644 index d9ddaac..0000000 --- a/tests/data/world/etsy_index.yml +++ /dev/null @@ -1,14 +0,0 @@ -users_index: - - - user_id: 1 - user_shard: 1 - login_name: peter - primary_email: foo@etsycorp.com - is_admin: 0 - - - user_id: 2 - user_shard: 1 - login_name: paul - primary_email: bar@etsycorp.com - is_admin: 1 - From 0cff27130625ceccf98ea80132796c1784b9bfec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Sun, 26 Feb 2017 16:25:47 -0500 Subject: [PATCH 26/92] forgot to add files (#1) --- src/Stanza.php | 177 +++++++++++++++++++++++++++ src/User.php | 37 ++++++ src/Variant.php | 271 ++++++++++++++++++++++++++++++++++++++++++ tests/StanzaTest.php | 127 ++++++++++++++++++++ tests/UserTest.php | 71 +++++++++++ tests/VariantTest.php | 75 ++++++++++++ 6 files changed, 758 insertions(+) create mode 100644 src/Stanza.php create mode 100644 src/User.php create mode 100644 src/Variant.php create mode 100644 tests/StanzaTest.php create mode 100644 tests/UserTest.php create mode 100644 tests/VariantTest.php diff --git a/src/Stanza.php b/src/Stanza.php new file mode 100644 index 0000000..8e3a5a8 --- /dev/null +++ b/src/Stanza.php @@ -0,0 +1,177 @@ +description = $this->parseDescription($stanza); + $this->enabled = $this->parseEnabled($stanza); + $this->users = $this->parseUsersOrGroups($stanza, 'users'); + $this->groups = $this->parseUsersOrGroups($stanza, 'groups'); + $this->sources = $this->parseUsersOrGroups($stanza, 'sources'); + $this->adminVariant = $this->parseVariantName($stanza, 'admin'); + $this->internalVariant = $this->parseVariantName($stanza, 'internal'); + $this->publicUrlOverride = $this->parsePublicURLOverride($stanza); + $this->bucketing = $this->parseBucketBy($stanza); + $this->exludeFrom = $this->parseExcludeFrom($stanza); + $this->start = $this->parseStart($stanza); + $this->end = $this->parseEnd($stanza); + } + + public function __get($name) + { + if (isset($this->$name)) return $this->$name; + throw new \Exception("$name is not a property of the Stanza class"); + } + + //////////////////////////////////////////////////////////////////////// + // Configuration parsing + + private function parseDescription(array $stanza) + { + if (isset($stanza['description'])) return $stanza['description']; + return 'No description.'; + } + + /** + * Parse the 'enabled' property of the feature's config stanza. + */ + private function parseEnabled(array $stanza) + { + $enabled = 0; + if (isset($stanza['enabled'])) $enabled = $stanza['enabled']; + if (!is_numeric($enabled) && !is_array($enabled)) { + throw new \Exception( + 'Malformed enabled property ' . json_encode($stanza) + ); + } + if (is_numeric($enabled) && $enabled < 0) { + throw new \Exception("enabled ($enabled) < 0"); + } + if (is_numeric($enabled) && $enabled > 100) { + throw new \Exception("enabled ($enabled) > 0"); + } + return ['on' => $enabled]; + } + + /** + * Parse the value of the 'users' and 'groups' properties of the + * feature's config stanza, returning an array mappinng the user + * or group names to they variant they should see. + */ + private function parseUsersOrGroups(array $stanza, $what) + { + $value = false; + if (isset($stanza[$what])) $value = $stanza[$what]; + if (is_string($value) || is_numeric($value)) { + // Users are configrued with their user names. Groups as + // numeric ids. (Not sure if that's a great idea.) + return [$value => 'on']; + } + + $result = []; + /** + * Is the given object an array value that could have been created + * with array(...) with no =>'s in the ...? + */ + if (!is_array($value)) return $result; + if (array_keys($value) === range(0, count($value) - 1)) { + foreach ($value as $who) $result[strtolower($who)] = 'on'; + return $result; + } + + $badKeys = false; + if (is_array($this->enabled)) { + $badKeys = array_keys(array_diff_key($value, $this->enabled)); + } + if ($badKeys) { + throw new \Exception("Unknown variants " . implode(', ', $badKeys)); + } + + foreach ($value as $variant => $whos) { + if (!is_array($whos)) $whos = [$whos]; + foreach ($whos as $who) $result[strtolower($who)] = $variant; + } + + return $result; + } + + /** + * Parse the variant name value for the 'admin' and 'internal' + * properties. If non-falsy, must be one of the keys in the + * enabled map unless enabled is 'on' or 'off'. + */ + private function parseVariantName(array $stanza, $what) + { + $value = false; + if (isset($stanza[$what])) $value = $stanza[$what]; + if (!$value) return false; + + if (!is_array($this->enabled) || isset($this->enabled['on'][$value])) { + return $value; + } + + throw new \Exception( + "Unknown variant $value " . json_encode($this->enabled) + ); + } + + private function parsePublicURLOverride(array $stanza) + { + if (!isset($stanza['public_url_override'])) return false; + return $stanza['public_url_override']; + } + + private function parseBucketBy(array $stanza) + { + if (isset($stanza['bucketing'])) return $stanza['bucketing']; + return 'uaid'; + } + + private function parseExcludeFrom(array $stanza) + { + if (!isset($stanza['exclude_from'])) return false; + + if (is_array($stanza['exclude_from']) && + (isset($stanza['exclude_from']['zips']) || + isset($stanza['exclude_from']['region']) || + isset($stanza['exclude_from']['country'])) + ) { + return $stanza['exclude_from']; + } + + throw new \Exception('bad exclude_from stanza' . json_encode($stanza)); + } + + private function parseStart(array $stanza) + { + if (!isset($stanza['start'])) return false; + $time = strtotime($stanza['start']); + if ($time) return $time; + throw new \Exception("{$stanza['start']} is not a valid time format"); + } + + private function parseEnd(array $stanza) + { + if (!isset($stanza['end'])) return false; + $time = strtotime($stanza['end']); + if ($time) return $time; + throw new \Exception("{$stanza['end']} is not a valid time format"); + } +} \ No newline at end of file diff --git a/src/User.php b/src/User.php new file mode 100644 index 0000000..eb680dd --- /dev/null +++ b/src/User.php @@ -0,0 +1,37 @@ +uaid = $user['user-uaid']; + if (!empty($user['user-id'])) $this->id = $user['user-id']; + if (!empty($user['user-name'])) $this->name = $user['user-name']; + if (!empty($user['is-admin'])) $this->isAdmin = $user['is-admin']; + if (!empty($user['user-group'])) $this->group = $user['user-group']; + if (!empty($user['zipcode'])) $this->zipcode = $user['zipcode']; + if (!empty($user['region'])) $this->region = $user['region']; + if (!empty($user['country'])) $this->country = $user['country']; + if (!empty($user['internal-ip'])) { + $this->internalIP = $user['internal-ip']; + } + } + + public function __get($name) + { + if (isset($this->$name)) return $this->$name ? $this->$name : false; + throw new \Exception("$name is not a property of the User class"); + } +} \ No newline at end of file diff --git a/src/Variant.php b/src/Variant.php new file mode 100644 index 0000000..547f900 --- /dev/null +++ b/src/Variant.php @@ -0,0 +1,271 @@ +world = $world; + } + + public function addStanza(Stanza $stanza) + { + $this->stanza = $stanza; + //Put the enabled value into a more useful form + //for actually doing bucketing. + $total = 0; + foreach ($stanza->enabled as $variant => $percentage) { + $total += $this->computePercantage($variant, $percentage, $total); + } + if (!($total > 100)) return $this; + throw new \Exception("Total of percentages > 100: $total"); + } + + public function addBucketingID($bucketingID) + { + $this->bucketingID = $bucketingID; + return $this; + } + + public function addName($name) + { + $this->name = $name; + return $this; + } + + public function __toString() + { + return $this->variantFromURL() ?: + $this->variantForUser() ?: + $this->variantForGroup() ?: + $this->variantForViewingGroup() ?: + $this->variantForSource() ?: + $this->variantForAdmin() ?: + $this->variantForInternal() ?: + $this->variantExcludedFrom() ?: + $this->variantTime() ?: + $this->variantByPercentage() ?: + 'off'; + } + + /** + * For internal requests or if the feature has public_url_override + * set to true, a specific variant can be specified in the + * 'features' query parameter. In all other cases return false, + * meaning nothing was specified. Note that foo:off will turn off + * the 'foo' feature. + */ + private function variantFromURL() + { + if (!$this->stanza->publicUrlOverride && + !$this->world->isInternalRequest() && + !$this->world->isAdmin() + ) { + return false; + } + + $urlFeatures = $this->world->urlFeatures(); + if (!$urlFeatures) return false; + + foreach (explode(',', $urlFeatures) as $f) { + $parts = explode(':', $f); + if ($parts[0] === $this->name) { + return isset($parts[1]) ? $parts[1] : 'on'; + } + } + + return false; + } + + /** + * Get the variant this user should see, if one was configured, + * false otherwise. + */ + private function variantForUser() + { + if (!$this->stanza->users) return false; + + $name = strtolower($this->world->userName()); + if (!isset($this->stanza->users[$name])) return false; + return $this->stanza->users[$name]; + } + + /** + * Get the variant visitor should see based on group + * they're currently viewing + */ + private function variantForViewingGroup() + { + foreach ($this->stanza->groups as $groupID => $variant) { + if ($this->world->viewingGroup($groupID)) return $variant; + } + return false; + } + + /** + * Get the variant visitor should see based on group + * they're currently viewing + */ + private function variantForSource() + { + foreach ($this->stanza->sources as $source => $variant) { + if ($this->world->isSource($source)) return $variant; + } + return false; + } + + /** + * Get the variant this user should see based on their group + * memberships, if one was configured, false otherwise. N.B. If + * the user is in multiple groups that are configured to see + * different variants, they'll get the variant for one of their + * groups but there's no saying which one. If this is a problem in + * practice we could make the configuration more complex. Or you + * can just provide a specific variant via the 'users' property. + */ + private function variantForGroup() + { + foreach ($this->stanza->groups as $groupID => $variant) { + if ($this->world->viewingGroup($groupID)) return $variant; + } + + return false; + } + + /** + * What variant, if any, should we return if the current user is + * an admin. + */ + private function variantForAdmin() + { + if ($this->stanza->adminVariant && $this->world->isAdmin()) { + return $this->stanza->adminVariant; + } + return false; + } + + /** + * What variant, if any, should we return for internal requests. + */ + private function variantForInternal() + { + if ($this->stanza->internalVariant && + $this->world->isInternalRequest() + ) { + return $this->stanza->internalVariant; + } + return false; + } + + private function variantExcludedFrom() + { + $excluded = $this->stanza->exludeFrom + && ( + ( + isset($this->stanza->exludeFrom['zips']) && + in_array( + $this->world->zipcode(), + $this->stanza->exludeFrom['zips'] + ) + ) + || ( + isset($this->stanza->exludeFrom['regions']) && + in_array( + $this->world->region(), + $this->stanza->exludeFrom['regions'] + ) + ) + || ( + isset($this->stanza->exludeFrom['countries']) && + in_array( + $this->world->country(), + $this->stanza->exludeFrom['countries'] + ) + ) + ); + return $excluded ? 'off' : false; + } + + private function variantTime() + { + $time = time(); + if (($this->stanza->start && $this->stanza->start < $time) || + ($this->stanza->end && $this->stanza->end > $time) + ) { + return 'off'; + } + return false; + } + + /** + * Finally, the normal case: use the percentage of users who + * should see each variant to map a random-ish number to a + * particular variant. + */ + private function variantByPercentage() + { + $n = 100 * $this->randomish(); + foreach ($this->percentages as $v) { + // === 100 check may not be necessary but I'm not good + // enough numerical analyst to be sure. + if ($n < $v[0] || $v[0] === 100) return $v[1]; + } + return false; + } + + /** + * A random-ish number in [0, 1) based on the feature name and $id + * unless we are bucketing completely at random + */ + private function randomish() + { + if ($this->stanza->bucketing === 'random') { + return mt_rand(0, mt_getrandmax() - 1) / mt_getrandmax(); + } + /** + * Map a hex value to the half-open interval [0, 1) while + * preserving uniformity of the input distribution. + */ + $id = hash('sha256', "{$this->name}-{$this->bucketingID}"); + $len = min(30, strlen($id)); + $v = 0; + for ($i = 0; $i < $len; ++$i) { + $v = ($v << 1) + (hexdec($id[$i]) < 8 ? 0 : 1); + } + + return $v / (1 << $len); + } + + /* + * Returns an array of pairs with the first element of the pair + * being the upper-boundary of the variants percentage and the + * second element being the name of the variant. + */ + private function computePercantage($variant, $percentage, $total) + { + if ((!is_numeric($percentage) && !is_array($percentage)) || + (is_numeric($percentage) && ($percentage < 0 || $percentage > 100)) + ) { + throw new \Exception('Bad percentage '. json_encode($percentage)); + } + if (is_numeric($percentage)) { + $this->percentages[] = [$total + $percentage, $variant]; + return $percentage; + } + foreach ($percentage as $variant => $percent) { + if (!is_numeric($percent) || $percent < 0 || $percent > 100) { + throw new \Exception('Bad percentage '. json_encode($percent)); + } + $total += $percent; + $this->percentages[] = [$total, $variant]; + } + return $total; + } +} \ No newline at end of file diff --git a/tests/StanzaTest.php b/tests/StanzaTest.php new file mode 100644 index 0000000..f864a9a --- /dev/null +++ b/tests/StanzaTest.php @@ -0,0 +1,127 @@ +stanza = new Stanza([ + 'description' => 'this is the description of the stanza', + 'enabled' => [ + 'test1' => 20, + 'test2' => 30, + 'test3' => 15, + 'test4' => 35 + ], + 'users' => ['user1', 'user2', 'user3'], + 'groups' => ['group1', 'group2', 'group3'], + 'sources' => ['source1', 'source2', 'source3'], + 'admin' => 'test3', + 'internal' => 'test1', + 'public_url_override' => true, + 'bucketing' => 'random', + 'exclude_from' => [ + 'zips' => [10014, 10023], + 'countries' => ['us', 'rd'], + 'regions' => ['ny', 'nj', 'ca'] + ], + 'start' => 20170314, + 'end' => 20170530 + ]); + } + + public function testDescription() + { + $this->assertEquals( + $this->stanza->description, + 'this is the description of the stanza' + ); + } + + public function testEnabled() + { + $this->assertEquals( + $this->stanza->enabled, + [ + 'on' => [ + 'test1' => 20, + 'test2' => 30, + 'test3' => 15, + 'test4' => 35 + ] + ] + ); + } + + public function testUsers() + { + $this->assertEquals( + $this->stanza->users, + ['user1' => 'on', 'user2' => 'on', 'user3' => 'on'] + ); + } + + public function testGroups() + { + $this->assertEquals( + $this->stanza->groups, + ['group1' => 'on', 'group2' => 'on', 'group3' => 'on'] + ); + } + + public function testSources() + { + $this->assertEquals( + $this->stanza->sources, + ['source1' => 'on', 'source2' => 'on', 'source3' => 'on'] + ); + } + + public function testAdminVariant() + { + $this->assertEquals($this->stanza->adminVariant, 'test3'); + } + + public function testInternalVariant() + { + $this->assertEquals($this->stanza->internalVariant, 'test1'); + } + + public function testPublicUrlOverride() + { + $this->assertEquals($this->stanza->publicUrlOverride, true); + } + + public function testBucketing() + { + $this->assertEquals($this->stanza->bucketing, 'random'); + } + + public function testExcludeFrom() + { + $this->assertEquals( + $this->stanza->exludeFrom, + [ + 'zips' => [10014, 10023], + 'countries' => ['us', 'rd'], + 'regions' => ['ny', 'nj', 'ca'] + ] + ); + } + + public function testStart() + { + $this->assertEquals($this->stanza->start, 1489449600); + } + + public function testEnd() + { + $this->assertEquals($this->stanza->end, 1496102400); + } +} \ No newline at end of file diff --git a/tests/UserTest.php b/tests/UserTest.php new file mode 100644 index 0000000..42ed2a0 --- /dev/null +++ b/tests/UserTest.php @@ -0,0 +1,71 @@ +user = new User([ + 'user-uaid' => 'as54gerfd', + 'user-id' => 5, + 'user-name' => 'testUserName', + 'is-admin' => false, + 'user-group' => 'group', + 'internal-ip' => false, + 'zipcode' => 10203, + 'region' => 'ny', + 'country' => 'us' + ]); + } + + public function testUaid() + { + $this->assertEquals($this->user->uaid, 'as54gerfd'); + } + + public function testId() + { + $this->assertEquals($this->user->id, 5); + } + + public function testName() + { + $this->assertEquals($this->user->name, 'testUserName'); + } + + public function testIsAdmin() + { + $this->assertEquals($this->user->isAdmin, false); + } + + public function testGroup() + { + $this->assertEquals($this->user->group, 'group'); + } + + public function testInternalIP() + { + $this->assertEquals($this->user->internalIP, false); + } + + public function testZipcode() + { + $this->assertEquals($this->user->zipcode, 10203); + } + + public function testRegion() + { + $this->assertEquals($this->user->region, 'ny'); + } + + public function testCounrty() + { + $this->assertEquals($this->user->country, 'us'); + } +} \ No newline at end of file diff --git a/tests/VariantTest.php b/tests/VariantTest.php new file mode 100644 index 0000000..19d464a --- /dev/null +++ b/tests/VariantTest.php @@ -0,0 +1,75 @@ +getMockBuilder('CafeMedia\Feature\World') + ->disableOriginalConstructor() + ->setMethods([ + 'configValue', + 'userID', + 'uaid', + 'isInternalRequest', + 'isAdmin', + 'urlFeatures', + 'userName', + 'viewingGroup', + 'isSource', + 'zipcode', + 'country', + 'region' + ]) + ->getMock(); + $world->method('configValue')->willReturn(['enabled' => 100]); + $world->method('userID')->willReturn(5); + $world->method('uaid')->willReturn('as54gerfd'); + $world->method('isInternalRequest')->willReturn(false); + $world->method('isAdmin')->willReturn(false); + $world->method('urlFeatures')->willReturn('feature'); + $world->method('userName')->willReturn('testUserName'); + $world->method('viewingGroup')->willReturn(false); + $world->method('country')->willReturn('us'); + $world->method('region')->willReturn('ny'); + $world->method('zipcode')->willReturn('12345'); + $this->variant = (new Variant($world)) + ->addStanza(new Stanza([ + 'description' => 'description of the stanza', + 'enabled' => [ + 'test1' => 20, + 'test2' => 30, + 'test3' => 15, + 'test4' => 35 + ], + 'users' => ['user1', 'user2', 'user3'], + 'groups' => ['group1', 'group2', 'group3'], + 'sources' => ['source1', 'source2', 'source3'], + 'admin' => 'test3', + 'internal' => 'test1', + 'public_url_override' => true, + 'bucketing' => 'random', + 'exclude_from' => [ + 'zips' => [10014, 10023], + 'countries' => ['us', 'rd'], + 'regions' => ['ny', 'nj', 'ca'] + ], + 'start' => 20170314, + 'end' => 20170530 + ])) + ->addBucketingID('123bucketingid321') + ->addName('test'); + } + + public function testToString() + { + $this->assertEquals((string)$this->variant, 'off'); + } +} \ No newline at end of file From 98aa9eebc970a4ebccd049347513ace67e20d154 Mon Sep 17 00:00:00 2001 From: Pablo Iglesias Date: Sun, 26 Feb 2017 23:16:57 -0500 Subject: [PATCH 27/92] fix start - end feature bug --- src/Config.php | 5 +++-- src/Feature.php | 2 +- src/Stanza.php | 4 ++-- src/Variant.php | 21 ++++----------------- tests/FeatureTest.php | 15 +++++++-------- tests/VariantTest.php | 6 +++--- 6 files changed, 20 insertions(+), 33 deletions(-) diff --git a/src/Config.php b/src/Config.php index c090c20..5830ea7 100644 --- a/src/Config.php +++ b/src/Config.php @@ -122,10 +122,11 @@ private function chooseVariant($bucketingID) return $this->cache[$bucketingID]; } - return $this->cache[$bucketingID] = (string)(new Variant($this->world)) + return $this->cache[$bucketingID] = (new Variant($this->world)) ->addStanza($this->stanza) ->addBucketingID($bucketingID) - ->addName($this->name); + ->addName($this->name) + ->getVariant(); } /** diff --git a/src/Feature.php b/src/Feature.php index d11e36b..e2acaa8 100644 --- a/src/Feature.php +++ b/src/Feature.php @@ -52,7 +52,7 @@ public function addSource($source) public function addUrl($url) { - $this->source = $url; + $this->url = $url; return $this; } diff --git a/src/Stanza.php b/src/Stanza.php index 8e3a5a8..9731098 100644 --- a/src/Stanza.php +++ b/src/Stanza.php @@ -46,7 +46,7 @@ public function __get($name) private function parseDescription(array $stanza) { if (isset($stanza['description'])) return $stanza['description']; - return 'No description.'; + return ''; } /** @@ -174,4 +174,4 @@ private function parseEnd(array $stanza) if ($time) return $time; throw new \Exception("{$stanza['end']} is not a valid time format"); } -} \ No newline at end of file +} diff --git a/src/Variant.php b/src/Variant.php index 547f900..6edbb53 100644 --- a/src/Variant.php +++ b/src/Variant.php @@ -40,12 +40,11 @@ public function addName($name) return $this; } - public function __toString() + public function getVariant() { return $this->variantFromURL() ?: $this->variantForUser() ?: $this->variantForGroup() ?: - $this->variantForViewingGroup() ?: $this->variantForSource() ?: $this->variantForAdmin() ?: $this->variantForInternal() ?: @@ -97,18 +96,6 @@ private function variantForUser() return $this->stanza->users[$name]; } - /** - * Get the variant visitor should see based on group - * they're currently viewing - */ - private function variantForViewingGroup() - { - foreach ($this->stanza->groups as $groupID => $variant) { - if ($this->world->viewingGroup($groupID)) return $variant; - } - return false; - } - /** * Get the variant visitor should see based on group * they're currently viewing @@ -196,8 +183,8 @@ private function variantExcludedFrom() private function variantTime() { $time = time(); - if (($this->stanza->start && $this->stanza->start < $time) || - ($this->stanza->end && $this->stanza->end > $time) + if (($this->stanza->start && $this->stanza->start > $time) || + ($this->stanza->end && $this->stanza->end < $time) ) { return 'off'; } @@ -268,4 +255,4 @@ private function computePercantage($variant, $percentage, $total) } return $total; } -} \ No newline at end of file +} diff --git a/tests/FeatureTest.php b/tests/FeatureTest.php index 0719f87..6d52510 100644 --- a/tests/FeatureTest.php +++ b/tests/FeatureTest.php @@ -26,14 +26,13 @@ public function setUp() 'admin' => 'test3', 'internal' => 'test1', 'public_url_override' => true, - 'bucketing' => 'random', 'exclude_from' => [ 'zips' => [10014, 10023], 'countries' => ['us', 'rd'], 'regions' => ['ny', 'nj', 'ca'] ], - 'start' => 20170314, - 'end' => 20170530 + 'start' => 20170214, + 'end' => 99990530 ] ])) ->addUrl('feature') @@ -51,7 +50,7 @@ public function setUp() public function testIsEnabled() { - $this->assertEquals($this->feature->isEnabled('testFeature'), false); + $this->assertEquals($this->feature->isEnabled('testFeature'), true); } public function testIsEnabledFor() @@ -76,13 +75,13 @@ public function testIsEnabledBucketingBy() { $this->assertEquals( $this->feature->isEnabledBucketingBy('testFeature', 'test'), - false + true ); } public function testVariant() { - $this->assertEquals($this->feature->variant('testFeature'), 'off'); + $this->assertEquals($this->feature->variant('testFeature'), 'test1'); } public function testVariantFor() @@ -99,7 +98,7 @@ public function testVariantFor() 'internal-ip' => false ] ), - 'off' + 'test4' ); } @@ -107,7 +106,7 @@ public function testVariantBucketingBy() { $this->assertEquals( $this->feature->variantBucketingBy('testFeature', 'test'), - 'off' + 'test2' ); } diff --git a/tests/VariantTest.php b/tests/VariantTest.php index 19d464a..0318948 100644 --- a/tests/VariantTest.php +++ b/tests/VariantTest.php @@ -68,8 +68,8 @@ public function setUp() ->addName('test'); } - public function testToString() + public function testGetVariant() { - $this->assertEquals((string)$this->variant, 'off'); + $this->assertEquals($this->variant->getVariant(), 'off'); } -} \ No newline at end of file +} From 0124b091829c2e56954064b5698c90668f6f6416 Mon Sep 17 00:00:00 2001 From: Pablo Iglesias Date: Sun, 26 Feb 2017 23:30:13 -0500 Subject: [PATCH 28/92] addin automated testing --- .travis.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..91b0931 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,10 @@ +language: php + +php: + - '5.6' + - '7.0' + - '7.1' + - hhvm + - nightly + +script: composer install && composer require "phpunit/phpunit" && php vendor/bin/phpunit From 87230858a37d38621cccff89744d00abaea60ba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Sun, 26 Feb 2017 23:37:52 -0500 Subject: [PATCH 29/92] Update README.md --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bb3b9f3..fca84c5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ -This is an archived file left for reference. +[![Build Status](https://travis-ci.org/PabloJoan/feature.svg?branch=master)](https://travis-ci.org/PabloJoan/feature) + + +Everything below is an archive, left for reference. # This is an Archived Project From 391bc636e9c15aa634582ba531e2b76748ebe923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Sun, 26 Feb 2017 23:40:16 -0500 Subject: [PATCH 30/92] Delete GENERALIZING.md --- GENERALIZING.md | 103 ------------------------------------------------ 1 file changed, 103 deletions(-) delete mode 100644 GENERALIZING.md diff --git a/GENERALIZING.md b/GENERALIZING.md deleted file mode 100644 index e381586..0000000 --- a/GENERALIZING.md +++ /dev/null @@ -1,103 +0,0 @@ -This is an archived file left for reference. - -# A theory about generalizing this code - -This code was written at Etsy to meet our specific needs and with a -strong goal of making the code as simple as possible to understand. -Which means that there are places where stuff is hardwired because we -didn’t need the flexibility to do things another way. - -Obviously, some of the concepts embedded in this code are not going to -be applicable outside the Etsy context. Thus if you want to use this -code in a different context you have two choices: fork and hack or -generalize. I’d actually suggest you start with the former. Hopefully -things are structured well enough that if you rip out the -Etsy-specific code you’ll be left with a few obvious holes to fill in -with your own stuff. I’ve started things down that path for you by -turning several methods into no-ops and marking with the comment -“IMPLEMENT FOR YOUR CONTEXT”. - -However even with those bits ripped out, the structure of things in -`master` is still tied to its Etsy heritage so, I've also made a quick -start at that in the `generalized` branch. Note that the code in this -branch is completely untested and may still be wrongheaded in many -ways. (There are plans at Etsy to finish up this work and the port it -back into our own codebase.) - -The basic approach I took in that branch was to introduce a new -abstraction, the "experimental unit". Every feature is tested relative -to some kind of experimental unit which is named in the feature's -configuration (under the `unit` key) though the World -implementation can provide a default. Each kind of experimental unit -can support: - -- explicit configuration of variants based on some characteristic of - the unit. - -- different bucketing schemes. - -As an example, the `Feature_EtsyRequestUnit` class, implements an -experimental unit that maps to a web request. Each web request (in the -Etsy context) has some information about the user who made the request -(at least a cookie called the UAID and possibly an Etsy user ID if -they are signed in). Additionally the request itself may have included -a `features` query param that specifies specific variants for specific -features and may also be an "internal" request, coming from someone -within Etsy. - -The configuration syntax for a feature configured with this -experimental unit (which is the default in the current implementation -of `World`) can be configured with `users`, `groups`, `admin`, -and `internal` keys, that specify variants to be assigned to specific -users, users in specific groups, all Etsy employees (called "admin"), -or for internal requests. - -This experimental unit also supports three bucketing styles: 'uaid', -'user', and 'random'. The 'uaid' style uses the cookie that is set on -every request as the bucketing ID while the 'user' style uses the user -ID of signed in users. Random bucketing, which assigns a variant -randomly on each request, is only used for operational ramupus without -user-visible effects such as switching from one backend database to -another. - -When a call is made to `Feature::isEnabled` or `Feature::variant`, the -experimental unit is responsible for saying whether a specific variant -should be used (via the `assignedVariant` method) and, if not, what id -should be used for bucketing the experimental unit into a variant (via -`bucketingID`). - -In the generalized branch, both those methods take a second argument, -`$data`, which is passed along to the `assignedVariant` and -`bucketingID` methods. In general, implementations of these methods -should ensure that any data they are passed is of the appropriate -type: there is an obligation on callers of the Feature API methods to -pass the appropriate kind of date for the kind of experimental unit -the feature has been configured with. - -One thing this generalization does is get rid of the need for the -`isEnabledFor`/`variantFor` and -`isEnabledBucketingBy`/`variantBucketingBy` methods. The main use -case, at Etsy, for the former pair is if we wanted to run an -experiment where insted of bucketing by the user making a request, we -want to bucket by the user who owns the shop the user is looking at. -In the current API, that is achieved by passing the user object -representing the shop owner to `isEnabledFor` and `variantFor`. And -the use case for the `bucketingBy` methods is when we want to bucket -on something that doesn't necessarily have an associated user. For -instance if we wanted to run an experiment on a random selection of -searches, we might use the search terms as the second argument to the -`bucketingBy` methods. - -In the generalized API, in the first case we would instead configure a -feature with a `unit` of, say, 'seller' that would map to a class that -either expects some object reperesenting the seller to be passed to -the Feature API calls or which knows how to figure out the seller from -the context of the request. And in the second case we would configure -a query with a `unit` of 'query' that maps to a class that expects a -query string passed as the `$data` argument to the Feature methods. -With such a class, we could then allow the feature to be configured to -return a specific variant for specific queries, e.g. if we want to -ensure that certain very popular queries are kept out of the treatment -group for whatever reason. - --Peter Seibel \ No newline at end of file From a349d3922af87f47c77f40bd6b6b0cac1c615f5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Mon, 27 Feb 2017 00:47:34 -0500 Subject: [PATCH 31/92] Update README.md --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/README.md b/README.md index fca84c5..8fb3987 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,35 @@ [![Build Status](https://travis-ci.org/PabloJoan/feature.svg?branch=master)](https://travis-ci.org/PabloJoan/feature) +Requires PHP 5.6 and above. + +# Installation + +```bash +composer require cafemedia/feature +``` + +# Usage + +```php +$config = [ + 'testFeature' => [ + 'description' => 'this is the description of the test feature', + 'enabled' => [ + 'variant1' => 100, //100% chance this variable will be chosen + 'variant2' => 0 + ], + ] +]; +$feature = (new Feature($config))->addUser([ + 'user-uaid' => 'unique identifier', //required + 'user-id' => 'logged in user ID', // if applicable + 'user-name' => 'logged in user name' // if applicable +]); + +$feature->isEnabled('testFeature'); // true +$feature->variant('variant1'); // true +$feature->variant('description'); // 'this is the description of the test feature' +``` Everything below is an archive, left for reference. From f1cacd1554da65e3d760d41b71d20be6c92914cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Mon, 27 Feb 2017 00:55:20 -0500 Subject: [PATCH 32/92] Update README.md --- README.md | 44 ++++++++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 8fb3987..8e16de5 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,14 @@ $feature->variant('variant1'); // true $feature->variant('description'); // 'this is the description of the test feature' ``` + +# TODO + +DOCUMENTATION!!!!! remove archived documentation by etsy and replace with new. +More tests. +Add more bucketing schemes. + + Everything below is an archive, left for reference. # This is an Archived Project @@ -54,22 +62,22 @@ including what variant was selected, in the events we fire. The two main API entry points are: - Feature::isEnabled('my_feature') + $feature->isEnabled('my_feature') which returns true when `my_feature` is enabled and, for multi-variant features: - Feature::variant('my_feature') + $feature->variant('my_feature') which returns the name of the particular variant which should be used. The single argument to each of these methods is the name of the feature to test. -A typical use of `Feature::isEnabled` for a single-variant feature +A typical use of `$feature->isEnabled` for a single-variant feature would look something like this: - if (Feature::isEnabled('my_feature')) { + if ($feature->isEnabled('my_feature')) { // do stuff } @@ -77,9 +85,9 @@ For a multi-variant feature, within the block guarded by the `Feature::isEnabled` check, we can determine the appropriate code to run for each variant with something like this: - if (Feature::isEnabled('my_feature')) { + if ($feature->('my_feature')) { - switch (Feature::variant('my_feature')) { + switch ($feature->variant('my_feature')) { case 'foo': // do stuff appropriate for the foo variant break; @@ -91,20 +99,20 @@ run for each variant with something like this: It is an error (and will be logged as such) to ask for the variant of a feature that is not enabled. So the calls to variant should always -be guarded by an `Feature::isEnabled` check. +be guarded by an `$feature->isEnabled` check. The API also provides two other pairs of methods that will be used much less frequently: - Feature::isEnabledFor('my_feature', $user) + $feature->isEnabledFor('my_feature', $user) - Feature::variantFor('my_feature', $user) + $feature->variantFor('my_feature', $user) and - Feature::isEnabledBucketingBy('my_feature', $bucketingID) + $feature->isEnabledBucketingBy('my_feature', $bucketingID) - Feature::variantBucketingBy('my_feature', $bucketingID) + $feature->variantBucketingBy('my_feature', $bucketingID) These methods exist only to support a couple very specific use-cases: when we want to enable or disable a feature based not on the user @@ -144,11 +152,11 @@ configuration. ### A totally enabled feature: - $server_config['foo'] = 'on'; + $server_config['foo'] = ['enabled' => 100]; ### A totally disabled feature: - $server_config['foo'] = 'off'; + $server_config['foo'] = ['enabled' => 0]; ### Feature with winning variant turned on for everyone @@ -413,10 +421,10 @@ There are a few ways to misuse the Feature API or misconfigure a feature that may be detected and logged. (Some of these are not currently detected but may be in the future.) - 1. Calling `Feature::variant` for a single-variant feature. + 1. Calling `$feature->variant` for a single-variant feature. - 1. Calling `Feature::variant` in code not guarded by an - `Feature::isEnabled` check. + 1. Calling `$feature->variant` in code not guarded by an + `$feature->isEnabled` check. 1. Including `'on'` as a variant name in a multi-variant feature. @@ -500,7 +508,7 @@ Here’s what will happen in those cases: calls to `Feature::variant` and any related conditional logic (e.g. switches on the variant name). - 1. Remove the `Feature::isEnabled` checks but keep the code they + 1. Remove the `$feature->isEnabled` checks but keep the code they guarded. 1. Remove the feature config. @@ -511,7 +519,7 @@ Here’s what will happen in those cases: variant (`'on'` for a single-variant feature). 1. Delete any code that implements other variants and remove the - calls to `Feature::variant` and any related conditional logic + calls to `$feature->variant` and any related conditional logic (e.g. switches on the variant name). 1. Add a new config named with a `feature_` prefix and set its value From c670b1049d8f78498b10a04467b4e04df918b405 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Mon, 27 Feb 2017 22:09:06 -0500 Subject: [PATCH 33/92] Update README.md --- README.md | 179 ++++++++---------------------------------------------- 1 file changed, 25 insertions(+), 154 deletions(-) diff --git a/README.md b/README.md index 8e16de5..c81e805 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Requires PHP 5.6 and above. composer require cafemedia/feature ``` -# Usage +# Basic Usage ```php $config = [ @@ -38,16 +38,9 @@ DOCUMENTATION!!!!! remove archived documentation by etsy and replace with new. More tests. Add more bucketing schemes. - -Everything below is an archive, left for reference. - -# This is an Archived Project - -Feature is no longer actively maintained and is no longer in sync with the version used internally at Etsy. - # Feature API -Etsy's Feature flagging API used for operational rampups and A/B +Feature flagging API used for operational rampups and A/B testing. The Feature API is how we selectively enable and disable features at a @@ -56,10 +49,6 @@ for operational ramp-ups and for A/B tests. A feature can be completely enabled, completely disabled, or something in between and can comprise a number of related variants. -For features that are not completely enabled or disabled, we log every -time we check whether a feature is enabled and include the result, -including what variant was selected, in the events we fire. - The two main API entry points are: $feature->isEnabled('my_feature') @@ -97,7 +86,7 @@ run for each variant with something like this: } } -It is an error (and will be logged as such) to ask for the variant of +It is an error to ask for the variant of a feature that is not enabled. So the calls to variant should always be guarded by an `$feature->isEnabled` check. @@ -136,13 +125,6 @@ as the bucketing ID. In general it is much more likely you want to use the plain old `isEnabled` and `variant` methods. -For Smarty templates, where static methods can’t readily be called, -there is an object, `$feature`, wired up in Tpl.php that exposes the -same four methods as the Feature API but as instance methods, for -instance: - - {% if $feature->isEnabled("my_feature") %} - ## Configuration cookbook There are a number of common configurations so before I explain the @@ -160,7 +142,7 @@ configuration. ### Feature with winning variant turned on for everyone - $server_config['foo'] = 'blue_background'; + $server_config['foo'] = ['enabled' => ['blue_background' => 100]]; ### Feature enabled only for admins: @@ -224,13 +206,10 @@ configuration. ### New feature intended only to be enabled by adding ?features=foo to a URL - $server_config['foo'] = array('enabled' => 0); - -This is kind of a funny edge case. It could also be written: - - $server_config['foo'] = array(); - -since a missing `'enabled'` is defaulted to 0. + $server_config['foo'] = array( + 'enabled' => 0, + 'public_url_override' => true + ); ## Configuration details @@ -241,16 +220,13 @@ Leaving aside a few shorthands that will be explained in a moment, the value of a feature config stanza is an array with a number of special keys, the most important of which is `'enabled'`. -In its full form, the value of the `'enabled'` property is either the -string `'off'`, meaning the feature is entirely disabled, any other -string, meaning the named variant is enabled for all requests, or an +In its full form, the value of the `'enabled'` property an array whose keys are names of variants and whose values are the percentage of requests that should see each variant. As a shorthand to support the common case of a feature with only one variant, `'enabled'` can also be specified as a percentage from 0 to -100 which is equivalent to specifying an array with the variant name -`'on'` and the given percentage. +100. The next four most important properties of a feature config stanza specify a particular variant that special classes of users should see: @@ -259,9 +235,7 @@ specify a particular variant that special classes of users should see: The `'admin'` and `'internal'` properties, if present, should name a variant that should be shown for all admin users or all internal requests. For single-variant features this name will almost always be -`'on'`. (Technically you could also specify `'off'` to turn off a -feature for admin users or internal requests that would be otherwise -enabled. But that would be weird.) For multi-variant features it can +`'on'`. For multi-variant features it can be any of the variants mentioned in the `'enabled'` array. The `'users'` and `'groups'` variants provide a mapping from variant @@ -281,11 +255,10 @@ and: $server_config['foo'] => array('users' => 'fred'); -None of these four properties have any effect if `'enabled'` is a -string since in those cases the feature is considered either entirely -enabled or disabled. They can, however, enable a variant of a feature -if no `'enabled'` value is provided or if the variant’s percentage is -0. +None of these four properties have any effect if `'enabled'` is +entirely enabled or disabled. They can, however, enable a variant of a +feature if no `'enabled'` value is provided or if the variant’s +percentage is 0. On the other hand, when an array `'enabled'` value is specified, as an aid to detecting typos, the variant names used in the `'admin'`, @@ -319,74 +292,12 @@ admin and internal requests, to turn on a feature and choose a variant via the `features` query param. Its value will almost always be true if it is present since it defaults to false if omitted. -Finally, two last shorthands: - -First, a config stanza with only the key `'enabled'` and a string -value can be replaced with just the string. So: - - $server_config['foo'] = array('enabled' => 'on'); - $server_config['bar'] = array('enabled' => 'off'); - $server_config['baz'] = array('enabled' => 'some_variant'); - -Can be written simply: - - $server_config['foo'] = 'on'; - $server_config['bar'] = 'off'; - $server_config['baz'] = 'some_variant'; - -And second, if a feature config is missing entirely, it’s equivalent -to specifying it as `'off'`. This allows dark changes to include code -that checks for a feature before it has been added to production.php. - -**Note for ops**: removing a feature config altogether, setting it to -the string `'off'`, or setting `'enabled'` to `'off'` all completely -disable the feature, ensuring that code guarded by -`Feature::isEnabled` for that feature will never run. The best way to -turn off an existing feature in an emergency would be to set -`'enabled'` to `'off'`. To facilitate that, we should try to keep the -`'enabled'` value on one line, whenever possible. Thus: - - $server_config['foo'] = array( - 'enabled' => array('foo' => 10, 'bar' => 10), - ); - -rather than - - $server_config['foo'] = array( - 'enabled' => array( - 'foo' => 10, - 'bar' => 10 - ), - ); - -so that the bleary-eyed, junior ops person at 3am can do this: - - $server_config['foo'] = array( - 'enabled' => 'off', // array('foo' => 10, 'bar' => 10), - ); - -rather than this, which breaks the config file: - - $server_config['foo'] = array( - 'enabled' => 'off', // array( - 'foo' => 10, - 'bar' => 10 - ), - ); - -Note, however, that removing the `'enabled'` property does mostly turn -off the feature it doesn’t completely disable it as it could still be -enabled via an `'admin'` property, etc. - ## Precedence: The precedence of the various mechanisms for enabling a feature are as follows. - - If `'enabled'` is a string (variant name or `'off'`) the feature - is entirely on or off for all requests. - - - Otherwise, if the request is from an admin user or is an internal + - If the request is from an admin user or is an internal request, or if `'public_url_override'` is true and the request contains a `features` query param that specifies a variant for the feature in question, that variant is used. The value of the @@ -426,8 +337,6 @@ currently detected but may be in the future.) 1. Calling `$feature->variant` in code not guarded by an `$feature->isEnabled` check. - 1. Including `'on'` as a variant name in a multi-variant feature. - 1. Setting `'enabled'` to numeric value less than 0 or greater than 100. @@ -437,7 +346,7 @@ currently detected but may be in the future.) 1. Setting `'enabled'` such that the sum of the variant percentages is greater than 100. - 1. Setting `'enabled'` to a non-numeric, non-string, non-array + 1. Setting `'enabled'` to a non-numeric, non-array value. 1. When `'enabled'` is an array, setting the `'users'` or `'groups'` @@ -458,7 +367,7 @@ the code, or deleting the code altogether. The basic life cycle of a feature might look like this: - 1. Developer writes some code guarded by `Feature::isEnabled` + 1. Developer writes some code guarded by `$feature->isEnabled` checks. In order to test the feature in development they will add configuration for the feature to `development.php` that turns it on for specific users or admin or sets `'enabled'` to 0 so they @@ -479,8 +388,7 @@ The basic life cycle of a feature might look like this: 1. During the rampup period the percentage of users exposed to the feature may be moved up and down until the developers and ops folks are convinced the code is fully baked. If serious problems - arise at any point, the new code can be completely disabled by - setting enabled to `'off'`. + arise at any point, the new code can be completely disabled. 1. If the feature is going to be part of an A/B experiment, then the developers will (working with the data team) figure out the best @@ -502,7 +410,7 @@ Here’s what will happen in those cases: ### To keep the feature as a permanent part of the web site without creating a top-level feature flag 1. Change the value of the feature config to the name of the winning - variant (`'on'` for a single-variant feature). + variant. 1. Delete any code that implements other variants and remove the calls to `Feature::variant` and any related conditional logic @@ -513,52 +421,15 @@ Here’s what will happen in those cases: 1. Remove the feature config. -### To keep a feature under the control of a full-fledged feature flag. (I.e. for things that will typically be enabled but which we want to preserve the ability to turn off with a simple config change.) - - 1. Change the value of the feature config to the name of the winning - variant (`'on'` for a single-variant feature). - - 1. Delete any code that implements other variants and remove the - calls to `$feature->variant` and any related conditional logic - (e.g. switches on the variant name). - - 1. Add a new config named with a `feature_` prefix and set its value - to `'on'`. - - 1. Change all the `Feature::isEnabled` checks for the old flag name - to the new feature flag. - - 1. Remove the old config. - ### To remove a feature all together - 1. Change the value of the feature config to `'off'`. + 1. Change the value of the feature config to `['enabled' => 0]`. - 1. Delete all code guarded by `Feature::isEnabled` checks and then + 1. Delete all code guarded by `$feature->isEnabled` checks and then remove the checks. 1. Remove the feature config. -### To run a new experiment based on the same code - - 1. Set the enabled value of the feature config to `'off'`. - - 1. Create a new feature config with a similar name but suffixed with - _vN where N is 2 if this is the second experiment, 3 if is the - third. Set it to `'off'`. - - 1. Change all the `Feature::isEnabled` checks for the old feature to - the new feature. - - 1. Delete the old config. - - 1. Implement the changes required for the new experiment, deleting - old variants and adding new ones as needed. - - 1. Rampup and then A/B test the new feature as normal. - - 1. Promote, cleanup, or re-experiment as appropriate. - ## A few style guidelines To make it easier to push features through this life cycle there are a @@ -574,15 +445,15 @@ using the Feature API but rather simply driving your code with some plain old config data. Second, the results of the Feature methods should not be cached, such -as by calling `Feature::isEnabled` once and storing the result in an +as by calling `$feature->isEnabled` once and storing the result in an instance variable of some controller. The Feature machinery already caches the results of the computation it does so it should already be -plenty fast to simply call `Feature::isEnabled` or `Feature::variant` +plenty fast to simply call `$feature->isEnabled` or `$feature->variant` whenever needed. This will again aid in finding the places that depend on a particular feature. Third, as a check that you’re using the Feature API properly, whenever -you have an if block whose test is a call to `Feature::isEnabled`, +you have an if block whose test is a call to `$feature->isEnabled`, make sure that it would make sense to either remove the check and keep the code or to delete the check and the code together. There shouldn’t be bits of code within a block guarded by an isEnabled check that From 8fa82fab5524e09d664b9642750cdaa77aef5384 Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Thu, 28 Dec 2017 13:12:33 -0500 Subject: [PATCH 34/92] v3.0. %100 test code coverage. --- .gitignore | 1 - .travis.yml | 5 +- README.md | 522 ++++++++++----------- composer.json | 39 +- phpunit.xml | 24 - src/Config.php | 250 ++++++---- src/Feature.php | 211 ++++----- src/Stanza.php | 177 ------- src/User.php | 37 -- src/Value/Admin.php | 17 + src/Value/Bucketing.php | 23 + src/Value/BucketingId.php | 18 + src/Value/CalculateBucketingId.php | 42 ++ src/Value/Description.php | 17 + src/Value/Enabled.php | 49 ++ src/Value/ExcludeFrom.php | 43 ++ src/Value/Feature.php | 81 ++++ src/Value/FeatureCollection.php | 31 ++ src/Value/Groups.php | 27 ++ src/Value/Internal.php | 17 + src/Value/Name.php | 18 + src/Value/PublicUrlOverride.php | 17 + src/Value/Source.php | 14 + src/Value/Sources.php | 27 ++ src/Value/Time.php | 34 ++ src/Value/Url.php | 42 ++ src/Value/User.php | 45 ++ src/Value/Users.php | 27 ++ src/Variant.php | 258 ----------- src/World.php | 145 ------ tests/ApiTest.php | 721 +++++++++++++++++++++++++++++ tests/BucketingTest.php | 34 ++ tests/CalculateBucketingIdTest.php | 57 +++ tests/ConfigTest.php | 156 +++---- tests/EnabledTest.php | 73 +++ tests/ExcludeFromTest.php | 40 ++ tests/FeatureCollectionTest.php | 37 ++ tests/FeatureTest.php | 223 ++++++--- tests/StanzaTest.php | 127 ----- tests/UrlTest.php | 43 ++ tests/UserTest.php | 71 --- tests/VariantTest.php | 75 --- tests/WorldTest.php | 92 ---- 43 files changed, 2337 insertions(+), 1670 deletions(-) delete mode 100644 phpunit.xml delete mode 100644 src/Stanza.php delete mode 100644 src/User.php create mode 100644 src/Value/Admin.php create mode 100644 src/Value/Bucketing.php create mode 100644 src/Value/BucketingId.php create mode 100644 src/Value/CalculateBucketingId.php create mode 100644 src/Value/Description.php create mode 100644 src/Value/Enabled.php create mode 100644 src/Value/ExcludeFrom.php create mode 100644 src/Value/Feature.php create mode 100644 src/Value/FeatureCollection.php create mode 100644 src/Value/Groups.php create mode 100644 src/Value/Internal.php create mode 100644 src/Value/Name.php create mode 100644 src/Value/PublicUrlOverride.php create mode 100644 src/Value/Source.php create mode 100644 src/Value/Sources.php create mode 100644 src/Value/Time.php create mode 100644 src/Value/Url.php create mode 100644 src/Value/User.php create mode 100644 src/Value/Users.php delete mode 100644 src/Variant.php delete mode 100644 src/World.php create mode 100644 tests/ApiTest.php create mode 100644 tests/BucketingTest.php create mode 100644 tests/CalculateBucketingIdTest.php create mode 100644 tests/EnabledTest.php create mode 100644 tests/ExcludeFromTest.php create mode 100644 tests/FeatureCollectionTest.php delete mode 100644 tests/StanzaTest.php create mode 100644 tests/UrlTest.php delete mode 100644 tests/UserTest.php delete mode 100644 tests/VariantTest.php delete mode 100644 tests/WorldTest.php diff --git a/.gitignore b/.gitignore index f7015c3..d1502b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ vendor/ -.idea/ composer.lock diff --git a/.travis.yml b/.travis.yml index 91b0931..a027304 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,9 @@ language: php php: - - '5.6' - '7.0' - '7.1' - - hhvm + - '7.2' - nightly -script: composer install && composer require "phpunit/phpunit" && php vendor/bin/phpunit +script: composer update && ./vendor/bin/phpstan analyse --level=max --debug -vvv src/ tests/ && ./vendor/bin/phpunit --stop-on-failure --fail-on-warning --fail-on-risky -v --debug --coverage-text --whitelist src/ tests/ diff --git a/README.md b/README.md index c81e805..2cfb3d4 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,78 @@ [![Build Status](https://travis-ci.org/PabloJoan/feature.svg?branch=master)](https://travis-ci.org/PabloJoan/feature) -Requires PHP 5.6 and above. +Requires PHP 7.0 and above. # Installation ```bash -composer require cafemedia/feature +composer require pablojoan/feature +``` + +# Running tests +```bash +./vendor/bin/phpstan analyse --level=max --debug -vvv src/ tests/ && ./vendor/bin/phpunit --stop-on-failure --fail-on-warning --fail-on-risky -v --debug --coverage-text --whitelist src/ tests/ ``` # Basic Usage ```php $config = [ - 'testFeature' => [ - 'description' => 'this is the description of the test feature', - 'enabled' => [ - 'variant1' => 100, //100% chance this variable will be chosen - 'variant2' => 0 - ], + features => [ + 'foo' => [ + 'description' => 'this is the description of the "foo" feature', + 'enabled' => [ + 'variant1' => 100, //100% chance this variable will be chosen + 'variant2' => 0 //0% chance this variable will be chosen + ] + ], + 'bar' => [ + 'description' => 'this is the description of the "bar" feature', + 'enabled' => [ + 'variant1' => 25, //25% chance this variable will be chosen + 'variant2' => 25, //25% chance this variable will be chosen + 'variant3' => 50 //50% chance this variable will be chosen + ], + 'bucketing' => 'uaid' //same uaid string will always return the same variant + ] + ], + 'user' => [ + 'uaid' => 'unique identifier', // ex. session id or cookie + 'id' => 'logged in user ID', // if applicable ] ]; -$feature = (new Feature($config))->addUser([ - 'user-uaid' => 'unique identifier', //required - 'user-id' => 'logged in user ID', // if applicable - 'user-name' => 'logged in user name' // if applicable -]); - -$feature->isEnabled('testFeature'); // true -$feature->variant('variant1'); // true -$feature->variant('description'); // 'this is the description of the test feature' -``` +$feature = new Feature($config); + +$feature->isEnabled('foo'); // true +$feature->variant('foo'); // 'variant1' +$feature->description('foo'); // 'this is the description of the "foo" feature' +``` # TODO DOCUMENTATION!!!!! remove archived documentation by etsy and replace with new. -More tests. +Improve Unit Tests. Use Mock Objects. +Depend on Interface Injecting to decouple code. Add more bucketing schemes. # Feature API -Feature flagging API used for operational rampups and A/B -testing. +Feature flagging API used for operational rampups and A/B testing. -The Feature API is how we selectively enable and disable features at a -very fine grain as well as enabling features for a percentage of users -for operational ramp-ups and for A/B tests. A feature can be -completely enabled, completely disabled, or something in between and -can comprise a number of related variants. +The Feature API is how we selectively enable and disable features at a very fine +grain as well as enabling features for a percentage of users for operational +ramp-ups and for A/B tests. +A feature can be completely enabled, completely disabled, or something in +between and can comprise a number of related variants. The two main API entry points are: - +```php $feature->isEnabled('my_feature') - -which returns true when `my_feature` is enabled and, for multi-variant -features: - +``` +which returns true when `my_feature` is enabled and, for multi-variant features: +```php $feature->variant('my_feature') - +``` which returns the name of the particular variant which should be used. The single argument to each of these methods is the name of the @@ -65,237 +80,223 @@ feature to test. A typical use of `$feature->isEnabled` for a single-variant feature would look something like this: - +```php if ($feature->isEnabled('my_feature')) { // do stuff } - -For a multi-variant feature, within the block guarded by the -`Feature::isEnabled` check, we can determine the appropriate code to -run for each variant with something like this: - - if ($feature->('my_feature')) { - - switch ($feature->variant('my_feature')) { - case 'foo': - // do stuff appropriate for the foo variant - break; - case 'bar': - // do stuff appropriate for the bar variant - break; - } +``` +For a multi-variant feature, we can determine the appropriate code to run for +each variant with something like this: +```php + switch ($feature->variant('my_feature')) { + case 'foo': + // do stuff appropriate for the 'foo' variant + break; + case 'bar': + // do stuff appropriate for the 'bar' variant + break; } - -It is an error to ask for the variant of -a feature that is not enabled. So the calls to variant should always -be guarded by an `$feature->isEnabled` check. +``` The API also provides two other pairs of methods that will be used much less frequently: - +```php $feature->isEnabledFor('my_feature', $user) $feature->variantFor('my_feature', $user) - +``` and - +```php $feature->isEnabledBucketingBy('my_feature', $bucketingID) $feature->variantBucketingBy('my_feature', $bucketingID) - -These methods exist only to support a couple very specific use-cases: -when we want to enable or disable a feature based not on the user -making the request but on some other user or when we want to bucket a -percentage of executions based on something entirely other than a -user.) The canonical case for the former, at Etsy, is if we wanted to -change something about how we deal with listings and instead of -enabling the feature for only some users but for all listings those -users see, but instead we want to enable it for all users but for only -some of the listings. Then we could use `isEnabledFor` and -`variantFor` and pass in the user object representing the owner of the -listing. That would also allow us to enable the feature for specific -listing owners. The `bucketingBy` methods serve a similar purpose -except when there either is no relevant user or where we don't want to -always put the same user in the same bucket. Thus if we wanted to -enable a certain feature for 10% of all listings displayed, -independent of both the user making the request and the user who owned -the listing, we could use `isEnabledBucketingBy` with the listing id -as the bucketing ID. - -In general it is much more likely you want to use the plain old -`isEnabled` and `variant` methods. +``` +These methods exist only to support a couple very specific use-cases: when we +want to enable or disable a feature based not on the user making the request but +on some other user or when we want to bucket a percentage of executions based on +something entirely other than a user.) The canonical case for the former, at +Etsy, is if we wanted to change something about how we deal with listings and +instead of enabling the feature for only some users but for all listings those +users see, but instead we want to enable it for all users but for only some of +the listings. Then we could use `isEnabledFor` and `variantFor` and pass in the +user object representing the owner of the listing. That would also allow us to +enable the feature for specific listing owners. The `bucketingBy` methods serve +a similar purpose except when there either is no relevant user or where we don't +want to always put the same user in the same bucket. Thus if we wanted to enable +a certain feature for 10% of all listings displayed, independent of both the +user making the request and the user who owned the listing, we could use `isEnabledBucketingBy` with the listing id as the bucketing ID. + +In general it is much more likely you want to use the plain old `isEnabled` and +`variant` methods. ## Configuration cookbook -There are a number of common configurations so before I explain the -complete syntax of the feature configuration stanzas, here are some of -the more common cases along with the most concise way to write the -configuration. +There are a number of common configurations so before I explain the complete +syntax of the feature configuration stanzas, here are some of the more common +cases along with the most concise way to write the configuration. ### A totally enabled feature: - +```php $server_config['foo'] = ['enabled' => 100]; - +``` ### A totally disabled feature: - +```php $server_config['foo'] = ['enabled' => 0]; - +``` ### Feature with winning variant turned on for everyone - +```php $server_config['foo'] = ['enabled' => ['blue_background' => 100]]; - +``` ### Feature enabled only for admins: - - $server_config['foo'] = array('admin' => 'on'); - +```php + $server_config['foo'] = ['admin' => 'on']; +``` ### Single-variant feature ramped up to 1% of users. - - $server_config['foo'] = array('enabled' => 1); - +```php + $server_config['foo'] = ['enabled' => 1]; +``` ### Multi-variant feature ramped up to 1% of users for each variant. - - $server_config['foo'] = array( - 'enabled' => array( +```php + $server_config['foo'] = [ + 'enabled' => [ 'blue_background' => 1, 'orange_background' => 1, 'pink_background' => 1, - ), - ); - + ], + ]; +``` ### Enabled for a single specific user. - - $server_config['foo'] = array('users' => 'fred'); - +```php + $server_config['foo'] = ['users' => 'fred']; +``` ### Enabled for a few specific users. - - $server_config['foo'] = array( - 'users' => array('fred', 'barney', 'wilma', 'betty'), - ); - +```php + $server_config['foo'] = [ + 'users' => ['fred', 'barney', 'wilma', 'betty'], + ]; +``` ### Enabled for a specific group - - $server_config['foo'] = array('groups' => 1234); - +```php + $server_config['foo'] = ['groups' => '1234']; +``` ### Enabled for 10% of regular users and all admin. - - $server_config['foo'] = array( +```php + $server_config['foo'] = [ 'enabled' => 10, 'admin' => 'on', - ); - + ]; +``` ### Feature ramped up to 1% of requests, bucketing at random rather than by user - - $server_config['foo'] = array( +```php + $server_config['foo'] = [ 'enabled' => 1, 'bucketing' => 'random', - ); - + ]; +``` +### Feature ramped up to 40% of requests, bucketing by user rather than at random +```php + $server_config['foo'] = [ + 'enabled' => 40, + 'bucketing' => 'user', + ]; +``` ### Single-variant feature in 50/50 A/B test - - $server_config['foo'] = array('enabled' => 50); - +```php + $server_config['foo'] = ['enabled' => 50]; +``` ### Multi-variant feature in A/B test with 20% of users seeing each variant (and 40% left in control group). - - $server_config['foo'] = array( - 'enabled' => array( +```php + $server_config['foo'] = [ + 'enabled' => [ 'blue_background' => 20, 'orange_background' => 20, 'pink_background' => 20, - ), - ); - + ], + ]; +``` ### New feature intended only to be enabled by adding ?features=foo to a URL - - $server_config['foo'] = array( +```php + $server_config['foo'] = [ 'enabled' => 0, 'public_url_override' => true - ); - + ]; +``` ## Configuration details -Each feature’s config stanza controls when the feature is enabled and -what variant should be used when it is. - -Leaving aside a few shorthands that will be explained in a moment, the -value of a feature config stanza is an array with a number of special -keys, the most important of which is `'enabled'`. - -In its full form, the value of the `'enabled'` property an -array whose keys are names of variants and whose values are the -percentage of requests that should see each variant. - -As a shorthand to support the common case of a feature with only one -variant, `'enabled'` can also be specified as a percentage from 0 to -100. - -The next four most important properties of a feature config stanza -specify a particular variant that special classes of users should see: -`'admin'`, `'internal'`, `'users'`, and `'groups'`. - -The `'admin'` and `'internal'` properties, if present, should name a -variant that should be shown for all admin users or all internal -requests. For single-variant features this name will almost always be -`'on'`. For multi-variant features it can -be any of the variants mentioned in the `'enabled'` array. - -The `'users'` and `'groups'` variants provide a mapping from variant -names to lists of users or numeric group ids. In the fully specified -case, the value will be an array whose keys are the names of variants -and whose values are lists of user names or group ids, as appropriate. -As a shorthand, if the list of user names or group ids is a single -element it can be specified with just the name or id. And as a further -shorthand, in the configuration of a single-variant feature, the value -of the `'users'` or `'groups'` property can simply be the value that -should be assigned to the `'on'` variant. So using both shorthands, +Each feature’s config stanza controls when the feature is enabled and what +variant should be used when it is. + +Leaving aside a few shorthands that will be explained in a moment, the value of +a feature config stanza is an array with a number of special keys, the most +important of which is `'enabled'`. + +In its full form, the value of the `'enabled'` property an array whose keys are +names of variants and whose values are the percentage of requests that should +see each variant. + +As a shorthand to support the common case of a feature with only one variant, +`'enabled'` can also be specified as a percentage from 0 to 100. + +The next four most important properties of a feature config stanza specify a +particular variant that special classes of users should see: `'admin'`, +`'internal'`, `'users'`, and `'groups'`. + +The `'admin'` and `'internal'` properties, if present, should name a variant +that should be shown for all admin users or all internal requests. For +single-variant features this name will almost always be `'on'`. +For multi-variant features it can be any of the variants mentioned in the +`'enabled'` array. + +The `'users'` and `'groups'` variants provide a mapping from variant names to +lists of users or numeric group ids. In the fully specified case, the value will +be an array whose keys are the names of variants and whose values are lists of +user names or group ids, as appropriate. As a shorthand, if the list of user +names or group ids is a single element it can be specified with just the name or +id. And as a further shorthand, in the configuration of a single-variant +feature, the value of the `'users'` or `'groups'` property can simply be the +value that should be assigned to the `'on'` variant. So using both shorthands, these are equivalent: - - $server_config['foo'] => array('users' => array('on' => array('fred'))); - +```php + $server_config['foo'] => ['users' => ['on' => ['fred']]]; +``` and: +```php + $server_config['foo'] => ['users' => 'fred']; +``` +None of these four properties have any effect if `'enabled'` is entirely enabled +or disabled. They can, however, enable a variant of a feature if no `'enabled'` +value is provided or if the variant’s percentage is 0. - $server_config['foo'] => array('users' => 'fred'); - -None of these four properties have any effect if `'enabled'` is -entirely enabled or disabled. They can, however, enable a variant of a -feature if no `'enabled'` value is provided or if the variant’s -percentage is 0. - -On the other hand, when an array `'enabled'` value is specified, as an -aid to detecting typos, the variant names used in the `'admin'`, -`'internal'`, `'users'`, and `'groups'` properties must also be keys -in the `'enabled'` array. So if any variants are specified via -`'enabled'`, they should all be, even if their percentage is set to 0. +On the other hand, when an array `'enabled'` value is specified, as an aid to +detecting typos, the variant names used in the `'admin'`, `'internal'`, +`'users'`, and `'groups'` properties must also be keys in the `'enabled'` array. +So if any variants are specified via `'enabled'`, they should all be, even if +their percentage is set to 0. The two remaining feature config properties are `'bucketing'` and -`'public_url_override'`. Bucketing specifies how users are bucketed -when a feature is enabled for only a percentage of users. The default -value, `'uaid'`, causes bucketing via the UAID cookie which means a -user will be in the same bucket regardless of whether they are signed -in or not. - -The bucketing value `'user'`, causes bucketing to be based on the -signed-in user id. Currently we fall back to bucketing by UAID if the -user is not signed in but this is problematic since it means that a -user can switch buckets if they sign in or out. (We may change the -behavior of this bucketing scheme to simply disable the feature for -users who are not signed in.) - -Finally the bucketing value `'random'`, causes each request to be -bucketed independently meaning that the same user will be in different -buckets on different requests. This is typically used for features -that should have no user-visible effects but where we want to ramp up -something like the switch from master to shards or a new version of +`'public_url_override'`. Bucketing specifies how users are bucketed when a +feature is enabled for only a percentage of users. The default value, +`'random'`, causes each request to be bucketed independently meaning that the +same user will be in different buckets on different requests. This is typically +used for features that should have no user-visible effects but where we want to +ramp up something like the switch from master to shards or a new version of jquery. -The `'public_url_override'` property allows all requests, not just -admin and internal requests, to turn on a feature and choose a variant -via the `features` query param. Its value will almost always be true -if it is present since it defaults to false if omitted. +The bucketing value `'user'`, causes bucketing to be based on the signed-in user +id. + +Finally the bucketing value, `'uaid'`, causes bucketing via the UAID cookie +which means a user will be in the same bucket regardless of whether they are +signed in or not. + +The `'public_url_override'` property allows all requests, not just admin and +internal requests, to turn on a feature and choose a variant via the `features` +query param. Its value will almost always be true if it is present since it +defaults to false if omitted. ## Precedence: -The precedence of the various mechanisms for enabling a feature are as -follows. +The precedence of the various mechanisms for enabling a feature are as follows. - If the request is from an admin user or is an internal request, or if `'public_url_override'` is true and the request @@ -314,8 +315,7 @@ follows. - Otherwise, if the request is from a member of a group specified in the `'groups'` property the specified variant is enabled. (The behavior when the user is a member of multiple groups that have - been assigned different variants is undefined. Beware nasal - demons.) + been assigned different variants is undefined. Beware nasal demons.) - Otherwise, if the request is from an admin, the `'admin'` variant is enabled. @@ -328,42 +328,28 @@ follows. ## Errors -There are a few ways to misuse the Feature API or misconfigure a -feature that may be detected and logged. (Some of these are not -currently detected but may be in the future.) - - 1. Calling `$feature->variant` for a single-variant feature. - - 1. Calling `$feature->variant` in code not guarded by an - `$feature->isEnabled` check. +There are a few ways to misuse the Feature API or misconfigure a feature that +may be detected. (Some of these are not currently detected but may be in the +future.) 1. Setting `'enabled'` to numeric value less than 0 or greater than 100. - 1. Setting the percentage value of a variant in `'enabled'` to a + 2. Setting the percentage value of a variant in `'enabled'` to a value less than 0 or greater than 100. - 1. Setting `'enabled'` such that the sum of the variant percentages + 3. Setting `'enabled'` such that the sum of the variant percentages is greater than 100. - 1. Setting `'enabled'` to a non-numeric, non-array - value. - - 1. When `'enabled'` is an array, setting the `'users'` or `'groups'` - property to an array that includes a key that is not a key in - `'enabled'`. - - 1. When `'enabled'` is an array, setting the `'admin'` or - `'internal'` property to a value that is not a key in `'enabled'`. + 4. Setting `'enabled'` to a non-numeric, non-array value. ## The life cycle of a feature -The Feature API was designed with a eye toward making it a bit easier -for us to push features through a predictable life cycle wherein a -feature can be created easily, ramped up, A/B tested, and then cleaned -up, either by being promoted to a full-fledged feature flag, by -removing the configuration and associated feature checks but keeping -the code, or deleting the code altogether. +The Feature API was designed with a eye toward making it a bit easier for us to +push features through a predictable life cycle wherein a feature can be created +easily, ramped up, A/B tested, and then cleaned up, either by being promoted to +a full-fledged feature flag, by removing the configuration and associated +feature checks but keeping the code, or deleting the code altogether. The basic life cycle of a feature might look like this: @@ -373,24 +359,24 @@ The basic life cycle of a feature might look like this: on for specific users or admin or sets `'enabled'` to 0 so they can test it with a URL query param. - 1. At some point the developer will add a config stanza to + 2. At some point the developer will add a config stanza to `production.php`. Initially this may just be a place holder that leaves the feature entirely disabled or it may turn it on for admin, etc. - 1. Once the feature is done, the `production.php` config will be + 3. Once the feature is done, the `production.php` config will be changed to enable the feature for a small percentage of users for an operational smoke test. For a single-variant feature this means setting `'enabled'` to a small numeric value; for a multi-variant feature it means setting `'enabled'` to an array that specifies a small percentage for each variant. - 1. During the rampup period the percentage of users exposed to the + 4. During the rampup period the percentage of users exposed to the feature may be moved up and down until the developers and ops folks are convinced the code is fully baked. If serious problems arise at any point, the new code can be completely disabled. - 1. If the feature is going to be part of an A/B experiment, then the + 5. If the feature is going to be part of an A/B experiment, then the developers will (working with the data team) figure out the best percentage of users to expose the feature to and how long the experiment will have to run in order to gather good experimental @@ -399,62 +385,52 @@ The basic life cycle of a feature might look like this: percentage of users. After this point the percentages should be left alone until the experiment is complete. -At this point there are a number of things that can happen: if the -experiment revealed a clear winner we may simply want to keep the -code, possibly putting it under control of a top-level feature flag -that ops can use to disable the feature for operational reasons. Or we -may want to discard all the code related to the feature. Or we may -want to run another experiment based on what we learned from this one. -Here’s what will happen in those cases: +At this point there are a number of things that can happen: if the experiment +revealed a clear winner we may simply want to keep the code, possibly putting it +under control of a top-level feature flag that ops can use to disable the +feature for operational reasons. Or we may want to discard all the code related +to the feature. Or we may want to run another experiment based on what we +learned from this one. Here’s what will happen in those cases: ### To keep the feature as a permanent part of the web site without creating a top-level feature flag 1. Change the value of the feature config to the name of the winning variant. - 1. Delete any code that implements other variants and remove the + 2. Delete any code that implements other variants and remove the calls to `Feature::variant` and any related conditional logic (e.g. switches on the variant name). - 1. Remove the `$feature->isEnabled` checks but keep the code they + 3. Remove the `Feature::isEnabled` checks but keep the code they guarded. - 1. Remove the feature config. + 4. Remove the feature config. ### To remove a feature all together 1. Change the value of the feature config to `['enabled' => 0]`. - 1. Delete all code guarded by `$feature->isEnabled` checks and then + 2. Delete all code guarded by `Feature::isEnabled` checks and then remove the checks. - 1. Remove the feature config. + 3. Remove the feature config. ## A few style guidelines -To make it easier to push features through this life cycle there are a -few coding guidelines to observe. - -First, the feature name argument to the Feature methods (`isEnabled`, -`variant`, `isEnabledFor`, and `variantFor`) should always be a string -literal. This will make it easier to find all the places that a -particular feature is checked. If you find yourself creating feature -names at run time and then checking them, you’re probably abusing the -Feature system. Chances are in such a case you don’t really want to be -using the Feature API but rather simply driving your code with some -plain old config data. - -Second, the results of the Feature methods should not be cached, such -as by calling `$feature->isEnabled` once and storing the result in an -instance variable of some controller. The Feature machinery already -caches the results of the computation it does so it should already be -plenty fast to simply call `$feature->isEnabled` or `$feature->variant` -whenever needed. This will again aid in finding the places that depend -on a particular feature. - -Third, as a check that you’re using the Feature API properly, whenever -you have an if block whose test is a call to `$feature->isEnabled`, -make sure that it would make sense to either remove the check and keep -the code or to delete the check and the code together. There shouldn’t -be bits of code within a block guarded by an isEnabled check that -needs to be salvaged if the feature is removed. +To make it easier to push features through this life cycle there are a few +coding guidelines to observe. + +First, the feature name argument to the Feature methods (`isEnabled`, `variant`, +`isEnabledFor`, and `variantFor`) should always be a string literal. This will +make it easier to find all the places that a particular feature is checked. If +you find yourself creating feature names at run time and then checking them, +you’re probably abusing the Feature system. Chances are in such a case you don’t +really want to be using the Feature API but rather simply driving your code with +some plain old config data. + +Second, as a check that you’re using the Feature API properly, whenever you have +an if block whose test is a call to `Feature::isEnabled`, make sure that it +would make sense to either remove the check and keep the code or to delete the +check and the code together. There shouldn’t be bits of code within a block +guarded by an isEnabled check that needs to be salvaged if the feature is +removed. diff --git a/composer.json b/composer.json index f2078c4..2355173 100644 --- a/composer.json +++ b/composer.json @@ -1,18 +1,27 @@ { - "name": "cafemedia/feature", - "description": "PSR-4 compliant Feature Flags library based on Etsy", - "authors": [ - { - "name": "Pablo Iglesias", - "email": "piglesias@cafemedia.com" + "name": "pablojoan/feature", + "description": "PSR-4 compliant Feature Flags library based on Etsy", + "authors": [ + { + "name": "Pablo Iglesias", + "email": "iglesias.pablo10@gmail.com" + } + ], + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.5", + "phpstan/phpstan": "^0.9.1" + }, + "autoload": { + "psr-4": { + "PabloJoan\\Feature\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "PabloJoan\\Feature\\Tests\\": "tests/" + } } - ], - "require": { - "php": ">=5.6" - }, - "autoload": { - "psr-4": { - "CafeMedia\\Feature\\": "src/" - } - } } diff --git a/phpunit.xml b/phpunit.xml deleted file mode 100644 index 2e25263..0000000 --- a/phpunit.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - ./tests/ - - - - - src - - - diff --git a/src/Config.php b/src/Config.php index 5830ea7..04d1cec 100644 --- a/src/Config.php +++ b/src/Config.php @@ -1,160 +1,212 @@ world = $world; + $this->user = $user; + $this->url = $url; + $this->source = $source; } - public function addName($name) + /** + * Is this feature enabled for the default id and the logged in user, if + * any? + */ + function isEnabled (Feature $feature) : bool { - $this->name = $name; - $this->stanza = new Stanza($this->world->configValue($name)); - return $this; + $id = (new CalculateBucketingId($this->user, $feature->bucketing()))->id(); + return $this->chooseVariant($feature, $id) !== 'off'; } - //////////////////////////////////////////////////////////////////////// - // Public API, though note that Feature.php is the only code that - // should be using this class directly. + /** + * What variant is enabled for the default id and the logged in user, if + * any? + */ + function variant (Feature $feature) : string + { + $id = (new CalculateBucketingId($this->user, $feature->bucketing()))->id(); + $variant = $this->chooseVariant($feature, $id); + return $variant !== 'off' ? $variant : ''; + } /** - * Is this feature enabled for the default id and the logged i user, if any? + * Is this feature enabled, bucketing on the given bucketing ID? (Other + * methods of enabling a feature and specifying a variant such as users, + * groups, and query parameters, will still work.) */ - public function isEnabled() + function isEnabledBucketingBy (Feature $feature, BucketingId $id) : bool { - return $this->chooseVariant($this->bucketingID()) !== 'off'; + return $this->chooseVariant($feature, $id) !== 'off'; } /** - * What variant is enabled for the default id and the logged in - * user, if any? + * What variant is enabled, bucketing on the given bucketing ID, if any? */ - public function variant() + function variantBucketingBy (Feature $feature, BucketingId $id) : string { - return $this->chooseVariant($this->bucketingID()); + $variant = $this->chooseVariant($feature, $id); + return $variant !== 'off' ? $variant : ''; } /** - * Is this feature enabled for the given user? + * Get the name of the variant we should use. Returns OFF if the feature is + * not enabled for $id. + * + * BucketingId $id - the id used to assign a variant based on the percentage + * of users that should see different variants. */ - public function isEnabledFor(User $user) + private function chooseVariant (Feature $feature, BucketingId $id) : string { - return $this->chooseVariant($user->id) === 'on'; + $variant = $this->variantFromURL($feature); + if ($variant) return $variant; + + $variant = $this->variantTime($feature); + if ($variant) return $variant; + + $variant = $this->variantExcludedFrom($feature); + if ($variant) return $variant; + + $variant = $this->variantForUser($feature); + if ($variant) return $variant; + + $variant = $this->variantForGroup($feature); + if ($variant) return $variant; + + $variant = $this->variantForSource($feature); + if ($variant) return $variant; + + $variant = $this->variantForInternal($feature); + if ($variant) return $variant; + + $variant = $this->variantForAdmin($feature); + if ($variant) return $variant; + + $variant = $this->variantByPercentage($feature, $id); + if ($variant) return $variant; + + return 'off'; } /** - * Is this feature enabled, bucketing on the given bucketing - * ID? (Other methods of enabling a feature and specifying a - * variant such as users, groups, and query parameters, will still - * work.) + * If the feature has public_url_override set to true, a specific variant + * can be specified in the 'features' query parameter. In all other cases + * return nothing, meaning nothing was specified. Note that foo:off will + * turn off the 'foo' feature. */ - public function isEnabledBucketingBy($bucketingID) + private function variantFromURL (Feature $feature) : string { - return $this->chooseVariant($bucketingID) !== 'off'; + $publicUrlOverride = $feature->publicUrlOverride(); + return $publicUrlOverride->variant($feature->name(), $this->url); } /** - * What variant is enabled for the given user? + * Get the variant this user should see, if one was configured, none + * otherwise. */ - public function variantFor(User $user) + private function variantForUser (Feature $feature) : string { - return $this->chooseVariant($user->id); + return $feature->users()->variant($this->user); } /** - * What variant is enabled, bucketing on the given bucketing ID, if any? + * Get the variant visitor should see based on group they're currently + * viewing. */ - public function variantBucketingBy($bucketingID) + private function variantForSource (Feature $feature) : string { - return $this->chooseVariant($bucketingID); + return $feature->sources()->variant($this->source); } /** - * Description of the feature. + * Get the variant this user should see based on their group memberships, if + * one was configured, none otherwise. N.B. If the user is in multiple + * groups that are configured to see different variants, they'll get the + * variant for one of their groups but there's no saying which one. If this + * is a problem in practice we could make the configuration more complex. Or + * you can just provide a specific variant via the 'users' property. */ - public function description() + private function variantForGroup (Feature $feature) : string { - return $this->stanza->description; + return $feature->groups()->variant($this->user); } - //////////////////////////////////////////////////////////////////////// - // Internals + /** + * What variant, if any, should we return if the current user is an admin. + */ + private function variantForAdmin (Feature $feature) : string + { + return $feature->admin()->variant($this->user); + } /** - * Get the name of the variant we should use. Returns OFF if the - * feature is not enabled for $id. When $inVariantMethod is - * true will also check the conditions that should hold for a - * correct call to variant or variantFor: they should not be - * called for features that are completely enabled (i.e. 'enabled' - * => 'on') since all such variant-specific code should have been - * cleaned up before changing the config and they should not be - * called if the feature is, in fact, disabled for the given id - * since those two methods should always be guarded by an - * isEnabled/isEnabledFor call. - * - * @param $bucketingID - the id used to assign a variant based on - * the percentage of users that should see different variants. + * What variant, if any, should we return for internal requests. */ - private function chooseVariant($bucketingID) + private function variantForInternal (Feature $feature) : string { - if (!$bucketingID) { - throw new \InvalidArgumentException('no bucketing ID supplied.'); - } + return $feature->internal()->variant($this->user); + } - $bucketingID = (string)$bucketingID; - if (isset($this->cache[$bucketingID])) { - return $this->cache[$bucketingID]; - } + private function variantExcludedFrom (Feature $feature) : string + { + return $feature->excludeFrom()->variant($this->user); + } - return $this->cache[$bucketingID] = (new Variant($this->world)) - ->addStanza($this->stanza) - ->addBucketingID($bucketingID) - ->addName($this->name) - ->getVariant(); + private function variantTime (Feature $feature) : string + { + return $feature->time()->variant(); + } + + /** + * Finally, the normal case: use the percentage of users who should see each + * variant to map a random-ish number to a particular variant. + */ + private function variantByPercentage (Feature $feature, BucketingId $id) : string + { + $n = 100 * $this->randomish($feature, $id); + foreach ($feature->enabled()->percentages() as $variant => $percent) { + if ($n < $percent || $percent === 100) return $variant; + } + return ''; } /** - * Return the globally accessible ID used by the one-arg isEnabled - * and variant methods based on the feature's bucketing property. + * A random-ish number between 0 and 1 based on the feature name and $id + * unless we are bucketing completely at random */ - private function bucketingID() - { - if ($this->stanza->bucketing === 'random' || - $this->stanza->bucketing === 'uaid' - ) { - // In the RANDOM case we still need a bucketing id to keep - // the assignment stable within a request. - // Note that when being run from outside of a web request - // (e.g. crons), - // there is no UAID, so we default to a static string - $uaid = $this->world->uaid(); - return $uaid ? $uaid : 'no uaid'; + private function randomish (Feature $feature, BucketingId $id) : float + { + if ((string) $feature->bucketing() === 'random') { + return mt_rand(0, mt_getrandmax() - 1) / mt_getrandmax(); } - if ($this->stanza->bucketing === 'user') { - $userID = $this->world->userID(); - // Not clear if this is right. There's an argument to be - // made that if we're bucketing by userID and the user is - // not logged in we should treat the feature as disabled. - return $userID ? $userID : $this->world->uaid(); + /** + * Map a hex value to the half-open interval bewtween 0 and 1 while + * preserving uniformity of the input distribution. + */ + $id = hash('sha256', $feature->name() . "-$id"); + $len = min(30, strlen($id)); + $x = 0; + for ($i = 0; $i < $len; ++$i) { + $x = ($x << 1) + (hexdec($id[$i]) < 8 ? 0 : 1); } - throw new \InvalidArgumentException( - "Bad bucketing: {$this->stanza->bucketing}" - ); + + return $x / (1 << $len); } } diff --git a/src/Feature.php b/src/Feature.php index e2acaa8..8347d6e 100644 --- a/src/Feature.php +++ b/src/Feature.php @@ -1,178 +1,159 @@ isEnabled('foo'); + * Feature->variant('foo'); * - * For cases when we want to bucket on a user other than the currently - * logged in user (e.g. to bucket how we treat listings by their - * owners) this secondary API is available: + * For cases when we want to bucket on a user other than the currently logged in + * user (e.g. to bucket how we treat listings by their owners) this secondary + * API is available: * - * Feature::isEnabledFor('foo', $user); - * Feature::variantFor('foo', $user); + * Feature->isEnabledFor('foo', $user); + * Feature->variantFor('foo', $user); * - * And for case when we want to bucket on something else entirely - * (such as a shop ID), we provide these two methods: + * And for case when we want to bucket on something else entirely (such as a + * shop ID), we provide these two methods: * - * Feature::isEnabledBucketingBy('foo', $bucketingID); - * Feature::variantBucketingBy('foo', $bucketingID); + * Feature->isEnabledBucketingBy('foo', $bucketingID); + * Feature->variantBucketingBy('foo', $bucketingID); */ class Feature { - private $world; - private $configCache = []; - private $features = []; - private $source = ''; - private $url = ''; + private $features; private $user; + private $url; + private $source; - public function __construct(array $config) + function __construct (array $input) { - $this->features = $config; + $this->features = new FeatureCollection($input['features'] ?? []); + $this->user = new User($input['user'] ?? []); + $this->url = new Url($input['url'] ?? ''); + $this->source = new Source($input['source'] ?? ''); } - public function addUser(array $user) + /* + * Replaces all features with a new set of features. + */ + function changeFeatures (array $features) { - $this->user = new User($user); - return $this; + $this->features = new FeatureCollection($features); } - public function addSource($source) + /* + * Replaces one existing feature with a new feature config of the same name. + */ + function changeFeature (string $name, array $feature) { - $this->source = $source; - return $this; + $this->features->change(new Name($name), $feature); } - public function addUrl($url) - { - $this->url = $url; - return $this; - } + /* + * Replaces the user used to calculate variants. + */ + function changeUser (array $user) { $this->user = new User($user); } - /** - * Test whether the named feature is enabled for the current user. + /* + * Replaces the url used to calculate variants. */ - public function isEnabled($name) - { - return $this->fromConfig($name)->isEnabled(); - } + function changeUrl (string $url) { $this->url = new Url($url); } - /** - * Test whether the named feature is enabled for a given - * user. This method should only be used when we want to bucket - * based on a user other than the current logged in user, e.g. if - * we are bucketing different listings based on their owner. + /* + * Replaces the source used to calculate variants. */ - public function isEnabledFor($name, array $user) + function changeSource (string $source) { - return $this->fromConfig($name)->isEnabledFor(new User($user)); + $this->source = new Source($source); } /** - * Test whether the named feature is enabled for a given - * arbitrary string. This method should only be used when we want to bucket - * based on something other than a user, - * e.g. shops, teams, treasuries, tags, etc. + * Test whether the named feature is enabled for the current user. */ - public function isEnabledBucketingBy($name, $string) + function isEnabled (string $name) : bool { - return $this->fromConfig($name)->isEnabledBucketingBy($string); + $config = new Config($this->user, $this->url, $this->source); + return $config->isEnabled($this->features->get(new Name($name))); } /** - * Get the name of the A/B variant for the named feature for the - * current user. Logs an error if called when isEnabled($name) - * doesn't return true. (I.e. calls to this method should only - * occur in blocks guarded by an isEnabled check.) - * - * Also logs an error if 'enabled' is 'on' for the named feature - * since there should be no variant-dependent code left when a - * feature has been fully enabled. To clean up a finished - * experiment, first set 'enabled' to the name of the winning - * variant. + * Test whether the named feature is enabled for a given user. This method + * should only be used when we want to bucket based on a user other than the + * current logged in user, e.g. if we are bucketing different listings based + * on their owner. */ - public function variant($name) + function isEnabledFor (string $name, array $user) : bool { - return $this->fromConfig($name)->variant(); + $config = new Config(new User($user), $this->url, $this->source); + return $config->isEnabled($this->features->get(new Name($name))); } /** - * Get the name of the A/B variant for the named feature for the - * given user. This method should only be used when we want to - * bucket based on a user other than the current logged in user, - * e.g. if we are bucketing different listings based on their - * owner. - * - * Logs an error if called when isEnabledFor($name, $user) doesn't - * return true. (I.e. calls to this method should only occur in - * blocks guarded by an isEnabledFor check.) - * Also logs an error if 'enabled' is 'on' for the named feature - * since there should be no variant-dependent code left when a - * feature has been fully enabled. To clean up a finished - * experiment, first set 'enabled' to the name of the winning - * variant. + * Test whether the named feature is enabled for a given arbitrary string. + * This method should only be used when we want to bucket based on something + * other than a user, e.g. shops, teams, treasuries, tags, etc. */ - public function variantFor($name, array $user) + function isEnabledBucketingBy (string $name, string $id) : bool { - return $this->fromConfig($name)->variantFor(new User($user)); + $config = new Config($this->user, $this->url, $this->source); + $feature = $this->features->get(new Name($name)); + return $config->isEnabledBucketingBy($feature, new BucketingId($id)); } /** - * Get the name of the A/B variant for the named feature, - * bucketing by the given bucketing ID. (For other checks such as - * admin, and user whitelists uses the current user which may or - * may not make sense. If it doesn't make sense, don't configure - * the feature to use those mechanisms.) Logs an error if called - * when isEnabled($name) doesn't return true. (I.e. calls to this - * method should only occur in blocks guarded by an isEnabled - * check.) - * - * Also logs an error if 'enabled' is 'on' for the named feature - * since there should be no variant-dependent code left when a - * feature has been fully enabled. To clean up a finished - * experiment, first set 'enabled' to the name of the winning - * variant. + * Get the name of the A/B variant for the named feature for the current + * user. */ - public function variantBucketingBy($name, $bucketingID) + function variant (string $name) : string { - return $this->fromConfig($name)->variantBucketingBy($bucketingID); + $config = new Config($this->user, $this->url, $this->source); + return $config->variant($this->features->get(new Name($name))); } - public function description($name) + /** + * Get the name of the A/B variant for the named feature for the given user. + * This method should only be used when we want to bucket based on a user + * other than the current logged in user, e.g. if we are bucketing different + * listings based on their owner. + */ + function variantFor (string $name, array $user) : string { - return $this->fromConfig($name)->description(); + $config = new Config(new User($user), $this->url, $this->source); + return $config->variant($this->features->get(new Name($name))); } /** - * Get the named feature object. We cache the object after - * building it from the config stanza to speed lookups. + * Get the name of the A/B variant for the named feature, bucketing by the + * given bucketing ID. (For other checks such as admin, and user whitelists + * uses the current user which may or may not make sense. If it doesn't + * make sense, don't configure the feature to use those mechanisms.) */ - private function fromConfig($name) + function variantBucketingBy (string $name, string $id) : string { - if (isset($this->configCache[$name])) return $this->configCache[$name]; - - $this->configCache[$name] = (new Config($this->world()))->addName($name); - return $this->configCache[$name]; + $config = new Config($this->user, $this->url, $this->source); + $feature = $this->features->get(new Name($name)); + return $config->variantBucketingBy($feature, new BucketingId($id)); } - /** - * This API always uses the default World. Config takes - * the world as an argument in order to ease unit testing. - */ - private function world() + function description (string $name) : string { - if ($this->world instanceof World) return $this->world; - $this->world = (new World($this->features))->addUser($this->user) - ->addSource($this->source) - ->addUrl($this->url); - unset($this->features, $this->user, $this->source, $this->url); - return $this->world; + return (string) $this->features->get(new Name($name))->description(); } } diff --git a/src/Stanza.php b/src/Stanza.php deleted file mode 100644 index 9731098..0000000 --- a/src/Stanza.php +++ /dev/null @@ -1,177 +0,0 @@ -description = $this->parseDescription($stanza); - $this->enabled = $this->parseEnabled($stanza); - $this->users = $this->parseUsersOrGroups($stanza, 'users'); - $this->groups = $this->parseUsersOrGroups($stanza, 'groups'); - $this->sources = $this->parseUsersOrGroups($stanza, 'sources'); - $this->adminVariant = $this->parseVariantName($stanza, 'admin'); - $this->internalVariant = $this->parseVariantName($stanza, 'internal'); - $this->publicUrlOverride = $this->parsePublicURLOverride($stanza); - $this->bucketing = $this->parseBucketBy($stanza); - $this->exludeFrom = $this->parseExcludeFrom($stanza); - $this->start = $this->parseStart($stanza); - $this->end = $this->parseEnd($stanza); - } - - public function __get($name) - { - if (isset($this->$name)) return $this->$name; - throw new \Exception("$name is not a property of the Stanza class"); - } - - //////////////////////////////////////////////////////////////////////// - // Configuration parsing - - private function parseDescription(array $stanza) - { - if (isset($stanza['description'])) return $stanza['description']; - return ''; - } - - /** - * Parse the 'enabled' property of the feature's config stanza. - */ - private function parseEnabled(array $stanza) - { - $enabled = 0; - if (isset($stanza['enabled'])) $enabled = $stanza['enabled']; - if (!is_numeric($enabled) && !is_array($enabled)) { - throw new \Exception( - 'Malformed enabled property ' . json_encode($stanza) - ); - } - if (is_numeric($enabled) && $enabled < 0) { - throw new \Exception("enabled ($enabled) < 0"); - } - if (is_numeric($enabled) && $enabled > 100) { - throw new \Exception("enabled ($enabled) > 0"); - } - return ['on' => $enabled]; - } - - /** - * Parse the value of the 'users' and 'groups' properties of the - * feature's config stanza, returning an array mappinng the user - * or group names to they variant they should see. - */ - private function parseUsersOrGroups(array $stanza, $what) - { - $value = false; - if (isset($stanza[$what])) $value = $stanza[$what]; - if (is_string($value) || is_numeric($value)) { - // Users are configrued with their user names. Groups as - // numeric ids. (Not sure if that's a great idea.) - return [$value => 'on']; - } - - $result = []; - /** - * Is the given object an array value that could have been created - * with array(...) with no =>'s in the ...? - */ - if (!is_array($value)) return $result; - if (array_keys($value) === range(0, count($value) - 1)) { - foreach ($value as $who) $result[strtolower($who)] = 'on'; - return $result; - } - - $badKeys = false; - if (is_array($this->enabled)) { - $badKeys = array_keys(array_diff_key($value, $this->enabled)); - } - if ($badKeys) { - throw new \Exception("Unknown variants " . implode(', ', $badKeys)); - } - - foreach ($value as $variant => $whos) { - if (!is_array($whos)) $whos = [$whos]; - foreach ($whos as $who) $result[strtolower($who)] = $variant; - } - - return $result; - } - - /** - * Parse the variant name value for the 'admin' and 'internal' - * properties. If non-falsy, must be one of the keys in the - * enabled map unless enabled is 'on' or 'off'. - */ - private function parseVariantName(array $stanza, $what) - { - $value = false; - if (isset($stanza[$what])) $value = $stanza[$what]; - if (!$value) return false; - - if (!is_array($this->enabled) || isset($this->enabled['on'][$value])) { - return $value; - } - - throw new \Exception( - "Unknown variant $value " . json_encode($this->enabled) - ); - } - - private function parsePublicURLOverride(array $stanza) - { - if (!isset($stanza['public_url_override'])) return false; - return $stanza['public_url_override']; - } - - private function parseBucketBy(array $stanza) - { - if (isset($stanza['bucketing'])) return $stanza['bucketing']; - return 'uaid'; - } - - private function parseExcludeFrom(array $stanza) - { - if (!isset($stanza['exclude_from'])) return false; - - if (is_array($stanza['exclude_from']) && - (isset($stanza['exclude_from']['zips']) || - isset($stanza['exclude_from']['region']) || - isset($stanza['exclude_from']['country'])) - ) { - return $stanza['exclude_from']; - } - - throw new \Exception('bad exclude_from stanza' . json_encode($stanza)); - } - - private function parseStart(array $stanza) - { - if (!isset($stanza['start'])) return false; - $time = strtotime($stanza['start']); - if ($time) return $time; - throw new \Exception("{$stanza['start']} is not a valid time format"); - } - - private function parseEnd(array $stanza) - { - if (!isset($stanza['end'])) return false; - $time = strtotime($stanza['end']); - if ($time) return $time; - throw new \Exception("{$stanza['end']} is not a valid time format"); - } -} diff --git a/src/User.php b/src/User.php deleted file mode 100644 index eb680dd..0000000 --- a/src/User.php +++ /dev/null @@ -1,37 +0,0 @@ -uaid = $user['user-uaid']; - if (!empty($user['user-id'])) $this->id = $user['user-id']; - if (!empty($user['user-name'])) $this->name = $user['user-name']; - if (!empty($user['is-admin'])) $this->isAdmin = $user['is-admin']; - if (!empty($user['user-group'])) $this->group = $user['user-group']; - if (!empty($user['zipcode'])) $this->zipcode = $user['zipcode']; - if (!empty($user['region'])) $this->region = $user['region']; - if (!empty($user['country'])) $this->country = $user['country']; - if (!empty($user['internal-ip'])) { - $this->internalIP = $user['internal-ip']; - } - } - - public function __get($name) - { - if (isset($this->$name)) return $this->$name ? $this->$name : false; - throw new \Exception("$name is not a property of the User class"); - } -} \ No newline at end of file diff --git a/src/Value/Admin.php b/src/Value/Admin.php new file mode 100644 index 0000000..b0c0600 --- /dev/null +++ b/src/Value/Admin.php @@ -0,0 +1,17 @@ +variant = $variant; } + + function variant (User $user) : string + { + return $user->isAdmin() ? $this->variant : ''; + } +} diff --git a/src/Value/Bucketing.php b/src/Value/Bucketing.php new file mode 100644 index 0000000..7620689 --- /dev/null +++ b/src/Value/Bucketing.php @@ -0,0 +1,23 @@ +by = $bucketBy; + + if (in_array($bucketBy, ['random', 'uaid', 'user'], true)) return; + + $error = 'bucketing must be either "random", "uaid" or "user". '; + $error .= $bucketBy; + throw new \Exception($error); + } + + function __toString () : string { return $this->by; } +} diff --git a/src/Value/BucketingId.php b/src/Value/BucketingId.php new file mode 100644 index 0000000..f4abaa6 --- /dev/null +++ b/src/Value/BucketingId.php @@ -0,0 +1,18 @@ +id = $id; + } + + function __toString () : string { return $this->id; } +} diff --git a/src/Value/CalculateBucketingId.php b/src/Value/CalculateBucketingId.php new file mode 100644 index 0000000..233d5d9 --- /dev/null +++ b/src/Value/CalculateBucketingId.php @@ -0,0 +1,42 @@ +user = $user; + $this->bucketing = (string) $bucketing; + } + + function id () : BucketingId + { + if ($this->bucketing === 'user' && !$this->user->id()) { + $error = 'user id must be provided if user bucketing is enabled.'; + throw new \Exception($error); + } + + if ($this->bucketing === 'uaid' && !$this->user->uaid()) { + $error = 'user uaid must be provided if uaid bucketing is enabled.'; + throw new \Exception($error); + } + + if ($this->bucketing === 'user') { + return new BucketingId($this->user->id()); + } + + if ($this->bucketing === 'uaid') { + return new BucketingId($this->user->uaid()); + } + + if (!$this->user->uaid()) return new BucketingId('no uaid'); + + return new BucketingId($this->user->uaid()); + } +} diff --git a/src/Value/Description.php b/src/Value/Description.php new file mode 100644 index 0000000..26f148b --- /dev/null +++ b/src/Value/Description.php @@ -0,0 +1,17 @@ +description = $description; + } + + function __toString () : string { return $this->description; } +} diff --git a/src/Value/Enabled.php b/src/Value/Enabled.php new file mode 100644 index 0000000..4b99b8a --- /dev/null +++ b/src/Value/Enabled.php @@ -0,0 +1,49 @@ +checkValueType($enabled); + + if (is_int($enabled)) $enabled = ['on' => $enabled]; + + $total = 0; + foreach ($enabled as $variant => $percent) { + $this->checkPercentage($percent); + + $total += $percent; + $this->percentages[$variant] = $total; + } + + if ($total <= 100) return; + + throw new \Exception("Total of percentages > 100: $total"); + } + + function percentages () : array { return $this->percentages; } + + private function checkValueType ($enabled) + { + if (is_int($enabled) || is_array($enabled)) return; + + $error = 'Malformed enabled property ' . json_encode($enabled); + throw new \Exception($error); + } + + private function checkPercentage (int $percent) + { + if ($percent >= 0 && $percent <= 100) return; + throw new \Exception('Bad percentage ' . json_encode($percent)); + } +} diff --git a/src/Value/ExcludeFrom.php b/src/Value/ExcludeFrom.php new file mode 100644 index 0000000..bf1a6f2 --- /dev/null +++ b/src/Value/ExcludeFrom.php @@ -0,0 +1,43 @@ +zips = $excludeFrom['zips']; + if ($regions) $this->regions = $excludeFrom['regions']; + if ($countries) $this->countries = $excludeFrom['countries']; + + if ($zips || $regions || $countries) return; + + $error = 'bad exclude_from stanza ' . json_encode($excludeFrom); + throw new \Exception($error); + } + + function variant (User $user) : string + { + $zips = in_array($user->zipcode(), $this->zips, true); + $regions = in_array($user->region(), $this->regions, true); + $countries = in_array($user->country(), $this->countries, true); + + return $zips || $regions || $countries ? 'off' : ''; + } +} diff --git a/src/Value/Feature.php b/src/Value/Feature.php new file mode 100644 index 0000000..9f10fd2 --- /dev/null +++ b/src/Value/Feature.php @@ -0,0 +1,81 @@ +name = $name; + $this->enabled = new Enabled($enabled); + $this->description = new Description($description); + $this->users = new Users($users); + $this->groups = new Groups($groups); + $this->sources = new Sources($sources); + $this->admin = new Admin($admin); + $this->internal = new Internal($internal); + $this->publicUrlOverride = new PublicUrlOverride($publicUrlOverride); + $this->excludeFrom = new ExcludeFrom($excludeFrom); + $this->time = new Time($start, $end); + $this->bucketing = new Bucketing($bucketing); + } + + function name () : Name { return $this->name; } + + function enabled () : Enabled { return $this->enabled; } + + function description () : Description { return $this->description; } + + function users () : Users { return $this->users; } + + function groups () : Groups { return $this->groups; } + + function sources () : Sources { return $this->sources; } + + function admin () : Admin { return $this->admin; } + + function internal () : Internal { return $this->internal; } + + function publicUrlOverride () : PublicUrlOverride + { + return $this->publicUrlOverride; + } + + function excludeFrom () : ExcludeFrom { return $this->excludeFrom; } + + function time () : Time { return $this->time; } + + function bucketing () : Bucketing { return $this->bucketing; } +} diff --git a/src/Value/FeatureCollection.php b/src/Value/FeatureCollection.php new file mode 100644 index 0000000..8ffc156 --- /dev/null +++ b/src/Value/FeatureCollection.php @@ -0,0 +1,31 @@ + $feature) { + $this->features[$name] = new Feature(new Name($name), $feature); + } + } + + function get (Name $name) : Feature + { + return $this->features[(string) $name]; + } + + function change (Name $name, array $feature) + { + if (!isset($this->features[(string) $name])) { + throw new \Exception("feature '$name' does not exist."); + } + + $this->features[(string) $name] = new Feature($name, $feature); + } +} diff --git a/src/Value/Groups.php b/src/Value/Groups.php new file mode 100644 index 0000000..dcf0086 --- /dev/null +++ b/src/Value/Groups.php @@ -0,0 +1,27 @@ + $groups) { + if (!is_array($groups)) $groups = [$groups]; + foreach ($groups as $group) $this->groups[$group] = $variant; + } + } + + function variant (User $user) : string + { + return $this->groups[$user->group()] ?? ''; + } +} diff --git a/src/Value/Internal.php b/src/Value/Internal.php new file mode 100644 index 0000000..afc6ea0 --- /dev/null +++ b/src/Value/Internal.php @@ -0,0 +1,17 @@ +variant = $variant; } + + function variant (User $user) : string + { + return $user->internalIP() ? $this->variant : ''; + } +} diff --git a/src/Value/Name.php b/src/Value/Name.php new file mode 100644 index 0000000..78b8130 --- /dev/null +++ b/src/Value/Name.php @@ -0,0 +1,18 @@ +name = $name; + } + + function __toString () : string { return $this->name; } +} diff --git a/src/Value/PublicUrlOverride.php b/src/Value/PublicUrlOverride.php new file mode 100644 index 0000000..96cbb3b --- /dev/null +++ b/src/Value/PublicUrlOverride.php @@ -0,0 +1,17 @@ +on = $on; } + + function variant (Name $name, Url $url) : string + { + return $this->on ? $url->variant($name) : ''; + } +} diff --git a/src/Value/Source.php b/src/Value/Source.php new file mode 100644 index 0000000..455e3ae --- /dev/null +++ b/src/Value/Source.php @@ -0,0 +1,14 @@ +source = $source; } + + function variant () : string { return $this->source; } +} diff --git a/src/Value/Sources.php b/src/Value/Sources.php new file mode 100644 index 0000000..5966b24 --- /dev/null +++ b/src/Value/Sources.php @@ -0,0 +1,27 @@ + $sources) { + if (!is_array($sources)) $sources = [$sources]; + foreach ($sources as $source) $this->sources[$source] = $variant; + } + } + + function variant (Source $source) : string + { + return $this->sources[$source->variant()] ?? ''; + } +} diff --git a/src/Value/Time.php b/src/Value/Time.php new file mode 100644 index 0000000..a83833d --- /dev/null +++ b/src/Value/Time.php @@ -0,0 +1,34 @@ +start = $this->timeValue($start); + if ($end) $this->end = $this->timeValue($end); + } + + function variant () : string + { + $time = time(); + + $startNotValid = $this->start && $this->start > $time; + $endNotValid = $this->end && $this->end < $time; + + return $startNotValid || $endNotValid ? 'off' : ''; + } + + private function timeValue (string $time) : int + { + $time = strtotime($time); + if (!$time) throw new \Exception("$time is not a valid time format"); + return $time; + } +} diff --git a/src/Value/Url.php b/src/Value/Url.php new file mode 100644 index 0000000..6997299 --- /dev/null +++ b/src/Value/Url.php @@ -0,0 +1,42 @@ +features = $query['feature'] ?? ''; + } + + function variant (Name $name) : string + { + $name = (string) $name; + + foreach (explode(',', $this->features) as $feature) { + $parts = explode(':', $feature); + if ($parts[0] === $name) return $parts[1] ?? 'on'; + } + + return ''; + } +} diff --git a/src/Value/User.php b/src/Value/User.php new file mode 100644 index 0000000..573030b --- /dev/null +++ b/src/Value/User.php @@ -0,0 +1,45 @@ +uaid = $user['uaid'] ?? ''; + $this->id = $user['id'] ?? ''; + $this->group = $user['group'] ?? ''; + $this->zipcode = $user['zipcode'] ?? ''; + $this->region = $user['region'] ?? ''; + $this->country = $user['country'] ?? ''; + $this->isAdmin = $user['is-admin'] ?? false; + $this->internalIP = $user['internal-ip'] ?? false; + } + + function uaid () : string { return $this->uaid; } + + function id () : string { return $this->id; } + + function country () : string { return $this->country; } + + function zipcode () : string { return $this->zipcode; } + + function region () : string { return $this->region; } + + function isAdmin () : bool { return $this->isAdmin; } + + function internalIP () : bool { return $this->internalIP; } + + function group () : string { return $this->group; } +} diff --git a/src/Value/Users.php b/src/Value/Users.php new file mode 100644 index 0000000..5a11f4e --- /dev/null +++ b/src/Value/Users.php @@ -0,0 +1,27 @@ + $users) { + if (!is_array($users)) $users = [$users]; + foreach ($users as $user) $this->users[$user] = $variant; + } + } + + function variant (User $user) : string + { + return $this->users[$user->id()] ?? ''; + } +} diff --git a/src/Variant.php b/src/Variant.php deleted file mode 100644 index 6edbb53..0000000 --- a/src/Variant.php +++ /dev/null @@ -1,258 +0,0 @@ -world = $world; - } - - public function addStanza(Stanza $stanza) - { - $this->stanza = $stanza; - //Put the enabled value into a more useful form - //for actually doing bucketing. - $total = 0; - foreach ($stanza->enabled as $variant => $percentage) { - $total += $this->computePercantage($variant, $percentage, $total); - } - if (!($total > 100)) return $this; - throw new \Exception("Total of percentages > 100: $total"); - } - - public function addBucketingID($bucketingID) - { - $this->bucketingID = $bucketingID; - return $this; - } - - public function addName($name) - { - $this->name = $name; - return $this; - } - - public function getVariant() - { - return $this->variantFromURL() ?: - $this->variantForUser() ?: - $this->variantForGroup() ?: - $this->variantForSource() ?: - $this->variantForAdmin() ?: - $this->variantForInternal() ?: - $this->variantExcludedFrom() ?: - $this->variantTime() ?: - $this->variantByPercentage() ?: - 'off'; - } - - /** - * For internal requests or if the feature has public_url_override - * set to true, a specific variant can be specified in the - * 'features' query parameter. In all other cases return false, - * meaning nothing was specified. Note that foo:off will turn off - * the 'foo' feature. - */ - private function variantFromURL() - { - if (!$this->stanza->publicUrlOverride && - !$this->world->isInternalRequest() && - !$this->world->isAdmin() - ) { - return false; - } - - $urlFeatures = $this->world->urlFeatures(); - if (!$urlFeatures) return false; - - foreach (explode(',', $urlFeatures) as $f) { - $parts = explode(':', $f); - if ($parts[0] === $this->name) { - return isset($parts[1]) ? $parts[1] : 'on'; - } - } - - return false; - } - - /** - * Get the variant this user should see, if one was configured, - * false otherwise. - */ - private function variantForUser() - { - if (!$this->stanza->users) return false; - - $name = strtolower($this->world->userName()); - if (!isset($this->stanza->users[$name])) return false; - return $this->stanza->users[$name]; - } - - /** - * Get the variant visitor should see based on group - * they're currently viewing - */ - private function variantForSource() - { - foreach ($this->stanza->sources as $source => $variant) { - if ($this->world->isSource($source)) return $variant; - } - return false; - } - - /** - * Get the variant this user should see based on their group - * memberships, if one was configured, false otherwise. N.B. If - * the user is in multiple groups that are configured to see - * different variants, they'll get the variant for one of their - * groups but there's no saying which one. If this is a problem in - * practice we could make the configuration more complex. Or you - * can just provide a specific variant via the 'users' property. - */ - private function variantForGroup() - { - foreach ($this->stanza->groups as $groupID => $variant) { - if ($this->world->viewingGroup($groupID)) return $variant; - } - - return false; - } - - /** - * What variant, if any, should we return if the current user is - * an admin. - */ - private function variantForAdmin() - { - if ($this->stanza->adminVariant && $this->world->isAdmin()) { - return $this->stanza->adminVariant; - } - return false; - } - - /** - * What variant, if any, should we return for internal requests. - */ - private function variantForInternal() - { - if ($this->stanza->internalVariant && - $this->world->isInternalRequest() - ) { - return $this->stanza->internalVariant; - } - return false; - } - - private function variantExcludedFrom() - { - $excluded = $this->stanza->exludeFrom - && ( - ( - isset($this->stanza->exludeFrom['zips']) && - in_array( - $this->world->zipcode(), - $this->stanza->exludeFrom['zips'] - ) - ) - || ( - isset($this->stanza->exludeFrom['regions']) && - in_array( - $this->world->region(), - $this->stanza->exludeFrom['regions'] - ) - ) - || ( - isset($this->stanza->exludeFrom['countries']) && - in_array( - $this->world->country(), - $this->stanza->exludeFrom['countries'] - ) - ) - ); - return $excluded ? 'off' : false; - } - - private function variantTime() - { - $time = time(); - if (($this->stanza->start && $this->stanza->start > $time) || - ($this->stanza->end && $this->stanza->end < $time) - ) { - return 'off'; - } - return false; - } - - /** - * Finally, the normal case: use the percentage of users who - * should see each variant to map a random-ish number to a - * particular variant. - */ - private function variantByPercentage() - { - $n = 100 * $this->randomish(); - foreach ($this->percentages as $v) { - // === 100 check may not be necessary but I'm not good - // enough numerical analyst to be sure. - if ($n < $v[0] || $v[0] === 100) return $v[1]; - } - return false; - } - - /** - * A random-ish number in [0, 1) based on the feature name and $id - * unless we are bucketing completely at random - */ - private function randomish() - { - if ($this->stanza->bucketing === 'random') { - return mt_rand(0, mt_getrandmax() - 1) / mt_getrandmax(); - } - /** - * Map a hex value to the half-open interval [0, 1) while - * preserving uniformity of the input distribution. - */ - $id = hash('sha256', "{$this->name}-{$this->bucketingID}"); - $len = min(30, strlen($id)); - $v = 0; - for ($i = 0; $i < $len; ++$i) { - $v = ($v << 1) + (hexdec($id[$i]) < 8 ? 0 : 1); - } - - return $v / (1 << $len); - } - - /* - * Returns an array of pairs with the first element of the pair - * being the upper-boundary of the variants percentage and the - * second element being the name of the variant. - */ - private function computePercantage($variant, $percentage, $total) - { - if ((!is_numeric($percentage) && !is_array($percentage)) || - (is_numeric($percentage) && ($percentage < 0 || $percentage > 100)) - ) { - throw new \Exception('Bad percentage '. json_encode($percentage)); - } - if (is_numeric($percentage)) { - $this->percentages[] = [$total + $percentage, $variant]; - return $percentage; - } - foreach ($percentage as $variant => $percent) { - if (!is_numeric($percent) || $percent < 0 || $percent > 100) { - throw new \Exception('Bad percentage '. json_encode($percent)); - } - $total += $percent; - $this->percentages[] = [$total, $variant]; - } - return $total; - } -} diff --git a/src/World.php b/src/World.php deleted file mode 100644 index e0a8816..0000000 --- a/src/World.php +++ /dev/null @@ -1,145 +0,0 @@ -features = $features; - } - - public function addUser(User $user) - { - $this->user = $user; - return $this; - } - - public function addSource($source) - { - $this->source = $source; - return $this; - } - - public function addUrl($url) - { - $this->url = $url; - return $this; - } - - /** - * Get the config value for the given key. - */ - public function configValue($name) - { - if (empty($this->features[$name]) || - !is_array($this->features[$name]) - ) { - throw new \Exception("no config available for feature $name"); - } - return $this->features[$name]; - } - - /** - * UAID of the current request. - */ - public function uaid() - { - return $this->user->uaid; - } - - /** - * User ID of the currently logged in user or null. - */ - public function userID() - { - return $this->user->id; - } - - /** - * Login name of the currently logged in user or null. Needs the - * ORM. If we're running as part of an Atlas request we ignore the - * passed in userID and return instead the Atlas user name. - */ - public function userName() - { - return $this->user->name; - } - - /** - * zipcode of the currently logged in user. - */ - public function zipcode() - { - return $this->user->zipcode; - } - - /** - * region of the currently logged in user. - */ - public function region() - { - return $this->user->region; - } - - /** - * country of the currently logged in user. - */ - public function country() - { - return $this->user->country; - } - - /** - * Is the visitor in a specific group? - */ - public function viewingGroup($groupID) - { - return $this->user->group == $groupID; - } - - /** - * Is the visitor from a particular source? - */ - public function isSource($source) - { - return $this->source == $source; - } - - /** - * Is the current user an admin? - */ - public function isAdmin() - { - return $this->user->isAdmin; - } - - /** - * Is this an internal request? - */ - public function isInternalRequest() - { - return $this->user->internalIP; - } - - /** - * 'features' query param for url overrides. - */ - public function urlFeatures() - { - return !empty($this->url) ? $this->url : ''; - } -} diff --git a/tests/ApiTest.php b/tests/ApiTest.php new file mode 100644 index 0000000..ed58836 --- /dev/null +++ b/tests/ApiTest.php @@ -0,0 +1,721 @@ + [ + 'testFeature' => ['enabled' => 100], + 'testFeature2' => ['enabled' => 0] + ] + ]); + + $this->assertEquals($feature->isEnabled('testFeature'), true); + $this->assertEquals($feature->isEnabled('testFeature2'), false); + + $this->assertEquals($feature->isEnabledFor('testFeature', []), true); + $this->assertEquals($feature->isEnabledFor('testFeature2', []), false); + + $this->assertEquals( + $feature->isEnabledBucketingBy('testFeature', 'testid1'), + true + ); + $this->assertEquals( + $feature->isEnabledBucketingBy('testFeature2', 'testid2'), + false + ); + + $this->assertEquals($feature->variant('testFeature'), 'on'); + $this->assertEquals($feature->variant('testFeature2'), ''); + + $this->assertEquals($feature->variantFor('testFeature', []), 'on'); + $this->assertEquals($feature->variantFor('testFeature2', []), ''); + + $this->assertEquals( + $feature->variantBucketingBy('testFeature', 'testid1'), + 'on' + ); + $this->assertEquals( + $feature->variantBucketingBy('testFeature2', 'testid2'), + '' + ); + + $feature->changeFeatures([ + 'testFeature' => ['enabled' => 0], + 'testFeature2' => ['enabled' => 100] + ]); + + $this->assertEquals($feature->isEnabled('testFeature'), false); + $this->assertEquals($feature->isEnabled('testFeature2'), true); + + $this->assertEquals($feature->isEnabledFor('testFeature', []), false); + $this->assertEquals($feature->isEnabledFor('testFeature2', []), true); + + $this->assertEquals( + $feature->isEnabledBucketingBy('testFeature', 'testid1'), + false + ); + $this->assertEquals( + $feature->isEnabledBucketingBy('testFeature2', 'testid2'), + true + ); + + $this->assertEquals($feature->variant('testFeature'), ''); + $this->assertEquals($feature->variant('testFeature2'), 'on'); + + $this->assertEquals($feature->variantFor('testFeature', []), ''); + $this->assertEquals($feature->variantFor('testFeature2', []), 'on'); + + $this->assertEquals( + $feature->variantBucketingBy('testFeature', 'testid1'), + '' + ); + $this->assertEquals( + $feature->variantBucketingBy('testFeature2', 'testid2'), + 'on' + ); + + $feature->changeFeature('testFeature2', ['enabled' => 0]); + + $this->assertEquals($feature->isEnabled('testFeature'), false); + $this->assertEquals($feature->isEnabled('testFeature2'), false); + + $this->assertEquals($feature->isEnabledFor('testFeature', []), false); + $this->assertEquals($feature->isEnabledFor('testFeature2', []), false); + + $this->assertEquals( + $feature->isEnabledBucketingBy('testFeature', 'testid1'), + false + ); + $this->assertEquals( + $feature->isEnabledBucketingBy('testFeature2', 'testid2'), + false + ); + + $this->assertEquals($feature->variant('testFeature'), ''); + $this->assertEquals($feature->variant('testFeature2'), ''); + + $this->assertEquals($feature->variantFor('testFeature', []), ''); + $this->assertEquals($feature->variantFor('testFeature2', []), ''); + + $this->assertEquals( + $feature->variantBucketingBy('testFeature', 'testid1'), + '' + ); + $this->assertEquals( + $feature->variantBucketingBy('testFeature2', 'testid2'), + '' + ); + } + + function testDescription () + { + $feature = new Feature([ + 'features' => [ + 'testFeature' => [ + 'enabled' => 100, + 'description' => 'testFeature' + ], + 'testFeature2' => [ + 'enabled' => 0, + 'description' => 'testFeature2' + ] + ] + ]); + $this->assertEquals( + $feature->description('testFeature'), + 'testFeature' + ); + $this->assertEquals( + $feature->description('testFeature2'), + 'testFeature2' + ); + } + + function testVariant () + { + $feature = new Feature([ + 'features' => [ + 'testFeature' => [ + 'enabled' => ['variant1' => 50, 'variant2' => 50], + ], + 'testFeature2' => [ + 'enabled' => ['variant3' => 25, 'variant4' => 25], + ] + ] + ]); + + $this->assertEquals($feature->isEnabled('testFeature'), true); + $this->assertEquals($feature->isEnabledFor('testFeature', []), true); + $this->assertEquals( + $feature->isEnabledBucketingBy('testFeature', 'testid1'), + true + ); + + $variant = in_array( + $feature->variant('testFeature'), + ['variant1', 'variant2'], + true + ); + $this->assertEquals($variant, true); + $variant = in_array( + $feature->variant('testFeature2'), + ['variant3', 'variant4', ''], + true + ); + $this->assertEquals($variant, true); + + $variant = in_array( + $feature->variantFor('testFeature', []), + ['variant1', 'variant2'], + true + ); + $this->assertEquals($variant, true); + $variant = in_array( + $feature->variantFor('testFeature2', []), + ['variant3', 'variant4', ''], + true + ); + $this->assertEquals($variant, true); + + $variant = in_array( + $feature->variantBucketingBy('testFeature', 'testid1'), + ['variant1', 'variant2'], + true + ); + $this->assertEquals($variant, true); + $variant = in_array( + $feature->variantBucketingBy('testFeature2', 'testid2'), + ['variant3', 'variant4', ''], + true + ); + $this->assertEquals($variant, true); + } + + function testUsers () + { + $feature = new Feature([ + 'features' => [ + 'testFeature' => [ + 'enabled' => 0, + 'users' => ['test1' => '2', 'test4' => ['7', '8', '9']], + ], + 'testFeature2' => [ + 'enabled' => ['variant1' => 25, 'variant2' => 25], + 'users' => ['variant2' => '5'] + ] + ], + 'user' => ['id' => '5'] + ]); + + $this->assertEquals($feature->isEnabled('testFeature'), false); + $this->assertEquals($feature->isEnabled('testFeature2'), true); + + $this->assertEquals($feature->variant('testFeature'), ''); + $this->assertEquals($feature->variant('testFeature2'), 'variant2'); + + $feature->changeUser(['id' => '7']); + $this->assertEquals($feature->isEnabled('testFeature'), true); + $this->assertEquals($feature->variant('testFeature'), 'test4'); + + $this->assertEquals($feature->isEnabledFor('testFeature', []), false); + $this->assertEquals( + $feature->isEnabledFor('testFeature', ['id' => '9']), + true + ); + $this->assertEquals( + $feature->isEnabledFor('testFeature', ['id' => '8']), + true + ); + $this->assertEquals( + $feature->isEnabledFor('testFeature', ['id' => '7']), + true + ); + $this->assertEquals( + $feature->isEnabledFor('testFeature', ['id' => '2']), + true + ); + $this->assertEquals( + $feature->isEnabledFor('testFeature', ['id' => '5']), + false + ); + $this->assertEquals( + $feature->isEnabledFor('testFeature2', ['id' => '5']), + true + ); + + $this->assertEquals($feature->variantFor('testFeature', []), ''); + $this->assertEquals( + $feature->variantFor('testFeature', ['id' => '9']), + 'test4' + ); + $this->assertEquals( + $feature->variantFor('testFeature', ['id' => '8']), + 'test4' + ); + $this->assertEquals( + $feature->variantFor('testFeature', ['id' => '7']), + 'test4' + ); + $this->assertEquals( + $feature->variantFor('testFeature', ['id' => '2']), + 'test1' + ); + $this->assertEquals( + $feature->variantFor('testFeature', ['id' => '5']), + '' + ); + $this->assertEquals( + $feature->variantFor('testFeature2', ['id' => '5']), + 'variant2' + ); + } + + function testGroups () + { + $feature = new Feature([ + 'features' => [ + 'testFeature' => [ + 'enabled' => 0, + 'groups' => ['test1' => '2', 'test4' => ['7', '8', '9']], + ], + 'testFeature2' => [ + 'enabled' => ['variant1' => 25, 'variant2' => 25], + 'groups' => ['variant2' => '5'] + ] + ], + 'user' => ['group' => '5'] + ]); + + $this->assertEquals($feature->isEnabled('testFeature'), false); + $this->assertEquals($feature->isEnabled('testFeature2'), true); + + $this->assertEquals($feature->variant('testFeature'), ''); + $this->assertEquals($feature->variant('testFeature2'), 'variant2'); + + $feature->changeUser(['group' => '7']); + $this->assertEquals($feature->isEnabled('testFeature'), true); + $this->assertEquals($feature->variant('testFeature'), 'test4'); + + $this->assertEquals($feature->isEnabledFor('testFeature', []), false); + $this->assertEquals( + $feature->isEnabledFor('testFeature', ['group' => '9']), + true + ); + $this->assertEquals( + $feature->isEnabledFor('testFeature', ['group' => '8']), + true + ); + $this->assertEquals( + $feature->isEnabledFor('testFeature', ['group' => '7']), + true + ); + $this->assertEquals( + $feature->isEnabledFor('testFeature', ['group' => '2']), + true + ); + $this->assertEquals( + $feature->isEnabledFor('testFeature', ['group' => '5']), + false + ); + $this->assertEquals( + $feature->isEnabledFor('testFeature2', ['group' => '5']), + true + ); + + $this->assertEquals($feature->variantFor('testFeature', []), ''); + $this->assertEquals( + $feature->variantFor('testFeature', ['group' => '9']), + 'test4' + ); + $this->assertEquals( + $feature->variantFor('testFeature', ['group' => '8']), + 'test4' + ); + $this->assertEquals( + $feature->variantFor('testFeature', ['group' => '7']), + 'test4' + ); + $this->assertEquals( + $feature->variantFor('testFeature', ['group' => '2']), + 'test1' + ); + $this->assertEquals( + $feature->variantFor('testFeature', ['group' => '5']), + '' + ); + $this->assertEquals( + $feature->variantFor('testFeature2', ['group' => '5']), + 'variant2' + ); + } + + function testSources () + { + $feature = new Feature([ + 'features' => [ + 'testFeature' => [ + 'enabled' => 0, + 'sources' => ['on' => 'test', 'off' => 'test2'], + ], + 'testFeature2' => [ + 'enabled' => 100, + 'sources' => ['off' => 'test', 'on' => 'test2'] + ] + ], + 'source' => 'test' + ]); + + $this->assertEquals($feature->isEnabled('testFeature'), true); + $this->assertEquals($feature->isEnabled('testFeature2'), false); + + $this->assertEquals($feature->isEnabledFor('testFeature', []), true); + $this->assertEquals($feature->isEnabledFor('testFeature2', []), false); + + $this->assertEquals( + $feature->isEnabledBucketingBy('testFeature', 'testid1'), + true + ); + $this->assertEquals( + $feature->isEnabledBucketingBy('testFeature2', 'testid2'), + false + ); + + $this->assertEquals($feature->variant('testFeature'), 'on'); + $this->assertEquals($feature->variant('testFeature2'), ''); + + $this->assertEquals($feature->variantFor('testFeature', []), 'on'); + $this->assertEquals($feature->variantFor('testFeature2', []), ''); + + $this->assertEquals( + $feature->variantBucketingBy('testFeature', 'testid1'), + 'on' + ); + $this->assertEquals( + $feature->variantBucketingBy('testFeature2', 'testid2'), + '' + ); + + $feature->changeSource('test2'); + + $this->assertEquals($feature->isEnabled('testFeature'), false); + $this->assertEquals($feature->isEnabled('testFeature2'), true); + + $this->assertEquals($feature->isEnabledFor('testFeature', []), false); + $this->assertEquals($feature->isEnabledFor('testFeature2', []), true); + + $this->assertEquals( + $feature->isEnabledBucketingBy('testFeature', 'testid1'), + false + ); + $this->assertEquals( + $feature->isEnabledBucketingBy('testFeature2', 'testid2'), + true + ); + + $this->assertEquals($feature->variant('testFeature'), ''); + $this->assertEquals($feature->variant('testFeature2'), 'on'); + + $this->assertEquals($feature->variantFor('testFeature', []), ''); + $this->assertEquals($feature->variantFor('testFeature2', []), 'on'); + + $this->assertEquals( + $feature->variantBucketingBy('testFeature', 'testid1'), + '' + ); + $this->assertEquals( + $feature->variantBucketingBy('testFeature2', 'testid2'), + 'on' + ); + } + + function testAdmin () + { + $feature = new Feature([ + 'features' => [ + 'testFeature' => ['enabled' => 0, 'admin' => 'on'], + 'testFeature2' => [ + 'enabled' => ['test1' => 100, 'test2' => 0], + 'admin' => 'test2' + ] + ], + 'user' => ['is-admin' => true] + ]); + + $this->assertEquals($feature->isEnabled('testFeature'), true); + $this->assertEquals($feature->isEnabled('testFeature2'), true); + + $this->assertEquals($feature->variant('testFeature'), 'on'); + $this->assertEquals($feature->variant('testFeature2'), 'test2'); + + $this->assertEquals($feature->isEnabledFor('testFeature', []), false); + $this->assertEquals($feature->variantFor('testFeature', []), ''); + $this->assertEquals($feature->isEnabledFor('testFeature2', []), true); + $this->assertEquals($feature->variantFor('testFeature2', []), 'test1'); + } + + function testInternal () + { + $feature = new Feature([ + 'features' => [ + 'testFeature' => ['enabled' => 0, 'internal' => 'on'], + 'testFeature2' => [ + 'enabled' => ['test1' => 100, 'test2' => 0], + 'internal' => 'test2' + ] + ], + 'user' => ['internal-ip' => true] + ]); + + $this->assertEquals($feature->isEnabled('testFeature'), true); + $this->assertEquals($feature->isEnabled('testFeature2'), true); + + $this->assertEquals($feature->variant('testFeature'), 'on'); + $this->assertEquals($feature->variant('testFeature2'), 'test2'); + + $this->assertEquals($feature->isEnabledFor('testFeature', []), false); + $this->assertEquals($feature->variantFor('testFeature', []), ''); + $this->assertEquals($feature->isEnabledFor('testFeature2', []), true); + $this->assertEquals($feature->variantFor('testFeature2', []), 'test1'); + } + + function testStart () + { + $feature = new Feature([ + 'features' => [ + 'testFeature' => ['enabled' => 100, 'start' => 'today'], + 'testFeature2' => ['enabled' => 100, 'start' => 'tomorrow'] + ] + ]); + + $this->assertEquals($feature->isEnabled('testFeature'), true); + $this->assertEquals($feature->isEnabled('testFeature2'), false); + + $this->assertEquals($feature->isEnabledFor('testFeature', []), true); + $this->assertEquals($feature->isEnabledFor('testFeature2', []), false); + + $this->assertEquals( + $feature->isEnabledBucketingBy('testFeature', 'testid1'), + true + ); + $this->assertEquals( + $feature->isEnabledBucketingBy('testFeature2', 'testid2'), + false + ); + + $this->assertEquals($feature->variant('testFeature'), 'on'); + $this->assertEquals($feature->variant('testFeature2'), ''); + + $this->assertEquals($feature->variantFor('testFeature', []), 'on'); + $this->assertEquals($feature->variantFor('testFeature2', []), ''); + + $this->assertEquals( + $feature->variantBucketingBy('testFeature', 'testid1'), + 'on' + ); + $this->assertEquals( + $feature->variantBucketingBy('testFeature2', 'testid2'), + '' + ); + } + + function testEnd () + { + $feature = new Feature([ + 'features' => [ + 'testFeature' => ['enabled' => 100, 'end' => 'tomorrow'], + 'testFeature2' => ['enabled' => 100, 'end' => 'yesterday'] + ] + ]); + + $this->assertEquals($feature->isEnabled('testFeature'), true); + $this->assertEquals($feature->isEnabled('testFeature2'), false); + + $this->assertEquals($feature->isEnabledFor('testFeature', []), true); + $this->assertEquals($feature->isEnabledFor('testFeature2', []), false); + + $this->assertEquals( + $feature->isEnabledBucketingBy('testFeature', 'testid1'), + true + ); + $this->assertEquals( + $feature->isEnabledBucketingBy('testFeature2', 'testid2'), + false + ); + + $this->assertEquals($feature->variant('testFeature'), 'on'); + $this->assertEquals($feature->variant('testFeature2'), ''); + + $this->assertEquals($feature->variantFor('testFeature', []), 'on'); + $this->assertEquals($feature->variantFor('testFeature2', []), ''); + + $this->assertEquals( + $feature->variantBucketingBy('testFeature', 'testid1'), + 'on' + ); + $this->assertEquals( + $feature->variantBucketingBy('testFeature2', 'testid2'), + '' + ); + } + + function testExcludeFrom () + { + $feature = new Feature([ + 'features' => [ + 'testFeature' => [ + 'enabled' => 100, + 'exclude_from' => ['zips' => ['10014', '10023']], + ], + 'testFeature2' => [ + 'enabled' => 100, + 'exclude_from' => ['countries' => ['us', 'rd']], + ], + 'testFeature3' => [ + 'enabled' => 100, + 'exclude_from' => ['regions' => ['ny', 'nj', 'ca']], + ] + ], + 'user' => [ + 'country' => 'us', + 'zipcode' => '10014', + 'region' => 'ny' + ] + ]); + + $this->assertEquals($feature->isEnabled('testFeature'), false); + $this->assertEquals($feature->isEnabled('testFeature2'), false); + $this->assertEquals($feature->isEnabled('testFeature3'), false); + + $this->assertEquals($feature->isEnabledFor('testFeature', []), true); + $this->assertEquals($feature->isEnabledFor('testFeature2', []), true); + $this->assertEquals($feature->isEnabledFor('testFeature3', []), true); + + $this->assertEquals( + $feature->isEnabledBucketingBy('testFeature', 'testid1'), + false + ); + $this->assertEquals( + $feature->isEnabledBucketingBy('testFeature2', 'testid2'), + false + ); + $this->assertEquals( + $feature->isEnabledBucketingBy('testFeature2', 'testid3'), + false + ); + + $this->assertEquals($feature->variant('testFeature'), ''); + $this->assertEquals($feature->variant('testFeature2'), ''); + $this->assertEquals($feature->variant('testFeature3'), ''); + + $this->assertEquals($feature->variantFor('testFeature', []), 'on'); + $this->assertEquals($feature->variantFor('testFeature2', []), 'on'); + $this->assertEquals($feature->variantFor('testFeature2', []), 'on'); + + $this->assertEquals( + $feature->variantBucketingBy('testFeature', 'testid1'), + '' + ); + $this->assertEquals( + $feature->variantBucketingBy('testFeature2', 'testid2'), + '' + ); + $this->assertEquals( + $feature->variantBucketingBy('testFeature2', 'testid3'), + '' + ); + } + + function testPublicUrlOverride () + { + $feature = new Feature([ + 'features' => [ + 'testFeature' => [ + 'enabled' => ['variant1' => 0, 'variant2' => 0], + 'public_url_override' => true + ], + 'testFeature2' => [ + 'enabled' => ['variant3' => 0, 'variant4' => 0], + 'public_url_override' => true + ] + ], + 'url' => 'http://www.testurl.com/?feature=testFeature:variant1,testFeature2:variant4' + ]); + + $this->assertEquals($feature->isEnabled('testFeature'), true); + $this->assertEquals($feature->isEnabled('testFeature2'), true); + + $this->assertEquals($feature->variant('testFeature'), 'variant1'); + $this->assertEquals($feature->variant('testFeature2'), 'variant4'); + + $feature->changeUrl( + 'http://www.testurl.com/?feature=testFeature:variant2,testFeature2:variant3' + ); + + $this->assertEquals($feature->isEnabled('testFeature'), true); + $this->assertEquals($feature->isEnabled('testFeature2'), true); + + $this->assertEquals($feature->variant('testFeature'), 'variant2'); + $this->assertEquals($feature->variant('testFeature2'), 'variant3'); + + $feature->changeUrl('http://www.testurl.com/'); + + $this->assertEquals($feature->isEnabled('testFeature'), false); + $this->assertEquals($feature->isEnabled('testFeature2'), false); + + $this->assertEquals($feature->variant('testFeature'), ''); + $this->assertEquals($feature->variant('testFeature2'), ''); + } + + function testBucketing () + { + $feature = new Feature([ + 'features' => [ + 'testFeature' => [ + 'enabled' => ['variant1' => 50, 'variant2' => 50], + 'bucketing' => 'random' + ], + 'testFeature2' => [ + 'enabled' => ['variant3' => 50, 'variant4' => 50], + 'bucketing' => 'uaid' + ], + 'testFeature3' => [ + 'enabled' => ['variant5' => 50, 'variant6' => 50], + 'bucketing' => 'user' + ] + ], + 'user' => ['id' => 'testid5', 'uaid' => 'randomteststring'] + ]); + + $variant = in_array( + $feature->variant('testFeature'), + ['variant1', 'variant2'], + true + ); + $this->assertEquals($variant, true); + $this->assertEquals($feature->variant('testFeature2'), 'variant3'); + $this->assertEquals($feature->variant('testFeature3'), 'variant5'); + + $this->assertEquals( + $feature->variantBucketingBy('testFeature2', 'testid1'), + 'variant4' + ); + $this->assertEquals( + $feature->variantBucketingBy('testFeature3', 'testid2'), + 'variant6' + ); + + $feature->changeUser(['id' => 'anotheruser', 'uaid' => 'string3']); + + $this->assertEquals($feature->variant('testFeature2'), 'variant4'); + $this->assertEquals($feature->variant('testFeature3'), 'variant6'); + } +} diff --git a/tests/BucketingTest.php b/tests/BucketingTest.php new file mode 100644 index 0000000..d60d4ae --- /dev/null +++ b/tests/BucketingTest.php @@ -0,0 +1,34 @@ +assertEquals((string) $bucketing, 'random'); + + $bucketing = new Bucketing('uaid'); + $this->assertEquals((string) $bucketing, 'uaid'); + + $bucketing = new Bucketing('user'); + $this->assertEquals((string) $bucketing, 'user'); + + try { + new Bucketing('some other string'); + } + catch (\Exception $e) + { + $this->assertEquals( + $e->getMessage(), + 'bucketing must be either "random", "uaid" or "user". some other string' + ); + } + } +} diff --git a/tests/CalculateBucketingIdTest.php b/tests/CalculateBucketingIdTest.php new file mode 100644 index 0000000..6e56f94 --- /dev/null +++ b/tests/CalculateBucketingIdTest.php @@ -0,0 +1,57 @@ +assertEquals($bucketing->id(), 'no uaid'); + + $bucketing = new CalculateBucketingId( + new User(['uaid' => 'test']), + new Bucketing('random') + ); + $this->assertEquals($bucketing->id(), 'test'); + + $bucketing = new CalculateBucketingId( + new User(['id' => 'test']), + new Bucketing('user') + ); + $this->assertEquals($bucketing->id(), 'test'); + + $bucketing = new CalculateBucketingId( + new User(['uaid' => 'test']), + new Bucketing('uaid') + ); + $this->assertEquals($bucketing->id(), 'test'); + + try { + (new CalculateBucketingId(new User([]), new Bucketing('uaid')))->id(); + } + catch (\Exception $e) + { + $this->assertEquals( + $e->getMessage(), + 'user uaid must be provided if uaid bucketing is enabled.' + ); + } + + try { + (new CalculateBucketingId(new User([]), new Bucketing('user')))->id(); + } + catch (\Exception $e) + { + $this->assertEquals( + $e->getMessage(), + 'user id must be provided if user bucketing is enabled.' + ); + } + } +} diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php index 5dcc890..20e3c48 100644 --- a/tests/ConfigTest.php +++ b/tests/ConfigTest.php @@ -1,130 +1,84 @@ getMockBuilder('CafeMedia\Feature\World') - ->disableOriginalConstructor() - ->setMethods([ - 'configValue', - 'userID', - 'uaid', - 'isInternalRequest', - 'isAdmin', - 'urlFeatures', - 'userName', - 'viewingGroup', - 'isSource', - 'country', - 'region', - 'zipcode' - ]) - ->getMock(); - $world->method('configValue')->willReturn([ - 'description' => 'this is the description of the stanza', - 'enabled' => [ - 'test1' => 20, - 'test2' => 30, - 'test3' => 15, - 'test4' => 35 - ], - 'users' => ['user1', 'user2', 'user3'], - 'groups' => ['group1', 'group2', 'group3'], - 'sources' => ['source1', 'source2', 'source3'], - 'admin' => 'test3', - 'internal' => 'test1', - 'public_url_override' => true, - 'bucketing' => 'random', - 'exclude_from' => [ - 'zips' => [10014, 10023], - 'countries' => ['us', 'rd'], - 'regions' => ['ny', 'nj', 'ca'] - ], - 'start' => 20170314, - 'end' => 20170530 - ]); - $world->method('userID')->willReturn(5); - $world->method('uaid')->willReturn('as54gerfd'); - $world->method('isInternalRequest')->willReturn(false); - $world->method('isAdmin')->willReturn(false); - $world->method('urlFeatures')->willReturn('feature'); - $world->method('userName')->willReturn('testUserName'); - $world->method('viewingGroup')->willReturn(false); - $world->method('isSource')->willReturn(false); - $world->method('country')->willReturn('us'); - $world->method('region')->willReturn('ny'); - $world->method('zipcode')->willReturn('12345'); - $this->config = (new Config($world))->addName('testFeature'); - $this->assertEquals($this->config instanceof Config, true); - } - - public function testIsEnabled() - { - $this->assertEquals($this->config->isEnabled('testFeature'), false); - } - - public function testVariant() + function setUp () { - $this->assertEquals($this->config->variant(), 'off'); - } - - public function testIsEnabledFor() - { - $this->assertEquals( - $this->config->isEnabledFor(new User([ - 'user-uaid' => 'as54gerfd', - 'user-id' => 5, - 'user-name' => 'testUserName', - 'is-admin' => false, - 'user-group' => 'group', - 'internal-ip' => false - ])), - false + $this->config = new Config(new User([]), new Url(''), new Source('')); + $this->feature = new Feature( + new Name('test'), + [ + 'description' => 'this is the description', + 'enabled' => [ + 'test1' => 20, + 'test2' => 30, + 'test3' => 15, + 'test4' => 35 + ], + 'users' => ['test1' => '2', 'test4' => '7'], + 'groups' => ['test1' => 'group1', 'test2' => 'group2'], + 'sources' => ['test3' => 'source1', 'test4' => 'source2'], + 'admin' => 'test3', + 'internal' => 'test1', + 'public_url_override' => true, + 'exclude_from' => [ + 'zips' => ['10014', '10023'], + 'countries' => ['us', 'rd'], + 'regions' => ['ny', 'nj', 'ca'] + ], + 'start' => '20170214', + 'end' => '99990530' + ] ); } - public function testIsEnabledBucketingBy() + function testIsEnabled () { - $this->assertEquals($this->config->isEnabledBucketingBy('test'), false); + $this->assertEquals($this->config->isEnabled($this->feature), true); } - public function testVariantFor() + function testVariant () { - $this->assertEquals( - $this->config->variantFor(new User([ - 'user-uaid' => 'as54gerfd', - 'user-id' => 5, - 'user-name' => 'testUserName', - 'is-admin' => false, - 'user-group' => 'group', - 'internal-ip' => false - ])), - 'off' + $variant = in_array( + $this->config->variant($this->feature), + ['test1', 'test2', 'test3', 'test4'], + true ); + $this->assertEquals($variant, true); } - public function testVariantBucketingBy() + function testIsEnabledBucketingBy () { $this->assertEquals( - $this->config->variantBucketingBy('test', 'test'), - 'off' + $this->config->isEnabledBucketingBy( + $this->feature, + new BucketingId('test') + ), + true ); } - public function testDescription() + function testVariantBucketingBy () { - $this->assertEquals( - $this->config->description('test'), - 'this is the description of the stanza' + $variant = in_array( + $this->config->variantBucketingBy( + $this->feature, + new BucketingId('as54gerfd') + ), + ['test1', 'test2', 'test3', 'test4'], + true ); + $this->assertEquals($variant, true); } } diff --git a/tests/EnabledTest.php b/tests/EnabledTest.php new file mode 100644 index 0000000..8232898 --- /dev/null +++ b/tests/EnabledTest.php @@ -0,0 +1,73 @@ +assertEquals($enabled->percentages(), ['on' => 0]); + + $enabled = new Enabled(100); + $this->assertEquals($enabled->percentages(), ['on' => 100]); + + $enabled = new Enabled(['on' => 50]); + $this->assertEquals($enabled->percentages(), ['on' => 50]); + + $enabled = new Enabled(['test1' => 23, 'test2' => 48]); + $this->assertEquals($enabled->percentages(), ['test1' => 23, 'test2' => 71]); + + $enabled = new Enabled(['test1' => 60, 'test2' => 40]); + $this->assertEquals($enabled->percentages(), ['test1' => 60, 'test2' => 100]); + + try { + new Enabled('string'); + } + catch (\Exception $e) + { + $this->assertEquals( + $e->getMessage(), + 'Malformed enabled property "string"' + ); + } + + try { + new Enabled(101); + } + catch (\Exception $e) + { + $this->assertEquals( + $e->getMessage(), + 'Bad percentage 101' + ); + } + + try { + new Enabled(-1); + } + catch (\Exception $e) + { + $this->assertEquals( + $e->getMessage(), + 'Bad percentage -1' + ); + } + + try { + new Enabled(['test1' => 60, 'test2' => 100]); + } + catch (\Exception $e) + { + $this->assertEquals( + $e->getMessage(), + 'Total of percentages > 100: 160' + ); + } + } +} diff --git a/tests/ExcludeFromTest.php b/tests/ExcludeFromTest.php new file mode 100644 index 0000000..244dd26 --- /dev/null +++ b/tests/ExcludeFromTest.php @@ -0,0 +1,40 @@ + ['10014', '10023'], + 'countries' => ['us', 'rd'], + 'regions' => ['ny', 'nj', 'ca'] + ]); + $this->assertEquals($excludeFrom->variant(new User(['zipcode' => '10014'])), 'off'); + $this->assertEquals($excludeFrom->variant(new User(['zipcode' => '10015'])), ''); + $this->assertEquals($excludeFrom->variant(new User(['country' => 'us'])), 'off'); + $this->assertEquals($excludeFrom->variant(new User(['country' => 'ur'])), ''); + $this->assertEquals($excludeFrom->variant(new User(['region' => 'ny'])), 'off'); + $this->assertEquals($excludeFrom->variant(new User(['region' => 'nn'])), ''); + + $excludeFrom = new ExcludeFrom([]); + $this->assertEquals($excludeFrom->variant(new User([])), ''); + + try { + new ExcludeFrom(['bad array' => 'with other stuff']); + } + catch (\Exception $e) + { + $this->assertEquals( + $e->getMessage(), + 'bad exclude_from stanza {"bad array":"with other stuff"}' + ); + } + } +} diff --git a/tests/FeatureCollectionTest.php b/tests/FeatureCollectionTest.php new file mode 100644 index 0000000..5db9339 --- /dev/null +++ b/tests/FeatureCollectionTest.php @@ -0,0 +1,37 @@ + ['enabled' => 0]]); + $this->assertEquals( + $features->get(new Name('test')), + new Feature(new Name('test'), ['enabled' => 0]) + ); + + $features->change(new Name('test'), ['enabled' => 100]); + $this->assertEquals( + $features->get(new Name('test')), + new Feature(new Name('test'), ['enabled' => 100]) + ); + + try { + $features->change(new Name('i dont exist'), []); + } + catch (\Exception $e) + { + $this->assertEquals( + $e->getMessage(), + "feature 'i dont exist' does not exist." + ); + } + } +} diff --git a/tests/FeatureTest.php b/tests/FeatureTest.php index 6d52510..6c3ed76 100644 --- a/tests/FeatureTest.php +++ b/tests/FeatureTest.php @@ -1,77 +1,94 @@ feature = (new Feature([ - 'testFeature' => [ - 'description' => 'this is the description', - 'enabled' => [ - 'test1' => 20, - 'test2' => 30, - 'test3' => 15, - 'test4' => 35 - ], - 'users' => ['user1', 'user2', 'user3'], - 'groups' => ['group1', 'group2', 'group3'], - 'sources' => ['source1', 'source2'], - 'admin' => 'test3', - 'internal' => 'test1', - 'public_url_override' => true, - 'exclude_from' => [ - 'zips' => [10014, 10023], - 'countries' => ['us', 'rd'], - 'regions' => ['ny', 'nj', 'ca'] - ], - 'start' => 20170214, - 'end' => 99990530 - ] - ])) - ->addUrl('feature') - ->addSource('') - ->addUser([ - 'user-uaid' => 'as54gerfd', - 'user-id' => 5, - 'user-name' => 'testUserName', - 'is-admin' => false, - 'user-group' => 'group', - 'internal-ip' => false - ]); - $this->assertEquals($this->feature instanceof Feature, true); + $this->feature = new Feature([ + 'features' => [ + 'testFeature' => [ + 'description' => 'this is the description', + 'enabled' => [ + 'test1' => 20, + 'test2' => 30, + 'test3' => 15, + 'test4' => 35 + ], + 'users' => ['test1' => '2', 'test4' => '7'], + 'groups' => ['test1' => 'group1', 'test2' => 'group2'], + 'sources' => ['test3' => 'source1', 'test4' => 'source2'], + 'admin' => 'test3', + 'internal' => 'test1', + 'public_url_override' => true, + 'exclude_from' => [ + 'zips' => ['10014', '10023'], + 'countries' => ['us', 'rd'], + 'regions' => ['ny', 'nj', 'ca'] + ], + 'start' => '20170214', + 'end' => '99990530' + ], + 'testFeature2' => ['enabled' => 0, 'bucketing' => 'random'] + ], + 'url' => 'http://www.testurl.com/?feature=testFeature:test3', + 'source' => 'source2', + 'user' => [ + 'uaid' => 'as54gerfd', + 'id' => '5', + 'is-admin' => false, + 'group' => 'group3', + 'internal-ip' => false + ] + ]); } - public function testIsEnabled() + function testIsEnabled () { $this->assertEquals($this->feature->isEnabled('testFeature'), true); + $this->assertEquals($this->feature->isEnabled('testFeature2'), false); } - public function testIsEnabledFor() + function testIsEnabledFor () { $this->assertEquals( $this->feature->isEnabledFor( - 'testFeature', + 'testFeature2', [ - 'user-uaid' => 'as54gerfd', - 'user-id' => 5, - 'user-name' => 'testUserName', + 'uaid' => 'as54gerfd', + 'id' => '5', 'is-admin' => false, - 'user-group' => 'group', + 'group' => 'group', 'internal-ip' => false ] ), false ); + + $this->assertEquals( + $this->feature->isEnabledFor( + 'testFeature', + [ + 'uaid' => 'kl23j4n5', + 'id' => '2', + 'is-admin' => false, + 'group' => 'group', + 'internal-ip' => false + ] + ), + true + ); } - public function testIsEnabledBucketingBy() + function testIsEnabledBucketingBy () { $this->assertEquals( $this->feature->isEnabledBucketingBy('testFeature', 'test'), @@ -79,42 +96,134 @@ public function testIsEnabledBucketingBy() ); } - public function testVariant() + function testVariant () { - $this->assertEquals($this->feature->variant('testFeature'), 'test1'); + $this->assertEquals($this->feature->variant('testFeature'), 'test3'); + $this->assertEquals($this->feature->variant('testFeature2'), ''); } - public function testVariantFor() + function testVariantFor () { $this->assertEquals( $this->feature->variantFor( 'testFeature', [ - 'user-uaid' => 'as54gerfd', - 'user-id' => 5, - 'user-name' => 'testUserName', + 'uaid' => 'as54gerfd', + 'id' => '7', 'is-admin' => false, - 'user-group' => 'group', + 'group' => 'group', 'internal-ip' => false ] ), - 'test4' + 'test3' ); } - public function testVariantBucketingBy() + function testVariantBucketingBy () { $this->assertEquals( $this->feature->variantBucketingBy('testFeature', 'test'), - 'test2' + 'test3' ); } - public function testDescription() + function testDescription () { $this->assertEquals( $this->feature->description('testFeature'), 'this is the description' ); } + + function testChangeFeatures () + { + $this->feature->changeFeatures([ + 'testFeature' => [ + 'description' => 'different description', + 'enabled' => [ + 'test1' => 0, + 'test2' => 0, + 'test3' => 0, + 'test4' => 0 + ] + ], + 'testFeature2' => ['enabled' => 100] + ]); + + $this->assertEquals($this->feature->isEnabled('testFeature'), false); + $this->assertEquals($this->feature->isEnabled('testFeature2'), true); + + $this->assertEquals($this->feature->variant('testFeature'), ''); + $this->assertEquals($this->feature->variant('testFeature2'), 'on'); + + $this->assertEquals( + $this->feature->description('testFeature'), + 'different description' + ); + } + + function testChangeFeature () + { + $this->feature->changeFeature( + 'testFeature2', + [ + 'enabled' => ['test1' => 0, 'test4' => 0], + 'users' => ['test1' => '2', 'test4' => '7'], + 'sources' => ['test1' => 'source3'], + 'public_url_override' => true + ] + ); + $this->assertEquals($this->feature->isEnabled('testFeature2'), false); + $this->assertEquals($this->feature->variant('testFeature2'), ''); + } + + function testChangeUser () + { + $this->feature->changeFeature( + 'testFeature2', + [ + 'enabled' => ['test1' => 0, 'test4' => 0], + 'users' => ['test1' => '2', 'test4' => '7'], + 'sources' => ['test1' => 'source3'], + 'public_url_override' => true + ] + ); + $this->feature->changeUser(['id' => '2']); + $this->assertEquals($this->feature->isEnabled('testFeature2'), true); + $this->assertEquals($this->feature->variant('testFeature2'), 'test1'); + } + + function testChangeUrl () + { + $this->feature->changeFeature( + 'testFeature2', + [ + 'enabled' => ['test1' => 0, 'test4' => 0], + 'users' => ['test1' => '2', 'test4' => '7'], + 'sources' => ['test1' => 'source3'], + 'public_url_override' => true + ] + ); + $this->feature->changeUrl( + 'http://www.testurl.com/?feature=testFeature2:test4' + ); + $this->assertEquals($this->feature->isEnabled('testFeature2'), true); + $this->assertEquals($this->feature->variant('testFeature2'), 'test4'); + } + + function testChangeSource () + { + $this->feature->changeFeature( + 'testFeature2', + [ + 'enabled' => ['test1' => 0, 'test4' => 0], + 'users' => ['test1' => '2', 'test4' => '7'], + 'sources' => ['test1' => 'source3'], + 'public_url_override' => true + ] + ); + $this->feature->changeSource('source3'); + $this->assertEquals($this->feature->isEnabled('testFeature2'), true); + $this->assertEquals($this->feature->variant('testFeature2'), 'test1'); + } } diff --git a/tests/StanzaTest.php b/tests/StanzaTest.php deleted file mode 100644 index f864a9a..0000000 --- a/tests/StanzaTest.php +++ /dev/null @@ -1,127 +0,0 @@ -stanza = new Stanza([ - 'description' => 'this is the description of the stanza', - 'enabled' => [ - 'test1' => 20, - 'test2' => 30, - 'test3' => 15, - 'test4' => 35 - ], - 'users' => ['user1', 'user2', 'user3'], - 'groups' => ['group1', 'group2', 'group3'], - 'sources' => ['source1', 'source2', 'source3'], - 'admin' => 'test3', - 'internal' => 'test1', - 'public_url_override' => true, - 'bucketing' => 'random', - 'exclude_from' => [ - 'zips' => [10014, 10023], - 'countries' => ['us', 'rd'], - 'regions' => ['ny', 'nj', 'ca'] - ], - 'start' => 20170314, - 'end' => 20170530 - ]); - } - - public function testDescription() - { - $this->assertEquals( - $this->stanza->description, - 'this is the description of the stanza' - ); - } - - public function testEnabled() - { - $this->assertEquals( - $this->stanza->enabled, - [ - 'on' => [ - 'test1' => 20, - 'test2' => 30, - 'test3' => 15, - 'test4' => 35 - ] - ] - ); - } - - public function testUsers() - { - $this->assertEquals( - $this->stanza->users, - ['user1' => 'on', 'user2' => 'on', 'user3' => 'on'] - ); - } - - public function testGroups() - { - $this->assertEquals( - $this->stanza->groups, - ['group1' => 'on', 'group2' => 'on', 'group3' => 'on'] - ); - } - - public function testSources() - { - $this->assertEquals( - $this->stanza->sources, - ['source1' => 'on', 'source2' => 'on', 'source3' => 'on'] - ); - } - - public function testAdminVariant() - { - $this->assertEquals($this->stanza->adminVariant, 'test3'); - } - - public function testInternalVariant() - { - $this->assertEquals($this->stanza->internalVariant, 'test1'); - } - - public function testPublicUrlOverride() - { - $this->assertEquals($this->stanza->publicUrlOverride, true); - } - - public function testBucketing() - { - $this->assertEquals($this->stanza->bucketing, 'random'); - } - - public function testExcludeFrom() - { - $this->assertEquals( - $this->stanza->exludeFrom, - [ - 'zips' => [10014, 10023], - 'countries' => ['us', 'rd'], - 'regions' => ['ny', 'nj', 'ca'] - ] - ); - } - - public function testStart() - { - $this->assertEquals($this->stanza->start, 1489449600); - } - - public function testEnd() - { - $this->assertEquals($this->stanza->end, 1496102400); - } -} \ No newline at end of file diff --git a/tests/UrlTest.php b/tests/UrlTest.php new file mode 100644 index 0000000..e27dd3f --- /dev/null +++ b/tests/UrlTest.php @@ -0,0 +1,43 @@ +assertEquals($url->variant(new Name('test')), ''); + + $url = new Url('http://www.testurl.com/'); + $this->assertEquals($url->variant(new Name('test')), ''); + + $url = new Url('http://www.testurl.com/?f=test:on'); + $this->assertEquals($url->variant(new Name('test')), ''); + + $url = new Url('http://www.testurl.com/?feature=test:on'); + $this->assertEquals($url->variant(new Name('test')), 'on'); + + $url = new Url('http://www.testurl.com/?feature=test:on,test:off'); + $this->assertEquals($url->variant(new Name('test')), 'on'); + + $url = new Url('http://www.testurl.com/?q=1&feature=test:off,test:on&a=2'); + $this->assertEquals($url->variant(new Name('test')), 'off'); + + try { + new Url('bad url string'); + } + catch (\Exception $e) + { + $this->assertEquals( + $e->getMessage(), + 'bad url string is not a valid url.' + ); + } + } +} \ No newline at end of file diff --git a/tests/UserTest.php b/tests/UserTest.php deleted file mode 100644 index 42ed2a0..0000000 --- a/tests/UserTest.php +++ /dev/null @@ -1,71 +0,0 @@ -user = new User([ - 'user-uaid' => 'as54gerfd', - 'user-id' => 5, - 'user-name' => 'testUserName', - 'is-admin' => false, - 'user-group' => 'group', - 'internal-ip' => false, - 'zipcode' => 10203, - 'region' => 'ny', - 'country' => 'us' - ]); - } - - public function testUaid() - { - $this->assertEquals($this->user->uaid, 'as54gerfd'); - } - - public function testId() - { - $this->assertEquals($this->user->id, 5); - } - - public function testName() - { - $this->assertEquals($this->user->name, 'testUserName'); - } - - public function testIsAdmin() - { - $this->assertEquals($this->user->isAdmin, false); - } - - public function testGroup() - { - $this->assertEquals($this->user->group, 'group'); - } - - public function testInternalIP() - { - $this->assertEquals($this->user->internalIP, false); - } - - public function testZipcode() - { - $this->assertEquals($this->user->zipcode, 10203); - } - - public function testRegion() - { - $this->assertEquals($this->user->region, 'ny'); - } - - public function testCounrty() - { - $this->assertEquals($this->user->country, 'us'); - } -} \ No newline at end of file diff --git a/tests/VariantTest.php b/tests/VariantTest.php deleted file mode 100644 index 0318948..0000000 --- a/tests/VariantTest.php +++ /dev/null @@ -1,75 +0,0 @@ -getMockBuilder('CafeMedia\Feature\World') - ->disableOriginalConstructor() - ->setMethods([ - 'configValue', - 'userID', - 'uaid', - 'isInternalRequest', - 'isAdmin', - 'urlFeatures', - 'userName', - 'viewingGroup', - 'isSource', - 'zipcode', - 'country', - 'region' - ]) - ->getMock(); - $world->method('configValue')->willReturn(['enabled' => 100]); - $world->method('userID')->willReturn(5); - $world->method('uaid')->willReturn('as54gerfd'); - $world->method('isInternalRequest')->willReturn(false); - $world->method('isAdmin')->willReturn(false); - $world->method('urlFeatures')->willReturn('feature'); - $world->method('userName')->willReturn('testUserName'); - $world->method('viewingGroup')->willReturn(false); - $world->method('country')->willReturn('us'); - $world->method('region')->willReturn('ny'); - $world->method('zipcode')->willReturn('12345'); - $this->variant = (new Variant($world)) - ->addStanza(new Stanza([ - 'description' => 'description of the stanza', - 'enabled' => [ - 'test1' => 20, - 'test2' => 30, - 'test3' => 15, - 'test4' => 35 - ], - 'users' => ['user1', 'user2', 'user3'], - 'groups' => ['group1', 'group2', 'group3'], - 'sources' => ['source1', 'source2', 'source3'], - 'admin' => 'test3', - 'internal' => 'test1', - 'public_url_override' => true, - 'bucketing' => 'random', - 'exclude_from' => [ - 'zips' => [10014, 10023], - 'countries' => ['us', 'rd'], - 'regions' => ['ny', 'nj', 'ca'] - ], - 'start' => 20170314, - 'end' => 20170530 - ])) - ->addBucketingID('123bucketingid321') - ->addName('test'); - } - - public function testGetVariant() - { - $this->assertEquals($this->variant->getVariant(), 'off'); - } -} diff --git a/tests/WorldTest.php b/tests/WorldTest.php deleted file mode 100644 index 523d443..0000000 --- a/tests/WorldTest.php +++ /dev/null @@ -1,92 +0,0 @@ -world = (new World(['test' => ['value']])) - ->addUrl('feature') - ->addSource('') - ->addUser(new User([ - 'user-uaid' => 'as54gerfd', - 'user-id' => 5, - 'user-name' => 'testUserName', - 'is-admin' => false, - 'user-group' => 'group', - 'internal-ip' => false, - 'zipcode' => 10203, - 'region' => 'ny', - 'country' => 'us' - ])); - $this->assertEquals($this->world instanceof World, true); - } - - public function testConfigValue() - { - $this->assertEquals($this->world->configValue('test'), ['value']); - } - - public function testUaid() - { - $this->assertEquals($this->world->uaid(), 'as54gerfd'); - } - - public function testUserId() - { - $this->assertEquals($this->world->userId(), 5); - } - - public function testUserName() - { - $this->assertEquals($this->world->userName(), 'testUserName'); - } - - public function testViewingGroup() - { - $this->assertEquals($this->world->viewingGroup('test'), false); - } - - public function testIsSource() - { - $this->assertEquals($this->world->isSource('test'), false); - $this->assertEquals($this->world->isSource(''), true); - } - - public function testIsAdmin() - { - $this->assertEquals($this->world->isAdmin(), false); - } - - public function testIsInternalRequest() - { - $this->assertEquals($this->world->isInternalRequest(), false); - } - - public function testUrlFeatures() - { - $this->assertEquals($this->world->urlFeatures(), 'feature'); - } - - public function testZipcode() - { - $this->assertEquals($this->world->zipcode(), 10203); - } - - public function testRegion() - { - $this->assertEquals($this->world->region(), 'ny'); - } - - public function testCounrty() - { - $this->assertEquals($this->world->country(), 'us'); - } -} \ No newline at end of file From 942ec19ea9f22dd722e276873d467501c47872f6 Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Thu, 28 Dec 2017 14:23:18 -0500 Subject: [PATCH 35/92] micro optimization --- src/Config.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Config.php b/src/Config.php index 04d1cec..2e8cb14 100644 --- a/src/Config.php +++ b/src/Config.php @@ -182,7 +182,7 @@ private function variantByPercentage (Feature $feature, BucketingId $id) : strin { $n = 100 * $this->randomish($feature, $id); foreach ($feature->enabled()->percentages() as $variant => $percent) { - if ($n < $percent || $percent === 100) return $variant; + if ($n < $percent) return $variant; } return ''; } @@ -194,19 +194,19 @@ private function variantByPercentage (Feature $feature, BucketingId $id) : strin private function randomish (Feature $feature, BucketingId $id) : float { if ((string) $feature->bucketing() === 'random') { - return mt_rand(0, mt_getrandmax() - 1) / mt_getrandmax(); + $max = mt_getrandmax(); + return mt_rand(0, $max - 1) / $max; } /** * Map a hex value to the half-open interval bewtween 0 and 1 while * preserving uniformity of the input distribution. */ $id = hash('sha256', $feature->name() . "-$id"); - $len = min(30, strlen($id)); $x = 0; - for ($i = 0; $i < $len; ++$i) { + for ($i = 0; $i < 30; ++$i) { $x = ($x << 1) + (hexdec($id[$i]) < 8 ? 0 : 1); } - return $x / (1 << $len); + return $x / 1073741824; // $x / 1 << 30 } } From e2f841bc1600638bc922cf56caeebd33b6e6cff6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Thu, 28 Dec 2017 17:14:04 -0500 Subject: [PATCH 36/92] Update README.md add code coverage tracking --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2cfb3d4..38780bd 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ [![Build Status](https://travis-ci.org/PabloJoan/feature.svg?branch=master)](https://travis-ci.org/PabloJoan/feature) - +[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/PabloJoan/feature/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/PabloJoan/feature/?branch=master) +[![Code Coverage](https://scrutinizer-ci.com/g/PabloJoan/feature/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/PabloJoan/feature/?branch=master) +[![Build Status](https://scrutinizer-ci.com/g/PabloJoan/feature/badges/build.png?b=master)](https://scrutinizer-ci.com/g/PabloJoan/feature/build-status/master) Requires PHP 7.0 and above. # Installation From bbf05d0da27d21d1f8ba9b17678fc6c7fab2928c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Thu, 28 Dec 2017 17:14:25 -0500 Subject: [PATCH 37/92] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 38780bd..c6e9057 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/PabloJoan/feature/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/PabloJoan/feature/?branch=master) [![Code Coverage](https://scrutinizer-ci.com/g/PabloJoan/feature/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/PabloJoan/feature/?branch=master) [![Build Status](https://scrutinizer-ci.com/g/PabloJoan/feature/badges/build.png?b=master)](https://scrutinizer-ci.com/g/PabloJoan/feature/build-status/master) + Requires PHP 7.0 and above. # Installation From ea6658b14e6b6bfc4a5a03fd7d434d65a5afa4f0 Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Sat, 30 Dec 2017 22:12:56 -0500 Subject: [PATCH 38/92] support to add a new feature and delete an existing feature. interface all the things! --- LICENSE | 22 -- README.md | 148 +++++----- composer.json | 6 + src/Config.php | 18 +- src/Contract/Admin.php | 12 + src/Contract/Bucketing.php | 12 + src/Contract/BucketingId.php | 12 + src/Contract/Description.php | 12 + src/Contract/Enabled.php | 12 + src/Contract/ExcludeFrom.php | 12 + src/Contract/Feature.php | 34 +++ src/Contract/FeatureCollection.php | 18 ++ src/Contract/Groups.php | 12 + src/Contract/Internal.php | 12 + src/Contract/Name.php | 12 + src/Contract/PublicUrlOverride.php | 12 + src/Contract/Source.php | 12 + src/Contract/Sources.php | 12 + src/Contract/Time.php | 12 + src/Contract/Url.php | 12 + src/Contract/User.php | 26 ++ src/Contract/Users.php | 12 + src/Feature.php | 17 ++ src/Value/Admin.php | 4 +- src/Value/Bucketing.php | 4 +- src/Value/BucketingId.php | 4 +- src/Value/CalculateBucketingId.php | 16 +- src/Value/Description.php | 4 +- src/Value/Enabled.php | 4 +- src/Value/ExcludeFrom.php | 4 +- src/Value/Feature.php | 47 +++- src/Value/FeatureCollection.php | 27 +- src/Value/Groups.php | 4 +- src/Value/Internal.php | 4 +- src/Value/Name.php | 4 +- src/Value/PublicUrlOverride.php | 8 +- src/Value/Source.php | 4 +- src/Value/Sources.php | 4 +- src/Value/Time.php | 4 +- src/Value/Url.php | 16 +- src/Value/User.php | 4 +- src/Value/Users.php | 4 +- tests/CalculateBucketingIdTest.php | 57 ---- tests/ConfigTest.php | 211 +++++++++++--- tests/ExcludeFromTest.php | 40 --- tests/FeatureCollectionTest.php | 37 --- tests/FeatureTest.php | 24 ++ tests/{ => Value}/BucketingTest.php | 2 +- tests/Value/CalculateBucketingIdTest.php | 168 +++++++++++ tests/{ => Value}/EnabledTest.php | 2 +- tests/Value/ExcludeFromTest.php | 124 ++++++++ tests/Value/FeatureCollectionTest.php | 343 +++++++++++++++++++++++ tests/{ => Value}/UrlTest.php | 22 +- 53 files changed, 1330 insertions(+), 339 deletions(-) delete mode 100644 LICENSE create mode 100644 src/Contract/Admin.php create mode 100644 src/Contract/Bucketing.php create mode 100644 src/Contract/BucketingId.php create mode 100644 src/Contract/Description.php create mode 100644 src/Contract/Enabled.php create mode 100644 src/Contract/ExcludeFrom.php create mode 100644 src/Contract/Feature.php create mode 100644 src/Contract/FeatureCollection.php create mode 100644 src/Contract/Groups.php create mode 100644 src/Contract/Internal.php create mode 100644 src/Contract/Name.php create mode 100644 src/Contract/PublicUrlOverride.php create mode 100644 src/Contract/Source.php create mode 100644 src/Contract/Sources.php create mode 100644 src/Contract/Time.php create mode 100644 src/Contract/Url.php create mode 100644 src/Contract/User.php create mode 100644 src/Contract/Users.php delete mode 100644 tests/CalculateBucketingIdTest.php delete mode 100644 tests/ExcludeFromTest.php delete mode 100644 tests/FeatureCollectionTest.php rename tests/{ => Value}/BucketingTest.php (95%) create mode 100644 tests/Value/CalculateBucketingIdTest.php rename tests/{ => Value}/EnabledTest.php (97%) create mode 100644 tests/Value/ExcludeFromTest.php create mode 100644 tests/Value/FeatureCollectionTest.php rename tests/{ => Value}/UrlTest.php (54%) diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 5e255be..0000000 --- a/LICENSE +++ /dev/null @@ -1,22 +0,0 @@ -Copyright (c) 2010 Etsy - -Permission is hereby granted, free of charge, to any person -obtaining a copy of this software and associated documentation -files (the "Software"), to deal in the Software without -restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 2cfb3d4..d1d7704 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ composer require pablojoan/feature ```php $config = [ - features => [ + 'features' => [ 'foo' => [ 'description' => 'this is the description of the "foo" feature', 'enabled' => [ @@ -50,9 +50,9 @@ $feature->description('foo'); // 'this is the description of the "foo" feature' # TODO -DOCUMENTATION!!!!! remove archived documentation by etsy and replace with new. -Improve Unit Tests. Use Mock Objects. -Depend on Interface Injecting to decouple code. +DOCUMENTATION!!!!! Especially for new features. +Replace magic strings with constants. +Write more usefull error messages. Add more bucketing schemes. # Feature API @@ -114,14 +114,14 @@ and These methods exist only to support a couple very specific use-cases: when we want to enable or disable a feature based not on the user making the request but on some other user or when we want to bucket a percentage of executions based on -something entirely other than a user.) The canonical case for the former, at -Etsy, is if we wanted to change something about how we deal with listings and -instead of enabling the feature for only some users but for all listings those -users see, but instead we want to enable it for all users but for only some of -the listings. Then we could use `isEnabledFor` and `variantFor` and pass in the -user object representing the owner of the listing. That would also allow us to -enable the feature for specific listing owners. The `bucketingBy` methods serve -a similar purpose except when there either is no relevant user or where we don't +something entirely other than a user.) The canonical case for the former is if +we wanted to change something about how we deal with listings and instead of +enabling the feature for only some users but for all listings those users see, +but instead we want to enable it for all users but for only some of the +listings. Then we could use `isEnabledFor` and `variantFor` and pass in the user +object representing the owner of the listing. That would also allow us to enable +the feature for specific listing owners. The `bucketingBy` methods serve a +similar purpose except when there either is no relevant user or where we don't want to always put the same user in the same bucket. Thus if we wanted to enable a certain feature for 10% of all listings displayed, independent of both the user making the request and the user who owned the listing, we could use `isEnabledBucketingBy` with the listing id as the bucketing ID. @@ -298,33 +298,32 @@ defaults to false if omitted. The precedence of the various mechanisms for enabling a feature are as follows. - - If the request is from an admin user or is an internal - request, or if `'public_url_override'` is true and the request - contains a `features` query param that specifies a variant for the - feature in question, that variant is used. The value of the - `features` param is a comma-delimited list of features where each - feature is either simply the name of the feature, indicating the - feature should be enabled with variant `'on'` or the name of a - feature, a colon, and the variant name. E.g. a request with - `features=foo,bar:x,baz:off` would turn on feature `foo`, turn on - feature `bar` with variant `x`, and turn off feature `baz`. + - If the request is from an admin user or is an internal request, or if + `'public_url_override'` is true and the request contains a `features` query + param that specifies a variant for the feature in question, that variant is + used. The value of the `features` param is a comma-delimited list of + features where each feature is either simply the name of the feature, + indicating the feature should be enabled with variant `'on'` or the name of + a feature, a colon, and the variant name. E.g. a request with + `features=foo,bar:x,baz:off` would turn on feature `foo`, turn on feature + `bar` with variant `x`, and turn off feature `baz`. - - Otherwise, if the request is from a user specified in the - `'users'` property, the specified variant is enabled. + - Otherwise, if the request is from a user specified in the `'users'` + property, the specified variant is enabled. - - Otherwise, if the request is from a member of a group specified in - the `'groups'` property the specified variant is enabled. (The - behavior when the user is a member of multiple groups that have - been assigned different variants is undefined. Beware nasal demons.) + - Otherwise, if the request is from a member of a group specified in the + `'groups'` property the specified variant is enabled. (The behavior when + the user is a member of multiple groups that have been assigned different + variants is undefined. Beware nasal demons.) - - Otherwise, if the request is from an admin, the `'admin'` variant - is enabled. + - Otherwise, if the request is from an admin, the `'admin'` variant is + enabled. - - Otherwise, if the request is an internal request, the `'internal'` - variant is enabled. + - Otherwise, if the request is an internal request, the `'internal'` variant + is enabled. - - Otherwise, the request is bucketed and a variant is chosen so that - the correct percentage of bucketed requests will see each variant. + - Otherwise, the request is bucketed and a variant is chosen so that the + correct percentage of bucketed requests will see each variant. ## Errors @@ -332,14 +331,13 @@ There are a few ways to misuse the Feature API or misconfigure a feature that may be detected. (Some of these are not currently detected but may be in the future.) - 1. Setting `'enabled'` to numeric value less than 0 or greater than - 100. + 1. Setting `'enabled'` to numeric value less than 0 or greater than 100. - 2. Setting the percentage value of a variant in `'enabled'` to a - value less than 0 or greater than 100. + 2. Setting the percentage value of a variant in `'enabled'` to a value less + than 0 or greater than 100. - 3. Setting `'enabled'` such that the sum of the variant percentages - is greater than 100. + 3. Setting `'enabled'` such that the sum of the variant percentages is greater + than 100. 4. Setting `'enabled'` to a non-numeric, non-array value. @@ -353,37 +351,33 @@ feature checks but keeping the code, or deleting the code altogether. The basic life cycle of a feature might look like this: - 1. Developer writes some code guarded by `$feature->isEnabled` - checks. In order to test the feature in development they will add - configuration for the feature to `development.php` that turns it - on for specific users or admin or sets `'enabled'` to 0 so they - can test it with a URL query param. - - 2. At some point the developer will add a config stanza to - `production.php`. Initially this may just be a place holder that - leaves the feature entirely disabled or it may turn it on for - admin, etc. - - 3. Once the feature is done, the `production.php` config will be - changed to enable the feature for a small percentage of users for - an operational smoke test. For a single-variant feature this means - setting `'enabled'` to a small numeric value; for a multi-variant - feature it means setting `'enabled'` to an array that specifies a - small percentage for each variant. - - 4. During the rampup period the percentage of users exposed to the - feature may be moved up and down until the developers and ops - folks are convinced the code is fully baked. If serious problems - arise at any point, the new code can be completely disabled. + 1. Developer writes some code guarded by `$feature->isEnabled` checks. In + order to test the feature in development they will add configuration for + the feature to `development.php` that turns it on for specific users or + admin or sets `'enabled'` to 0 so they can test it with a URL query param. + + 2. At some point the developer will add a config stanza to `production.php`. + Initially this may just be a place holder that leaves the feature entirely + disabled or it may turn it on for admin, etc. + + 3. Once the feature is done, the `production.php` config will be changed to + enable the feature for a small percentage of users for an operational smoke + test. For a single-variant feature this means setting `'enabled'` to a + small numeric value; for a multi-variant feature it means setting + `'enabled'` to an array that specifies a small percentage for each variant. + + 4. During the rampup period the percentage of users exposed to the feature may + be moved up and down until the developers and ops folks are convinced the + code is fully baked. If serious problems arise at any point, the new code + can be completely disabled. 5. If the feature is going to be part of an A/B experiment, then the - developers will (working with the data team) figure out the best - percentage of users to expose the feature to and how long the - experiment will have to run in order to gather good experimental - data. To launch the experiment the production config will be - changed to enable the feature or its variants for the appropriate - percentage of users. After this point the percentages should be - left alone until the experiment is complete. + developers will (working with the data team) figure out the best percentage + of users to expose the feature to and how long the experiment will have to + run in order to gather good experimental data. To launch the experiment the + production config will be changed to enable the feature or its variants for + the appropriate percentage of users. After this point the percentages + should be left alone until the experiment is complete. At this point there are a number of things that can happen: if the experiment revealed a clear winner we may simply want to keep the code, possibly putting it @@ -394,15 +388,13 @@ learned from this one. Here’s what will happen in those cases: ### To keep the feature as a permanent part of the web site without creating a top-level feature flag - 1. Change the value of the feature config to the name of the winning - variant. + 1. Change the value of the feature config to the name of the winning variant. - 2. Delete any code that implements other variants and remove the - calls to `Feature::variant` and any related conditional logic - (e.g. switches on the variant name). + 2. Delete any code that implements other variants and remove the calls to + `Feature::variant` and any related conditional logic (e.g. switches on the + variant name). - 3. Remove the `Feature::isEnabled` checks but keep the code they - guarded. + 3. Remove the `Feature::isEnabled` checks but keep the code they guarded. 4. Remove the feature config. @@ -410,8 +402,8 @@ learned from this one. Here’s what will happen in those cases: 1. Change the value of the feature config to `['enabled' => 0]`. - 2. Delete all code guarded by `Feature::isEnabled` checks and then - remove the checks. + 2. Delete all code guarded by `Feature::isEnabled` checks and then remove the + checks. 3. Remove the feature config. diff --git a/composer.json b/composer.json index 2355173..fe59f2b 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,12 @@ "email": "iglesias.pablo10@gmail.com" } ], + "keywords": [ + "feature-flags", + "ab-testing", + "a/b", + "feature" + ], "require": { "php": ">=7.0" }, diff --git a/src/Config.php b/src/Config.php index 2e8cb14..7cfaa56 100644 --- a/src/Config.php +++ b/src/Config.php @@ -4,14 +4,8 @@ namespace PabloJoan\Feature; -use PabloJoan\Feature\Value\{ - User, - Url, - Source, - Feature, - BucketingId, - CalculateBucketingId -}; +use PabloJoan\Feature\Value\CalculateBucketingId; +use PabloJoan\Feature\Contract\{ User, Url, Source, Feature, BucketingId }; class Config { @@ -164,11 +158,17 @@ private function variantForInternal (Feature $feature) : string return $feature->internal()->variant($this->user); } + /** + * Is this user excluded from seeing this feature because of their location? + */ private function variantExcludedFrom (Feature $feature) : string { return $feature->excludeFrom()->variant($this->user); } + /** + * Is this feature within the enabled time it was configured? + */ private function variantTime (Feature $feature) : string { return $feature->time()->variant(); @@ -207,6 +207,6 @@ private function randomish (Feature $feature, BucketingId $id) : float $x = ($x << 1) + (hexdec($id[$i]) < 8 ? 0 : 1); } - return $x / 1073741824; // $x / 1 << 30 + return $x / 1073741824; // $x / ( 1 << 30 ) } } diff --git a/src/Contract/Admin.php b/src/Contract/Admin.php new file mode 100644 index 0000000..f7fb28b --- /dev/null +++ b/src/Contract/Admin.php @@ -0,0 +1,12 @@ +features->change(new Name($name), $feature); } + /* + * Adds one new feature config to the collection of features. Feature name + * must be unique. + */ + function addFeature (string $name, array $feature) + { + $this->features->add(new Name($name), $feature); + } + + /* + * Removes one existing feature from the collection. + */ + function removeFeature (string $name) + { + $this->features->remove(new Name($name)); + } + /* * Replaces the user used to calculate variants. */ diff --git a/src/Value/Admin.php b/src/Value/Admin.php index b0c0600..3697dc1 100644 --- a/src/Value/Admin.php +++ b/src/Value/Admin.php @@ -4,7 +4,9 @@ namespace PabloJoan\Feature\Value; -class Admin +use PabloJoan\Feature\Contract\{ Admin as AdminContract, User }; + +class Admin implements AdminContract { private $variant = ''; diff --git a/src/Value/Bucketing.php b/src/Value/Bucketing.php index 7620689..f509121 100644 --- a/src/Value/Bucketing.php +++ b/src/Value/Bucketing.php @@ -4,7 +4,9 @@ namespace PabloJoan\Feature\Value; -class Bucketing +use PabloJoan\Feature\Contract\Bucketing as BucketingContract; + +class Bucketing implements BucketingContract { private $by = 'random'; diff --git a/src/Value/BucketingId.php b/src/Value/BucketingId.php index f4abaa6..b2cffc1 100644 --- a/src/Value/BucketingId.php +++ b/src/Value/BucketingId.php @@ -4,7 +4,9 @@ namespace PabloJoan\Feature\Value; -class BucketingId +use PabloJoan\Feature\Contract\BucketingId as BucketingIdContract; + +class BucketingId implements BucketingIdContract { private $id = ''; diff --git a/src/Value/CalculateBucketingId.php b/src/Value/CalculateBucketingId.php index 233d5d9..432d4f3 100644 --- a/src/Value/CalculateBucketingId.php +++ b/src/Value/CalculateBucketingId.php @@ -4,6 +4,12 @@ namespace PabloJoan\Feature\Value; +use PabloJoan\Feature\Contract\{ + User, + Bucketing, + BucketingId as BucketingIdContract +}; + class CalculateBucketingId { private $user; @@ -15,7 +21,7 @@ function __construct (User $user, Bucketing $bucketing) $this->bucketing = (string) $bucketing; } - function id () : BucketingId + function id () : BucketingIdContract { if ($this->bucketing === 'user' && !$this->user->id()) { $error = 'user id must be provided if user bucketing is enabled.'; @@ -35,8 +41,12 @@ function id () : BucketingId return new BucketingId($this->user->uaid()); } - if (!$this->user->uaid()) return new BucketingId('no uaid'); + if ($this->bucketing === 'random' && !$this->user->uaid()) { + return new BucketingId('no uaid'); + } - return new BucketingId($this->user->uaid()); + if ($this->bucketing === 'random') { + return new BucketingId($this->user->uaid()); + } } } diff --git a/src/Value/Description.php b/src/Value/Description.php index 26f148b..3a41f4a 100644 --- a/src/Value/Description.php +++ b/src/Value/Description.php @@ -4,7 +4,9 @@ namespace PabloJoan\Feature\Value; -class Description +use PabloJoan\Feature\Contract\Description as DescriptionContract; + +class Description implements DescriptionContract { private $description = ''; diff --git a/src/Value/Enabled.php b/src/Value/Enabled.php index 4b99b8a..9703c92 100644 --- a/src/Value/Enabled.php +++ b/src/Value/Enabled.php @@ -4,11 +4,13 @@ namespace PabloJoan\Feature\Value; +use PabloJoan\Feature\Contract\Enabled as EnabledContract; + /** * Parse the 'enabled' property of the feature's config stanza. * Returns the upper-boundary of the variants percentage. */ -class Enabled +class Enabled implements EnabledContract { private $percentages = []; diff --git a/src/Value/ExcludeFrom.php b/src/Value/ExcludeFrom.php index bf1a6f2..c8654b8 100644 --- a/src/Value/ExcludeFrom.php +++ b/src/Value/ExcludeFrom.php @@ -4,7 +4,9 @@ namespace PabloJoan\Feature\Value; -class ExcludeFrom +use PabloJoan\Feature\Contract\{ ExcludeFrom as ExcludeFromContract, User }; + +class ExcludeFrom implements ExcludeFromContract { private $zips = []; private $regions = []; diff --git a/src/Value/Feature.php b/src/Value/Feature.php index 9f10fd2..d0c99d4 100644 --- a/src/Value/Feature.php +++ b/src/Value/Feature.php @@ -4,11 +4,27 @@ namespace PabloJoan\Feature\Value; +use PabloJoan\Feature\Contract\{ + Feature as FeatureContract, + Name as NameContract, + Enabled as EnabledContract, + Description as DescriptionContract, + Users as UsersContract, + Groups as GroupsContract, + Sources as SourcesContract, + Admin as AdminContract, + Internal as InternalContract, + PublicUrlOverride as PublicUrlOverrideContract, + ExcludeFrom as ExcludeFromContract, + Time as TimeContract, + Bucketing as BucketingContract +}; + /** * A feature that can be enabled, disabled, ramped up, and A/B tested, as well * as enabled for certain classes of users. */ -class Feature +class Feature implements FeatureContract { private $name; private $enabled; @@ -23,7 +39,7 @@ class Feature private $time; private $bucketing; - function __construct (Name $name, array $feature) + function __construct (NameContract $name, array $feature) { $enabled = $feature['enabled'] ?? 0; $description = $feature['description'] ?? ''; @@ -52,30 +68,33 @@ function __construct (Name $name, array $feature) $this->bucketing = new Bucketing($bucketing); } - function name () : Name { return $this->name; } + function name () : NameContract { return $this->name; } - function enabled () : Enabled { return $this->enabled; } + function enabled () : EnabledContract { return $this->enabled; } - function description () : Description { return $this->description; } + function description () : DescriptionContract + { + return $this->description; + } - function users () : Users { return $this->users; } + function users () : UsersContract { return $this->users; } - function groups () : Groups { return $this->groups; } + function groups () : GroupsContract { return $this->groups; } - function sources () : Sources { return $this->sources; } + function sources () : SourcesContract { return $this->sources; } - function admin () : Admin { return $this->admin; } + function admin () : AdminContract { return $this->admin; } - function internal () : Internal { return $this->internal; } + function internal () : InternalContract { return $this->internal; } - function publicUrlOverride () : PublicUrlOverride + function publicUrlOverride () : PublicUrlOverrideContract { return $this->publicUrlOverride; } - function excludeFrom () : ExcludeFrom { return $this->excludeFrom; } + function excludeFrom () : ExcludeFromContract { return $this->excludeFrom; } - function time () : Time { return $this->time; } + function time () : TimeContract { return $this->time; } - function bucketing () : Bucketing { return $this->bucketing; } + function bucketing () : BucketingContract { return $this->bucketing; } } diff --git a/src/Value/FeatureCollection.php b/src/Value/FeatureCollection.php index 8ffc156..03f508e 100644 --- a/src/Value/FeatureCollection.php +++ b/src/Value/FeatureCollection.php @@ -4,7 +4,13 @@ namespace PabloJoan\Feature\Value; -class FeatureCollection +use PabloJoan\Feature\Contract\{ + FeatureCollection as FeatureCollectionContract, + Feature as FeatureContract, + Name as NameContract +}; + +class FeatureCollection implements FeatureCollectionContract { private $features = []; @@ -15,12 +21,12 @@ function __construct (array $features) } } - function get (Name $name) : Feature + function get (NameContract $name) : FeatureContract { - return $this->features[(string) $name]; + return $this->features[(string) $name] ?? new Feature($name, []); } - function change (Name $name, array $feature) + function change (NameContract $name, array $feature) { if (!isset($this->features[(string) $name])) { throw new \Exception("feature '$name' does not exist."); @@ -28,4 +34,17 @@ function change (Name $name, array $feature) $this->features[(string) $name] = new Feature($name, $feature); } + + function add (NameContract $name, array $feature) + { + if (isset($this->features[(string) $name])) { + throw new \Exception("feature '$name' already exists."); + } + $this->features[(string) $name] = new Feature($name, $feature); + } + + function remove (NameContract $name) + { + unset($this->features[(string) $name]); + } } diff --git a/src/Value/Groups.php b/src/Value/Groups.php index dcf0086..5997942 100644 --- a/src/Value/Groups.php +++ b/src/Value/Groups.php @@ -4,11 +4,13 @@ namespace PabloJoan\Feature\Value; +use PabloJoan\Feature\Contract\{ User, Groups as GroupsContract }; + /** * Parse the value of the 'groups' properties of the feature's config stanza, * returning an array mappinng the group names to the variant they should see. */ -class Groups +class Groups implements GroupsContract { private $groups = []; diff --git a/src/Value/Internal.php b/src/Value/Internal.php index afc6ea0..2d1b163 100644 --- a/src/Value/Internal.php +++ b/src/Value/Internal.php @@ -4,7 +4,9 @@ namespace PabloJoan\Feature\Value; -class Internal +use PabloJoan\Feature\Contract\{ Internal as InternalContract, User }; + +class Internal implements InternalContract { private $variant = ''; diff --git a/src/Value/Name.php b/src/Value/Name.php index 78b8130..a2464fc 100644 --- a/src/Value/Name.php +++ b/src/Value/Name.php @@ -4,7 +4,9 @@ namespace PabloJoan\Feature\Value; -class Name +use PabloJoan\Feature\Contract\Name as NameContract; + +class Name implements NameContract { private $name = ''; diff --git a/src/Value/PublicUrlOverride.php b/src/Value/PublicUrlOverride.php index 96cbb3b..0ed2984 100644 --- a/src/Value/PublicUrlOverride.php +++ b/src/Value/PublicUrlOverride.php @@ -4,7 +4,13 @@ namespace PabloJoan\Feature\Value; -class PublicUrlOverride +use PabloJoan\Feature\Contract\{ + PublicUrlOverride as PublicUrlOverrideContract, + Name, + Url +}; + +class PublicUrlOverride implements PublicUrlOverrideContract { private $on = false; diff --git a/src/Value/Source.php b/src/Value/Source.php index 455e3ae..8ce039b 100644 --- a/src/Value/Source.php +++ b/src/Value/Source.php @@ -4,7 +4,9 @@ namespace PabloJoan\Feature\Value; -class Source +use PabloJoan\Feature\Contract\Source as SourceContract; + +class Source implements SourceContract { private $source = ''; diff --git a/src/Value/Sources.php b/src/Value/Sources.php index 5966b24..6136552 100644 --- a/src/Value/Sources.php +++ b/src/Value/Sources.php @@ -4,11 +4,13 @@ namespace PabloJoan\Feature\Value; +use PabloJoan\Feature\Contract\{ Sources as SourcesContract, Source }; + /** * Parse the value of the 'sources' properties of the feature's config stanza, * returning an array mappinng the source names to the variant they should see. */ -class Sources +class Sources implements SourcesContract { private $sources = []; diff --git a/src/Value/Time.php b/src/Value/Time.php index a83833d..4e384c4 100644 --- a/src/Value/Time.php +++ b/src/Value/Time.php @@ -4,7 +4,9 @@ namespace PabloJoan\Feature\Value; -class Time +use PabloJoan\Feature\Contract\Time as TimeContract; + +class Time implements TimeContract { private $start = 0; private $end = 0; diff --git a/src/Value/Url.php b/src/Value/Url.php index 6997299..10ad6d9 100644 --- a/src/Value/Url.php +++ b/src/Value/Url.php @@ -4,9 +4,11 @@ namespace PabloJoan\Feature\Value; -class Url +use PabloJoan\Feature\Contract\{ Url as UrlContract, Name }; + +class Url implements UrlContract { - private $features = ''; + private $features = []; function __construct (string $url) { @@ -25,16 +27,18 @@ function __construct (string $url) $query[$x[0]] = $x[1] ?? ''; } - $this->features = $query['feature'] ?? ''; + foreach (explode(',', $query['feature'] ?? '') as $feature) { + $parts = explode(':', $feature); + $this->features[$parts[0]] = $parts[1] ?? 'on'; + } } function variant (Name $name) : string { $name = (string) $name; - foreach (explode(',', $this->features) as $feature) { - $parts = explode(':', $feature); - if ($parts[0] === $name) return $parts[1] ?? 'on'; + foreach ($this->features as $feature => $variant) { + if ($feature === $name) return $variant ?? 'on'; } return ''; diff --git a/src/Value/User.php b/src/Value/User.php index 573030b..85bb791 100644 --- a/src/Value/User.php +++ b/src/Value/User.php @@ -4,7 +4,9 @@ namespace PabloJoan\Feature\Value; -class User +use PabloJoan\Feature\Contract\User as UserContract; + +class User implements UserContract { private $uaid = ''; private $id = ''; diff --git a/src/Value/Users.php b/src/Value/Users.php index 5a11f4e..9563a6d 100644 --- a/src/Value/Users.php +++ b/src/Value/Users.php @@ -4,11 +4,13 @@ namespace PabloJoan\Feature\Value; +use PabloJoan\Feature\Contract\{ Users as UsersContract, User }; + /** * Parse the value of the 'users' properties of the feature's config stanza, * returning an array mappinng the user names to the variant they should see. */ -class Users +class Users implements UsersContract { private $users = []; diff --git a/tests/CalculateBucketingIdTest.php b/tests/CalculateBucketingIdTest.php deleted file mode 100644 index 6e56f94..0000000 --- a/tests/CalculateBucketingIdTest.php +++ /dev/null @@ -1,57 +0,0 @@ -assertEquals($bucketing->id(), 'no uaid'); - - $bucketing = new CalculateBucketingId( - new User(['uaid' => 'test']), - new Bucketing('random') - ); - $this->assertEquals($bucketing->id(), 'test'); - - $bucketing = new CalculateBucketingId( - new User(['id' => 'test']), - new Bucketing('user') - ); - $this->assertEquals($bucketing->id(), 'test'); - - $bucketing = new CalculateBucketingId( - new User(['uaid' => 'test']), - new Bucketing('uaid') - ); - $this->assertEquals($bucketing->id(), 'test'); - - try { - (new CalculateBucketingId(new User([]), new Bucketing('uaid')))->id(); - } - catch (\Exception $e) - { - $this->assertEquals( - $e->getMessage(), - 'user uaid must be provided if uaid bucketing is enabled.' - ); - } - - try { - (new CalculateBucketingId(new User([]), new Bucketing('user')))->id(); - } - catch (\Exception $e) - { - $this->assertEquals( - $e->getMessage(), - 'user id must be provided if user bucketing is enabled.' - ); - } - } -} diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php index 20e3c48..1e0a316 100644 --- a/tests/ConfigTest.php +++ b/tests/ConfigTest.php @@ -5,7 +5,25 @@ namespace PabloJoan\Feature\Tests; use PabloJoan\Feature\Config; -use PabloJoan\Feature\Value\{ User, BucketingId, Url, Source, Feature, Name }; +use PabloJoan\Feature\Contract\{ + Feature, + Name, + Enabled, + Description, + Users, + Groups, + Sources, + Admin, + Internal, + PublicUrlOverride, + ExcludeFrom, + Time, + Bucketing, + BucketingId, + User, + Url, + Source +}; use PHPUnit\Framework\TestCase; class ConfigTest extends TestCase @@ -15,32 +33,142 @@ class ConfigTest extends TestCase function setUp () { - $this->config = new Config(new User([]), new Url(''), new Source('')); - $this->feature = new Feature( - new Name('test'), - [ - 'description' => 'this is the description', - 'enabled' => [ + $user = new class ([]) implements User { + function __construct (array $user) { unset($user); } + function uaid () : string { return 'randomID'; } + function id () : string { return ''; } + function country () : string { return ''; } + function zipcode () : string { return ''; } + function region () : string { return ''; } + function isAdmin () : bool { return false; } + function internalIP () : bool { return false; } + function group () : string { return ''; } + }; + $url = new class ('') implements Url { + function __construct (string $url) { unset($url); } + function variant (Name $name) : string { return ''; } + }; + $source = new class ('') implements Source { + function __construct (string $source) { unset($source); } + function variant () : string { return ''; } + }; + $name = new class ('') implements Name { + function __construct (string $name) { unset($name); } + function __toString () : string { return 'test'; } + }; + $admin = new class ('') implements Admin { + function __construct (string $variant) { unset($variant); } + function variant (User $user) : string { return 'test2'; } + }; + $bucketing = new class ('') implements Bucketing { + function __construct (string $bucketBy) { unset($bucketBy); } + function __toString () : string { return 'uaid'; } + }; + $description = new class ('') implements Description { + function __construct (string $description) { unset($description); } + function __toString () : string { return 'this is the description';} + }; + $excludeFrom = new class ([]) implements ExcludeFrom { + function __construct (array $excludeFrom) { unset($excludeFrom); } + function variant (User $user) : string { return 'on'; } + }; + $groups = new class ([]) implements Groups { + function __construct (array $stanza) { unset($stanza); } + function variant (User $user) : string { return 'test1'; } + }; + $internal = new class ('') implements Internal { + function __construct (string $variant) { unset($variant); } + function variant (User $user) : string { return ''; } + }; + $publicUrlOverride = new class (false) implements PublicUrlOverride { + function __construct (bool $on) { unset($on); } + function variant (Name $name, Url $url) : string { return 'test4'; } + }; + $sources = new class ([]) implements Sources { + function __construct (array $stanza) { unset($stanza); } + function variant (Source $source) : string { return 'test3'; } + }; + $users = new class ([]) implements Users { + function __construct (array $stanza) { unset($stanza); } + function variant (User $user) : string { return 'test4'; } + }; + $time = new class ('', '') implements Time { + function __construct (string $start, string $end) { + unset($start); + unset($end); + } + function variant () : string { return 'off'; } + }; + $enabled = new class (0) implements Enabled { + function __construct ($enabled) { unset($enabled); } + function percentages () : array { + return [ 'test1' => 20, - 'test2' => 30, - 'test3' => 15, - 'test4' => 35 - ], - 'users' => ['test1' => '2', 'test4' => '7'], - 'groups' => ['test1' => 'group1', 'test2' => 'group2'], - 'sources' => ['test3' => 'source1', 'test4' => 'source2'], - 'admin' => 'test3', - 'internal' => 'test1', - 'public_url_override' => true, - 'exclude_from' => [ - 'zips' => ['10014', '10023'], - 'countries' => ['us', 'rd'], - 'regions' => ['ny', 'nj', 'ca'] - ], - 'start' => '20170214', - 'end' => '99990530' - ] - ); + 'test2' => 50, + 'test3' => 65, + 'test4' => 100 + ]; + } + }; + + $features = [ + 'admin' => $admin, + 'bucketing' => $bucketing, + 'description' => $description, + 'excludeFrom' => $excludeFrom, + 'groups' => $groups, + 'internal' => $internal, + 'publicUrlOverride' => $publicUrlOverride, + 'sources' => $sources, + 'users' => $users, + 'time' => $time, + 'enabled' => $enabled + ]; + $feature = new class ($name, $features) implements Feature { + private $name; + private $enabled; + private $description; + private $users; + private $groups; + private $sources; + private $admin; + private $internal; + private $publicUrlOverride; + private $excludeFrom; + private $time; + private $bucketing; + function __construct (Name $name, array $feature) { + $this->name = $name; + $this->admin = $feature['admin']; + $this->bucketing = $feature['bucketing']; + $this->description = $feature['description']; + $this->excludeFrom = $feature['excludeFrom']; + $this->groups = $feature['groups']; + $this->internal = $feature['internal']; + $this->publicUrlOverride = $feature['publicUrlOverride']; + $this->sources = $feature['sources']; + $this->users = $feature['users']; + $this->time = $feature['time']; + $this->enabled = $feature['enabled']; + } + function name () : Name { return $this->name; } + function enabled () : Enabled { return $this->enabled; } + function description () : Description { return $this->description; } + function users () : Users { return $this->users; } + function groups () : Groups { return $this->groups; } + function sources () : Sources { return $this->sources; } + function admin () : Admin { return $this->admin; } + function internal () : Internal { return $this->internal; } + function publicUrlOverride () : PublicUrlOverride { + return $this->publicUrlOverride; + } + function excludeFrom () : ExcludeFrom { return $this->excludeFrom; } + function time () : Time { return $this->time; } + function bucketing () : Bucketing { return $this->bucketing; } + }; + + $this->config = new Config($user, $url, $source); + $this->feature = $feature; } function testIsEnabled () @@ -50,35 +178,30 @@ function testIsEnabled () function testVariant () { - $variant = in_array( - $this->config->variant($this->feature), - ['test1', 'test2', 'test3', 'test4'], - true - ); - $this->assertEquals($variant, true); + $this->assertEquals($this->config->variant($this->feature), 'test4'); } function testIsEnabledBucketingBy () { + $bucketingId = new class ('') implements BucketingId { + function __construct (string $id) { unset($id); } + function __toString () : string { return 'test'; } + }; $this->assertEquals( - $this->config->isEnabledBucketingBy( - $this->feature, - new BucketingId('test') - ), + $this->config->isEnabledBucketingBy($this->feature, $bucketingId), true ); } function testVariantBucketingBy () { - $variant = in_array( - $this->config->variantBucketingBy( - $this->feature, - new BucketingId('as54gerfd') - ), - ['test1', 'test2', 'test3', 'test4'], - true + $bucketingId = new class ('') implements BucketingId { + function __construct (string $id) { unset($id); } + function __toString () : string { return 'as54gerfd'; } + }; + $this->assertEquals( + $this->config->variantBucketingBy($this->feature, $bucketingId), + 'test4' ); - $this->assertEquals($variant, true); } } diff --git a/tests/ExcludeFromTest.php b/tests/ExcludeFromTest.php deleted file mode 100644 index 244dd26..0000000 --- a/tests/ExcludeFromTest.php +++ /dev/null @@ -1,40 +0,0 @@ - ['10014', '10023'], - 'countries' => ['us', 'rd'], - 'regions' => ['ny', 'nj', 'ca'] - ]); - $this->assertEquals($excludeFrom->variant(new User(['zipcode' => '10014'])), 'off'); - $this->assertEquals($excludeFrom->variant(new User(['zipcode' => '10015'])), ''); - $this->assertEquals($excludeFrom->variant(new User(['country' => 'us'])), 'off'); - $this->assertEquals($excludeFrom->variant(new User(['country' => 'ur'])), ''); - $this->assertEquals($excludeFrom->variant(new User(['region' => 'ny'])), 'off'); - $this->assertEquals($excludeFrom->variant(new User(['region' => 'nn'])), ''); - - $excludeFrom = new ExcludeFrom([]); - $this->assertEquals($excludeFrom->variant(new User([])), ''); - - try { - new ExcludeFrom(['bad array' => 'with other stuff']); - } - catch (\Exception $e) - { - $this->assertEquals( - $e->getMessage(), - 'bad exclude_from stanza {"bad array":"with other stuff"}' - ); - } - } -} diff --git a/tests/FeatureCollectionTest.php b/tests/FeatureCollectionTest.php deleted file mode 100644 index 5db9339..0000000 --- a/tests/FeatureCollectionTest.php +++ /dev/null @@ -1,37 +0,0 @@ - ['enabled' => 0]]); - $this->assertEquals( - $features->get(new Name('test')), - new Feature(new Name('test'), ['enabled' => 0]) - ); - - $features->change(new Name('test'), ['enabled' => 100]); - $this->assertEquals( - $features->get(new Name('test')), - new Feature(new Name('test'), ['enabled' => 100]) - ); - - try { - $features->change(new Name('i dont exist'), []); - } - catch (\Exception $e) - { - $this->assertEquals( - $e->getMessage(), - "feature 'i dont exist' does not exist." - ); - } - } -} diff --git a/tests/FeatureTest.php b/tests/FeatureTest.php index 6c3ed76..4f254b9 100644 --- a/tests/FeatureTest.php +++ b/tests/FeatureTest.php @@ -135,6 +135,30 @@ function testDescription () ); } + function testAddFeature () + { + $this->assertEquals($this->feature->isEnabled('newFeature'), false); + $this->assertEquals($this->feature->variant('newFeature'), ''); + + $this->feature->addFeature('newFeature', ['enabled' => 100]); + $this->assertEquals($this->feature->isEnabled('newFeature'), true); + $this->assertEquals($this->feature->variant('newFeature'), 'on'); + } + + function testRemoveFeature () + { + $this->assertEquals($this->feature->isEnabled('newFeature2'), false); + $this->assertEquals($this->feature->variant('newFeature2'), ''); + + $this->feature->addFeature('newFeature2', ['enabled' => 100]); + $this->assertEquals($this->feature->isEnabled('newFeature2'), true); + $this->assertEquals($this->feature->variant('newFeature2'), 'on'); + + $this->feature->removeFeature('newFeature2'); + $this->assertEquals($this->feature->isEnabled('newFeature2'), false); + $this->assertEquals($this->feature->variant('newFeature2'), ''); + } + function testChangeFeatures () { $this->feature->changeFeatures([ diff --git a/tests/BucketingTest.php b/tests/Value/BucketingTest.php similarity index 95% rename from tests/BucketingTest.php rename to tests/Value/BucketingTest.php index d60d4ae..db584a5 100644 --- a/tests/BucketingTest.php +++ b/tests/Value/BucketingTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PabloJoan\Feature\Tests; +namespace PabloJoan\Feature\Tests\Value; use PabloJoan\Feature\Value\Bucketing; use PHPUnit\Framework\TestCase; diff --git a/tests/Value/CalculateBucketingIdTest.php b/tests/Value/CalculateBucketingIdTest.php new file mode 100644 index 0000000..8fc68e9 --- /dev/null +++ b/tests/Value/CalculateBucketingIdTest.php @@ -0,0 +1,168 @@ +assertEquals($bucketing->id(), 'no uaid'); + + $user = new class ([]) implements User { + function __construct (array $user) { unset($user); } + function uaid () : string { return 'test'; } + function id () : string { return ''; } + function country () : string { return ''; } + function zipcode () : string { return ''; } + function region () : string { return ''; } + function isAdmin () : bool { return false; } + function internalIP () : bool { return false; } + function group () : string { return ''; } + }; + $bucketing = new class ('') implements Bucketing { + function __construct (string $bucketBy) { unset($bucketBy); } + function __toString () : string { return 'random'; } + }; + $bucketing = new CalculateBucketingId($user, $bucketing); + $this->assertEquals($bucketing->id(), 'test'); + + $user = new class ([]) implements User { + function __construct (array $user) { unset($user); } + function uaid () : string { return ''; } + function id () : string { return 'test'; } + function country () : string { return ''; } + function zipcode () : string { return ''; } + function region () : string { return ''; } + function isAdmin () : bool { return false; } + function internalIP () : bool { return false; } + function group () : string { return ''; } + }; + $bucketing = new class ('') implements Bucketing { + function __construct (string $bucketBy) { unset($bucketBy); } + function __toString () : string { return 'user'; } + }; + $bucketing = new CalculateBucketingId($user, $bucketing); + $this->assertEquals($bucketing->id(), 'test'); + + $user = new class ([]) implements User { + function __construct (array $user) { unset($user); } + function uaid () : string { return 'test'; } + function id () : string { return ''; } + function country () : string { return ''; } + function zipcode () : string { return ''; } + function region () : string { return ''; } + function isAdmin () : bool { return false; } + function internalIP () : bool { return false; } + function group () : string { return ''; } + }; + $bucketing = new class ('') implements Bucketing { + function __construct (string $bucketBy) { unset($bucketBy); } + function __toString () : string { return 'uaid'; } + }; + $bucketing = new CalculateBucketingId($user, $bucketing); + $this->assertEquals($bucketing->id(), 'test'); + + $user = new class ([]) implements User { + function __construct (array $user) { unset($user); } + function uaid () : string { return ''; } + function id () : string { return ''; } + function country () : string { return ''; } + function zipcode () : string { return ''; } + function region () : string { return ''; } + function isAdmin () : bool { return false; } + function internalIP () : bool { return false; } + function group () : string { return ''; } + }; + $bucketing = new class ('') implements Bucketing { + function __construct (string $bucketBy) { unset($bucketBy); } + function __toString () : string { return 'uaid'; } + }; + try { + (new CalculateBucketingId($user, $bucketing))->id(); + } + catch (\Exception $e) + { + $this->assertEquals( + $e->getMessage(), + 'user uaid must be provided if uaid bucketing is enabled.' + ); + } + + $user = new class ([]) implements User { + function __construct (array $user) { unset($user); } + function uaid () : string { return ''; } + function id () : string { return ''; } + function country () : string { return ''; } + function zipcode () : string { return ''; } + function region () : string { return ''; } + function isAdmin () : bool { return false; } + function internalIP () : bool { return false; } + function group () : string { return ''; } + }; + $bucketing = new class ('') implements Bucketing { + function __construct (string $bucketBy) { unset($bucketBy); } + function __toString () : string { return 'user'; } + }; + try { + (new CalculateBucketingId($user, $bucketing))->id(); + } + catch (\Exception $e) + { + $this->assertEquals( + $e->getMessage(), + 'user id must be provided if user bucketing is enabled.' + ); + } + + $user = new class ([]) implements User { + function __construct (array $user) { unset($user); } + function uaid () : string { return ''; } + function id () : string { return ''; } + function country () : string { return ''; } + function zipcode () : string { return ''; } + function region () : string { return ''; } + function isAdmin () : bool { return false; } + function internalIP () : bool { return false; } + function group () : string { return ''; } + }; + $bucketing = new class ('') implements Bucketing { + function __construct (string $bucketBy) { unset($bucketBy); } + function __toString () : string { return 'some other string'; } + }; + try { + (new CalculateBucketingId($user, $bucketing))->id(); + } + catch (\Error $e) + { + $this->assertEquals( + $e->getMessage(), + 'Return value of ' . + 'PabloJoan\Feature\Value\CalculateBucketingId::id() must ' . + 'implement interface PabloJoan\Feature\Contract\BucketingId, ' . + 'none returned' + ); + } + } +} diff --git a/tests/EnabledTest.php b/tests/Value/EnabledTest.php similarity index 97% rename from tests/EnabledTest.php rename to tests/Value/EnabledTest.php index 8232898..ca05130 100644 --- a/tests/EnabledTest.php +++ b/tests/Value/EnabledTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PabloJoan\Feature\Tests; +namespace PabloJoan\Feature\Tests\Value; use PabloJoan\Feature\Value\Enabled; use PHPUnit\Framework\TestCase; diff --git a/tests/Value/ExcludeFromTest.php b/tests/Value/ExcludeFromTest.php new file mode 100644 index 0000000..3fdea05 --- /dev/null +++ b/tests/Value/ExcludeFromTest.php @@ -0,0 +1,124 @@ + ['10014', '10023'], + 'countries' => ['us', 'rd'], + 'regions' => ['ny', 'nj', 'ca'] + ]); + + $user = new class ([]) implements User { + function __construct (array $user) { unset($user); } + function uaid () : string { return ''; } + function id () : string { return ''; } + function country () : string { return ''; } + function zipcode () : string { return '10014'; } + function region () : string { return ''; } + function isAdmin () : bool { return false; } + function internalIP () : bool { return false; } + function group () : string { return ''; } + }; + $this->assertEquals($excludeFrom->variant($user), 'off'); + + $user = new class ([]) implements User { + function __construct (array $user) { unset($user); } + function uaid () : string { return ''; } + function id () : string { return ''; } + function country () : string { return ''; } + function zipcode () : string { return '10015'; } + function region () : string { return ''; } + function isAdmin () : bool { return false; } + function internalIP () : bool { return false; } + function group () : string { return ''; } + }; + $this->assertEquals($excludeFrom->variant($user), ''); + + $user = new class ([]) implements User { + function __construct (array $user) { unset($user); } + function uaid () : string { return ''; } + function id () : string { return ''; } + function country () : string { return 'us'; } + function zipcode () : string { return ''; } + function region () : string { return ''; } + function isAdmin () : bool { return false; } + function internalIP () : bool { return false; } + function group () : string { return ''; } + }; + $this->assertEquals($excludeFrom->variant($user), 'off'); + + $user = new class ([]) implements User { + function __construct (array $user) { unset($user); } + function uaid () : string { return ''; } + function id () : string { return ''; } + function country () : string { return 'ur'; } + function zipcode () : string { return ''; } + function region () : string { return ''; } + function isAdmin () : bool { return false; } + function internalIP () : bool { return false; } + function group () : string { return ''; } + }; + $this->assertEquals($excludeFrom->variant($user), ''); + + $user = new class ([]) implements User { + function __construct (array $user) { unset($user); } + function uaid () : string { return ''; } + function id () : string { return ''; } + function country () : string { return ''; } + function zipcode () : string { return ''; } + function region () : string { return 'ny'; } + function isAdmin () : bool { return false; } + function internalIP () : bool { return false; } + function group () : string { return ''; } + }; + $this->assertEquals($excludeFrom->variant($user), 'off'); + + $user = new class ([]) implements User { + function __construct (array $user) { unset($user); } + function uaid () : string { return ''; } + function id () : string { return ''; } + function country () : string { return ''; } + function zipcode () : string { return ''; } + function region () : string { return 'nn'; } + function isAdmin () : bool { return false; } + function internalIP () : bool { return false; } + function group () : string { return ''; } + }; + $this->assertEquals($excludeFrom->variant($user), ''); + + $excludeFrom = new ExcludeFrom([]); + $user = new class ([]) implements User { + function __construct (array $user) { unset($user); } + function uaid () : string { return ''; } + function id () : string { return ''; } + function country () : string { return ''; } + function zipcode () : string { return ''; } + function region () : string { return ''; } + function isAdmin () : bool { return false; } + function internalIP () : bool { return false; } + function group () : string { return ''; } + }; + $this->assertEquals($excludeFrom->variant($user), ''); + + try { + new ExcludeFrom(['bad array' => 'with other stuff']); + } + catch (\Exception $e) + { + $this->assertEquals( + $e->getMessage(), + 'bad exclude_from stanza {"bad array":"with other stuff"}' + ); + } + } +} diff --git a/tests/Value/FeatureCollectionTest.php b/tests/Value/FeatureCollectionTest.php new file mode 100644 index 0000000..40b64ee --- /dev/null +++ b/tests/Value/FeatureCollectionTest.php @@ -0,0 +1,343 @@ + 0]; } + }; + + $features = [ + 'admin' => $admin, + 'bucketing' => $bucketing, + 'description' => $description, + 'excludeFrom' => $excludeFrom, + 'groups' => $groups, + 'internal' => $internal, + 'publicUrlOverride' => $publicUrlOverride, + 'sources' => $sources, + 'users' => $users, + 'time' => $time, + 'enabled' => $enabled + ]; + + $feature = new class ($name, $features) implements Feature { + private $name; + private $enabled; + private $description; + private $users; + private $groups; + private $sources; + private $admin; + private $internal; + private $publicUrlOverride; + private $excludeFrom; + private $time; + private $bucketing; + function __construct (Name $name, array $feature) { + $this->name = $name; + $this->admin = $feature['admin']; + $this->bucketing = $feature['bucketing']; + $this->description = $feature['description']; + $this->excludeFrom = $feature['excludeFrom']; + $this->groups = $feature['groups']; + $this->internal = $feature['internal']; + $this->publicUrlOverride = $feature['publicUrlOverride']; + $this->sources = $feature['sources']; + $this->users = $feature['users']; + $this->time = $feature['time']; + $this->enabled = $feature['enabled']; + } + function name () : Name { return $this->name; } + function enabled () : Enabled { return $this->enabled; } + function description () : Description { return $this->description; } + function users () : Users { return $this->users; } + function groups () : Groups { return $this->groups; } + function sources () : Sources { return $this->sources; } + function admin () : Admin { return $this->admin; } + function internal () : Internal { return $this->internal; } + function publicUrlOverride () : PublicUrlOverride { + return $this->publicUrlOverride; + } + function excludeFrom () : ExcludeFrom { return $this->excludeFrom; } + function time () : Time { return $this->time; } + function bucketing () : Bucketing { return $this->bucketing; } + }; + $collection = new FeatureCollection(['test' => ['enabled' => 0]]); + $this->assertEquals( + (string) $collection->get($name)->name(), + (string) $feature->name() + ); + $this->assertEquals( + $collection->get($name)->publicUrlOverride()->variant($name, $url), + $feature->publicUrlOverride()->variant($name, $url) + ); + $this->assertEquals( + $collection->get($name)->users()->variant($user), + $feature->users()->variant($user) + ); + $this->assertEquals( + $collection->get($name)->sources()->variant($source), + $feature->sources()->variant($source) + ); + $this->assertEquals( + $collection->get($name)->groups()->variant($user), + $feature->groups()->variant($user) + ); + $this->assertEquals( + $collection->get($name)->admin()->variant($user), + $feature->admin()->variant($user) + ); + $this->assertEquals( + $collection->get($name)->internal()->variant($user), + $feature->internal()->variant($user) + ); + $this->assertEquals( + $collection->get($name)->excludeFrom()->variant($user), + $feature->excludeFrom()->variant($user) + ); + $this->assertEquals( + $collection->get($name)->time()->variant(), + $feature->time()->variant() + ); + $this->assertEquals( + $collection->get($name)->enabled()->percentages(), + $feature->enabled()->percentages() + ); + $this->assertEquals( + (string) $collection->get($name)->bucketing(), + (string) $feature->bucketing() + ); + + $collection->change($name, ['enabled' => 100]); + $enabled = new class (0) implements Enabled { + function __construct ($enabled) { unset($enabled); } + function percentages () : array { return ['on' => 100]; } + }; + $features['enabled'] = $enabled; + $feature = new class ($name, $features) implements Feature { + private $name; + private $enabled; + private $description; + private $users; + private $groups; + private $sources; + private $admin; + private $internal; + private $publicUrlOverride; + private $excludeFrom; + private $time; + private $bucketing; + function __construct (Name $name, array $feature) { + $this->name = $name; + $this->admin = $feature['admin']; + $this->bucketing = $feature['bucketing']; + $this->description = $feature['description']; + $this->excludeFrom = $feature['excludeFrom']; + $this->groups = $feature['groups']; + $this->internal = $feature['internal']; + $this->publicUrlOverride = $feature['publicUrlOverride']; + $this->sources = $feature['sources']; + $this->users = $feature['users']; + $this->time = $feature['time']; + $this->enabled = $feature['enabled']; + } + function name () : Name { return $this->name; } + function enabled () : Enabled { return $this->enabled; } + function description () : Description { return $this->description; } + function users () : Users { return $this->users; } + function groups () : Groups { return $this->groups; } + function sources () : Sources { return $this->sources; } + function admin () : Admin { return $this->admin; } + function internal () : Internal { return $this->internal; } + function publicUrlOverride () : PublicUrlOverride { + return $this->publicUrlOverride; + } + function excludeFrom () : ExcludeFrom { return $this->excludeFrom; } + function time () : Time { return $this->time; } + function bucketing () : Bucketing { return $this->bucketing; } + }; + $this->assertEquals( + (string) $collection->get($name)->name(), + (string) $feature->name() + ); + $this->assertEquals( + $collection->get($name)->publicUrlOverride()->variant($name, $url), + $feature->publicUrlOverride()->variant($name, $url) + ); + $this->assertEquals( + $collection->get($name)->users()->variant($user), + $feature->users()->variant($user) + ); + $this->assertEquals( + $collection->get($name)->sources()->variant($source), + $feature->sources()->variant($source) + ); + $this->assertEquals( + $collection->get($name)->groups()->variant($user), + $feature->groups()->variant($user) + ); + $this->assertEquals( + $collection->get($name)->admin()->variant($user), + $feature->admin()->variant($user) + ); + $this->assertEquals( + $collection->get($name)->internal()->variant($user), + $feature->internal()->variant($user) + ); + $this->assertEquals( + $collection->get($name)->excludeFrom()->variant($user), + $feature->excludeFrom()->variant($user) + ); + $this->assertEquals( + $collection->get($name)->time()->variant(), + $feature->time()->variant() + ); + $this->assertEquals( + $collection->get($name)->enabled()->percentages(), + $feature->enabled()->percentages() + ); + $this->assertEquals( + (string) $collection->get($name)->bucketing(), + (string) $feature->bucketing() + ); + + $name = new class ('') implements Name { + function __construct (string $name) { unset($name); } + function __toString () : string { return 'i dont exist'; } + }; + try { + $collection->change($name, []); + } + catch (\Exception $e) + { + $this->assertEquals( + $e->getMessage(), + "feature 'i dont exist' does not exist." + ); + } + + $name = new class ('') implements Name { + function __construct (string $name) { unset($name); } + function __toString () : string { return 'test'; } + }; + try { + $collection->add($name, []); + } + catch (\Exception $e) + { + $this->assertEquals($e->getMessage(), + "feature 'test' already exists." + ); + } + + $name = new class ('') implements Name { + function __construct (string $name) { unset($name); } + function __toString () : string { return 'newFeature'; } + }; + $collection->add($name, ['enabled' => 100]); + $this->assertEquals( + $collection->get($name)->enabled()->percentages(), + ['on' => 100] + ); + + $collection->remove($name); + $this->assertEquals( + $collection->get($name)->enabled()->percentages(), + ['on' => 0] + ); + } +} diff --git a/tests/UrlTest.php b/tests/Value/UrlTest.php similarity index 54% rename from tests/UrlTest.php rename to tests/Value/UrlTest.php index e27dd3f..75dc9bd 100644 --- a/tests/UrlTest.php +++ b/tests/Value/UrlTest.php @@ -2,32 +2,38 @@ declare(strict_types=1); -namespace PabloJoan\Feature\Tests; +namespace PabloJoan\Feature\Tests\Value; -use PabloJoan\Feature\Value\{ Url, Name }; +use PabloJoan\Feature\Value\Url; +use PabloJoan\Feature\Contract\Name; use PHPUnit\Framework\TestCase; class UrlTest extends TestCase { function testVariant () { + $name = new class ('') implements Name { + function __construct (string $name) { unset($name); } + function __toString () : string { return 'test'; } + }; + $url = new Url(''); - $this->assertEquals($url->variant(new Name('test')), ''); + $this->assertEquals($url->variant($name), ''); $url = new Url('http://www.testurl.com/'); - $this->assertEquals($url->variant(new Name('test')), ''); + $this->assertEquals($url->variant($name), ''); $url = new Url('http://www.testurl.com/?f=test:on'); - $this->assertEquals($url->variant(new Name('test')), ''); + $this->assertEquals($url->variant($name), ''); $url = new Url('http://www.testurl.com/?feature=test:on'); - $this->assertEquals($url->variant(new Name('test')), 'on'); + $this->assertEquals($url->variant($name), 'on'); $url = new Url('http://www.testurl.com/?feature=test:on,test:off'); - $this->assertEquals($url->variant(new Name('test')), 'on'); + $this->assertEquals($url->variant($name), 'off'); $url = new Url('http://www.testurl.com/?q=1&feature=test:off,test:on&a=2'); - $this->assertEquals($url->variant(new Name('test')), 'off'); + $this->assertEquals($url->variant($name), 'on'); try { new Url('bad url string'); From bee1256ef127bb17a3eeb80747760ef3393bfb88 Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Sat, 30 Dec 2017 22:43:42 -0500 Subject: [PATCH 39/92] update README --- README.md | 97 ++++++------------------------------------------------- 1 file changed, 9 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index d1d7704..fc520c1 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,8 @@ the feature for specific listing owners. The `bucketingBy` methods serve a similar purpose except when there either is no relevant user or where we don't want to always put the same user in the same bucket. Thus if we wanted to enable a certain feature for 10% of all listings displayed, independent of both the -user making the request and the user who owned the listing, we could use `isEnabledBucketingBy` with the listing id as the bucketing ID. +user making the request and the user who owned the listing, we could use +`isEnabledBucketingBy` with the listing id as the bucketing ID. In general it is much more likely you want to use the plain old `isEnabled` and `variant` methods. @@ -316,12 +317,12 @@ The precedence of the various mechanisms for enabling a feature are as follows. the user is a member of multiple groups that have been assigned different variants is undefined. Beware nasal demons.) - - Otherwise, if the request is from an admin, the `'admin'` variant is - enabled. - - Otherwise, if the request is an internal request, the `'internal'` variant is enabled. + - Otherwise, if the request is from an admin, the `'admin'` variant is + enabled. + - Otherwise, the request is bucketed and a variant is chosen so that the correct percentage of bucketed requests will see each variant. @@ -341,88 +342,8 @@ future.) 4. Setting `'enabled'` to a non-numeric, non-array value. -## The life cycle of a feature - -The Feature API was designed with a eye toward making it a bit easier for us to -push features through a predictable life cycle wherein a feature can be created -easily, ramped up, A/B tested, and then cleaned up, either by being promoted to -a full-fledged feature flag, by removing the configuration and associated -feature checks but keeping the code, or deleting the code altogether. - -The basic life cycle of a feature might look like this: - - 1. Developer writes some code guarded by `$feature->isEnabled` checks. In - order to test the feature in development they will add configuration for - the feature to `development.php` that turns it on for specific users or - admin or sets `'enabled'` to 0 so they can test it with a URL query param. - - 2. At some point the developer will add a config stanza to `production.php`. - Initially this may just be a place holder that leaves the feature entirely - disabled or it may turn it on for admin, etc. - - 3. Once the feature is done, the `production.php` config will be changed to - enable the feature for a small percentage of users for an operational smoke - test. For a single-variant feature this means setting `'enabled'` to a - small numeric value; for a multi-variant feature it means setting - `'enabled'` to an array that specifies a small percentage for each variant. - - 4. During the rampup period the percentage of users exposed to the feature may - be moved up and down until the developers and ops folks are convinced the - code is fully baked. If serious problems arise at any point, the new code - can be completely disabled. - - 5. If the feature is going to be part of an A/B experiment, then the - developers will (working with the data team) figure out the best percentage - of users to expose the feature to and how long the experiment will have to - run in order to gather good experimental data. To launch the experiment the - production config will be changed to enable the feature or its variants for - the appropriate percentage of users. After this point the percentages - should be left alone until the experiment is complete. - -At this point there are a number of things that can happen: if the experiment -revealed a clear winner we may simply want to keep the code, possibly putting it -under control of a top-level feature flag that ops can use to disable the -feature for operational reasons. Or we may want to discard all the code related -to the feature. Or we may want to run another experiment based on what we -learned from this one. Here’s what will happen in those cases: - -### To keep the feature as a permanent part of the web site without creating a top-level feature flag - - 1. Change the value of the feature config to the name of the winning variant. - - 2. Delete any code that implements other variants and remove the calls to - `Feature::variant` and any related conditional logic (e.g. switches on the - variant name). - - 3. Remove the `Feature::isEnabled` checks but keep the code they guarded. - - 4. Remove the feature config. - -### To remove a feature all together - - 1. Change the value of the feature config to `['enabled' => 0]`. - - 2. Delete all code guarded by `Feature::isEnabled` checks and then remove the - checks. - - 3. Remove the feature config. - -## A few style guidelines - -To make it easier to push features through this life cycle there are a few -coding guidelines to observe. - -First, the feature name argument to the Feature methods (`isEnabled`, `variant`, -`isEnabledFor`, and `variantFor`) should always be a string literal. This will -make it easier to find all the places that a particular feature is checked. If -you find yourself creating feature names at run time and then checking them, -you’re probably abusing the Feature system. Chances are in such a case you don’t -really want to be using the Feature API but rather simply driving your code with -some plain old config data. + 5. Setting `'bucketing'` to `'user'` and not providing an id string to the + user array. -Second, as a check that you’re using the Feature API properly, whenever you have -an if block whose test is a call to `Feature::isEnabled`, make sure that it -would make sense to either remove the check and keep the code or to delete the -check and the code together. There shouldn’t be bits of code within a block -guarded by an isEnabled check that needs to be salvaged if the feature is -removed. + 6. Setting `'bucketing'` to `'uaid'` and not providing a uaid string to the + user array. From f55437e8a9a57e4a4fbc5a668e9cb9036e5834d6 Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Sun, 31 Dec 2017 18:24:58 -0500 Subject: [PATCH 40/92] fix potential bug in bucketing by functions. add some TXT documentation --- README.md | 7 +- docs/Feature_Class_API.txt | 399 +++++++++++++++++++++++++++++++++++++ src/Config.php | 3 +- src/Feature.php | 4 +- tests/ApiTest.php | 16 +- 5 files changed, 415 insertions(+), 14 deletions(-) create mode 100644 docs/Feature_Class_API.txt diff --git a/README.md b/README.md index fc520c1..b3df26c 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,9 @@ composer require pablojoan/feature # Basic Usage ```php + +use PabloJoan\Feature\Feature; // Import the namespace. + $config = [ 'features' => [ 'foo' => [ @@ -43,8 +46,8 @@ $config = [ $feature = new Feature($config); -$feature->isEnabled('foo'); // true -$feature->variant('foo'); // 'variant1' +$feature->isEnabled('foo'); // true +$feature->variant('foo'); // 'variant1' $feature->description('foo'); // 'this is the description of the "foo" feature' ``` diff --git a/docs/Feature_Class_API.txt b/docs/Feature_Class_API.txt new file mode 100644 index 0000000..fd96899 --- /dev/null +++ b/docs/Feature_Class_API.txt @@ -0,0 +1,399 @@ +The Feature Class + +This is the API entry point to the application. Here we will document all of the +public functions and state their purpose. + + + +How to create a Feature Class Instance? +Feature::__construct ( array ) : void + +use PabloJoan\Feature\Feature; // Import the namespace. + +$config_array = [ + 'features' => [ /* the $config_array['features'] array. */ ], + // See the $config_array['features'] section for documentation. + + 'user' => [ /* the $config_array['user'] array. */ ], + // See the $config_array['user'] section for documentation. + + 'url' => 'a valid URL string according to http://www.faqs.org/rfcs/rfc2396', + // See the $config_array['url'] section for documentation. + + 'source' => 'any string that categorizes the source of origin or entry.' + // See the $config_array['source'] section for documentation. +]; + +$feature = new Feature( $config_array ); + +The Feature Class requires the $config_array to initiate. Please refer to the +config document to learn more about the $config_array and how to construct it. + + + +Feature::changeFeatures ( array ) : void + +$feature = new Feature( $config_array ); +$feature->changeFeatures( $config_array['features'] ); + +The Feature::changeFeatures function allows you to replace the entire +$config_array['features'] at runtime without having to reconstruct the Feature +Class. + + + +Feature::changeFeature ( string , array ) : void + +$feature_array = [ + 'description' => 'string describing the feature', + // See the $feature_array['description'] section for documentation. + + 'enabled' => 0, + // the $feature_array['enabled'] array or an integer between 0 and 100. + // See the $feature_array['enabled'] section for documentation. + + 'users' => [ /* the $feature_array['users'] array */ ], + // See the $feature_array['users'] section for documentation. + + 'groups' => [ /* the $feature_array['groups'] array */ ], + // See the $feature_array['groups'] section for documentation. + + 'sources' => [ /* the $feature_array['sources'] array */ ], + // See the $feature_array['sources'] section for documentation. + + 'admin' => 'the variant for an admin user', + // See the $feature_array['admin'] section for documentation. + + 'internal' => 'the variant for an internal request', + // See the $feature_array['internal'] section for documentation. + + 'public_url_override' => false, // boolean value enabling overide from URL. + // See the $feature_array['public_url_override'] section for documentation. + + 'exclude_from' => [ /* the $feature_array['exclude_from'] array */ ], + // See the $feature_array['exclude_from'] section for documentation. + + 'start' => 'a supported date or time formatted string according to php.net', + // See the $feature_array['start'] section for documentation. + + 'end' => 'a supported date or time formatted string according to php.net', + // See the $feature_array['end'] section for documentation. + + 'bucketing' => 'either "random" (default), "uaid", or "user" is supported.' + // See the $feature_array['bucketing'] section for documentation. +]; + +$feature = new Feature( $config_array ); +$feature->changeFeature( 'feature_name', $feature_array ); + +The Feature::changeFeature function allows you to replace one existing feature +configuration from your $config_array['features'] array, using the +$feature_array, at runtime without having to reconstruct the Feature Class. If +the 'feature_name' given is a feature name that is not registered, an exception +will be thrown. Please refer to the feature document to learn more about the +$feature_array and how to construct it. + + + +Feature::addFeature ( string , array ) : void + +$feature_array = [ + 'description' => 'string describing the feature', + // See the $feature_array['description'] section for documentation. + + 'enabled' => 0, + // the $feature_array['enabled'] array or an integer between 0 and 100. + // See the $feature_array['enabled'] section for documentation. + + 'users' => [ /* the $feature_array['users'] array */ ], + // See the $feature_array['users'] section for documentation. + + 'groups' => [ /* the $feature_array['groups'] array */ ], + // See the $feature_array['groups'] section for documentation. + + 'sources' => [ /* the $feature_array['sources'] array */ ], + // See the $feature_array['sources'] section for documentation. + + 'admin' => 'the variant for an admin user', + // See the $feature_array['admin'] section for documentation. + + 'internal' => 'the variant for an internal request', + // See the $feature_array['internal'] section for documentation. + + 'public_url_override' => false, // boolean value enabling overide from URL. + // See the $feature_array['public_url_override'] section for documentation. + + 'exclude_from' => [ /* the $feature_array['exclude_from'] array */ ], + // See the $feature_array['exclude_from'] section for documentation. + + 'start' => 'a supported date or time formatted string according to php.net', + // See the $feature_array['start'] section for documentation. + + 'end' => 'a supported date or time formatted string according to php.net', + // See the $feature_array['end'] section for documentation. + + 'bucketing' => 'either "random" (default), "uaid", or "user" is supported.' + // See the $feature_array['bucketing'] section for documentation. +]; + +$feature = new Feature( $config_array ); +$feature->addFeature( 'feature_name', $feature_array ); + +The Feature::addFeature function allows you to add one new feature configuration +from that did not exist in your $config_array['features'] array, using the +$feature_array, at runtime without having to reconstruct the Feature Class. If +the 'feature_name' given is a feature name that already exists as a registered +feature, an exception will be thrown. Please refer to the feature document to +learn more about the $feature_array and how to construct it. + + + +Feature::removeFeature ( string ) : void + +$feature = new Feature( $config_array ); +$feature->removeFeature( 'feature_name' ); + +The Feature::removeFeature function allows you to remove one existing feature +configuration from your $config_array['features'] array at runtime without +having to reconstruct the Feature Class. + + + +Feature::changeUser ( array ) : void + +$user_array = [ + 'uaid' => 'the UAID of the user. Could be a cookie, session_id, etc.', + // See the $user_array['uaid'] section for documentation. + + 'id' => 'the ID of a user registered in a database. Could be user_name.', + // See the $user_array['id'] section for documentation. + + 'group' => 'a group the user is a part of.', + // See the $user_array['group'] section for documentation. + + 'zipcode' => 'the zipcode the user is from, or the zipcode of the request.', + // See the $user_array['zipcode'] section for documentation. + + 'region' => 'the region the user is from, or the region of the request.', + // See the $user_array['region'] section for documentation. + + 'country' => 'the country the user is from, or the country of the request.' + // See the $user_array['country'] section for documentation. + + 'is-admin' => false, // boolean value answering "is this an admin user?" + // See the $user_array['is-admin'] section for documentation. + + 'internal-ip' => false // boolean value answering "is the request internal?" + // See the $user_array['internal-ip'] section for documentation. +]; + +$feature = new Feature( $config_array ); +$feature->changeUser( $user_array ); + +The Feature::changeUser function allows you to replace the $config_array['user'] +array, used to calculate variants, at runtime without having to reconstruct the +Feature Class. Please refer to the user document to learn more about the +$user_array and how to construct it. + + + +Feature::changeUrl ( string ) : void + +$url_string = 'http://www.example.com/test/?feature=feature_name:variant'; +// 'a valid URL string according to http://www.faqs.org/rfcs/rfc2396'; + +$feature = new Feature( $config_array ); +$feature->changeUrl( $url_string ); + +The Feature::changeUrl function allows you to replace the $config_array['url'] +string, used to calculate variants, at runtime without having to reconstruct the +Feature Class. Please refer to the Url document to learn more about the +$url_string and how to construct it. + + + +Feature::changeSource ( string ) : void + +$source_string = 'twitter'; +// A string labling where is the source from the request from. Like utm_source. + +$feature = new Feature( $config_array ); +$feature->changeSource( $source_string ); + +The Feature::changeSource function allows you to replace the +$config_array['source'] string, used to calculate variants, at runtime without +having to reconstruct the Feature Class. Please refer to the Url document to +learn more about the $url_string and how to construct it. + + + +Feature::isEnabled ( string ) : boolean + +$config_array = [ + 'features' => [ + 'foo' => [ 'enabled' => 100 ], + 'bar' => [ 'enabled' => 0 ] + ] +]; + +$feature = new Feature( $config_array ); +echo $feature->isEnabled( 'foo' ); // true +echo $feature->isEnabled( 'bar' ); // false + +The Feature::isEnabled function checks weather a feature_name is enabled after +percentage calculation or by its $feature_array configuration. Please refer to +the feature document to learn more about the $feature_array and how to construct +it. + + + +Feature::isEnabledFor ( string, array ) : boolean + +$config_array = [ + 'features' => [ + 'foo' => [ + 'enabled' => 0, + 'users' => [ 'on' => [ 'user_1', 'user_2' ] ] + ], + 'bar' => [ + 'enabled' => 100, + 'users' => [ 'off' => [ 'user_3' ] ] + ], + ] +]; + +$feature = new Feature( $config_array ); + +echo $feature->isEnabled( 'foo' ); // false +echo $feature->isEnabled( 'bar' ); // true + +$user_array = [ 'id' => 'user_1' ]; +echo $feature->isEnabledFor( 'foo', $user_array ); // true +echo $feature->isEnabledFor( 'bar', $user_array ); // true + +$user_array = [ 'id' => 'user_2' ]; +echo $feature->isEnabledFor( 'foo', $user_array ); // true +echo $feature->isEnabledFor( 'bar', $user_array ); // true + +$user_array = [ 'id' => 'user_3' ]; +echo $feature->isEnabledFor( 'foo', $user_array ); // false +echo $feature->isEnabledFor( 'bar', $user_array ); // false + +The Feature::isEnabledFor function checks weather a feature_name is enabled to +a different $user_array that is not the one registered with +$config_array['user']. Please refer to the user document to learn more about the +$user_array and how to construct it. + + + +Feature::isEnabledBucketingBy ( string, string ) : boolean + +$bucketing_id = 'custom bucketing string used to calculate variants'; + +$feature = new Feature( $config_array ); +echo $feature->isEnabledBucketingBy( 'feature_name', $bucketing_id ); // boolean + +The Feature::isEnabledBucketingBy function checks weather a feature_name is +enabled using a custom $bucketing_id string instead of the bucketing id the +library uses for you. Please refer to the bucketing document to learn more about +the $bucketing_id string. + + + +Feature::variant ( string ) : string + +$config_array = [ + 'features' => [ + 'foo' => [ 'variant1' => 0, 'variant2' => 100 ], + 'bar' => [ 'enabled' => 100 ], + 'test' => [ 'enabled' => 0 ] + ] +]; + +$feature = new Feature( $config_array ); +echo $feature->variant( 'foo' ); // 'variant2' +echo $feature->variant( 'bar' ); // 'on' +echo $feature->variant( 'test' ); // '' + +The Feature::variant function returns the enabled variant string of a given +feature_name after percentage calculation or by its $feature_array +configuration. If none are enabled, an empty string is returned. Please refer to +the feature document to learn more about the $feature_array and how to construct +it. + + + +Feature::variantFor ( string, array ) : string + +$config_array = [ + 'features' => [ + 'foo' => [ + 'enabled' => [ 'variant1' => 0, 'variant2' => 100 ], + 'users' => [ 'variant1' => [ 'user_1', 'user_2' ] ] + ], + 'bar' => [ + 'enabled' => 0, + 'users' => [ 'on' => [ 'user_3' ] ] + ], + ] +]; + +$feature = new Feature( $config_array ); + +echo $feature->variant( 'foo' ); // 'variant2' +echo $feature->variant( 'bar' ); // '' + +$user_array = [ 'id' => 'user_1' ]; +echo $feature->variantFor( 'foo', $user_array ); // 'variant1' +echo $feature->variantFor( 'bar', $user_array ); // '' + +$user_array = [ 'id' => 'user_2' ]; +echo $feature->variantFor( 'foo', $user_array ); // 'variant1' +echo $feature->variantFor( 'bar', $user_array ); // '' + +$user_array = [ 'id' => 'user_3' ]; +echo $feature->variantFor( 'foo', $user_array ); // 'variant2' +echo $feature->variantFor( 'bar', $user_array ); // 'on' + +The Feature::variantFor function returns the enabled variant string of a given +feature_name to a different $user_array that is not the one registered with +$config_array['user']. If none are enabled, an empty string is returned. Please +refer to the user document to learn more about the $user_array and how to +construct it. + + + +Feature::variantBucketingBy ( string, string ) : string + +$bucketing_id = 'custom bucketing string used to calculate variants'; + +$feature = new Feature( $config_array ); +echo $feature->variantBucketingBy( 'feature_name', $bucketing_id ); // 'variant' + +The Feature::variantBucketingBy function returns the enabled variant string of a +given feature_name using a custom $bucketing_id string instead of the bucketing +id the library uses for you. If none are enabled, an empty string is returned. +Please refer to the bucketing document to learn more about the $bucketing_id +string. + + + +Feature::description ( string ) : string + +$config_array = [ + 'features' => [ + 'foo' => [ 'description' => 'foo description' ], + 'bar' => [ 'enabled' => 100 ], + 'test' => [ 'description' => 'test deacription' ] + ] +]; + +$feature = new Feature( $config_array ); +echo $feature->description( 'foo' ); // 'foo description' +echo $feature->description( 'bar' ); // '' +echo $feature->description( 'test' ); // 'test deacription' + +The Feature::description function returns the description string of a +feature_name. If none is provided, an empty string is returned. Please refer to +the feature document to learn more about the $feature_array and how to construct +it. diff --git a/src/Config.php b/src/Config.php index 7cfaa56..b00db55 100644 --- a/src/Config.php +++ b/src/Config.php @@ -194,8 +194,7 @@ private function variantByPercentage (Feature $feature, BucketingId $id) : strin private function randomish (Feature $feature, BucketingId $id) : float { if ((string) $feature->bucketing() === 'random') { - $max = mt_getrandmax(); - return mt_rand(0, $max - 1) / $max; + return random_int(0, PHP_INT_MAX - 1) / PHP_INT_MAX; } /** * Map a hex value to the half-open interval bewtween 0 and 1 while diff --git a/src/Feature.php b/src/Feature.php index 66a22bb..382f03f 100644 --- a/src/Feature.php +++ b/src/Feature.php @@ -129,7 +129,7 @@ function isEnabledFor (string $name, array $user) : bool */ function isEnabledBucketingBy (string $name, string $id) : bool { - $config = new Config($this->user, $this->url, $this->source); + $config = new Config(new User([]), $this->url, $this->source); $feature = $this->features->get(new Name($name)); return $config->isEnabledBucketingBy($feature, new BucketingId($id)); } @@ -164,7 +164,7 @@ function variantFor (string $name, array $user) : string */ function variantBucketingBy (string $name, string $id) : string { - $config = new Config($this->user, $this->url, $this->source); + $config = new Config(new User([]), $this->url, $this->source); $feature = $this->features->get(new Name($name)); return $config->variantBucketingBy($feature, new BucketingId($id)); } diff --git a/tests/ApiTest.php b/tests/ApiTest.php index ed58836..3c1755e 100644 --- a/tests/ApiTest.php +++ b/tests/ApiTest.php @@ -601,15 +601,15 @@ function testExcludeFrom () $this->assertEquals( $feature->isEnabledBucketingBy('testFeature', 'testid1'), - false + true ); $this->assertEquals( $feature->isEnabledBucketingBy('testFeature2', 'testid2'), - false + true ); $this->assertEquals( - $feature->isEnabledBucketingBy('testFeature2', 'testid3'), - false + $feature->isEnabledBucketingBy('testFeature3', 'testid3'), + true ); $this->assertEquals($feature->variant('testFeature'), ''); @@ -622,15 +622,15 @@ function testExcludeFrom () $this->assertEquals( $feature->variantBucketingBy('testFeature', 'testid1'), - '' + 'on' ); $this->assertEquals( $feature->variantBucketingBy('testFeature2', 'testid2'), - '' + 'on' ); $this->assertEquals( - $feature->variantBucketingBy('testFeature2', 'testid3'), - '' + $feature->variantBucketingBy('testFeature3', 'testid3'), + 'on' ); } From 048948a2ea6db8b47454537cbb75d91c3132dd29 Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Sun, 31 Dec 2017 18:26:26 -0500 Subject: [PATCH 41/92] adjust README after merging --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index a0de7f4..7618a8b 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,6 @@ Replace magic strings with constants. Write more usefull error messages. Add more bucketing schemes. -Feature is no longer actively maintained and is no longer in sync with the version used internally at Etsy. - - # Feature API Feature flagging API used for operational rampups and A/B testing. From 02ec1ce86e4098dea450c38e3e00f91e024264fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Sun, 31 Dec 2017 18:36:33 -0500 Subject: [PATCH 42/92] Update composer.json --- composer.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index fe59f2b..5e516db 100644 --- a/composer.json +++ b/composer.json @@ -10,8 +10,9 @@ "keywords": [ "feature-flags", "ab-testing", - "a/b", - "feature" + "ab-test", + "feature", + "feature-flagging" ], "require": { "php": ">=7.0" From 677e67902f442b9b2348dd9c8e51af135f9b2b60 Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Tue, 2 Jan 2018 19:11:11 -0500 Subject: [PATCH 43/92] move documentation to github wiki --- docs/Feature_Class_API.txt | 399 ------------------------------------- src/Feature.php | 2 +- tests/FeatureTest.php | 8 + 3 files changed, 9 insertions(+), 400 deletions(-) delete mode 100644 docs/Feature_Class_API.txt diff --git a/docs/Feature_Class_API.txt b/docs/Feature_Class_API.txt deleted file mode 100644 index fd96899..0000000 --- a/docs/Feature_Class_API.txt +++ /dev/null @@ -1,399 +0,0 @@ -The Feature Class - -This is the API entry point to the application. Here we will document all of the -public functions and state their purpose. - - - -How to create a Feature Class Instance? -Feature::__construct ( array ) : void - -use PabloJoan\Feature\Feature; // Import the namespace. - -$config_array = [ - 'features' => [ /* the $config_array['features'] array. */ ], - // See the $config_array['features'] section for documentation. - - 'user' => [ /* the $config_array['user'] array. */ ], - // See the $config_array['user'] section for documentation. - - 'url' => 'a valid URL string according to http://www.faqs.org/rfcs/rfc2396', - // See the $config_array['url'] section for documentation. - - 'source' => 'any string that categorizes the source of origin or entry.' - // See the $config_array['source'] section for documentation. -]; - -$feature = new Feature( $config_array ); - -The Feature Class requires the $config_array to initiate. Please refer to the -config document to learn more about the $config_array and how to construct it. - - - -Feature::changeFeatures ( array ) : void - -$feature = new Feature( $config_array ); -$feature->changeFeatures( $config_array['features'] ); - -The Feature::changeFeatures function allows you to replace the entire -$config_array['features'] at runtime without having to reconstruct the Feature -Class. - - - -Feature::changeFeature ( string , array ) : void - -$feature_array = [ - 'description' => 'string describing the feature', - // See the $feature_array['description'] section for documentation. - - 'enabled' => 0, - // the $feature_array['enabled'] array or an integer between 0 and 100. - // See the $feature_array['enabled'] section for documentation. - - 'users' => [ /* the $feature_array['users'] array */ ], - // See the $feature_array['users'] section for documentation. - - 'groups' => [ /* the $feature_array['groups'] array */ ], - // See the $feature_array['groups'] section for documentation. - - 'sources' => [ /* the $feature_array['sources'] array */ ], - // See the $feature_array['sources'] section for documentation. - - 'admin' => 'the variant for an admin user', - // See the $feature_array['admin'] section for documentation. - - 'internal' => 'the variant for an internal request', - // See the $feature_array['internal'] section for documentation. - - 'public_url_override' => false, // boolean value enabling overide from URL. - // See the $feature_array['public_url_override'] section for documentation. - - 'exclude_from' => [ /* the $feature_array['exclude_from'] array */ ], - // See the $feature_array['exclude_from'] section for documentation. - - 'start' => 'a supported date or time formatted string according to php.net', - // See the $feature_array['start'] section for documentation. - - 'end' => 'a supported date or time formatted string according to php.net', - // See the $feature_array['end'] section for documentation. - - 'bucketing' => 'either "random" (default), "uaid", or "user" is supported.' - // See the $feature_array['bucketing'] section for documentation. -]; - -$feature = new Feature( $config_array ); -$feature->changeFeature( 'feature_name', $feature_array ); - -The Feature::changeFeature function allows you to replace one existing feature -configuration from your $config_array['features'] array, using the -$feature_array, at runtime without having to reconstruct the Feature Class. If -the 'feature_name' given is a feature name that is not registered, an exception -will be thrown. Please refer to the feature document to learn more about the -$feature_array and how to construct it. - - - -Feature::addFeature ( string , array ) : void - -$feature_array = [ - 'description' => 'string describing the feature', - // See the $feature_array['description'] section for documentation. - - 'enabled' => 0, - // the $feature_array['enabled'] array or an integer between 0 and 100. - // See the $feature_array['enabled'] section for documentation. - - 'users' => [ /* the $feature_array['users'] array */ ], - // See the $feature_array['users'] section for documentation. - - 'groups' => [ /* the $feature_array['groups'] array */ ], - // See the $feature_array['groups'] section for documentation. - - 'sources' => [ /* the $feature_array['sources'] array */ ], - // See the $feature_array['sources'] section for documentation. - - 'admin' => 'the variant for an admin user', - // See the $feature_array['admin'] section for documentation. - - 'internal' => 'the variant for an internal request', - // See the $feature_array['internal'] section for documentation. - - 'public_url_override' => false, // boolean value enabling overide from URL. - // See the $feature_array['public_url_override'] section for documentation. - - 'exclude_from' => [ /* the $feature_array['exclude_from'] array */ ], - // See the $feature_array['exclude_from'] section for documentation. - - 'start' => 'a supported date or time formatted string according to php.net', - // See the $feature_array['start'] section for documentation. - - 'end' => 'a supported date or time formatted string according to php.net', - // See the $feature_array['end'] section for documentation. - - 'bucketing' => 'either "random" (default), "uaid", or "user" is supported.' - // See the $feature_array['bucketing'] section for documentation. -]; - -$feature = new Feature( $config_array ); -$feature->addFeature( 'feature_name', $feature_array ); - -The Feature::addFeature function allows you to add one new feature configuration -from that did not exist in your $config_array['features'] array, using the -$feature_array, at runtime without having to reconstruct the Feature Class. If -the 'feature_name' given is a feature name that already exists as a registered -feature, an exception will be thrown. Please refer to the feature document to -learn more about the $feature_array and how to construct it. - - - -Feature::removeFeature ( string ) : void - -$feature = new Feature( $config_array ); -$feature->removeFeature( 'feature_name' ); - -The Feature::removeFeature function allows you to remove one existing feature -configuration from your $config_array['features'] array at runtime without -having to reconstruct the Feature Class. - - - -Feature::changeUser ( array ) : void - -$user_array = [ - 'uaid' => 'the UAID of the user. Could be a cookie, session_id, etc.', - // See the $user_array['uaid'] section for documentation. - - 'id' => 'the ID of a user registered in a database. Could be user_name.', - // See the $user_array['id'] section for documentation. - - 'group' => 'a group the user is a part of.', - // See the $user_array['group'] section for documentation. - - 'zipcode' => 'the zipcode the user is from, or the zipcode of the request.', - // See the $user_array['zipcode'] section for documentation. - - 'region' => 'the region the user is from, or the region of the request.', - // See the $user_array['region'] section for documentation. - - 'country' => 'the country the user is from, or the country of the request.' - // See the $user_array['country'] section for documentation. - - 'is-admin' => false, // boolean value answering "is this an admin user?" - // See the $user_array['is-admin'] section for documentation. - - 'internal-ip' => false // boolean value answering "is the request internal?" - // See the $user_array['internal-ip'] section for documentation. -]; - -$feature = new Feature( $config_array ); -$feature->changeUser( $user_array ); - -The Feature::changeUser function allows you to replace the $config_array['user'] -array, used to calculate variants, at runtime without having to reconstruct the -Feature Class. Please refer to the user document to learn more about the -$user_array and how to construct it. - - - -Feature::changeUrl ( string ) : void - -$url_string = 'http://www.example.com/test/?feature=feature_name:variant'; -// 'a valid URL string according to http://www.faqs.org/rfcs/rfc2396'; - -$feature = new Feature( $config_array ); -$feature->changeUrl( $url_string ); - -The Feature::changeUrl function allows you to replace the $config_array['url'] -string, used to calculate variants, at runtime without having to reconstruct the -Feature Class. Please refer to the Url document to learn more about the -$url_string and how to construct it. - - - -Feature::changeSource ( string ) : void - -$source_string = 'twitter'; -// A string labling where is the source from the request from. Like utm_source. - -$feature = new Feature( $config_array ); -$feature->changeSource( $source_string ); - -The Feature::changeSource function allows you to replace the -$config_array['source'] string, used to calculate variants, at runtime without -having to reconstruct the Feature Class. Please refer to the Url document to -learn more about the $url_string and how to construct it. - - - -Feature::isEnabled ( string ) : boolean - -$config_array = [ - 'features' => [ - 'foo' => [ 'enabled' => 100 ], - 'bar' => [ 'enabled' => 0 ] - ] -]; - -$feature = new Feature( $config_array ); -echo $feature->isEnabled( 'foo' ); // true -echo $feature->isEnabled( 'bar' ); // false - -The Feature::isEnabled function checks weather a feature_name is enabled after -percentage calculation or by its $feature_array configuration. Please refer to -the feature document to learn more about the $feature_array and how to construct -it. - - - -Feature::isEnabledFor ( string, array ) : boolean - -$config_array = [ - 'features' => [ - 'foo' => [ - 'enabled' => 0, - 'users' => [ 'on' => [ 'user_1', 'user_2' ] ] - ], - 'bar' => [ - 'enabled' => 100, - 'users' => [ 'off' => [ 'user_3' ] ] - ], - ] -]; - -$feature = new Feature( $config_array ); - -echo $feature->isEnabled( 'foo' ); // false -echo $feature->isEnabled( 'bar' ); // true - -$user_array = [ 'id' => 'user_1' ]; -echo $feature->isEnabledFor( 'foo', $user_array ); // true -echo $feature->isEnabledFor( 'bar', $user_array ); // true - -$user_array = [ 'id' => 'user_2' ]; -echo $feature->isEnabledFor( 'foo', $user_array ); // true -echo $feature->isEnabledFor( 'bar', $user_array ); // true - -$user_array = [ 'id' => 'user_3' ]; -echo $feature->isEnabledFor( 'foo', $user_array ); // false -echo $feature->isEnabledFor( 'bar', $user_array ); // false - -The Feature::isEnabledFor function checks weather a feature_name is enabled to -a different $user_array that is not the one registered with -$config_array['user']. Please refer to the user document to learn more about the -$user_array and how to construct it. - - - -Feature::isEnabledBucketingBy ( string, string ) : boolean - -$bucketing_id = 'custom bucketing string used to calculate variants'; - -$feature = new Feature( $config_array ); -echo $feature->isEnabledBucketingBy( 'feature_name', $bucketing_id ); // boolean - -The Feature::isEnabledBucketingBy function checks weather a feature_name is -enabled using a custom $bucketing_id string instead of the bucketing id the -library uses for you. Please refer to the bucketing document to learn more about -the $bucketing_id string. - - - -Feature::variant ( string ) : string - -$config_array = [ - 'features' => [ - 'foo' => [ 'variant1' => 0, 'variant2' => 100 ], - 'bar' => [ 'enabled' => 100 ], - 'test' => [ 'enabled' => 0 ] - ] -]; - -$feature = new Feature( $config_array ); -echo $feature->variant( 'foo' ); // 'variant2' -echo $feature->variant( 'bar' ); // 'on' -echo $feature->variant( 'test' ); // '' - -The Feature::variant function returns the enabled variant string of a given -feature_name after percentage calculation or by its $feature_array -configuration. If none are enabled, an empty string is returned. Please refer to -the feature document to learn more about the $feature_array and how to construct -it. - - - -Feature::variantFor ( string, array ) : string - -$config_array = [ - 'features' => [ - 'foo' => [ - 'enabled' => [ 'variant1' => 0, 'variant2' => 100 ], - 'users' => [ 'variant1' => [ 'user_1', 'user_2' ] ] - ], - 'bar' => [ - 'enabled' => 0, - 'users' => [ 'on' => [ 'user_3' ] ] - ], - ] -]; - -$feature = new Feature( $config_array ); - -echo $feature->variant( 'foo' ); // 'variant2' -echo $feature->variant( 'bar' ); // '' - -$user_array = [ 'id' => 'user_1' ]; -echo $feature->variantFor( 'foo', $user_array ); // 'variant1' -echo $feature->variantFor( 'bar', $user_array ); // '' - -$user_array = [ 'id' => 'user_2' ]; -echo $feature->variantFor( 'foo', $user_array ); // 'variant1' -echo $feature->variantFor( 'bar', $user_array ); // '' - -$user_array = [ 'id' => 'user_3' ]; -echo $feature->variantFor( 'foo', $user_array ); // 'variant2' -echo $feature->variantFor( 'bar', $user_array ); // 'on' - -The Feature::variantFor function returns the enabled variant string of a given -feature_name to a different $user_array that is not the one registered with -$config_array['user']. If none are enabled, an empty string is returned. Please -refer to the user document to learn more about the $user_array and how to -construct it. - - - -Feature::variantBucketingBy ( string, string ) : string - -$bucketing_id = 'custom bucketing string used to calculate variants'; - -$feature = new Feature( $config_array ); -echo $feature->variantBucketingBy( 'feature_name', $bucketing_id ); // 'variant' - -The Feature::variantBucketingBy function returns the enabled variant string of a -given feature_name using a custom $bucketing_id string instead of the bucketing -id the library uses for you. If none are enabled, an empty string is returned. -Please refer to the bucketing document to learn more about the $bucketing_id -string. - - - -Feature::description ( string ) : string - -$config_array = [ - 'features' => [ - 'foo' => [ 'description' => 'foo description' ], - 'bar' => [ 'enabled' => 100 ], - 'test' => [ 'description' => 'test deacription' ] - ] -]; - -$feature = new Feature( $config_array ); -echo $feature->description( 'foo' ); // 'foo description' -echo $feature->description( 'bar' ); // '' -echo $feature->description( 'test' ); // 'test deacription' - -The Feature::description function returns the description string of a -feature_name. If none is provided, an empty string is returned. Please refer to -the feature document to learn more about the $feature_array and how to construct -it. diff --git a/src/Feature.php b/src/Feature.php index 382f03f..1707e40 100644 --- a/src/Feature.php +++ b/src/Feature.php @@ -42,7 +42,7 @@ class Feature private $url; private $source; - function __construct (array $input) + function __construct (array $input = []) { $this->features = new FeatureCollection($input['features'] ?? []); $this->user = new User($input['user'] ?? []); diff --git a/tests/FeatureTest.php b/tests/FeatureTest.php index 4f254b9..7936d75 100644 --- a/tests/FeatureTest.php +++ b/tests/FeatureTest.php @@ -250,4 +250,12 @@ function testChangeSource () $this->assertEquals($this->feature->isEnabled('testFeature2'), true); $this->assertEquals($this->feature->variant('testFeature2'), 'test1'); } + + function emptyTest () + { + $feature = new Feature(); + $this->assertEquals($feature->isEnabled('testFeature2'), false); + $this->assertEquals($feature->variant('testFeature2'), ''); + $this->assertEquals($feature->description('testFeature'), ''); + } } From 0bdf95f03ff37f605b1f1f5f53194ee0dbebd7b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Thu, 4 Jan 2018 16:53:23 -0500 Subject: [PATCH 44/92] use --no-interaction on coposer install in travis file --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index a027304..7aeaf81 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,4 +6,4 @@ php: - '7.2' - nightly -script: composer update && ./vendor/bin/phpstan analyse --level=max --debug -vvv src/ tests/ && ./vendor/bin/phpunit --stop-on-failure --fail-on-warning --fail-on-risky -v --debug --coverage-text --whitelist src/ tests/ +script: composer install --no-interaction && ./vendor/bin/phpstan analyse --level=max --debug -vvv src/ tests/ && ./vendor/bin/phpunit --stop-on-failure --fail-on-warning --fail-on-risky -v --debug --coverage-text --whitelist src/ tests/ From 37c5b871c0eb23d554bf27175a43c59dd40621a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Sun, 7 Jan 2018 01:34:59 -0500 Subject: [PATCH 45/92] Update README.md --- README.md | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 7618a8b..22d4aaa 100644 --- a/README.md +++ b/README.md @@ -270,15 +270,8 @@ and: ```php $server_config['foo'] => ['users' => 'fred']; ``` -None of these four properties have any effect if `'enabled'` is entirely enabled -or disabled. They can, however, enable a variant of a feature if no `'enabled'` -value is provided or if the variant’s percentage is 0. - -On the other hand, when an array `'enabled'` value is specified, as an aid to -detecting typos, the variant names used in the `'admin'`, `'internal'`, -`'users'`, and `'groups'` properties must also be keys in the `'enabled'` array. -So if any variants are specified via `'enabled'`, they should all be, even if -their percentage is set to 0. +They can enable a variant of a feature if no `'enabled'` value is provided or +if the variant’s percentage is 0. The two remaining feature config properties are `'bucketing'` and `'public_url_override'`. Bucketing specifies how users are bucketed when a From ec7da13e351162b11ddafc30c74ab251f5a05d56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Sun, 7 Jan 2018 01:42:05 -0500 Subject: [PATCH 46/92] Update README.md --- README.md | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 22d4aaa..dc1e148 100644 --- a/README.md +++ b/README.md @@ -144,27 +144,27 @@ cases along with the most concise way to write the configuration. ### A totally enabled feature: ```php - $server_config['foo'] = ['enabled' => 100]; + $server_config['features']['foo'] = ['enabled' => 100]; ``` ### A totally disabled feature: ```php - $server_config['foo'] = ['enabled' => 0]; + $server_config['features']['foo'] = ['enabled' => 0]; ``` ### Feature with winning variant turned on for everyone ```php - $server_config['foo'] = ['enabled' => ['blue_background' => 100]]; + $server_config['features']['foo'] = ['enabled' => ['blue_background' => 100]]; ``` ### Feature enabled only for admins: ```php - $server_config['foo'] = ['admin' => 'on']; + $server_config['features']['foo'] = ['admin' => 'on']; ``` ### Single-variant feature ramped up to 1% of users. ```php - $server_config['foo'] = ['enabled' => 1]; + $server_config['features']['foo'] = ['enabled' => 1]; ``` ### Multi-variant feature ramped up to 1% of users for each variant. ```php - $server_config['foo'] = [ + $server_config['features']['foo'] = [ 'enabled' => [ 'blue_background' => 1, 'orange_background' => 1, @@ -174,46 +174,46 @@ cases along with the most concise way to write the configuration. ``` ### Enabled for a single specific user. ```php - $server_config['foo'] = ['users' => 'fred']; + $server_config['features']['foo'] = ['users' => 'fred']; ``` ### Enabled for a few specific users. ```php - $server_config['foo'] = [ + $server_config['features']['foo'] = [ 'users' => ['fred', 'barney', 'wilma', 'betty'], ]; ``` ### Enabled for a specific group ```php - $server_config['foo'] = ['groups' => '1234']; + $server_config['features']['foo'] = ['groups' => '1234']; ``` ### Enabled for 10% of regular users and all admin. ```php - $server_config['foo'] = [ + $server_config['features']['foo'] = [ 'enabled' => 10, 'admin' => 'on', ]; ``` ### Feature ramped up to 1% of requests, bucketing at random rather than by user ```php - $server_config['foo'] = [ + $server_config['features']['foo'] = [ 'enabled' => 1, 'bucketing' => 'random', ]; ``` ### Feature ramped up to 40% of requests, bucketing by user rather than at random ```php - $server_config['foo'] = [ + $server_config['features']['foo'] = [ 'enabled' => 40, 'bucketing' => 'user', ]; ``` ### Single-variant feature in 50/50 A/B test ```php - $server_config['foo'] = ['enabled' => 50]; + $server_config['features']['foo'] = ['enabled' => 50]; ``` ### Multi-variant feature in A/B test with 20% of users seeing each variant (and 40% left in control group). ```php - $server_config['foo'] = [ + $server_config['features']['foo'] = [ 'enabled' => [ 'blue_background' => 20, 'orange_background' => 20, @@ -223,7 +223,7 @@ cases along with the most concise way to write the configuration. ``` ### New feature intended only to be enabled by adding ?features=foo to a URL ```php - $server_config['foo'] = [ + $server_config['features']['foo'] = [ 'enabled' => 0, 'public_url_override' => true ]; @@ -264,11 +264,11 @@ feature, the value of the `'users'` or `'groups'` property can simply be the value that should be assigned to the `'on'` variant. So using both shorthands, these are equivalent: ```php - $server_config['foo'] => ['users' => ['on' => ['fred']]]; + $server_config['features']['foo'] => ['users' => ['on' => ['fred']]]; ``` and: ```php - $server_config['foo'] => ['users' => 'fred']; + $server_config['features']['foo'] => ['users' => 'fred']; ``` They can enable a variant of a feature if no `'enabled'` value is provided or if the variant’s percentage is 0. From be57b51cbc14a549cd9c08ea56d7ccd7c65340ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Sun, 7 Jan 2018 01:49:42 -0500 Subject: [PATCH 47/92] Update README.md --- README.md | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index dc1e148..6ab9cac 100644 --- a/README.md +++ b/README.md @@ -174,17 +174,17 @@ cases along with the most concise way to write the configuration. ``` ### Enabled for a single specific user. ```php - $server_config['features']['foo'] = ['users' => 'fred']; + $server_config['features']['foo'] = ['users' => ['on' => 'fred']]; ``` ### Enabled for a few specific users. ```php $server_config['features']['foo'] = [ - 'users' => ['fred', 'barney', 'wilma', 'betty'], + 'users' => ['on' => ['fred', 'barney', 'wilma', 'betty']] ]; ``` ### Enabled for a specific group ```php - $server_config['features']['foo'] = ['groups' => '1234']; + $server_config['features']['foo'] = ['groups' => ['on' => '1234']]; ``` ### Enabled for 10% of regular users and all admin. ```php @@ -259,16 +259,9 @@ lists of users or numeric group ids. In the fully specified case, the value will be an array whose keys are the names of variants and whose values are lists of user names or group ids, as appropriate. As a shorthand, if the list of user names or group ids is a single element it can be specified with just the name or -id. And as a further shorthand, in the configuration of a single-variant -feature, the value of the `'users'` or `'groups'` property can simply be the -value that should be assigned to the `'on'` variant. So using both shorthands, -these are equivalent: -```php - $server_config['features']['foo'] => ['users' => ['on' => ['fred']]]; -``` -and: +id. ```php - $server_config['features']['foo'] => ['users' => 'fred']; + $server_config['features']['foo'] => ['users' => ['on' => 'fred']]; ``` They can enable a variant of a feature if no `'enabled'` value is provided or if the variant’s percentage is 0. From 4ac6704d14aab527baaa551ed7a2fdde2a6c73a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Sun, 7 Jan 2018 01:52:14 -0500 Subject: [PATCH 48/92] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6ab9cac..d008988 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,7 @@ user names or group ids, as appropriate. As a shorthand, if the list of user names or group ids is a single element it can be specified with just the name or id. ```php - $server_config['features']['foo'] => ['users' => ['on' => 'fred']]; + $server_config['features']['foo'] = ['users' => ['on' => 'fred']]; ``` They can enable a variant of a feature if no `'enabled'` value is provided or if the variant’s percentage is 0. From f02d23dc59bc5950e3a7ced68aeb79d199ce8bd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Sun, 7 Jan 2018 02:26:22 -0500 Subject: [PATCH 49/92] Update composer.json --- composer.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 5e516db..5e96789 100644 --- a/composer.json +++ b/composer.json @@ -7,12 +7,18 @@ "email": "iglesias.pablo10@gmail.com" } ], + "license": "MIT", "keywords": [ "feature-flags", "ab-testing", "ab-test", "feature", - "feature-flagging" + "feature-flagging", + "feature-toggles", + "feature-toggle", + "ab-tests", + "a-b-testing", + "a-b-test" ], "require": { "php": ">=7.0" From f5150b43012aed5431eb542aff54b4bb37b35825 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Sun, 7 Jan 2018 02:37:30 -0500 Subject: [PATCH 50/92] Update composer.json --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 5e96789..2fd4d8a 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,8 @@ "feature-toggle", "ab-tests", "a-b-testing", - "a-b-test" + "a-b-test", + "toggle" ], "require": { "php": ">=7.0" From f69142fefd553849582420252fe867149bd53cc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Sun, 7 Jan 2018 13:37:54 -0500 Subject: [PATCH 51/92] Update README.md --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index d008988..8307f33 100644 --- a/README.md +++ b/README.md @@ -291,8 +291,7 @@ defaults to false if omitted. The precedence of the various mechanisms for enabling a feature are as follows. - - If the request is from an admin user or is an internal request, or if - `'public_url_override'` is true and the request contains a `features` query + - If `'public_url_override'` is true and the request contains a `features` query param that specifies a variant for the feature in question, that variant is used. The value of the `features` param is a comma-delimited list of features where each feature is either simply the name of the feature, From 597445f5b06adbb36d7a58793face7e4c549d234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Sun, 7 Jan 2018 16:13:38 -0500 Subject: [PATCH 52/92] Completed documentation!! --- README.md | 43 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8307f33..a362b8c 100644 --- a/README.md +++ b/README.md @@ -56,11 +56,52 @@ $feature->description('foo'); // 'this is the description of the "foo" feature' # TODO -DOCUMENTATION!!!!! Especially for new features. Replace magic strings with constants. Write more usefull error messages. Add more bucketing schemes. +# Documentation + +For a quick summary and common use cases, please read the rest of this README. +For full documentation, check [the wiki page](https://github.com/PabloJoan/feature/wiki) + +## Full list of features + +* [Feature Class](https://github.com/PabloJoan/feature/wiki/The-Feature-Class) +* * [Feature::__construct ( array ) : void](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#how-to-create-a-feature-class-instance) +* * [Feature::changeFeatures ( array ) : void](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featurechangefeatures--array---void) +* * [Feature::changeFeature ( string , array ) : void](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featurechangefeature--string--array---void) +* * [Feature::addFeature ( string , array ) : void](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featureaddfeature--string--array---void) +* * [Feature::removeFeature ( string ) : void](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featureremovefeature--string---void) +* * [Feature::changeUser ( array ) : void](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featurechangeuser--array---void) +* * [Feature::changeUrl ( string ) : void](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featurechangeurl--string---void) +* * [Feature::changeSource ( string ) : void](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featurechangesource--string---void) +* * [Feature::isEnabled ( string ) : boolean](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featureisenabled--string---boolean) +* * [Feature::isEnabledFor ( string, array ) : boolean](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featureisenabledfor--string-array---boolean) +* * [Feature::isEnabledBucketingBy ( string, string ) : boolean](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featureisenabledbucketingby--string-string---boolean) +* * [Feature::variant ( string ) : string](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featurevariant--string---string) +* * [Feature::variantFor ( string, array ) : string](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featurevariantfor--string-array---string) +* * [Feature::variantBucketingBy ( string, string ) : string](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featurevariantbucketingby--string-string---string) +* * [Feature::description ( string ) : string](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featuredescription--string---string) + +* [The Config Array API](https://github.com/PabloJoan/feature/wiki/Config-API) +* * [$config_array['features']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeatures) +* * * [$config_array['features']['feature_name']['description']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_namedescription) +* * * [$config_array['features']['feature_name']['enabled']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_nameenabled) +* * * [$config_array['features']['feature_name']['users']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_nameusers) +* * * [$config_array['features']['feature_name']['groups']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_namegroups) +* * * [$config_array['features']['feature_name']['sources']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_namesources) +* * * [$config_array['features']['feature_name']['admin']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_nameadmin) +* * * [$config_array['features']['feature_name']['internal']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_nameinternal) +* * * [$config_array['features']['feature_name']['public_url_override']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_namepublic_url_override) +* * * [$config_array['features']['feature_name']['bucketing']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_namebucketing) +* * * [$config_array['features']['feature_name']['exclude_from']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_nameexclude_from) +* * * [$config_array['features']['feature_name']['start']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_namestart) +* * * [$config_array['features']['feature_name']['end']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_nameend) +* * [$config_array['user']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayuser) +* * [$config_array['url']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayurl) +* * [$config_array['source']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arraysource) + # Feature API Feature flagging API used for operational rampups and A/B testing. From 736477a004ff2c0fb77bdc3d0a9bebb3ea99e952 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Sun, 7 Jan 2018 17:52:22 -0500 Subject: [PATCH 53/92] Update README.md --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index a362b8c..f2a7c36 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ [![Build Status](https://travis-ci.org/PabloJoan/feature.svg?branch=master)](https://travis-ci.org/PabloJoan/feature) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/PabloJoan/feature/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/PabloJoan/feature/?branch=master) [![Code Coverage](https://scrutinizer-ci.com/g/PabloJoan/feature/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/PabloJoan/feature/?branch=master) -[![Build Status](https://scrutinizer-ci.com/g/PabloJoan/feature/badges/build.png?b=master)](https://scrutinizer-ci.com/g/PabloJoan/feature/build-status/master) Requires PHP 7.0 and above. From e8ea39cbdfd087a46f3846514ddd3818b8de6e1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Tue, 9 Jan 2018 15:31:11 -0500 Subject: [PATCH 54/92] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index f2a7c36..127721c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ +[![Latest Stable Version](https://poser.pugx.org/pablojoan/feature/v/stable)](https://packagist.org/packages/pablojoan/feature) [![Build Status](https://travis-ci.org/PabloJoan/feature.svg?branch=master)](https://travis-ci.org/PabloJoan/feature) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/PabloJoan/feature/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/PabloJoan/feature/?branch=master) [![Code Coverage](https://scrutinizer-ci.com/g/PabloJoan/feature/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/PabloJoan/feature/?branch=master) +[![License](https://poser.pugx.org/pablojoan/feature/license)](https://packagist.org/packages/pablojoan/feature) Requires PHP 7.0 and above. From e2233fc8d477dc477bc3bb4565375526f53fe904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Tue, 9 Jan 2018 15:37:36 -0500 Subject: [PATCH 55/92] Create LICENSE --- LICENSE | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cf1ab25 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to From b4c7169b4e534ce1f53c6ef344174a5e224fb6b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Tue, 9 Jan 2018 15:41:31 -0500 Subject: [PATCH 56/92] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 127721c..b740e33 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Build Status](https://travis-ci.org/PabloJoan/feature.svg?branch=master)](https://travis-ci.org/PabloJoan/feature) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/PabloJoan/feature/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/PabloJoan/feature/?branch=master) [![Code Coverage](https://scrutinizer-ci.com/g/PabloJoan/feature/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/PabloJoan/feature/?branch=master) -[![License](https://poser.pugx.org/pablojoan/feature/license)](https://packagist.org/packages/pablojoan/feature) +[![GitHub license](https://img.shields.io/github/license/PabloJoan/feature.svg)](https://github.com/PabloJoan/feature/blob/master/LICENSE) Requires PHP 7.0 and above. From cdab16b20944d0021fb462f18cb84be0f51de582 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Tue, 9 Jan 2018 16:07:54 -0500 Subject: [PATCH 57/92] Update composer.json --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 2fd4d8a..331a27a 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,7 @@ "email": "iglesias.pablo10@gmail.com" } ], - "license": "MIT", + "license": "Unlicense", "keywords": [ "feature-flags", "ab-testing", From 2e4cbaec3a23226d5f4e93e60568f51d7538496c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Tue, 9 Jan 2018 16:10:39 -0500 Subject: [PATCH 58/92] Create UNLICENSE --- UNLICENSE | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 UNLICENSE diff --git a/UNLICENSE b/UNLICENSE new file mode 100644 index 0000000..68a49da --- /dev/null +++ b/UNLICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to From d4bc7750329cdd8e6eb9a6d771a04183b3e232ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Tue, 9 Jan 2018 16:10:52 -0500 Subject: [PATCH 59/92] Delete LICENSE --- LICENSE | 24 ------------------------ 1 file changed, 24 deletions(-) delete mode 100644 LICENSE diff --git a/LICENSE b/LICENSE deleted file mode 100644 index cf1ab25..0000000 --- a/LICENSE +++ /dev/null @@ -1,24 +0,0 @@ -This is free and unencumbered software released into the public domain. - -Anyone is free to copy, modify, publish, use, compile, sell, or -distribute this software, either in source code form or as a compiled -binary, for any purpose, commercial or non-commercial, and by any -means. - -In jurisdictions that recognize copyright laws, the author or authors -of this software dedicate any and all copyright interest in the -software to the public domain. We make this dedication for the benefit -of the public at large and to the detriment of our heirs and -successors. We intend this dedication to be an overt act of -relinquishment in perpetuity of all present and future rights to this -software under copyright law. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR -OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, -ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. - -For more information, please refer to From 972ea21fad15f14d4018339a666b18631961f18a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Tue, 9 Jan 2018 17:08:52 -0500 Subject: [PATCH 60/92] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b740e33..a9bd246 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +[![PHP from Packagist](https://img.shields.io/packagist/php-v/pablojoan/feature.svg)]() [![Latest Stable Version](https://poser.pugx.org/pablojoan/feature/v/stable)](https://packagist.org/packages/pablojoan/feature) [![Build Status](https://travis-ci.org/PabloJoan/feature.svg?branch=master)](https://travis-ci.org/PabloJoan/feature) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/PabloJoan/feature/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/PabloJoan/feature/?branch=master) From c57e9cf03a28f10c5664eed654c2570c64dcc7b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Tue, 9 Jan 2018 22:31:43 -0500 Subject: [PATCH 61/92] bit shift confuses people change hashing algorithm to haval192,3 because I'm only iterating 47 characters and haval192,3 provides 48 unlike SHA256's 64. 3 is the smallest iteration available. We don't need strong hashing here, speed is more important. 140737488355328 is the highest number php can handle this division before it rounds up the float to 1. --- src/Config.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Config.php b/src/Config.php index b00db55..c0f1dfe 100644 --- a/src/Config.php +++ b/src/Config.php @@ -200,12 +200,12 @@ private function randomish (Feature $feature, BucketingId $id) : float * Map a hex value to the half-open interval bewtween 0 and 1 while * preserving uniformity of the input distribution. */ - $id = hash('sha256', $feature->name() . "-$id"); + $id = hash('haval192,3', $feature->name() . "-$id"); $x = 0; - for ($i = 0; $i < 30; ++$i) { - $x = ($x << 1) + (hexdec($id[$i]) < 8 ? 0 : 1); + for ($i = 0; $i < 47; ++$i) { + $x = ($x * 2) + (hexdec($id[$i]) < 8 ? 0 : 1); } - return $x / 1073741824; // $x / ( 1 << 30 ) + return $x / 140737488355328; // ( 2 ** 47 ) is the max value of $x } } From 29246e4fda0d3341f320e59f9836612dd7d9ad30 Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Tue, 9 Jan 2018 22:42:14 -0500 Subject: [PATCH 62/92] update unit tests for bucketing change. --- tests/ApiTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/ApiTest.php b/tests/ApiTest.php index 3c1755e..e37ef0a 100644 --- a/tests/ApiTest.php +++ b/tests/ApiTest.php @@ -706,7 +706,7 @@ function testBucketing () $this->assertEquals( $feature->variantBucketingBy('testFeature2', 'testid1'), - 'variant4' + 'variant3' ); $this->assertEquals( $feature->variantBucketingBy('testFeature3', 'testid2'), @@ -715,7 +715,7 @@ function testBucketing () $feature->changeUser(['id' => 'anotheruser', 'uaid' => 'string3']); - $this->assertEquals($feature->variant('testFeature2'), 'variant4'); - $this->assertEquals($feature->variant('testFeature3'), 'variant6'); + $this->assertEquals($feature->variant('testFeature2'), 'variant3'); + $this->assertEquals($feature->variant('testFeature3'), 'variant5'); } } From 26728801fbd24582efba5df837c83fc7ec7042a1 Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Sun, 28 Oct 2018 17:11:02 -0400 Subject: [PATCH 63/92] simplifying code --- .travis.yml | 4 +- README.md | 17 +- composer.json | 12 +- src/Config.php | 116 ++++---- src/Contract/Admin.php | 12 - src/Contract/Bucketing.php | 12 - src/Contract/BucketingId.php | 12 - src/Contract/Description.php | 12 - src/Contract/Enabled.php | 12 - src/Contract/ExcludeFrom.php | 12 - src/Contract/Feature.php | 34 --- src/Contract/FeatureCollection.php | 18 -- src/Contract/Groups.php | 12 - src/Contract/Internal.php | 12 - src/Contract/Name.php | 12 - src/Contract/PublicUrlOverride.php | 12 - src/Contract/Source.php | 12 - src/Contract/Sources.php | 12 - src/Contract/Time.php | 12 - src/Contract/Url.php | 12 - src/Contract/User.php | 26 -- src/Contract/Users.php | 12 - src/Feature.php | 86 +++--- src/Value/Admin.php | 11 +- src/Value/Bucketing.php | 39 ++- src/Value/BucketingId.php | 20 -- src/Value/CalculateBucketingId.php | 49 ++-- src/Value/Description.php | 19 -- src/Value/Enabled.php | 43 ++- src/Value/ExcludeFrom.php | 37 +-- src/Value/Feature.php | 78 +++--- src/Value/FeatureCollection.php | 36 +-- src/Value/Groups.php | 9 +- src/Value/Internal.php | 11 +- src/Value/Name.php | 20 -- src/Value/PublicUrlOverride.php | 17 +- src/Value/Source.php | 16 -- src/Value/Sources.php | 15 +- src/Value/Time.php | 24 +- src/Value/Url.php | 31 +- src/Value/User.php | 60 ++-- src/Value/Users.php | 11 +- src/Value/Variant.php | 11 + tests/ApiTest.php | 2 +- tests/ConfigTest.php | 207 -------------- tests/FeatureTest.php | 14 +- tests/Value/BucketingTest.php | 34 --- tests/Value/CalculateBucketingIdTest.php | 168 ----------- tests/Value/EnabledTest.php | 73 ----- tests/Value/ExcludeFromTest.php | 124 -------- tests/Value/FeatureCollectionTest.php | 343 ----------------------- tests/Value/UrlTest.php | 49 ---- 52 files changed, 365 insertions(+), 1699 deletions(-) delete mode 100644 src/Contract/Admin.php delete mode 100644 src/Contract/Bucketing.php delete mode 100644 src/Contract/BucketingId.php delete mode 100644 src/Contract/Description.php delete mode 100644 src/Contract/Enabled.php delete mode 100644 src/Contract/ExcludeFrom.php delete mode 100644 src/Contract/Feature.php delete mode 100644 src/Contract/FeatureCollection.php delete mode 100644 src/Contract/Groups.php delete mode 100644 src/Contract/Internal.php delete mode 100644 src/Contract/Name.php delete mode 100644 src/Contract/PublicUrlOverride.php delete mode 100644 src/Contract/Source.php delete mode 100644 src/Contract/Sources.php delete mode 100644 src/Contract/Time.php delete mode 100644 src/Contract/Url.php delete mode 100644 src/Contract/User.php delete mode 100644 src/Contract/Users.php delete mode 100644 src/Value/BucketingId.php delete mode 100644 src/Value/Description.php delete mode 100644 src/Value/Name.php delete mode 100644 src/Value/Source.php create mode 100644 src/Value/Variant.php delete mode 100644 tests/ConfigTest.php delete mode 100644 tests/Value/BucketingTest.php delete mode 100644 tests/Value/CalculateBucketingIdTest.php delete mode 100644 tests/Value/EnabledTest.php delete mode 100644 tests/Value/ExcludeFromTest.php delete mode 100644 tests/Value/FeatureCollectionTest.php delete mode 100644 tests/Value/UrlTest.php diff --git a/.travis.yml b/.travis.yml index 7aeaf81..84171aa 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,7 @@ language: php php: - - '7.0' - - '7.1' - '7.2' - nightly -script: composer install --no-interaction && ./vendor/bin/phpstan analyse --level=max --debug -vvv src/ tests/ && ./vendor/bin/phpunit --stop-on-failure --fail-on-warning --fail-on-risky -v --debug --coverage-text --whitelist src/ tests/ +script: composer install --no-interaction && composer phpstan && composer phpunit diff --git a/README.md b/README.md index a9bd246..79225c0 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ composer require pablojoan/feature # Running tests ```bash -./vendor/bin/phpstan analyse --level=max --debug -vvv src/ tests/ && ./vendor/bin/phpunit --stop-on-failure --fail-on-warning --fail-on-risky -v --debug --coverage-text --whitelist src/ tests/ +composer phpstan && composer phpunit ``` # Basic Usage @@ -70,14 +70,13 @@ For full documentation, check [the wiki page](https://github.com/PabloJoan/featu ## Full list of features * [Feature Class](https://github.com/PabloJoan/feature/wiki/The-Feature-Class) -* * [Feature::__construct ( array ) : void](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#how-to-create-a-feature-class-instance) -* * [Feature::changeFeatures ( array ) : void](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featurechangefeatures--array---void) -* * [Feature::changeFeature ( string , array ) : void](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featurechangefeature--string--array---void) -* * [Feature::addFeature ( string , array ) : void](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featureaddfeature--string--array---void) -* * [Feature::removeFeature ( string ) : void](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featureremovefeature--string---void) -* * [Feature::changeUser ( array ) : void](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featurechangeuser--array---void) -* * [Feature::changeUrl ( string ) : void](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featurechangeurl--string---void) -* * [Feature::changeSource ( string ) : void](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featurechangesource--string---void) +* * [Feature::__construct ( array ) : Feature](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#how-to-create-a-feature-class-instance) +* * [Feature::changeFeatures ( array ) : Feature](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featurechangefeatures--array---Feature) +* * [Feature::setFeature ( string , array ) : Feature](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featuresetfeature--string--array---Feature) +* * [Feature::removeFeature ( string ) : Feature](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featureremovefeature--string---Feature) +* * [Feature::changeUser ( array ) : Feature](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featurechangeuser--array---Feature) +* * [Feature::changeUrl ( string ) : Feature](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featurechangeurl--string---Feature) +* * [Feature::changeSource ( string ) : Feature](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featurechangesource--string---Feature) * * [Feature::isEnabled ( string ) : boolean](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featureisenabled--string---boolean) * * [Feature::isEnabledFor ( string, array ) : boolean](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featureisenabledfor--string-array---boolean) * * [Feature::isEnabledBucketingBy ( string, string ) : boolean](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featureisenabledbucketingby--string-string---boolean) diff --git a/composer.json b/composer.json index 331a27a..f5b9dab 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "pablojoan/feature", - "description": "PSR-4 compliant Feature Flags library based on Etsy", + "description": "PSR-4 compliant Feature Flags library", "authors": [ { "name": "Pablo Iglesias", @@ -22,11 +22,15 @@ "toggle" ], "require": { - "php": ">=7.0" + "php": ">=7.2" }, "require-dev": { - "phpunit/phpunit": "^6.5", - "phpstan/phpstan": "^0.9.1" + "phpunit/phpunit": "*", + "phpstan/phpstan": "*" + }, + "scripts": { + "phpstan": "./vendor/bin/phpstan analyse --level=max --debug -vvv src/ tests/", + "phpunit": "./vendor/bin/phpunit --stop-on-failure --fail-on-warning --fail-on-risky -v --debug --coverage-text --whitelist src/ tests/" }, "autoload": { "psr-4": { diff --git a/src/Config.php b/src/Config.php index c0f1dfe..66f9270 100644 --- a/src/Config.php +++ b/src/Config.php @@ -4,8 +4,14 @@ namespace PabloJoan\Feature; -use PabloJoan\Feature\Value\CalculateBucketingId; -use PabloJoan\Feature\Contract\{ User, Url, Source, Feature, BucketingId }; +use PabloJoan\Feature\Value\{ + CalculateBucketingId, + User, + Url, + Feature, + Bucketing, + Variant +}; class Config { @@ -13,7 +19,7 @@ class Config private $url; private $source; - function __construct (User $user, Url $url, Source $source) + function __construct (User $user, Url $url, string $source) { $this->user = $user; $this->url = $url; @@ -26,8 +32,8 @@ function __construct (User $user, Url $url, Source $source) */ function isEnabled (Feature $feature) : bool { - $id = (new CalculateBucketingId($this->user, $feature->bucketing()))->id(); - return $this->chooseVariant($feature, $id) !== 'off'; + $id = new CalculateBucketingId($this->user, $feature->bucketing()); + return Variant::OFF !== $this->chooseVariant($feature, $id->id()); } /** @@ -36,9 +42,9 @@ function isEnabled (Feature $feature) : bool */ function variant (Feature $feature) : string { - $id = (new CalculateBucketingId($this->user, $feature->bucketing()))->id(); - $variant = $this->chooseVariant($feature, $id); - return $variant !== 'off' ? $variant : ''; + $id = new CalculateBucketingId($this->user, $feature->bucketing()); + $variant = $this->chooseVariant($feature, $id->id()); + return $variant !== Variant::OFF ? $variant : ''; } /** @@ -46,18 +52,18 @@ function variant (Feature $feature) : string * methods of enabling a feature and specifying a variant such as users, * groups, and query parameters, will still work.) */ - function isEnabledBucketingBy (Feature $feature, BucketingId $id) : bool + function isEnabledBucketingBy (Feature $feature, string $id) : bool { - return $this->chooseVariant($feature, $id) !== 'off'; + return $this->chooseVariant($feature, $id) !== Variant::OFF; } /** * What variant is enabled, bucketing on the given bucketing ID, if any? */ - function variantBucketingBy (Feature $feature, BucketingId $id) : string + function variantBucketingBy (Feature $feature, string $id) : string { $variant = $this->chooseVariant($feature, $id); - return $variant !== 'off' ? $variant : ''; + return $variant !== Variant::OFF ? $variant : ''; } /** @@ -67,36 +73,18 @@ function variantBucketingBy (Feature $feature, BucketingId $id) : string * BucketingId $id - the id used to assign a variant based on the percentage * of users that should see different variants. */ - private function chooseVariant (Feature $feature, BucketingId $id) : string + private function chooseVariant (Feature $feature, string $id) : string { - $variant = $this->variantFromURL($feature); - if ($variant) return $variant; - - $variant = $this->variantTime($feature); - if ($variant) return $variant; - - $variant = $this->variantExcludedFrom($feature); - if ($variant) return $variant; - - $variant = $this->variantForUser($feature); - if ($variant) return $variant; - - $variant = $this->variantForGroup($feature); - if ($variant) return $variant; - - $variant = $this->variantForSource($feature); - if ($variant) return $variant; - - $variant = $this->variantForInternal($feature); - if ($variant) return $variant; - - $variant = $this->variantForAdmin($feature); - if ($variant) return $variant; - - $variant = $this->variantByPercentage($feature, $id); - if ($variant) return $variant; - - return 'off'; + return $this->variantFromURL($feature) ?: + $this->variantTime($feature) ?: + $this->variantExcludedFrom($feature) ?: + $this->variantForUser($feature) ?: + $this->variantForGroup($feature) ?: + $this->variantForSource($feature) ?: + $this->variantForInternal($feature) ?: + $this->variantForAdmin($feature) ?: + $this->variantByPercentage($feature, $id) ?: + Variant::OFF; } /** @@ -107,8 +95,10 @@ private function chooseVariant (Feature $feature, BucketingId $id) : string */ private function variantFromURL (Feature $feature) : string { - $publicUrlOverride = $feature->publicUrlOverride(); - return $publicUrlOverride->variant($feature->name(), $this->url); + return $feature->publicUrlOverride()->variant( + $feature->name(), + $this->url + ); } /** @@ -178,32 +168,40 @@ private function variantTime (Feature $feature) : string * Finally, the normal case: use the percentage of users who should see each * variant to map a random-ish number to a particular variant. */ - private function variantByPercentage (Feature $feature, BucketingId $id) : string + private function variantByPercentage (Feature $feature, string $id) : string { - $n = 100 * $this->randomish($feature, $id); - foreach ($feature->enabled()->percentages() as $variant => $percent) { - if ($n < $percent) return $variant; - } - return ''; + return $feature->enabled()->variantByPercentage( + $this->randomish($feature, $id) + ); } /** - * A random-ish number between 0 and 1 based on the feature name and $id + * A random-ish number between 0 and 100 based on the feature name and $id * unless we are bucketing completely at random */ - private function randomish (Feature $feature, BucketingId $id) : float + private function randomish (Feature $feature, string $id) : float { - if ((string) $feature->bucketing() === 'random') { - return random_int(0, PHP_INT_MAX - 1) / PHP_INT_MAX; + switch ($feature->bucketing()->by()) { + case Bucketing::RANDOM: + $x = random_int(0, PHP_INT_MAX - 1) / PHP_INT_MAX; + + default: + $x = $this->numberFromHash($feature, $id); } - /** - * Map a hex value to the half-open interval bewtween 0 and 1 while - * preserving uniformity of the input distribution. - */ - $id = hash('haval192,3', $feature->name() . "-$id"); + + return $x * 100; + } + + /** + * Map a hex value to the half-open interval between 0 and 1 while + * preserving uniformity of the input distribution. + */ + private function numberFromHash (Feature $feature, string $id) : float + { + $hash = hash('haval192,3', $feature->name() . "-$id"); $x = 0; for ($i = 0; $i < 47; ++$i) { - $x = ($x * 2) + (hexdec($id[$i]) < 8 ? 0 : 1); + $x = ($x * 2) + (hexdec($hash[$i]) < 8 ? 0 : 1); } return $x / 140737488355328; // ( 2 ** 47 ) is the max value of $x diff --git a/src/Contract/Admin.php b/src/Contract/Admin.php deleted file mode 100644 index f7fb28b..0000000 --- a/src/Contract/Admin.php +++ /dev/null @@ -1,12 +0,0 @@ -features = new FeatureCollection($input['features'] ?? []); $this->user = new User($input['user'] ?? []); $this->url = new Url($input['url'] ?? ''); - $this->source = new Source($input['source'] ?? ''); + $this->source = $input['source'] ?? ''; } - /* + /** * Replaces all features with a new set of features. */ - function changeFeatures (array $features) + function changeFeatures (array $features) : Feature { $this->features = new FeatureCollection($features); + return $this; } - /* + /** * Replaces one existing feature with a new feature config of the same name. + * If feature does not exist, it adds one new feature config to the + * collection of features. */ - function changeFeature (string $name, array $feature) - { - $this->features->change(new Name($name), $feature); - } - - /* - * Adds one new feature config to the collection of features. Feature name - * must be unique. - */ - function addFeature (string $name, array $feature) + function setFeature (string $name, array $feature) : Feature { - $this->features->add(new Name($name), $feature); + $this->features->set($name, $feature); + return $this; } - /* + /** * Removes one existing feature from the collection. */ - function removeFeature (string $name) + function removeFeature (string $name) : Feature { - $this->features->remove(new Name($name)); + $this->features->remove($name); + return $this; } - /* + /** * Replaces the user used to calculate variants. */ - function changeUser (array $user) { $this->user = new User($user); } + function changeUser (array $user) : Feature + { + $this->user = new User($user); + return $this; + } - /* + /** * Replaces the url used to calculate variants. */ - function changeUrl (string $url) { $this->url = new Url($url); } + function changeUrl (string $url) : Feature + { + $this->url = new Url($url); + return $this; + } - /* + /** * Replaces the source used to calculate variants. */ - function changeSource (string $source) + function changeSource (string $source) : Feature { - $this->source = new Source($source); + $this->source = $source; + return $this; } /** @@ -107,7 +109,7 @@ function changeSource (string $source) function isEnabled (string $name) : bool { $config = new Config($this->user, $this->url, $this->source); - return $config->isEnabled($this->features->get(new Name($name))); + return $config->isEnabled($this->features->get($name)); } /** @@ -119,7 +121,7 @@ function isEnabled (string $name) : bool function isEnabledFor (string $name, array $user) : bool { $config = new Config(new User($user), $this->url, $this->source); - return $config->isEnabled($this->features->get(new Name($name))); + return $config->isEnabled($this->features->get($name)); } /** @@ -129,9 +131,11 @@ function isEnabledFor (string $name, array $user) : bool */ function isEnabledBucketingBy (string $name, string $id) : bool { - $config = new Config(new User([]), $this->url, $this->source); - $feature = $this->features->get(new Name($name)); - return $config->isEnabledBucketingBy($feature, new BucketingId($id)); + $config = new Config(new User([]), $this->url, $this->source); + return $config->isEnabledBucketingBy( + $this->features->get($name), + $id + ); } /** @@ -141,7 +145,7 @@ function isEnabledBucketingBy (string $name, string $id) : bool function variant (string $name) : string { $config = new Config($this->user, $this->url, $this->source); - return $config->variant($this->features->get(new Name($name))); + return $config->variant($this->features->get($name)); } /** @@ -153,7 +157,7 @@ function variant (string $name) : string function variantFor (string $name, array $user) : string { $config = new Config(new User($user), $this->url, $this->source); - return $config->variant($this->features->get(new Name($name))); + return $config->variant($this->features->get($name)); } /** @@ -165,12 +169,14 @@ function variantFor (string $name, array $user) : string function variantBucketingBy (string $name, string $id) : string { $config = new Config(new User([]), $this->url, $this->source); - $feature = $this->features->get(new Name($name)); - return $config->variantBucketingBy($feature, new BucketingId($id)); + return $config->variantBucketingBy( + $this->features->get($name), + $id + ); } function description (string $name) : string { - return (string) $this->features->get(new Name($name))->description(); + return (string) $this->features->get($name)->description(); } } diff --git a/src/Value/Admin.php b/src/Value/Admin.php index 3697dc1..eea0990 100644 --- a/src/Value/Admin.php +++ b/src/Value/Admin.php @@ -4,13 +4,14 @@ namespace PabloJoan\Feature\Value; -use PabloJoan\Feature\Contract\{ Admin as AdminContract, User }; - -class Admin implements AdminContract +class Admin { - private $variant = ''; + private $variant; - function __construct (string $variant) { $this->variant = $variant; } + function __construct (string $variant) + { + $this->variant = $variant; + } function variant (User $user) : string { diff --git a/src/Value/Bucketing.php b/src/Value/Bucketing.php index f509121..4f4a5ff 100644 --- a/src/Value/Bucketing.php +++ b/src/Value/Bucketing.php @@ -4,22 +4,39 @@ namespace PabloJoan\Feature\Value; -use PabloJoan\Feature\Contract\Bucketing as BucketingContract; - -class Bucketing implements BucketingContract +class Bucketing { - private $by = 'random'; + private $by; + + const RANDOM = 0; + const UAID = 1; + const USER = 2; function __construct (string $bucketBy) { - $this->by = $bucketBy; - - if (in_array($bucketBy, ['random', 'uaid', 'user'], true)) return; + switch ($bucketBy) { + case 'random': + $bucketBy = self::RANDOM; + break; + + case 'uaid': + $bucketBy = self::RANDOM; + break; + + case 'user': + $bucketBy = self::RANDOM; + break; + + default: + $bucketBy = self::RANDOM; + break; + } - $error = 'bucketing must be either "random", "uaid" or "user". '; - $error .= $bucketBy; - throw new \Exception($error); + $this->by = $bucketBy; } - function __toString () : string { return $this->by; } + function by () : int + { + return $this->by; + } } diff --git a/src/Value/BucketingId.php b/src/Value/BucketingId.php deleted file mode 100644 index b2cffc1..0000000 --- a/src/Value/BucketingId.php +++ /dev/null @@ -1,20 +0,0 @@ -id = $id; - } - - function __toString () : string { return $this->id; } -} diff --git a/src/Value/CalculateBucketingId.php b/src/Value/CalculateBucketingId.php index 432d4f3..4112096 100644 --- a/src/Value/CalculateBucketingId.php +++ b/src/Value/CalculateBucketingId.php @@ -4,49 +4,34 @@ namespace PabloJoan\Feature\Value; -use PabloJoan\Feature\Contract\{ - User, - Bucketing, - BucketingId as BucketingIdContract -}; - class CalculateBucketingId { private $user; - private $bucketing = 'random'; + private $bucketing; function __construct (User $user, Bucketing $bucketing) { $this->user = $user; - $this->bucketing = (string) $bucketing; + $this->bucketing = $bucketing; } - function id () : BucketingIdContract + function id () : string { - if ($this->bucketing === 'user' && !$this->user->id()) { - $error = 'user id must be provided if user bucketing is enabled.'; - throw new \Exception($error); - } - - if ($this->bucketing === 'uaid' && !$this->user->uaid()) { - $error = 'user uaid must be provided if uaid bucketing is enabled.'; - throw new \Exception($error); - } - - if ($this->bucketing === 'user') { - return new BucketingId($this->user->id()); + $id = ''; + switch ($this->bucketing->by()) { + case Bucketing::USER: + $id = $this->user->id(); + break; + + case Bucketing::UAID: + $id = $this->user->uaid(); + break; + + case Bucketing::RANDOM: + $id = $this->user->uaid() ? $this->user->uaid() : 'no uaid'; + break; } - if ($this->bucketing === 'uaid') { - return new BucketingId($this->user->uaid()); - } - - if ($this->bucketing === 'random' && !$this->user->uaid()) { - return new BucketingId('no uaid'); - } - - if ($this->bucketing === 'random') { - return new BucketingId($this->user->uaid()); - } + return $id; } } diff --git a/src/Value/Description.php b/src/Value/Description.php deleted file mode 100644 index 3a41f4a..0000000 --- a/src/Value/Description.php +++ /dev/null @@ -1,19 +0,0 @@ -description = $description; - } - - function __toString () : string { return $this->description; } -} diff --git a/src/Value/Enabled.php b/src/Value/Enabled.php index 9703c92..3c55551 100644 --- a/src/Value/Enabled.php +++ b/src/Value/Enabled.php @@ -4,48 +4,39 @@ namespace PabloJoan\Feature\Value; -use PabloJoan\Feature\Contract\Enabled as EnabledContract; - /** * Parse the 'enabled' property of the feature's config stanza. * Returns the upper-boundary of the variants percentage. */ -class Enabled implements EnabledContract +class Enabled { - private $percentages = []; + private $percentages; function __construct ($enabled) { - $this->checkValueType($enabled); - - if (is_int($enabled)) $enabled = ['on' => $enabled]; - $total = 0; - foreach ($enabled as $variant => $percent) { - $this->checkPercentage($percent); - - $total += $percent; + foreach ((array) $enabled as $variant => $percent) { + $total += $this->percentage($percent); + $variant = is_int($variant) ? Variant::ON : $variant; $this->percentages[$variant] = $total; } - - if ($total <= 100) return; - - throw new \Exception("Total of percentages > 100: $total"); } - function percentages () : array { return $this->percentages; } - - private function checkValueType ($enabled) + function variantByPercentage (float $number) : string { - if (is_int($enabled) || is_array($enabled)) return; - - $error = 'Malformed enabled property ' . json_encode($enabled); - throw new \Exception($error); + foreach ($this->percentages as $variant => $percent) { + $withinThreshold = $number < $percent; + switch ($withinThreshold) { + case true: + return $variant; + break; + } + } + return ''; } - private function checkPercentage (int $percent) + private function percentage (int $percent) : int { - if ($percent >= 0 && $percent <= 100) return; - throw new \Exception('Bad percentage ' . json_encode($percent)); + return ($percent >= 0 && $percent <= 100) ? $percent : 0; } } diff --git a/src/Value/ExcludeFrom.php b/src/Value/ExcludeFrom.php index c8654b8..2570a20 100644 --- a/src/Value/ExcludeFrom.php +++ b/src/Value/ExcludeFrom.php @@ -4,42 +4,33 @@ namespace PabloJoan\Feature\Value; -use PabloJoan\Feature\Contract\{ ExcludeFrom as ExcludeFromContract, User }; - -class ExcludeFrom implements ExcludeFromContract +class ExcludeFrom { - private $zips = []; - private $regions = []; - private $countries = []; + private $zips; + private $regions; + private $countries; function __construct (array $excludeFrom) { - if (!$excludeFrom) return; - - $zips = isset($excludeFrom['zips']) && is_array($excludeFrom['zips']); + $zips = isset($excludeFrom['zips']) && \is_array($excludeFrom['zips']); $regions = isset($excludeFrom['regions']); - $regions = $regions && is_array($excludeFrom['regions']); + $regions = $regions && \is_array($excludeFrom['regions']); $countries = isset($excludeFrom['countries']); - $countries = $countries && is_array($excludeFrom['countries']); - - if ($zips) $this->zips = $excludeFrom['zips']; - if ($regions) $this->regions = $excludeFrom['regions']; - if ($countries) $this->countries = $excludeFrom['countries']; - - if ($zips || $regions || $countries) return; + $countries = $countries && \is_array($excludeFrom['countries']); - $error = 'bad exclude_from stanza ' . json_encode($excludeFrom); - throw new \Exception($error); + $this->zips = $zips ? $excludeFrom['zips'] : []; + $this->regions = $regions ? $excludeFrom['regions'] : []; + $this->countries = $countries ? $excludeFrom['countries'] : []; } function variant (User $user) : string { - $zips = in_array($user->zipcode(), $this->zips, true); - $regions = in_array($user->region(), $this->regions, true); - $countries = in_array($user->country(), $this->countries, true); + $zips = \in_array($user->zipcode(), $this->zips, true); + $regions = \in_array($user->region(), $this->regions, true); + $countries = \in_array($user->country(), $this->countries, true); - return $zips || $regions || $countries ? 'off' : ''; + return $zips || $regions || $countries ? Variant::OFF : ''; } } diff --git a/src/Value/Feature.php b/src/Value/Feature.php index d0c99d4..9bf7fa7 100644 --- a/src/Value/Feature.php +++ b/src/Value/Feature.php @@ -4,27 +4,11 @@ namespace PabloJoan\Feature\Value; -use PabloJoan\Feature\Contract\{ - Feature as FeatureContract, - Name as NameContract, - Enabled as EnabledContract, - Description as DescriptionContract, - Users as UsersContract, - Groups as GroupsContract, - Sources as SourcesContract, - Admin as AdminContract, - Internal as InternalContract, - PublicUrlOverride as PublicUrlOverrideContract, - ExcludeFrom as ExcludeFromContract, - Time as TimeContract, - Bucketing as BucketingContract -}; - /** * A feature that can be enabled, disabled, ramped up, and A/B tested, as well * as enabled for certain classes of users. */ -class Feature implements FeatureContract +class Feature { private $name; private $enabled; @@ -39,7 +23,7 @@ class Feature implements FeatureContract private $time; private $bucketing; - function __construct (NameContract $name, array $feature) + function __construct (string $name, array $feature) { $enabled = $feature['enabled'] ?? 0; $description = $feature['description'] ?? ''; @@ -52,11 +36,11 @@ function __construct (NameContract $name, array $feature) $excludeFrom = $feature['exclude_from'] ?? []; $start = $feature['start'] ?? ''; $end = $feature['end'] ?? ''; - $bucketing = $feature['bucketing'] ?? 'random'; + $bucketing = $feature['bucketing'] ?? ''; $this->name = $name; $this->enabled = new Enabled($enabled); - $this->description = new Description($description); + $this->description = $description; $this->users = new Users($users); $this->groups = new Groups($groups); $this->sources = new Sources($sources); @@ -68,33 +52,63 @@ function __construct (NameContract $name, array $feature) $this->bucketing = new Bucketing($bucketing); } - function name () : NameContract { return $this->name; } + function name () : string + { + return $this->name; + } - function enabled () : EnabledContract { return $this->enabled; } + function enabled () : Enabled + { + return $this->enabled; + } - function description () : DescriptionContract + function description () : string { return $this->description; } - function users () : UsersContract { return $this->users; } + function users () : Users + { + return $this->users; + } - function groups () : GroupsContract { return $this->groups; } + function groups () : Groups + { + return $this->groups; + } - function sources () : SourcesContract { return $this->sources; } + function sources () : Sources + { + return $this->sources; + } - function admin () : AdminContract { return $this->admin; } + function admin () : Admin + { + return $this->admin; + } - function internal () : InternalContract { return $this->internal; } + function internal () : Internal + { + return $this->internal; + } - function publicUrlOverride () : PublicUrlOverrideContract + function publicUrlOverride () : PublicUrlOverride { return $this->publicUrlOverride; } - function excludeFrom () : ExcludeFromContract { return $this->excludeFrom; } + function excludeFrom () : ExcludeFrom + { + return $this->excludeFrom; + } - function time () : TimeContract { return $this->time; } + function time () : Time + { + return $this->time; + } - function bucketing () : BucketingContract { return $this->bucketing; } + function bucketing () : Bucketing + { + return $this->bucketing; + } } diff --git a/src/Value/FeatureCollection.php b/src/Value/FeatureCollection.php index 03f508e..5b75b4c 100644 --- a/src/Value/FeatureCollection.php +++ b/src/Value/FeatureCollection.php @@ -4,47 +4,31 @@ namespace PabloJoan\Feature\Value; -use PabloJoan\Feature\Contract\{ - FeatureCollection as FeatureCollectionContract, - Feature as FeatureContract, - Name as NameContract -}; - -class FeatureCollection implements FeatureCollectionContract +class FeatureCollection { private $features = []; function __construct (array $features) { foreach ($features as $name => $feature) { - $this->features[$name] = new Feature(new Name($name), $feature); + $this->features[$name] = new Feature($name, $feature); } } - function get (NameContract $name) : FeatureContract + function get (string $name) : Feature { - return $this->features[(string) $name] ?? new Feature($name, []); + return $this->features[$name] ?? new Feature($name, []); } - function change (NameContract $name, array $feature) + function set (string $name, array $feature) : FeatureCollection { - if (!isset($this->features[(string) $name])) { - throw new \Exception("feature '$name' does not exist."); - } - - $this->features[(string) $name] = new Feature($name, $feature); - } - - function add (NameContract $name, array $feature) - { - if (isset($this->features[(string) $name])) { - throw new \Exception("feature '$name' already exists."); - } - $this->features[(string) $name] = new Feature($name, $feature); + $this->features[$name] = new Feature($name, $feature); + return $this; } - function remove (NameContract $name) + function remove (string $name) : FeatureCollection { - unset($this->features[(string) $name]); + unset($this->features[$name]); + return $this; } } diff --git a/src/Value/Groups.php b/src/Value/Groups.php index 5997942..f33ddb9 100644 --- a/src/Value/Groups.php +++ b/src/Value/Groups.php @@ -4,21 +4,20 @@ namespace PabloJoan\Feature\Value; -use PabloJoan\Feature\Contract\{ User, Groups as GroupsContract }; - /** * Parse the value of the 'groups' properties of the feature's config stanza, * returning an array mappinng the group names to the variant they should see. */ -class Groups implements GroupsContract +class Groups { private $groups = []; function __construct (array $stanza) { foreach ($stanza as $variant => $groups) { - if (!is_array($groups)) $groups = [$groups]; - foreach ($groups as $group) $this->groups[$group] = $variant; + foreach ((array) $groups as $group) { + $this->groups[$group] = $variant; + } } } diff --git a/src/Value/Internal.php b/src/Value/Internal.php index 2d1b163..0d91ed3 100644 --- a/src/Value/Internal.php +++ b/src/Value/Internal.php @@ -4,13 +4,14 @@ namespace PabloJoan\Feature\Value; -use PabloJoan\Feature\Contract\{ Internal as InternalContract, User }; - -class Internal implements InternalContract +class Internal { - private $variant = ''; + private $variant; - function __construct (string $variant) { $this->variant = $variant; } + function __construct (string $variant) + { + $this->variant = $variant; + } function variant (User $user) : string { diff --git a/src/Value/Name.php b/src/Value/Name.php deleted file mode 100644 index a2464fc..0000000 --- a/src/Value/Name.php +++ /dev/null @@ -1,20 +0,0 @@ -name = $name; - } - - function __toString () : string { return $this->name; } -} diff --git a/src/Value/PublicUrlOverride.php b/src/Value/PublicUrlOverride.php index 0ed2984..349c176 100644 --- a/src/Value/PublicUrlOverride.php +++ b/src/Value/PublicUrlOverride.php @@ -4,19 +4,16 @@ namespace PabloJoan\Feature\Value; -use PabloJoan\Feature\Contract\{ - PublicUrlOverride as PublicUrlOverrideContract, - Name, - Url -}; - -class PublicUrlOverride implements PublicUrlOverrideContract +class PublicUrlOverride { - private $on = false; + private $on; - function __construct (bool $on) { $this->on = $on; } + function __construct (bool $on) + { + $this->on = $on; + } - function variant (Name $name, Url $url) : string + function variant (string $name, Url $url) : string { return $this->on ? $url->variant($name) : ''; } diff --git a/src/Value/Source.php b/src/Value/Source.php deleted file mode 100644 index 8ce039b..0000000 --- a/src/Value/Source.php +++ /dev/null @@ -1,16 +0,0 @@ -source = $source; } - - function variant () : string { return $this->source; } -} diff --git a/src/Value/Sources.php b/src/Value/Sources.php index 6136552..de2771e 100644 --- a/src/Value/Sources.php +++ b/src/Value/Sources.php @@ -4,26 +4,25 @@ namespace PabloJoan\Feature\Value; -use PabloJoan\Feature\Contract\{ Sources as SourcesContract, Source }; - /** * Parse the value of the 'sources' properties of the feature's config stanza, * returning an array mappinng the source names to the variant they should see. */ -class Sources implements SourcesContract +class Sources { - private $sources = []; + private $sources; function __construct (array $stanza) { foreach ($stanza as $variant => $sources) { - if (!is_array($sources)) $sources = [$sources]; - foreach ($sources as $source) $this->sources[$source] = $variant; + foreach ((array) $sources as $source) { + $this->sources[$source] = $variant; + } } } - function variant (Source $source) : string + function variant (string $source) : string { - return $this->sources[$source->variant()] ?? ''; + return $this->sources[$source] ?? ''; } } diff --git a/src/Value/Time.php b/src/Value/Time.php index 4e384c4..ba77aae 100644 --- a/src/Value/Time.php +++ b/src/Value/Time.php @@ -4,17 +4,18 @@ namespace PabloJoan\Feature\Value; -use PabloJoan\Feature\Contract\Time as TimeContract; - -class Time implements TimeContract +class Time { - private $start = 0; - private $end = 0; + private $start; + private $end; function __construct (string $start, string $end) { - if ($start) $this->start = $this->timeValue($start); - if ($end) $this->end = $this->timeValue($end); + $start = strtotime($start); + $this->start = $start ? $start : 0; + + $end = strtotime($end); + $this->end = $end ? $end : 0; } function variant () : string @@ -24,13 +25,6 @@ function variant () : string $startNotValid = $this->start && $this->start > $time; $endNotValid = $this->end && $this->end < $time; - return $startNotValid || $endNotValid ? 'off' : ''; - } - - private function timeValue (string $time) : int - { - $time = strtotime($time); - if (!$time) throw new \Exception("$time is not a valid time format"); - return $time; + return $startNotValid || $endNotValid ? Variant::OFF : ''; } } diff --git a/src/Value/Url.php b/src/Value/Url.php index 10ad6d9..d17366e 100644 --- a/src/Value/Url.php +++ b/src/Value/Url.php @@ -4,43 +4,30 @@ namespace PabloJoan\Feature\Value; -use PabloJoan\Feature\Contract\{ Url as UrlContract, Name }; - -class Url implements UrlContract +class Url { - private $features = []; + private $features; function __construct (string $url) { - if (!$url) return; - - if (!filter_var($url, FILTER_VALIDATE_URL)) { - throw new \Exception("$url is not a valid url."); - } - - $url = parse_url($url, PHP_URL_QUERY); - if (!$url) return; + $url = filter_var($url, FILTER_VALIDATE_URL); + $url = $url ? parse_url($url, PHP_URL_QUERY) : ''; + $url = $url ? html_entity_decode($url) : ''; $query = []; - foreach (explode('&', html_entity_decode($url)) as $val) { + foreach (explode('&', $url) as $val) { $x = explode('=', $val); $query[$x[0]] = $x[1] ?? ''; } foreach (explode(',', $query['feature'] ?? '') as $feature) { $parts = explode(':', $feature); - $this->features[$parts[0]] = $parts[1] ?? 'on'; + $this->features[$parts[0]] = $parts[1] ?? Variant::ON; } } - function variant (Name $name) : string + function variant (string $name) : string { - $name = (string) $name; - - foreach ($this->features as $feature => $variant) { - if ($feature === $name) return $variant ?? 'on'; - } - - return ''; + return $this->features[$name] ?? ''; } } diff --git a/src/Value/User.php b/src/Value/User.php index 85bb791..1c4c1f3 100644 --- a/src/Value/User.php +++ b/src/Value/User.php @@ -4,18 +4,16 @@ namespace PabloJoan\Feature\Value; -use PabloJoan\Feature\Contract\User as UserContract; - -class User implements UserContract +class User { - private $uaid = ''; - private $id = ''; - private $country = ''; - private $zipcode = ''; - private $region = ''; - private $isAdmin = false; - private $internalIP = false; - private $group = ''; + private $uaid; + private $id; + private $country; + private $zipcode; + private $region; + private $isAdmin; + private $internalIP; + private $group; function __construct (array $user) { @@ -29,19 +27,43 @@ function __construct (array $user) $this->internalIP = $user['internal-ip'] ?? false; } - function uaid () : string { return $this->uaid; } + function uaid () : string + { + return $this->uaid; + } - function id () : string { return $this->id; } + function id () : string + { + return $this->id; + } - function country () : string { return $this->country; } + function country () : string + { + return $this->country; + } - function zipcode () : string { return $this->zipcode; } + function zipcode () : string + { + return $this->zipcode; + } - function region () : string { return $this->region; } + function region () : string + { + return $this->region; + } - function isAdmin () : bool { return $this->isAdmin; } + function isAdmin () : bool + { + return $this->isAdmin; + } - function internalIP () : bool { return $this->internalIP; } + function internalIP () : bool + { + return $this->internalIP; + } - function group () : string { return $this->group; } + function group () : string + { + return $this->group; + } } diff --git a/src/Value/Users.php b/src/Value/Users.php index 9563a6d..e21d6a2 100644 --- a/src/Value/Users.php +++ b/src/Value/Users.php @@ -4,21 +4,20 @@ namespace PabloJoan\Feature\Value; -use PabloJoan\Feature\Contract\{ Users as UsersContract, User }; - /** * Parse the value of the 'users' properties of the feature's config stanza, * returning an array mappinng the user names to the variant they should see. */ -class Users implements UsersContract +class Users { - private $users = []; + private $users; function __construct (array $stanza) { foreach ($stanza as $variant => $users) { - if (!is_array($users)) $users = [$users]; - foreach ($users as $user) $this->users[$user] = $variant; + foreach ((array) $users as $user) { + $this->users[$user] = $variant; + } } } diff --git a/src/Value/Variant.php b/src/Value/Variant.php new file mode 100644 index 0000000..7654a41 --- /dev/null +++ b/src/Value/Variant.php @@ -0,0 +1,11 @@ +changeFeature('testFeature2', ['enabled' => 0]); + $feature->setFeature('testFeature2', ['enabled' => 0]); $this->assertEquals($feature->isEnabled('testFeature'), false); $this->assertEquals($feature->isEnabled('testFeature2'), false); diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php deleted file mode 100644 index 1e0a316..0000000 --- a/tests/ConfigTest.php +++ /dev/null @@ -1,207 +0,0 @@ - 20, - 'test2' => 50, - 'test3' => 65, - 'test4' => 100 - ]; - } - }; - - $features = [ - 'admin' => $admin, - 'bucketing' => $bucketing, - 'description' => $description, - 'excludeFrom' => $excludeFrom, - 'groups' => $groups, - 'internal' => $internal, - 'publicUrlOverride' => $publicUrlOverride, - 'sources' => $sources, - 'users' => $users, - 'time' => $time, - 'enabled' => $enabled - ]; - $feature = new class ($name, $features) implements Feature { - private $name; - private $enabled; - private $description; - private $users; - private $groups; - private $sources; - private $admin; - private $internal; - private $publicUrlOverride; - private $excludeFrom; - private $time; - private $bucketing; - function __construct (Name $name, array $feature) { - $this->name = $name; - $this->admin = $feature['admin']; - $this->bucketing = $feature['bucketing']; - $this->description = $feature['description']; - $this->excludeFrom = $feature['excludeFrom']; - $this->groups = $feature['groups']; - $this->internal = $feature['internal']; - $this->publicUrlOverride = $feature['publicUrlOverride']; - $this->sources = $feature['sources']; - $this->users = $feature['users']; - $this->time = $feature['time']; - $this->enabled = $feature['enabled']; - } - function name () : Name { return $this->name; } - function enabled () : Enabled { return $this->enabled; } - function description () : Description { return $this->description; } - function users () : Users { return $this->users; } - function groups () : Groups { return $this->groups; } - function sources () : Sources { return $this->sources; } - function admin () : Admin { return $this->admin; } - function internal () : Internal { return $this->internal; } - function publicUrlOverride () : PublicUrlOverride { - return $this->publicUrlOverride; - } - function excludeFrom () : ExcludeFrom { return $this->excludeFrom; } - function time () : Time { return $this->time; } - function bucketing () : Bucketing { return $this->bucketing; } - }; - - $this->config = new Config($user, $url, $source); - $this->feature = $feature; - } - - function testIsEnabled () - { - $this->assertEquals($this->config->isEnabled($this->feature), true); - } - - function testVariant () - { - $this->assertEquals($this->config->variant($this->feature), 'test4'); - } - - function testIsEnabledBucketingBy () - { - $bucketingId = new class ('') implements BucketingId { - function __construct (string $id) { unset($id); } - function __toString () : string { return 'test'; } - }; - $this->assertEquals( - $this->config->isEnabledBucketingBy($this->feature, $bucketingId), - true - ); - } - - function testVariantBucketingBy () - { - $bucketingId = new class ('') implements BucketingId { - function __construct (string $id) { unset($id); } - function __toString () : string { return 'as54gerfd'; } - }; - $this->assertEquals( - $this->config->variantBucketingBy($this->feature, $bucketingId), - 'test4' - ); - } -} diff --git a/tests/FeatureTest.php b/tests/FeatureTest.php index 7936d75..8789af6 100644 --- a/tests/FeatureTest.php +++ b/tests/FeatureTest.php @@ -140,7 +140,7 @@ function testAddFeature () $this->assertEquals($this->feature->isEnabled('newFeature'), false); $this->assertEquals($this->feature->variant('newFeature'), ''); - $this->feature->addFeature('newFeature', ['enabled' => 100]); + $this->feature->setFeature('newFeature', ['enabled' => 100]); $this->assertEquals($this->feature->isEnabled('newFeature'), true); $this->assertEquals($this->feature->variant('newFeature'), 'on'); } @@ -150,7 +150,7 @@ function testRemoveFeature () $this->assertEquals($this->feature->isEnabled('newFeature2'), false); $this->assertEquals($this->feature->variant('newFeature2'), ''); - $this->feature->addFeature('newFeature2', ['enabled' => 100]); + $this->feature->setFeature('newFeature2', ['enabled' => 100]); $this->assertEquals($this->feature->isEnabled('newFeature2'), true); $this->assertEquals($this->feature->variant('newFeature2'), 'on'); @@ -186,9 +186,9 @@ function testChangeFeatures () ); } - function testChangeFeature () + function testSetFeature () { - $this->feature->changeFeature( + $this->feature->setFeature( 'testFeature2', [ 'enabled' => ['test1' => 0, 'test4' => 0], @@ -203,7 +203,7 @@ function testChangeFeature () function testChangeUser () { - $this->feature->changeFeature( + $this->feature->setFeature( 'testFeature2', [ 'enabled' => ['test1' => 0, 'test4' => 0], @@ -219,7 +219,7 @@ function testChangeUser () function testChangeUrl () { - $this->feature->changeFeature( + $this->feature->setFeature( 'testFeature2', [ 'enabled' => ['test1' => 0, 'test4' => 0], @@ -237,7 +237,7 @@ function testChangeUrl () function testChangeSource () { - $this->feature->changeFeature( + $this->feature->setFeature( 'testFeature2', [ 'enabled' => ['test1' => 0, 'test4' => 0], diff --git a/tests/Value/BucketingTest.php b/tests/Value/BucketingTest.php deleted file mode 100644 index db584a5..0000000 --- a/tests/Value/BucketingTest.php +++ /dev/null @@ -1,34 +0,0 @@ -assertEquals((string) $bucketing, 'random'); - - $bucketing = new Bucketing('uaid'); - $this->assertEquals((string) $bucketing, 'uaid'); - - $bucketing = new Bucketing('user'); - $this->assertEquals((string) $bucketing, 'user'); - - try { - new Bucketing('some other string'); - } - catch (\Exception $e) - { - $this->assertEquals( - $e->getMessage(), - 'bucketing must be either "random", "uaid" or "user". some other string' - ); - } - } -} diff --git a/tests/Value/CalculateBucketingIdTest.php b/tests/Value/CalculateBucketingIdTest.php deleted file mode 100644 index 8fc68e9..0000000 --- a/tests/Value/CalculateBucketingIdTest.php +++ /dev/null @@ -1,168 +0,0 @@ -assertEquals($bucketing->id(), 'no uaid'); - - $user = new class ([]) implements User { - function __construct (array $user) { unset($user); } - function uaid () : string { return 'test'; } - function id () : string { return ''; } - function country () : string { return ''; } - function zipcode () : string { return ''; } - function region () : string { return ''; } - function isAdmin () : bool { return false; } - function internalIP () : bool { return false; } - function group () : string { return ''; } - }; - $bucketing = new class ('') implements Bucketing { - function __construct (string $bucketBy) { unset($bucketBy); } - function __toString () : string { return 'random'; } - }; - $bucketing = new CalculateBucketingId($user, $bucketing); - $this->assertEquals($bucketing->id(), 'test'); - - $user = new class ([]) implements User { - function __construct (array $user) { unset($user); } - function uaid () : string { return ''; } - function id () : string { return 'test'; } - function country () : string { return ''; } - function zipcode () : string { return ''; } - function region () : string { return ''; } - function isAdmin () : bool { return false; } - function internalIP () : bool { return false; } - function group () : string { return ''; } - }; - $bucketing = new class ('') implements Bucketing { - function __construct (string $bucketBy) { unset($bucketBy); } - function __toString () : string { return 'user'; } - }; - $bucketing = new CalculateBucketingId($user, $bucketing); - $this->assertEquals($bucketing->id(), 'test'); - - $user = new class ([]) implements User { - function __construct (array $user) { unset($user); } - function uaid () : string { return 'test'; } - function id () : string { return ''; } - function country () : string { return ''; } - function zipcode () : string { return ''; } - function region () : string { return ''; } - function isAdmin () : bool { return false; } - function internalIP () : bool { return false; } - function group () : string { return ''; } - }; - $bucketing = new class ('') implements Bucketing { - function __construct (string $bucketBy) { unset($bucketBy); } - function __toString () : string { return 'uaid'; } - }; - $bucketing = new CalculateBucketingId($user, $bucketing); - $this->assertEquals($bucketing->id(), 'test'); - - $user = new class ([]) implements User { - function __construct (array $user) { unset($user); } - function uaid () : string { return ''; } - function id () : string { return ''; } - function country () : string { return ''; } - function zipcode () : string { return ''; } - function region () : string { return ''; } - function isAdmin () : bool { return false; } - function internalIP () : bool { return false; } - function group () : string { return ''; } - }; - $bucketing = new class ('') implements Bucketing { - function __construct (string $bucketBy) { unset($bucketBy); } - function __toString () : string { return 'uaid'; } - }; - try { - (new CalculateBucketingId($user, $bucketing))->id(); - } - catch (\Exception $e) - { - $this->assertEquals( - $e->getMessage(), - 'user uaid must be provided if uaid bucketing is enabled.' - ); - } - - $user = new class ([]) implements User { - function __construct (array $user) { unset($user); } - function uaid () : string { return ''; } - function id () : string { return ''; } - function country () : string { return ''; } - function zipcode () : string { return ''; } - function region () : string { return ''; } - function isAdmin () : bool { return false; } - function internalIP () : bool { return false; } - function group () : string { return ''; } - }; - $bucketing = new class ('') implements Bucketing { - function __construct (string $bucketBy) { unset($bucketBy); } - function __toString () : string { return 'user'; } - }; - try { - (new CalculateBucketingId($user, $bucketing))->id(); - } - catch (\Exception $e) - { - $this->assertEquals( - $e->getMessage(), - 'user id must be provided if user bucketing is enabled.' - ); - } - - $user = new class ([]) implements User { - function __construct (array $user) { unset($user); } - function uaid () : string { return ''; } - function id () : string { return ''; } - function country () : string { return ''; } - function zipcode () : string { return ''; } - function region () : string { return ''; } - function isAdmin () : bool { return false; } - function internalIP () : bool { return false; } - function group () : string { return ''; } - }; - $bucketing = new class ('') implements Bucketing { - function __construct (string $bucketBy) { unset($bucketBy); } - function __toString () : string { return 'some other string'; } - }; - try { - (new CalculateBucketingId($user, $bucketing))->id(); - } - catch (\Error $e) - { - $this->assertEquals( - $e->getMessage(), - 'Return value of ' . - 'PabloJoan\Feature\Value\CalculateBucketingId::id() must ' . - 'implement interface PabloJoan\Feature\Contract\BucketingId, ' . - 'none returned' - ); - } - } -} diff --git a/tests/Value/EnabledTest.php b/tests/Value/EnabledTest.php deleted file mode 100644 index ca05130..0000000 --- a/tests/Value/EnabledTest.php +++ /dev/null @@ -1,73 +0,0 @@ -assertEquals($enabled->percentages(), ['on' => 0]); - - $enabled = new Enabled(100); - $this->assertEquals($enabled->percentages(), ['on' => 100]); - - $enabled = new Enabled(['on' => 50]); - $this->assertEquals($enabled->percentages(), ['on' => 50]); - - $enabled = new Enabled(['test1' => 23, 'test2' => 48]); - $this->assertEquals($enabled->percentages(), ['test1' => 23, 'test2' => 71]); - - $enabled = new Enabled(['test1' => 60, 'test2' => 40]); - $this->assertEquals($enabled->percentages(), ['test1' => 60, 'test2' => 100]); - - try { - new Enabled('string'); - } - catch (\Exception $e) - { - $this->assertEquals( - $e->getMessage(), - 'Malformed enabled property "string"' - ); - } - - try { - new Enabled(101); - } - catch (\Exception $e) - { - $this->assertEquals( - $e->getMessage(), - 'Bad percentage 101' - ); - } - - try { - new Enabled(-1); - } - catch (\Exception $e) - { - $this->assertEquals( - $e->getMessage(), - 'Bad percentage -1' - ); - } - - try { - new Enabled(['test1' => 60, 'test2' => 100]); - } - catch (\Exception $e) - { - $this->assertEquals( - $e->getMessage(), - 'Total of percentages > 100: 160' - ); - } - } -} diff --git a/tests/Value/ExcludeFromTest.php b/tests/Value/ExcludeFromTest.php deleted file mode 100644 index 3fdea05..0000000 --- a/tests/Value/ExcludeFromTest.php +++ /dev/null @@ -1,124 +0,0 @@ - ['10014', '10023'], - 'countries' => ['us', 'rd'], - 'regions' => ['ny', 'nj', 'ca'] - ]); - - $user = new class ([]) implements User { - function __construct (array $user) { unset($user); } - function uaid () : string { return ''; } - function id () : string { return ''; } - function country () : string { return ''; } - function zipcode () : string { return '10014'; } - function region () : string { return ''; } - function isAdmin () : bool { return false; } - function internalIP () : bool { return false; } - function group () : string { return ''; } - }; - $this->assertEquals($excludeFrom->variant($user), 'off'); - - $user = new class ([]) implements User { - function __construct (array $user) { unset($user); } - function uaid () : string { return ''; } - function id () : string { return ''; } - function country () : string { return ''; } - function zipcode () : string { return '10015'; } - function region () : string { return ''; } - function isAdmin () : bool { return false; } - function internalIP () : bool { return false; } - function group () : string { return ''; } - }; - $this->assertEquals($excludeFrom->variant($user), ''); - - $user = new class ([]) implements User { - function __construct (array $user) { unset($user); } - function uaid () : string { return ''; } - function id () : string { return ''; } - function country () : string { return 'us'; } - function zipcode () : string { return ''; } - function region () : string { return ''; } - function isAdmin () : bool { return false; } - function internalIP () : bool { return false; } - function group () : string { return ''; } - }; - $this->assertEquals($excludeFrom->variant($user), 'off'); - - $user = new class ([]) implements User { - function __construct (array $user) { unset($user); } - function uaid () : string { return ''; } - function id () : string { return ''; } - function country () : string { return 'ur'; } - function zipcode () : string { return ''; } - function region () : string { return ''; } - function isAdmin () : bool { return false; } - function internalIP () : bool { return false; } - function group () : string { return ''; } - }; - $this->assertEquals($excludeFrom->variant($user), ''); - - $user = new class ([]) implements User { - function __construct (array $user) { unset($user); } - function uaid () : string { return ''; } - function id () : string { return ''; } - function country () : string { return ''; } - function zipcode () : string { return ''; } - function region () : string { return 'ny'; } - function isAdmin () : bool { return false; } - function internalIP () : bool { return false; } - function group () : string { return ''; } - }; - $this->assertEquals($excludeFrom->variant($user), 'off'); - - $user = new class ([]) implements User { - function __construct (array $user) { unset($user); } - function uaid () : string { return ''; } - function id () : string { return ''; } - function country () : string { return ''; } - function zipcode () : string { return ''; } - function region () : string { return 'nn'; } - function isAdmin () : bool { return false; } - function internalIP () : bool { return false; } - function group () : string { return ''; } - }; - $this->assertEquals($excludeFrom->variant($user), ''); - - $excludeFrom = new ExcludeFrom([]); - $user = new class ([]) implements User { - function __construct (array $user) { unset($user); } - function uaid () : string { return ''; } - function id () : string { return ''; } - function country () : string { return ''; } - function zipcode () : string { return ''; } - function region () : string { return ''; } - function isAdmin () : bool { return false; } - function internalIP () : bool { return false; } - function group () : string { return ''; } - }; - $this->assertEquals($excludeFrom->variant($user), ''); - - try { - new ExcludeFrom(['bad array' => 'with other stuff']); - } - catch (\Exception $e) - { - $this->assertEquals( - $e->getMessage(), - 'bad exclude_from stanza {"bad array":"with other stuff"}' - ); - } - } -} diff --git a/tests/Value/FeatureCollectionTest.php b/tests/Value/FeatureCollectionTest.php deleted file mode 100644 index 40b64ee..0000000 --- a/tests/Value/FeatureCollectionTest.php +++ /dev/null @@ -1,343 +0,0 @@ - 0]; } - }; - - $features = [ - 'admin' => $admin, - 'bucketing' => $bucketing, - 'description' => $description, - 'excludeFrom' => $excludeFrom, - 'groups' => $groups, - 'internal' => $internal, - 'publicUrlOverride' => $publicUrlOverride, - 'sources' => $sources, - 'users' => $users, - 'time' => $time, - 'enabled' => $enabled - ]; - - $feature = new class ($name, $features) implements Feature { - private $name; - private $enabled; - private $description; - private $users; - private $groups; - private $sources; - private $admin; - private $internal; - private $publicUrlOverride; - private $excludeFrom; - private $time; - private $bucketing; - function __construct (Name $name, array $feature) { - $this->name = $name; - $this->admin = $feature['admin']; - $this->bucketing = $feature['bucketing']; - $this->description = $feature['description']; - $this->excludeFrom = $feature['excludeFrom']; - $this->groups = $feature['groups']; - $this->internal = $feature['internal']; - $this->publicUrlOverride = $feature['publicUrlOverride']; - $this->sources = $feature['sources']; - $this->users = $feature['users']; - $this->time = $feature['time']; - $this->enabled = $feature['enabled']; - } - function name () : Name { return $this->name; } - function enabled () : Enabled { return $this->enabled; } - function description () : Description { return $this->description; } - function users () : Users { return $this->users; } - function groups () : Groups { return $this->groups; } - function sources () : Sources { return $this->sources; } - function admin () : Admin { return $this->admin; } - function internal () : Internal { return $this->internal; } - function publicUrlOverride () : PublicUrlOverride { - return $this->publicUrlOverride; - } - function excludeFrom () : ExcludeFrom { return $this->excludeFrom; } - function time () : Time { return $this->time; } - function bucketing () : Bucketing { return $this->bucketing; } - }; - $collection = new FeatureCollection(['test' => ['enabled' => 0]]); - $this->assertEquals( - (string) $collection->get($name)->name(), - (string) $feature->name() - ); - $this->assertEquals( - $collection->get($name)->publicUrlOverride()->variant($name, $url), - $feature->publicUrlOverride()->variant($name, $url) - ); - $this->assertEquals( - $collection->get($name)->users()->variant($user), - $feature->users()->variant($user) - ); - $this->assertEquals( - $collection->get($name)->sources()->variant($source), - $feature->sources()->variant($source) - ); - $this->assertEquals( - $collection->get($name)->groups()->variant($user), - $feature->groups()->variant($user) - ); - $this->assertEquals( - $collection->get($name)->admin()->variant($user), - $feature->admin()->variant($user) - ); - $this->assertEquals( - $collection->get($name)->internal()->variant($user), - $feature->internal()->variant($user) - ); - $this->assertEquals( - $collection->get($name)->excludeFrom()->variant($user), - $feature->excludeFrom()->variant($user) - ); - $this->assertEquals( - $collection->get($name)->time()->variant(), - $feature->time()->variant() - ); - $this->assertEquals( - $collection->get($name)->enabled()->percentages(), - $feature->enabled()->percentages() - ); - $this->assertEquals( - (string) $collection->get($name)->bucketing(), - (string) $feature->bucketing() - ); - - $collection->change($name, ['enabled' => 100]); - $enabled = new class (0) implements Enabled { - function __construct ($enabled) { unset($enabled); } - function percentages () : array { return ['on' => 100]; } - }; - $features['enabled'] = $enabled; - $feature = new class ($name, $features) implements Feature { - private $name; - private $enabled; - private $description; - private $users; - private $groups; - private $sources; - private $admin; - private $internal; - private $publicUrlOverride; - private $excludeFrom; - private $time; - private $bucketing; - function __construct (Name $name, array $feature) { - $this->name = $name; - $this->admin = $feature['admin']; - $this->bucketing = $feature['bucketing']; - $this->description = $feature['description']; - $this->excludeFrom = $feature['excludeFrom']; - $this->groups = $feature['groups']; - $this->internal = $feature['internal']; - $this->publicUrlOverride = $feature['publicUrlOverride']; - $this->sources = $feature['sources']; - $this->users = $feature['users']; - $this->time = $feature['time']; - $this->enabled = $feature['enabled']; - } - function name () : Name { return $this->name; } - function enabled () : Enabled { return $this->enabled; } - function description () : Description { return $this->description; } - function users () : Users { return $this->users; } - function groups () : Groups { return $this->groups; } - function sources () : Sources { return $this->sources; } - function admin () : Admin { return $this->admin; } - function internal () : Internal { return $this->internal; } - function publicUrlOverride () : PublicUrlOverride { - return $this->publicUrlOverride; - } - function excludeFrom () : ExcludeFrom { return $this->excludeFrom; } - function time () : Time { return $this->time; } - function bucketing () : Bucketing { return $this->bucketing; } - }; - $this->assertEquals( - (string) $collection->get($name)->name(), - (string) $feature->name() - ); - $this->assertEquals( - $collection->get($name)->publicUrlOverride()->variant($name, $url), - $feature->publicUrlOverride()->variant($name, $url) - ); - $this->assertEquals( - $collection->get($name)->users()->variant($user), - $feature->users()->variant($user) - ); - $this->assertEquals( - $collection->get($name)->sources()->variant($source), - $feature->sources()->variant($source) - ); - $this->assertEquals( - $collection->get($name)->groups()->variant($user), - $feature->groups()->variant($user) - ); - $this->assertEquals( - $collection->get($name)->admin()->variant($user), - $feature->admin()->variant($user) - ); - $this->assertEquals( - $collection->get($name)->internal()->variant($user), - $feature->internal()->variant($user) - ); - $this->assertEquals( - $collection->get($name)->excludeFrom()->variant($user), - $feature->excludeFrom()->variant($user) - ); - $this->assertEquals( - $collection->get($name)->time()->variant(), - $feature->time()->variant() - ); - $this->assertEquals( - $collection->get($name)->enabled()->percentages(), - $feature->enabled()->percentages() - ); - $this->assertEquals( - (string) $collection->get($name)->bucketing(), - (string) $feature->bucketing() - ); - - $name = new class ('') implements Name { - function __construct (string $name) { unset($name); } - function __toString () : string { return 'i dont exist'; } - }; - try { - $collection->change($name, []); - } - catch (\Exception $e) - { - $this->assertEquals( - $e->getMessage(), - "feature 'i dont exist' does not exist." - ); - } - - $name = new class ('') implements Name { - function __construct (string $name) { unset($name); } - function __toString () : string { return 'test'; } - }; - try { - $collection->add($name, []); - } - catch (\Exception $e) - { - $this->assertEquals($e->getMessage(), - "feature 'test' already exists." - ); - } - - $name = new class ('') implements Name { - function __construct (string $name) { unset($name); } - function __toString () : string { return 'newFeature'; } - }; - $collection->add($name, ['enabled' => 100]); - $this->assertEquals( - $collection->get($name)->enabled()->percentages(), - ['on' => 100] - ); - - $collection->remove($name); - $this->assertEquals( - $collection->get($name)->enabled()->percentages(), - ['on' => 0] - ); - } -} diff --git a/tests/Value/UrlTest.php b/tests/Value/UrlTest.php deleted file mode 100644 index 75dc9bd..0000000 --- a/tests/Value/UrlTest.php +++ /dev/null @@ -1,49 +0,0 @@ -assertEquals($url->variant($name), ''); - - $url = new Url('http://www.testurl.com/'); - $this->assertEquals($url->variant($name), ''); - - $url = new Url('http://www.testurl.com/?f=test:on'); - $this->assertEquals($url->variant($name), ''); - - $url = new Url('http://www.testurl.com/?feature=test:on'); - $this->assertEquals($url->variant($name), 'on'); - - $url = new Url('http://www.testurl.com/?feature=test:on,test:off'); - $this->assertEquals($url->variant($name), 'off'); - - $url = new Url('http://www.testurl.com/?q=1&feature=test:off,test:on&a=2'); - $this->assertEquals($url->variant($name), 'on'); - - try { - new Url('bad url string'); - } - catch (\Exception $e) - { - $this->assertEquals( - $e->getMessage(), - 'bad url string is not a valid url.' - ); - } - } -} \ No newline at end of file From 887713e818abcb0cffcfefe428851d52e5444f7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Sun, 28 Oct 2018 17:12:26 -0400 Subject: [PATCH 64/92] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 79225c0..464e854 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Code Coverage](https://scrutinizer-ci.com/g/PabloJoan/feature/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/PabloJoan/feature/?branch=master) [![GitHub license](https://img.shields.io/github/license/PabloJoan/feature.svg)](https://github.com/PabloJoan/feature/blob/master/LICENSE) -Requires PHP 7.0 and above. +Requires PHP 7.2 and above. # Installation From d0d99d43e28714ee4849402421037bffe7f89b99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Sun, 28 Oct 2018 17:12:59 -0400 Subject: [PATCH 65/92] Update README.md --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 464e854..2016837 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ -[![PHP from Packagist](https://img.shields.io/packagist/php-v/pablojoan/feature.svg)]() [![Latest Stable Version](https://poser.pugx.org/pablojoan/feature/v/stable)](https://packagist.org/packages/pablojoan/feature) [![Build Status](https://travis-ci.org/PabloJoan/feature.svg?branch=master)](https://travis-ci.org/PabloJoan/feature) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/PabloJoan/feature/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/PabloJoan/feature/?branch=master) From 9bf9181f7ece8269d3554f8ba887ae094bdf8ae8 Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Sun, 28 Oct 2018 17:44:50 -0400 Subject: [PATCH 66/92] uneeded type casting --- src/Feature.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Feature.php b/src/Feature.php index 10e9f47..bcc4701 100644 --- a/src/Feature.php +++ b/src/Feature.php @@ -177,6 +177,6 @@ function variantBucketingBy (string $name, string $id) : string function description (string $name) : string { - return (string) $this->features->get($name)->description(); + return $this->features->get($name)->description(); } } From 41df5ee98ae04cd6b5d66881faadb8f72b4cb725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Sun, 28 Oct 2018 17:48:16 -0400 Subject: [PATCH 67/92] Update README.md --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 2016837..db511e7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ -[![Latest Stable Version](https://poser.pugx.org/pablojoan/feature/v/stable)](https://packagist.org/packages/pablojoan/feature) [![Build Status](https://travis-ci.org/PabloJoan/feature.svg?branch=master)](https://travis-ci.org/PabloJoan/feature) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/PabloJoan/feature/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/PabloJoan/feature/?branch=master) [![Code Coverage](https://scrutinizer-ci.com/g/PabloJoan/feature/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/PabloJoan/feature/?branch=master) From a984a1c8c0bd7e1a204e5d78ddd18c5d83974663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Sun, 28 Oct 2018 17:49:44 -0400 Subject: [PATCH 68/92] Update README.md --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index db511e7..e6a95e3 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,6 @@ $feature->description('foo'); // 'this is the description of the "foo" feature' # TODO -Replace magic strings with constants. -Write more usefull error messages. Add more bucketing schemes. # Documentation From 04cc91fc7a553b1cf23a281edff49d22be10d46d Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Sun, 28 Oct 2018 18:02:26 -0400 Subject: [PATCH 69/92] simplify code --- src/Config.php | 9 ++++---- src/Value/Bucketing.php | 20 ++++++++++++++++ src/Value/CalculateBucketingId.php | 37 ------------------------------ 3 files changed, 24 insertions(+), 42 deletions(-) delete mode 100644 src/Value/CalculateBucketingId.php diff --git a/src/Config.php b/src/Config.php index 66f9270..4585b33 100644 --- a/src/Config.php +++ b/src/Config.php @@ -5,7 +5,6 @@ namespace PabloJoan\Feature; use PabloJoan\Feature\Value\{ - CalculateBucketingId, User, Url, Feature, @@ -32,8 +31,8 @@ function __construct (User $user, Url $url, string $source) */ function isEnabled (Feature $feature) : bool { - $id = new CalculateBucketingId($this->user, $feature->bucketing()); - return Variant::OFF !== $this->chooseVariant($feature, $id->id()); + $id = $feature->bucketing()->id($this->user); + return Variant::OFF !== $this->chooseVariant($feature, $id); } /** @@ -42,8 +41,8 @@ function isEnabled (Feature $feature) : bool */ function variant (Feature $feature) : string { - $id = new CalculateBucketingId($this->user, $feature->bucketing()); - $variant = $this->chooseVariant($feature, $id->id()); + $id = $feature->bucketing()->id($this->user); + $variant = $this->chooseVariant($feature, $id); return $variant !== Variant::OFF ? $variant : ''; } diff --git a/src/Value/Bucketing.php b/src/Value/Bucketing.php index 4f4a5ff..21ce6cc 100644 --- a/src/Value/Bucketing.php +++ b/src/Value/Bucketing.php @@ -39,4 +39,24 @@ function by () : int { return $this->by; } + + function id (User $user) : string + { + $id = ''; + switch ($this->by) { + case Bucketing::USER: + $id = $user->id(); + break; + + case Bucketing::UAID: + $id = $user->uaid(); + break; + + case Bucketing::RANDOM: + $id = $user->uaid() ? $user->uaid() : 'no uaid'; + break; + } + + return $id; + } } diff --git a/src/Value/CalculateBucketingId.php b/src/Value/CalculateBucketingId.php deleted file mode 100644 index 4112096..0000000 --- a/src/Value/CalculateBucketingId.php +++ /dev/null @@ -1,37 +0,0 @@ -user = $user; - $this->bucketing = $bucketing; - } - - function id () : string - { - $id = ''; - switch ($this->bucketing->by()) { - case Bucketing::USER: - $id = $this->user->id(); - break; - - case Bucketing::UAID: - $id = $this->user->uaid(); - break; - - case Bucketing::RANDOM: - $id = $this->user->uaid() ? $this->user->uaid() : 'no uaid'; - break; - } - - return $id; - } -} From 94e3bbb96cad75cbbc90607bb5406948db337705 Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Mon, 29 Oct 2018 13:25:56 -0400 Subject: [PATCH 70/92] rename public_url_override to url_override --- README.md | 10 ++-- src/Config.php | 32 ++++++------ src/Feature.php | 6 +-- src/Value/ExcludeFrom.php | 8 +-- src/Value/Feature.php | 50 +++++++++---------- ...{PublicUrlOverride.php => UrlOverride.php} | 2 +- src/Value/User.php | 14 +++--- tests/ApiTest.php | 6 +-- tests/FeatureTest.php | 10 ++-- 9 files changed, 69 insertions(+), 69 deletions(-) rename src/Value/{PublicUrlOverride.php => UrlOverride.php} (92%) diff --git a/README.md b/README.md index e6a95e3..1737476 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ For full documentation, check [the wiki page](https://github.com/PabloJoan/featu * * * [$config_array['features']['feature_name']['sources']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_namesources) * * * [$config_array['features']['feature_name']['admin']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_nameadmin) * * * [$config_array['features']['feature_name']['internal']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_nameinternal) -* * * [$config_array['features']['feature_name']['public_url_override']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_namepublic_url_override) +* * * [$config_array['features']['feature_name']['url_override']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_nameurl_override) * * * [$config_array['features']['feature_name']['bucketing']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_namebucketing) * * * [$config_array['features']['feature_name']['exclude_from']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_nameexclude_from) * * * [$config_array['features']['feature_name']['start']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_namestart) @@ -263,7 +263,7 @@ cases along with the most concise way to write the configuration. ```php $server_config['features']['foo'] = [ 'enabled' => 0, - 'public_url_override' => true + 'url_override' => true ]; ``` ## Configuration details @@ -305,7 +305,7 @@ They can enable a variant of a feature if no `'enabled'` value is provided or if the variant’s percentage is 0. The two remaining feature config properties are `'bucketing'` and -`'public_url_override'`. Bucketing specifies how users are bucketed when a +`'url_override'`. Bucketing specifies how users are bucketed when a feature is enabled for only a percentage of users. The default value, `'random'`, causes each request to be bucketed independently meaning that the same user will be in different buckets on different requests. This is typically @@ -320,7 +320,7 @@ Finally the bucketing value, `'uaid'`, causes bucketing via the UAID cookie which means a user will be in the same bucket regardless of whether they are signed in or not. -The `'public_url_override'` property allows all requests, not just admin and +The `'url_override'` property allows all requests, not just admin and internal requests, to turn on a feature and choose a variant via the `features` query param. Its value will almost always be true if it is present since it defaults to false if omitted. @@ -329,7 +329,7 @@ defaults to false if omitted. The precedence of the various mechanisms for enabling a feature are as follows. - - If `'public_url_override'` is true and the request contains a `features` query + - If `'url_override'` is true and the request contains a `features` query param that specifies a variant for the feature in question, that variant is used. The value of the `features` param is a comma-delimited list of features where each feature is either simply the name of the feature, diff --git a/src/Config.php b/src/Config.php index 4585b33..fcfa13f 100644 --- a/src/Config.php +++ b/src/Config.php @@ -20,8 +20,8 @@ class Config function __construct (User $user, Url $url, string $source) { - $this->user = $user; - $this->url = $url; + $this->user = $user; + $this->url = $url; $this->source = $source; } @@ -74,27 +74,27 @@ function variantBucketingBy (Feature $feature, string $id) : string */ private function chooseVariant (Feature $feature, string $id) : string { - return $this->variantFromURL($feature) ?: - $this->variantTime($feature) ?: - $this->variantExcludedFrom($feature) ?: - $this->variantForUser($feature) ?: - $this->variantForGroup($feature) ?: - $this->variantForSource($feature) ?: - $this->variantForInternal($feature) ?: - $this->variantForAdmin($feature) ?: - $this->variantByPercentage($feature, $id) ?: + return $this->variantFromURL ($feature) ?: + $this->variantTime ($feature) ?: + $this->variantExcludedFrom ($feature) ?: + $this->variantForUser ($feature) ?: + $this->variantForGroup ($feature) ?: + $this->variantForSource ($feature) ?: + $this->variantForInternal ($feature) ?: + $this->variantForAdmin ($feature) ?: + $this->variantByPercentage ($feature, $id) ?: Variant::OFF; } /** - * If the feature has public_url_override set to true, a specific variant + * If the feature has url_override set to true, a specific variant * can be specified in the 'features' query parameter. In all other cases * return nothing, meaning nothing was specified. Note that foo:off will * turn off the 'foo' feature. */ private function variantFromURL (Feature $feature) : string { - return $feature->publicUrlOverride()->variant( + return $feature->urlOverride()->variant( $feature->name(), $this->url ); @@ -185,7 +185,7 @@ private function randomish (Feature $feature, string $id) : float $x = random_int(0, PHP_INT_MAX - 1) / PHP_INT_MAX; default: - $x = $this->numberFromHash($feature, $id); + $x = $this->numberFromHash($feature->name() . "-$id"); } return $x * 100; @@ -195,9 +195,9 @@ private function randomish (Feature $feature, string $id) : float * Map a hex value to the half-open interval between 0 and 1 while * preserving uniformity of the input distribution. */ - private function numberFromHash (Feature $feature, string $id) : float + private function numberFromHash (string $strToHash) : float { - $hash = hash('haval192,3', $feature->name() . "-$id"); + $hash = hash('haval192,3', $strToHash); $x = 0; for ($i = 0; $i < 47; ++$i) { $x = ($x * 2) + (hexdec($hash[$i]) < 8 ? 0 : 1); diff --git a/src/Feature.php b/src/Feature.php index bcc4701..3312e06 100644 --- a/src/Feature.php +++ b/src/Feature.php @@ -42,9 +42,9 @@ class Feature function __construct (array $input = null) { $this->features = new FeatureCollection($input['features'] ?? []); - $this->user = new User($input['user'] ?? []); - $this->url = new Url($input['url'] ?? ''); - $this->source = $input['source'] ?? ''; + $this->user = new User($input['user'] ?? []); + $this->url = new Url($input['url'] ?? ''); + $this->source = $input['source'] ?? ''; } /** diff --git a/src/Value/ExcludeFrom.php b/src/Value/ExcludeFrom.php index 2570a20..c592860 100644 --- a/src/Value/ExcludeFrom.php +++ b/src/Value/ExcludeFrom.php @@ -20,15 +20,15 @@ function __construct (array $excludeFrom) $countries = isset($excludeFrom['countries']); $countries = $countries && \is_array($excludeFrom['countries']); - $this->zips = $zips ? $excludeFrom['zips'] : []; - $this->regions = $regions ? $excludeFrom['regions'] : []; + $this->zips = $zips ? $excludeFrom['zips'] : []; + $this->regions = $regions ? $excludeFrom['regions'] : []; $this->countries = $countries ? $excludeFrom['countries'] : []; } function variant (User $user) : string { - $zips = \in_array($user->zipcode(), $this->zips, true); - $regions = \in_array($user->region(), $this->regions, true); + $zips = \in_array($user->zipcode(), $this->zips, true); + $regions = \in_array($user->region(), $this->regions, true); $countries = \in_array($user->country(), $this->countries, true); return $zips || $regions || $countries ? Variant::OFF : ''; diff --git a/src/Value/Feature.php b/src/Value/Feature.php index 9bf7fa7..833d6fa 100644 --- a/src/Value/Feature.php +++ b/src/Value/Feature.php @@ -18,38 +18,38 @@ class Feature private $sources; private $admin; private $internal; - private $publicUrlOverride; + private $urlOverride; private $excludeFrom; private $time; private $bucketing; function __construct (string $name, array $feature) { - $enabled = $feature['enabled'] ?? 0; - $description = $feature['description'] ?? ''; - $users = $feature['users'] ?? []; - $groups = $feature['groups'] ?? []; - $sources = $feature['sources'] ?? []; - $admin = $feature['admin'] ?? ''; - $internal = $feature['internal'] ?? ''; - $publicUrlOverride = $feature['public_url_override'] ?? false; + $enabled = $feature['enabled'] ?? 0; + $users = $feature['users'] ?? []; + $groups = $feature['groups'] ?? []; + $sources = $feature['sources'] ?? []; $excludeFrom = $feature['exclude_from'] ?? []; - $start = $feature['start'] ?? ''; - $end = $feature['end'] ?? ''; - $bucketing = $feature['bucketing'] ?? ''; - - $this->name = $name; - $this->enabled = new Enabled($enabled); + $description = $feature['description'] ?? ''; + $admin = $feature['admin'] ?? ''; + $internal = $feature['internal'] ?? ''; + $start = $feature['start'] ?? ''; + $end = $feature['end'] ?? ''; + $bucketing = $feature['bucketing'] ?? ''; + $urlOverride = $feature['url_override'] ?? false; + + $this->name = $name; $this->description = $description; - $this->users = new Users($users); - $this->groups = new Groups($groups); - $this->sources = new Sources($sources); - $this->admin = new Admin($admin); - $this->internal = new Internal($internal); - $this->publicUrlOverride = new PublicUrlOverride($publicUrlOverride); + $this->enabled = new Enabled($enabled); + $this->users = new Users($users); + $this->groups = new Groups($groups); + $this->sources = new Sources($sources); + $this->admin = new Admin($admin); + $this->internal = new Internal($internal); + $this->urlOverride = new UrlOverride($urlOverride); $this->excludeFrom = new ExcludeFrom($excludeFrom); - $this->time = new Time($start, $end); - $this->bucketing = new Bucketing($bucketing); + $this->time = new Time($start, $end); + $this->bucketing = new Bucketing($bucketing); } function name () : string @@ -92,9 +92,9 @@ function internal () : Internal return $this->internal; } - function publicUrlOverride () : PublicUrlOverride + function urlOverride () : UrlOverride { - return $this->publicUrlOverride; + return $this->urlOverride; } function excludeFrom () : ExcludeFrom diff --git a/src/Value/PublicUrlOverride.php b/src/Value/UrlOverride.php similarity index 92% rename from src/Value/PublicUrlOverride.php rename to src/Value/UrlOverride.php index 349c176..996c0e8 100644 --- a/src/Value/PublicUrlOverride.php +++ b/src/Value/UrlOverride.php @@ -4,7 +4,7 @@ namespace PabloJoan\Feature\Value; -class PublicUrlOverride +class UrlOverride { private $on; diff --git a/src/Value/User.php b/src/Value/User.php index 1c4c1f3..573e558 100644 --- a/src/Value/User.php +++ b/src/Value/User.php @@ -17,13 +17,13 @@ class User function __construct (array $user) { - $this->uaid = $user['uaid'] ?? ''; - $this->id = $user['id'] ?? ''; - $this->group = $user['group'] ?? ''; - $this->zipcode = $user['zipcode'] ?? ''; - $this->region = $user['region'] ?? ''; - $this->country = $user['country'] ?? ''; - $this->isAdmin = $user['is-admin'] ?? false; + $this->uaid = $user['uaid'] ?? ''; + $this->id = $user['id'] ?? ''; + $this->group = $user['group'] ?? ''; + $this->zipcode = $user['zipcode'] ?? ''; + $this->region = $user['region'] ?? ''; + $this->country = $user['country'] ?? ''; + $this->isAdmin = $user['is-admin'] ?? false; $this->internalIP = $user['internal-ip'] ?? false; } diff --git a/tests/ApiTest.php b/tests/ApiTest.php index 57fc2b9..36a5f93 100644 --- a/tests/ApiTest.php +++ b/tests/ApiTest.php @@ -634,17 +634,17 @@ function testExcludeFrom () ); } - function testPublicUrlOverride () + function testUrlOverride () { $feature = new Feature([ 'features' => [ 'testFeature' => [ 'enabled' => ['variant1' => 0, 'variant2' => 0], - 'public_url_override' => true + 'url_override' => true ], 'testFeature2' => [ 'enabled' => ['variant3' => 0, 'variant4' => 0], - 'public_url_override' => true + 'url_override' => true ] ], 'url' => 'http://www.testurl.com/?feature=testFeature:variant1,testFeature2:variant4' diff --git a/tests/FeatureTest.php b/tests/FeatureTest.php index 8789af6..6a169dc 100644 --- a/tests/FeatureTest.php +++ b/tests/FeatureTest.php @@ -28,7 +28,7 @@ function setUp () 'sources' => ['test3' => 'source1', 'test4' => 'source2'], 'admin' => 'test3', 'internal' => 'test1', - 'public_url_override' => true, + 'url_override' => true, 'exclude_from' => [ 'zips' => ['10014', '10023'], 'countries' => ['us', 'rd'], @@ -194,7 +194,7 @@ function testSetFeature () 'enabled' => ['test1' => 0, 'test4' => 0], 'users' => ['test1' => '2', 'test4' => '7'], 'sources' => ['test1' => 'source3'], - 'public_url_override' => true + 'url_override' => true ] ); $this->assertEquals($this->feature->isEnabled('testFeature2'), false); @@ -209,7 +209,7 @@ function testChangeUser () 'enabled' => ['test1' => 0, 'test4' => 0], 'users' => ['test1' => '2', 'test4' => '7'], 'sources' => ['test1' => 'source3'], - 'public_url_override' => true + 'url_override' => true ] ); $this->feature->changeUser(['id' => '2']); @@ -225,7 +225,7 @@ function testChangeUrl () 'enabled' => ['test1' => 0, 'test4' => 0], 'users' => ['test1' => '2', 'test4' => '7'], 'sources' => ['test1' => 'source3'], - 'public_url_override' => true + 'url_override' => true ] ); $this->feature->changeUrl( @@ -243,7 +243,7 @@ function testChangeSource () 'enabled' => ['test1' => 0, 'test4' => 0], 'users' => ['test1' => '2', 'test4' => '7'], 'sources' => ['test1' => 'source3'], - 'public_url_override' => true + 'url_override' => true ] ); $this->feature->changeSource('source3'); From 9255b482def0bdf034feef0c45e0382411bc06c5 Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Sat, 3 Nov 2018 19:55:38 -0400 Subject: [PATCH 71/92] bug fixes --- src/Bucketing/Calculator/Id.php | 25 ++++++++ src/Bucketing/Calculator/Random.php | 14 +++++ src/Bucketing/Random.php | 21 +++++++ src/Bucketing/Type.php | 18 ++++++ src/Bucketing/Uaid.php | 21 +++++++ src/Bucketing/User.php | 21 +++++++ src/Config.php | 35 +---------- src/Feature.php | 24 +++---- src/Value/Bucketing.php | 62 ------------------- src/Value/Enabled.php | 17 +++-- src/Value/Feature.php | 15 ++++- .../{FeatureCollection.php => Features.php} | 19 +++--- tests/ApiTest.php | 8 +-- 13 files changed, 168 insertions(+), 132 deletions(-) create mode 100644 src/Bucketing/Calculator/Id.php create mode 100644 src/Bucketing/Calculator/Random.php create mode 100644 src/Bucketing/Random.php create mode 100644 src/Bucketing/Type.php create mode 100644 src/Bucketing/Uaid.php create mode 100644 src/Bucketing/User.php delete mode 100644 src/Value/Bucketing.php rename src/Value/{FeatureCollection.php => Features.php} (66%) diff --git a/src/Bucketing/Calculator/Id.php b/src/Bucketing/Calculator/Id.php new file mode 100644 index 0000000..dbb9095 --- /dev/null +++ b/src/Bucketing/Calculator/Id.php @@ -0,0 +1,25 @@ +uaid() ?: 'no uaid'; + } + + function number (string $idToHash) : float + { + return (new Calculator)->number(); + } +} diff --git a/src/Bucketing/Type.php b/src/Bucketing/Type.php new file mode 100644 index 0000000..49c86e5 --- /dev/null +++ b/src/Bucketing/Type.php @@ -0,0 +1,18 @@ +uaid(); + } + + function number (string $idToHash) : float + { + return (new Calculator)->number($idToHash); + } +} diff --git a/src/Bucketing/User.php b/src/Bucketing/User.php new file mode 100644 index 0000000..8f80adb --- /dev/null +++ b/src/Bucketing/User.php @@ -0,0 +1,21 @@ +id(); + } + + function number (string $idToHash) : float + { + return (new Calculator)->number($idToHash); + } +} diff --git a/src/Config.php b/src/Config.php index fcfa13f..64a4c79 100644 --- a/src/Config.php +++ b/src/Config.php @@ -8,7 +8,6 @@ User, Url, Feature, - Bucketing, Variant }; @@ -170,39 +169,7 @@ private function variantTime (Feature $feature) : string private function variantByPercentage (Feature $feature, string $id) : string { return $feature->enabled()->variantByPercentage( - $this->randomish($feature, $id) + $feature->bucketing()->number($id) ); } - - /** - * A random-ish number between 0 and 100 based on the feature name and $id - * unless we are bucketing completely at random - */ - private function randomish (Feature $feature, string $id) : float - { - switch ($feature->bucketing()->by()) { - case Bucketing::RANDOM: - $x = random_int(0, PHP_INT_MAX - 1) / PHP_INT_MAX; - - default: - $x = $this->numberFromHash($feature->name() . "-$id"); - } - - return $x * 100; - } - - /** - * Map a hex value to the half-open interval between 0 and 1 while - * preserving uniformity of the input distribution. - */ - private function numberFromHash (string $strToHash) : float - { - $hash = hash('haval192,3', $strToHash); - $x = 0; - for ($i = 0; $i < 47; ++$i) { - $x = ($x * 2) + (hexdec($hash[$i]) < 8 ? 0 : 1); - } - - return $x / 140737488355328; // ( 2 ** 47 ) is the max value of $x - } } diff --git a/src/Feature.php b/src/Feature.php index 3312e06..a01d785 100644 --- a/src/Feature.php +++ b/src/Feature.php @@ -5,7 +5,7 @@ namespace PabloJoan\Feature; use PabloJoan\Feature\Value\{ - FeatureCollection, + Features, User, Url }; @@ -41,7 +41,7 @@ class Feature function __construct (array $input = null) { - $this->features = new FeatureCollection($input['features'] ?? []); + $this->features = new Features($input['features'] ?? []); $this->user = new User($input['user'] ?? []); $this->url = new Url($input['url'] ?? ''); $this->source = $input['source'] ?? ''; @@ -52,7 +52,7 @@ function __construct (array $input = null) */ function changeFeatures (array $features) : Feature { - $this->features = new FeatureCollection($features); + $this->features = new Features($features); return $this; } @@ -63,7 +63,7 @@ function changeFeatures (array $features) : Feature */ function setFeature (string $name, array $feature) : Feature { - $this->features->set($name, $feature); + $this->features[$name] = $feature; return $this; } @@ -72,7 +72,7 @@ function setFeature (string $name, array $feature) : Feature */ function removeFeature (string $name) : Feature { - $this->features->remove($name); + unset($this->features[$name]); return $this; } @@ -109,7 +109,7 @@ function changeSource (string $source) : Feature function isEnabled (string $name) : bool { $config = new Config($this->user, $this->url, $this->source); - return $config->isEnabled($this->features->get($name)); + return $config->isEnabled($this->features[$name]); } /** @@ -121,7 +121,7 @@ function isEnabled (string $name) : bool function isEnabledFor (string $name, array $user) : bool { $config = new Config(new User($user), $this->url, $this->source); - return $config->isEnabled($this->features->get($name)); + return $config->isEnabled($this->features[$name]); } /** @@ -133,7 +133,7 @@ function isEnabledBucketingBy (string $name, string $id) : bool { $config = new Config(new User([]), $this->url, $this->source); return $config->isEnabledBucketingBy( - $this->features->get($name), + $this->features[$name], $id ); } @@ -145,7 +145,7 @@ function isEnabledBucketingBy (string $name, string $id) : bool function variant (string $name) : string { $config = new Config($this->user, $this->url, $this->source); - return $config->variant($this->features->get($name)); + return $config->variant($this->features[$name]); } /** @@ -157,7 +157,7 @@ function variant (string $name) : string function variantFor (string $name, array $user) : string { $config = new Config(new User($user), $this->url, $this->source); - return $config->variant($this->features->get($name)); + return $config->variant($this->features[$name]); } /** @@ -170,13 +170,13 @@ function variantBucketingBy (string $name, string $id) : string { $config = new Config(new User([]), $this->url, $this->source); return $config->variantBucketingBy( - $this->features->get($name), + $this->features[$name], $id ); } function description (string $name) : string { - return $this->features->get($name)->description(); + return $this->features[$name]->description(); } } diff --git a/src/Value/Bucketing.php b/src/Value/Bucketing.php deleted file mode 100644 index 21ce6cc..0000000 --- a/src/Value/Bucketing.php +++ /dev/null @@ -1,62 +0,0 @@ -by = $bucketBy; - } - - function by () : int - { - return $this->by; - } - - function id (User $user) : string - { - $id = ''; - switch ($this->by) { - case Bucketing::USER: - $id = $user->id(); - break; - - case Bucketing::UAID: - $id = $user->uaid(); - break; - - case Bucketing::RANDOM: - $id = $user->uaid() ? $user->uaid() : 'no uaid'; - break; - } - - return $id; - } -} diff --git a/src/Value/Enabled.php b/src/Value/Enabled.php index 3c55551..a426622 100644 --- a/src/Value/Enabled.php +++ b/src/Value/Enabled.php @@ -20,19 +20,18 @@ function __construct ($enabled) $variant = is_int($variant) ? Variant::ON : $variant; $this->percentages[$variant] = $total; } + asort($this->percentages, SORT_NUMERIC); } function variantByPercentage (float $number) : string { - foreach ($this->percentages as $variant => $percent) { - $withinThreshold = $number < $percent; - switch ($withinThreshold) { - case true: - return $variant; - break; - } - } - return ''; + $threshHold = function ($percent) use ($number) { + return $number < $percent; + }; + + $variant = key(array_filter($this->percentages, $threshHold)); + + return (string) ($variant ?: ''); } private function percentage (int $percent) : int diff --git a/src/Value/Feature.php b/src/Value/Feature.php index 833d6fa..9f50d71 100644 --- a/src/Value/Feature.php +++ b/src/Value/Feature.php @@ -4,6 +4,8 @@ namespace PabloJoan\Feature\Value; +use PabloJoan\Feature\Bucketing\Type as BucketType; + /** * A feature that can be enabled, disabled, ramped up, and A/B tested, as well * as enabled for certain classes of users. @@ -35,8 +37,8 @@ function __construct (string $name, array $feature) $internal = $feature['internal'] ?? ''; $start = $feature['start'] ?? ''; $end = $feature['end'] ?? ''; - $bucketing = $feature['bucketing'] ?? ''; $urlOverride = $feature['url_override'] ?? false; + $bucketing = $feature['bucketing'] ?? 'Random'; $this->name = $name; $this->description = $description; @@ -49,7 +51,7 @@ function __construct (string $name, array $feature) $this->urlOverride = new UrlOverride($urlOverride); $this->excludeFrom = new ExcludeFrom($excludeFrom); $this->time = new Time($start, $end); - $this->bucketing = new Bucketing($bucketing); + $this->bucketing = $this->bucketingClass($bucketing); } function name () : string @@ -107,8 +109,15 @@ function time () : Time return $this->time; } - function bucketing () : Bucketing + function bucketing () : BucketType { return $this->bucketing; } + + private function bucketingClass (string $bucketing) : BucketType + { + $namespace = "PabloJoan\\Feature\\Bucketing\\"; + $bucketing = $namespace . ucfirst($bucketing); + return new $bucketing; + } } diff --git a/src/Value/FeatureCollection.php b/src/Value/Features.php similarity index 66% rename from src/Value/FeatureCollection.php rename to src/Value/Features.php index 5b75b4c..dc92aa3 100644 --- a/src/Value/FeatureCollection.php +++ b/src/Value/Features.php @@ -4,7 +4,7 @@ namespace PabloJoan\Feature\Value; -class FeatureCollection +class Features implements \ArrayAccess { private $features = []; @@ -15,20 +15,23 @@ function __construct (array $features) } } - function get (string $name) : Feature + function offsetSet ($name, $feature) { - return $this->features[$name] ?? new Feature($name, []); + $this->features[$name] = new Feature($name, $feature); } - function set (string $name, array $feature) : FeatureCollection + function offsetExists ($name) { - $this->features[$name] = new Feature($name, $feature); - return $this; + return isset($this->features[$name]); } - function remove (string $name) : FeatureCollection + function offsetUnset ($name) { unset($this->features[$name]); - return $this; + } + + function offsetGet ($name) : Feature + { + return $this->features[$name] ?? new Feature($name, []); } } diff --git a/tests/ApiTest.php b/tests/ApiTest.php index 36a5f93..254bfd9 100644 --- a/tests/ApiTest.php +++ b/tests/ApiTest.php @@ -701,8 +701,8 @@ function testBucketing () true ); $this->assertEquals($variant, true); - $this->assertEquals($feature->variant('testFeature2'), 'variant3'); - $this->assertEquals($feature->variant('testFeature3'), 'variant5'); + $this->assertEquals($feature->variant('testFeature2'), 'variant4'); + $this->assertEquals($feature->variant('testFeature3'), 'variant6'); $this->assertEquals( $feature->variantBucketingBy('testFeature2', 'testid1'), @@ -715,7 +715,7 @@ function testBucketing () $feature->changeUser(['id' => 'anotheruser', 'uaid' => 'string3']); - $this->assertEquals($feature->variant('testFeature2'), 'variant3'); - $this->assertEquals($feature->variant('testFeature3'), 'variant5'); + $this->assertEquals($feature->variant('testFeature2'), 'variant4'); + $this->assertEquals($feature->variant('testFeature3'), 'variant6'); } } From 744001e530fb9f8327a871de47935d2132b939c4 Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Sun, 4 Nov 2018 12:15:59 -0500 Subject: [PATCH 72/92] strict type annonymous function --- src/Value/Enabled.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Value/Enabled.php b/src/Value/Enabled.php index a426622..d189aed 100644 --- a/src/Value/Enabled.php +++ b/src/Value/Enabled.php @@ -25,7 +25,7 @@ function __construct ($enabled) function variantByPercentage (float $number) : string { - $threshHold = function ($percent) use ($number) { + $threshHold = function (int $percent) use ($number) : bool { return $number < $percent; }; From 356d3bd2b44fb45990b0cbf87c25b1957b282c3a Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Wed, 26 May 2021 21:52:36 -0400 Subject: [PATCH 73/92] Simplifying feature api. Breaking changes. PHP >= 8.0 --- .travis.yml | 4 +- README.md | 264 ++-------- composer.json | 9 +- psalm.xml | 29 ++ src/Bucketing/Calculator/Random.php | 14 - src/Bucketing/{Calculator => }/Id.php | 14 +- src/Bucketing/Random.php | 15 +- src/Bucketing/Type.php | 6 +- src/Bucketing/Uaid.php | 21 - src/Bucketing/User.php | 21 - src/Config.php | 175 ------- src/Configurations/Collection.php | 28 + src/Configurations/Config.php | 74 +++ src/Feature.php | 171 +----- src/Value/Admin.php | 20 - src/Value/Enabled.php | 41 -- src/Value/ExcludeFrom.php | 36 -- src/Value/Feature.php | 123 ----- src/Value/Features.php | 37 -- src/Value/Groups.php | 28 - src/Value/Internal.php | 20 - src/Value/Sources.php | 28 - src/Value/Time.php | 30 -- src/Value/Url.php | 33 -- src/Value/UrlOverride.php | 20 - src/Value/User.php | 69 --- src/Value/Users.php | 28 - src/Value/Variant.php | 11 - tests/ApiTest.php | 721 -------------------------- tests/FeatureTest.php | 320 +++--------- 30 files changed, 295 insertions(+), 2115 deletions(-) create mode 100644 psalm.xml delete mode 100644 src/Bucketing/Calculator/Random.php rename src/Bucketing/{Calculator => }/Id.php (53%) delete mode 100644 src/Bucketing/Uaid.php delete mode 100644 src/Bucketing/User.php delete mode 100644 src/Config.php create mode 100644 src/Configurations/Collection.php create mode 100644 src/Configurations/Config.php delete mode 100644 src/Value/Admin.php delete mode 100644 src/Value/Enabled.php delete mode 100644 src/Value/ExcludeFrom.php delete mode 100644 src/Value/Feature.php delete mode 100644 src/Value/Features.php delete mode 100644 src/Value/Groups.php delete mode 100644 src/Value/Internal.php delete mode 100644 src/Value/Sources.php delete mode 100644 src/Value/Time.php delete mode 100644 src/Value/Url.php delete mode 100644 src/Value/UrlOverride.php delete mode 100644 src/Value/User.php delete mode 100644 src/Value/Users.php delete mode 100644 src/Value/Variant.php delete mode 100644 tests/ApiTest.php diff --git a/.travis.yml b/.travis.yml index 84171aa..d0cad91 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,7 @@ language: php php: - - '7.2' + - '8.0' - nightly -script: composer install --no-interaction && composer phpstan && composer phpunit +script: composer install --no-interaction && composer test \ No newline at end of file diff --git a/README.md b/README.md index 1737476..917c469 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Code Coverage](https://scrutinizer-ci.com/g/PabloJoan/feature/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/PabloJoan/feature/?branch=master) [![GitHub license](https://img.shields.io/github/license/PabloJoan/feature.svg)](https://github.com/PabloJoan/feature/blob/master/LICENSE) -Requires PHP 7.2 and above. +Requires PHP 8.0 and above. # Installation @@ -11,11 +11,6 @@ Requires PHP 7.2 and above. composer require pablojoan/feature ``` -# Running tests -```bash -composer phpstan && composer phpunit -``` - # Basic Usage ```php @@ -23,27 +18,19 @@ composer phpstan && composer phpunit use PabloJoan\Feature\Feature; // Import the namespace. $config = [ - 'features' => [ - 'foo' => [ - 'description' => 'this is the description of the "foo" feature', - 'enabled' => [ - 'variant1' => 100, //100% chance this variable will be chosen - 'variant2' => 0 //0% chance this variable will be chosen - ] - ], - 'bar' => [ - 'description' => 'this is the description of the "bar" feature', - 'enabled' => [ - 'variant1' => 25, //25% chance this variable will be chosen - 'variant2' => 25, //25% chance this variable will be chosen - 'variant3' => 50 //50% chance this variable will be chosen - ], - 'bucketing' => 'uaid' //same uaid string will always return the same variant + 'foo' => [ + 'enabled' => [ + 'variant1' => 100, //100% chance this variable will be chosen + 'variant2' => 0 //0% chance this variable will be chosen ] ], - 'user' => [ - 'uaid' => 'unique identifier', // ex. session id or cookie - 'id' => 'logged in user ID', // if applicable + 'bar' => [ + 'enabled' => [ + 'variant1' => 25, //25% chance this variable will be chosen + 'variant2' => 25, //25% chance this variable will be chosen + 'variant3' => 50 //50% chance this variable will be chosen + ], + 'bucketing' => 'id' //same id string will always return the same variant ] ]; @@ -51,53 +38,9 @@ $feature = new Feature($config); $feature->isEnabled('foo'); // true $feature->variant('foo'); // 'variant1' -$feature->description('foo'); // 'this is the description of the "foo" feature' ``` -# TODO - -Add more bucketing schemes. - -# Documentation - For a quick summary and common use cases, please read the rest of this README. -For full documentation, check [the wiki page](https://github.com/PabloJoan/feature/wiki) - -## Full list of features - -* [Feature Class](https://github.com/PabloJoan/feature/wiki/The-Feature-Class) -* * [Feature::__construct ( array ) : Feature](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#how-to-create-a-feature-class-instance) -* * [Feature::changeFeatures ( array ) : Feature](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featurechangefeatures--array---Feature) -* * [Feature::setFeature ( string , array ) : Feature](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featuresetfeature--string--array---Feature) -* * [Feature::removeFeature ( string ) : Feature](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featureremovefeature--string---Feature) -* * [Feature::changeUser ( array ) : Feature](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featurechangeuser--array---Feature) -* * [Feature::changeUrl ( string ) : Feature](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featurechangeurl--string---Feature) -* * [Feature::changeSource ( string ) : Feature](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featurechangesource--string---Feature) -* * [Feature::isEnabled ( string ) : boolean](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featureisenabled--string---boolean) -* * [Feature::isEnabledFor ( string, array ) : boolean](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featureisenabledfor--string-array---boolean) -* * [Feature::isEnabledBucketingBy ( string, string ) : boolean](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featureisenabledbucketingby--string-string---boolean) -* * [Feature::variant ( string ) : string](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featurevariant--string---string) -* * [Feature::variantFor ( string, array ) : string](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featurevariantfor--string-array---string) -* * [Feature::variantBucketingBy ( string, string ) : string](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featurevariantbucketingby--string-string---string) -* * [Feature::description ( string ) : string](https://github.com/PabloJoan/feature/wiki/The-Feature-Class#featuredescription--string---string) - -* [The Config Array API](https://github.com/PabloJoan/feature/wiki/Config-API) -* * [$config_array['features']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeatures) -* * * [$config_array['features']['feature_name']['description']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_namedescription) -* * * [$config_array['features']['feature_name']['enabled']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_nameenabled) -* * * [$config_array['features']['feature_name']['users']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_nameusers) -* * * [$config_array['features']['feature_name']['groups']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_namegroups) -* * * [$config_array['features']['feature_name']['sources']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_namesources) -* * * [$config_array['features']['feature_name']['admin']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_nameadmin) -* * * [$config_array['features']['feature_name']['internal']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_nameinternal) -* * * [$config_array['features']['feature_name']['url_override']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_nameurl_override) -* * * [$config_array['features']['feature_name']['bucketing']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_namebucketing) -* * * [$config_array['features']['feature_name']['exclude_from']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_nameexclude_from) -* * * [$config_array['features']['feature_name']['start']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_namestart) -* * * [$config_array['features']['feature_name']['end']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayfeaturesfeature_nameend) -* * [$config_array['user']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayuser) -* * [$config_array['url']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arrayurl) -* * [$config_array['source']](https://github.com/PabloJoan/feature/wiki/Config-API#config_arraysource) # Feature API @@ -142,38 +85,6 @@ each variant with something like this: } ``` -The API also provides two other pairs of methods that will be used -much less frequently: -```php - $feature->isEnabledFor('my_feature', $user) - - $feature->variantFor('my_feature', $user) -``` -and -```php - $feature->isEnabledBucketingBy('my_feature', $bucketingID) - - $feature->variantBucketingBy('my_feature', $bucketingID) -``` -These methods exist only to support a couple very specific use-cases: when we -want to enable or disable a feature based not on the user making the request but -on some other user or when we want to bucket a percentage of executions based on -something entirely other than a user.) The canonical case for the former is if -we wanted to change something about how we deal with listings and instead of -enabling the feature for only some users but for all listings those users see, -but instead we want to enable it for all users but for only some of the -listings. Then we could use `isEnabledFor` and `variantFor` and pass in the user -object representing the owner of the listing. That would also allow us to enable -the feature for specific listing owners. The `bucketingBy` methods serve a -similar purpose except when there either is no relevant user or where we don't -want to always put the same user in the same bucket. Thus if we wanted to enable -a certain feature for 10% of all listings displayed, independent of both the -user making the request and the user who owned the listing, we could use -`isEnabledBucketingBy` with the listing id as the bucketing ID. - -In general it is much more likely you want to use the plain old `isEnabled` and -`variant` methods. - ## Configuration cookbook There are a number of common configurations so before I explain the complete @@ -182,27 +93,23 @@ cases along with the most concise way to write the configuration. ### A totally enabled feature: ```php - $server_config['features']['foo'] = ['enabled' => 100]; + $server_config['foo'] = ['enabled' => 100]; ``` ### A totally disabled feature: ```php - $server_config['features']['foo'] = ['enabled' => 0]; + $server_config['foo'] = ['enabled' => 0]; ``` ### Feature with winning variant turned on for everyone ```php - $server_config['features']['foo'] = ['enabled' => ['blue_background' => 100]]; -``` -### Feature enabled only for admins: -```php - $server_config['features']['foo'] = ['admin' => 'on']; + $server_config['foo'] = ['enabled' => ['blue_background' => 100]]; ``` ### Single-variant feature ramped up to 1% of users. ```php - $server_config['features']['foo'] = ['enabled' => 1]; + $server_config['foo'] = ['enabled' => 1]; ``` ### Multi-variant feature ramped up to 1% of users for each variant. ```php - $server_config['features']['foo'] = [ + $server_config['foo'] = [ 'enabled' => [ 'blue_background' => 1, 'orange_background' => 1, @@ -210,70 +117,47 @@ cases along with the most concise way to write the configuration. ], ]; ``` -### Enabled for a single specific user. -```php - $server_config['features']['foo'] = ['users' => ['on' => 'fred']]; -``` -### Enabled for a few specific users. -```php - $server_config['features']['foo'] = [ - 'users' => ['on' => ['fred', 'barney', 'wilma', 'betty']] - ]; -``` -### Enabled for a specific group +### Enabled for 10% of regular users. ```php - $server_config['features']['foo'] = ['groups' => ['on' => '1234']]; -``` -### Enabled for 10% of regular users and all admin. -```php - $server_config['features']['foo'] = [ - 'enabled' => 10, - 'admin' => 'on', + $server_config['foo'] = [ + 'enabled' => 10 ]; ``` -### Feature ramped up to 1% of requests, bucketing at random rather than by user +### Feature ramped up to 1% of requests, bucketing at random rather than by id ```php - $server_config['features']['foo'] = [ + $server_config['foo'] = [ 'enabled' => 1, - 'bucketing' => 'random', + 'bucketing' => 'random' ]; ``` -### Feature ramped up to 40% of requests, bucketing by user rather than at random +### Feature ramped up to 40% of requests, bucketing by id rather than at random ```php - $server_config['features']['foo'] = [ + $server_config['foo'] = [ 'enabled' => 40, - 'bucketing' => 'user', + 'bucketing' => 'id' ]; ``` ### Single-variant feature in 50/50 A/B test ```php - $server_config['features']['foo'] = ['enabled' => 50]; + $server_config['foo'] = ['enabled' => 50]; ``` ### Multi-variant feature in A/B test with 20% of users seeing each variant (and 40% left in control group). ```php - $server_config['features']['foo'] = [ + $server_config['foo'] = [ 'enabled' => [ 'blue_background' => 20, 'orange_background' => 20, - 'pink_background' => 20, + 'pink_background' => 20 ], ]; ``` -### New feature intended only to be enabled by adding ?features=foo to a URL -```php - $server_config['features']['foo'] = [ - 'enabled' => 0, - 'url_override' => true - ]; -``` ## Configuration details Each feature’s config stanza controls when the feature is enabled and what variant should be used when it is. -Leaving aside a few shorthands that will be explained in a moment, the value of -a feature config stanza is an array with a number of special keys, the most -important of which is `'enabled'`. +The value of a feature config stanza is an array with a number of special +keys, the most important of which is `'enabled'`. In its full form, the value of the `'enabled'` property an array whose keys are names of variants and whose values are the percentage of requests that should @@ -282,78 +166,15 @@ see each variant. As a shorthand to support the common case of a feature with only one variant, `'enabled'` can also be specified as a percentage from 0 to 100. -The next four most important properties of a feature config stanza specify a -particular variant that special classes of users should see: `'admin'`, -`'internal'`, `'users'`, and `'groups'`. - -The `'admin'` and `'internal'` properties, if present, should name a variant -that should be shown for all admin users or all internal requests. For -single-variant features this name will almost always be `'on'`. -For multi-variant features it can be any of the variants mentioned in the -`'enabled'` array. - -The `'users'` and `'groups'` variants provide a mapping from variant names to -lists of users or numeric group ids. In the fully specified case, the value will -be an array whose keys are the names of variants and whose values are lists of -user names or group ids, as appropriate. As a shorthand, if the list of user -names or group ids is a single element it can be specified with just the name or -id. -```php - $server_config['features']['foo'] = ['users' => ['on' => 'fred']]; -``` -They can enable a variant of a feature if no `'enabled'` value is provided or -if the variant’s percentage is 0. - -The two remaining feature config properties are `'bucketing'` and -`'url_override'`. Bucketing specifies how users are bucketed when a -feature is enabled for only a percentage of users. The default value, -`'random'`, causes each request to be bucketed independently meaning that the -same user will be in different buckets on different requests. This is typically -used for features that should have no user-visible effects but where we want to -ramp up something like the switch from master to shards or a new version of -jquery. - -The bucketing value `'user'`, causes bucketing to be based on the signed-in user -id. +The remaining feature config property is `'bucketing'`. Bucketing specifies +how users are bucketed when a feature is enabled for only a percentage of users. +The default value, `'random'`, causes each request to be bucketed independently, +meaning that the same user will be in different buckets on different requests. +This is typically used for features that should have no user-visible effects +but where we want to ramp up something like the switch from master to shards +or a new version of JS code. -Finally the bucketing value, `'uaid'`, causes bucketing via the UAID cookie -which means a user will be in the same bucket regardless of whether they are -signed in or not. - -The `'url_override'` property allows all requests, not just admin and -internal requests, to turn on a feature and choose a variant via the `features` -query param. Its value will almost always be true if it is present since it -defaults to false if omitted. - -## Precedence: - -The precedence of the various mechanisms for enabling a feature are as follows. - - - If `'url_override'` is true and the request contains a `features` query - param that specifies a variant for the feature in question, that variant is - used. The value of the `features` param is a comma-delimited list of - features where each feature is either simply the name of the feature, - indicating the feature should be enabled with variant `'on'` or the name of - a feature, a colon, and the variant name. E.g. a request with - `features=foo,bar:x,baz:off` would turn on feature `foo`, turn on feature - `bar` with variant `x`, and turn off feature `baz`. - - - Otherwise, if the request is from a user specified in the `'users'` - property, the specified variant is enabled. - - - Otherwise, if the request is from a member of a group specified in the - `'groups'` property the specified variant is enabled. (The behavior when - the user is a member of multiple groups that have been assigned different - variants is undefined. Beware nasal demons.) - - - Otherwise, if the request is an internal request, the `'internal'` variant - is enabled. - - - Otherwise, if the request is from an admin, the `'admin'` variant is - enabled. - - - Otherwise, the request is bucketed and a variant is chosen so that the - correct percentage of bucketed requests will see each variant. +The bucketing value `'id'`, causes bucketing to be based on the given id. ## Errors @@ -371,8 +192,5 @@ future.) 4. Setting `'enabled'` to a non-numeric, non-array value. - 5. Setting `'bucketing'` to `'user'` and not providing an id string to the - user array. - - 6. Setting `'bucketing'` to `'uaid'` and not providing a uaid string to the - user array. + 5. Setting `'bucketing'` to `'id'` and not providing an id string to the + `$feature->variant` or the `$feature->isEnabled` function. diff --git a/composer.json b/composer.json index f5b9dab..1a7fefe 100644 --- a/composer.json +++ b/composer.json @@ -22,15 +22,16 @@ "toggle" ], "require": { - "php": ">=7.2" + "php": ">=8.0" }, "require-dev": { "phpunit/phpunit": "*", - "phpstan/phpstan": "*" + "phpstan/phpstan": "*", + "vimeo/psalm": "*", + "squizlabs/php_codesniffer": "*" }, "scripts": { - "phpstan": "./vendor/bin/phpstan analyse --level=max --debug -vvv src/ tests/", - "phpunit": "./vendor/bin/phpunit --stop-on-failure --fail-on-warning --fail-on-risky -v --debug --coverage-text --whitelist src/ tests/" + "test": "./vendor/bin/phpcbf --standard=PSR12 src/ tests/ && ./vendor/bin/phpstan analyse --level=max --debug -vvv src/ tests/ && ./vendor/bin/psalm && php -d xdebug.mode=coverage ./vendor/bin/phpunit --stop-on-failure --fail-on-warning --fail-on-risky -v --debug --coverage-text --whitelist src/ tests/ && ./vendor/bin/phpcs --standard=PSR12 src/ tests/" }, "autoload": { "psr-4": { diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..4ab8899 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + diff --git a/src/Bucketing/Calculator/Random.php b/src/Bucketing/Calculator/Random.php deleted file mode 100644 index 470aecd..0000000 --- a/src/Bucketing/Calculator/Random.php +++ /dev/null @@ -1,14 +0,0 @@ -uaid() ?: 'no uaid'; - } - - function number (string $idToHash) : float + public function randomIshNumber(string $idToHash = ''): float { - return (new Calculator)->number(); + $x = random_int(0, PHP_INT_MAX - 1) / PHP_INT_MAX; + return $x * 100; } } diff --git a/src/Bucketing/Type.php b/src/Bucketing/Type.php index 49c86e5..7e78582 100644 --- a/src/Bucketing/Type.php +++ b/src/Bucketing/Type.php @@ -4,15 +4,11 @@ namespace PabloJoan\Feature\Bucketing; -use PabloJoan\Feature\Value\User; - interface Type { - function id (User $user) : string; - /** * A random-ish number between 0 and 100 based on the feature name and $id * unless we are bucketing completely at random */ - function number (string $idToHash) : float; + public function randomIshNumber(string $idToHash = ''): float; } diff --git a/src/Bucketing/Uaid.php b/src/Bucketing/Uaid.php deleted file mode 100644 index a2f7a5d..0000000 --- a/src/Bucketing/Uaid.php +++ /dev/null @@ -1,21 +0,0 @@ -uaid(); - } - - function number (string $idToHash) : float - { - return (new Calculator)->number($idToHash); - } -} diff --git a/src/Bucketing/User.php b/src/Bucketing/User.php deleted file mode 100644 index 8f80adb..0000000 --- a/src/Bucketing/User.php +++ /dev/null @@ -1,21 +0,0 @@ -id(); - } - - function number (string $idToHash) : float - { - return (new Calculator)->number($idToHash); - } -} diff --git a/src/Config.php b/src/Config.php deleted file mode 100644 index 64a4c79..0000000 --- a/src/Config.php +++ /dev/null @@ -1,175 +0,0 @@ -user = $user; - $this->url = $url; - $this->source = $source; - } - - /** - * Is this feature enabled for the default id and the logged in user, if - * any? - */ - function isEnabled (Feature $feature) : bool - { - $id = $feature->bucketing()->id($this->user); - return Variant::OFF !== $this->chooseVariant($feature, $id); - } - - /** - * What variant is enabled for the default id and the logged in user, if - * any? - */ - function variant (Feature $feature) : string - { - $id = $feature->bucketing()->id($this->user); - $variant = $this->chooseVariant($feature, $id); - return $variant !== Variant::OFF ? $variant : ''; - } - - /** - * Is this feature enabled, bucketing on the given bucketing ID? (Other - * methods of enabling a feature and specifying a variant such as users, - * groups, and query parameters, will still work.) - */ - function isEnabledBucketingBy (Feature $feature, string $id) : bool - { - return $this->chooseVariant($feature, $id) !== Variant::OFF; - } - - /** - * What variant is enabled, bucketing on the given bucketing ID, if any? - */ - function variantBucketingBy (Feature $feature, string $id) : string - { - $variant = $this->chooseVariant($feature, $id); - return $variant !== Variant::OFF ? $variant : ''; - } - - /** - * Get the name of the variant we should use. Returns OFF if the feature is - * not enabled for $id. - * - * BucketingId $id - the id used to assign a variant based on the percentage - * of users that should see different variants. - */ - private function chooseVariant (Feature $feature, string $id) : string - { - return $this->variantFromURL ($feature) ?: - $this->variantTime ($feature) ?: - $this->variantExcludedFrom ($feature) ?: - $this->variantForUser ($feature) ?: - $this->variantForGroup ($feature) ?: - $this->variantForSource ($feature) ?: - $this->variantForInternal ($feature) ?: - $this->variantForAdmin ($feature) ?: - $this->variantByPercentage ($feature, $id) ?: - Variant::OFF; - } - - /** - * If the feature has url_override set to true, a specific variant - * can be specified in the 'features' query parameter. In all other cases - * return nothing, meaning nothing was specified. Note that foo:off will - * turn off the 'foo' feature. - */ - private function variantFromURL (Feature $feature) : string - { - return $feature->urlOverride()->variant( - $feature->name(), - $this->url - ); - } - - /** - * Get the variant this user should see, if one was configured, none - * otherwise. - */ - private function variantForUser (Feature $feature) : string - { - return $feature->users()->variant($this->user); - } - - /** - * Get the variant visitor should see based on group they're currently - * viewing. - */ - private function variantForSource (Feature $feature) : string - { - return $feature->sources()->variant($this->source); - } - - /** - * Get the variant this user should see based on their group memberships, if - * one was configured, none otherwise. N.B. If the user is in multiple - * groups that are configured to see different variants, they'll get the - * variant for one of their groups but there's no saying which one. If this - * is a problem in practice we could make the configuration more complex. Or - * you can just provide a specific variant via the 'users' property. - */ - private function variantForGroup (Feature $feature) : string - { - return $feature->groups()->variant($this->user); - } - - /** - * What variant, if any, should we return if the current user is an admin. - */ - private function variantForAdmin (Feature $feature) : string - { - return $feature->admin()->variant($this->user); - } - - /** - * What variant, if any, should we return for internal requests. - */ - private function variantForInternal (Feature $feature) : string - { - return $feature->internal()->variant($this->user); - } - - /** - * Is this user excluded from seeing this feature because of their location? - */ - private function variantExcludedFrom (Feature $feature) : string - { - return $feature->excludeFrom()->variant($this->user); - } - - /** - * Is this feature within the enabled time it was configured? - */ - private function variantTime (Feature $feature) : string - { - return $feature->time()->variant(); - } - - /** - * Finally, the normal case: use the percentage of users who should see each - * variant to map a random-ish number to a particular variant. - */ - private function variantByPercentage (Feature $feature, string $id) : string - { - return $feature->enabled()->variantByPercentage( - $feature->bucketing()->number($id) - ); - } -} diff --git a/src/Configurations/Collection.php b/src/Configurations/Collection.php new file mode 100644 index 0000000..f26f009 --- /dev/null +++ b/src/Configurations/Collection.php @@ -0,0 +1,28 @@ + $configurations + */ + public function __construct(array $configurations) + { + foreach ($configurations as $featureName => $config) { + $this->configurations[(string)$featureName] = new Config(config: $config); + } + } + + public function get(string $featureName): Config + { + return $this->configurations[$featureName]; + } +} diff --git a/src/Configurations/Config.php b/src/Configurations/Config.php new file mode 100644 index 0000000..0756b65 --- /dev/null +++ b/src/Configurations/Config.php @@ -0,0 +1,74 @@ + 0]) + { + $bucketing = $config['bucketing'] ?? 'random'; + $this->bucketing = match ($bucketing) { + 'random' => new BucketRandom(), + 'id' => new BucketId(), + default => throw new \Exception("bucketing option: $bucketing not supported.") + }; + + $this->parseEnabled(enabled: $config['enabled'] ?? 0); + } + + /** + * The percentage of users who should see each variant to + * map a random-ish number to a particular variant. + */ + public function variantByPercentage(string $id): string + { + $number = $this->bucketing->randomIshNumber(idToHash: $id); + + $percentRange = fn (int $percent): bool => $number < $percent; + + $variant = (string) key(array_filter($this->percentages, $percentRange)); + + return ($variant || $variant === '0') ? $variant : ''; + } + + /** + * Parse the 'enabled' property of the feature's config stanza. + * Returns the upper-boundary of the variants percentage. + * + * @param int|array $enabled + */ + private function parseEnabled(int|array $enabled): void + { + $total = 0; + foreach ((array) $enabled as $variant => $percent) { + $total += $this->percentage(percent: $percent); + $this->percentages[(string)$variant] = $total; + } + asort($this->percentages, SORT_NUMERIC); + } + + private function percentage(int $percent): int + { + return ($percent >= 0 && $percent <= 100) ? $percent : 0; + } +} diff --git a/src/Feature.php b/src/Feature.php index a01d785..8df30a0 100644 --- a/src/Feature.php +++ b/src/Feature.php @@ -4,11 +4,7 @@ namespace PabloJoan\Feature; -use PabloJoan\Feature\Value\{ - Features, - User, - Url -}; +use PabloJoan\Feature\Configurations\Collection; /** * The public API testing whether a specific feature is enabled and, if so, what @@ -16,167 +12,46 @@ * * Primary public API: * - * Feature->isEnabled('foo'); - * Feature->variant('foo'); + * Feature->isEnabled(featureName: 'foo'); + * Feature->variant(featureName: 'foo'); * * For cases when we want to bucket on a user other than the currently logged in - * user (e.g. to bucket how we treat listings by their owners) this secondary - * API is available: + * user, on something else entirely (such as a shop ID), or any arbitrary + * string, pass the string value as a second parameter. * - * Feature->isEnabledFor('foo', $user); - * Feature->variantFor('foo', $user); - * - * And for case when we want to bucket on something else entirely (such as a - * shop ID), we provide these two methods: - * - * Feature->isEnabledBucketingBy('foo', $bucketingID); - * Feature->variantBucketingBy('foo', $bucketingID); + * Feature->isEnabled(featureName: 'foo', id: $id); + * Feature->variant(featureName: 'foo', id: $id); */ -class Feature +final class Feature { - private $features; - private $user; - private $url; - private $source; - - function __construct (array $input = null) - { - $this->features = new Features($input['features'] ?? []); - $this->user = new User($input['user'] ?? []); - $this->url = new Url($input['url'] ?? ''); - $this->source = $input['source'] ?? ''; - } - - /** - * Replaces all features with a new set of features. - */ - function changeFeatures (array $features) : Feature - { - $this->features = new Features($features); - return $this; - } + private Collection $features; /** - * Replaces one existing feature with a new feature config of the same name. - * If feature does not exist, it adds one new feature config to the - * collection of features. + * @param array $features */ - function setFeature (string $name, array $feature) : Feature + public function __construct(array $features) { - $this->features[$name] = $feature; - return $this; + $this->features = new Collection(configurations: $features); } /** - * Removes one existing feature from the collection. + * Test whether the named feature is enabled for a given user + * or arbitrary string. */ - function removeFeature (string $name) : Feature + public function isEnabled(string $featureName, string $id = ''): bool { - unset($this->features[$name]); - return $this; + return '' !== $this->variant(featureName: $featureName, id: $id); } /** - * Replaces the user used to calculate variants. + * Get the name of the A/B variant for the named feature for + * the given user or arbitrary string. Returns an empty string + * if the feature is not enabled for $userId. */ - function changeUser (array $user) : Feature - { - $this->user = new User($user); - return $this; - } - - /** - * Replaces the url used to calculate variants. - */ - function changeUrl (string $url) : Feature - { - $this->url = new Url($url); - return $this; - } - - /** - * Replaces the source used to calculate variants. - */ - function changeSource (string $source) : Feature - { - $this->source = $source; - return $this; - } - - /** - * Test whether the named feature is enabled for the current user. - */ - function isEnabled (string $name) : bool - { - $config = new Config($this->user, $this->url, $this->source); - return $config->isEnabled($this->features[$name]); - } - - /** - * Test whether the named feature is enabled for a given user. This method - * should only be used when we want to bucket based on a user other than the - * current logged in user, e.g. if we are bucketing different listings based - * on their owner. - */ - function isEnabledFor (string $name, array $user) : bool - { - $config = new Config(new User($user), $this->url, $this->source); - return $config->isEnabled($this->features[$name]); - } - - /** - * Test whether the named feature is enabled for a given arbitrary string. - * This method should only be used when we want to bucket based on something - * other than a user, e.g. shops, teams, treasuries, tags, etc. - */ - function isEnabledBucketingBy (string $name, string $id) : bool - { - $config = new Config(new User([]), $this->url, $this->source); - return $config->isEnabledBucketingBy( - $this->features[$name], - $id - ); - } - - /** - * Get the name of the A/B variant for the named feature for the current - * user. - */ - function variant (string $name) : string - { - $config = new Config($this->user, $this->url, $this->source); - return $config->variant($this->features[$name]); - } - - /** - * Get the name of the A/B variant for the named feature for the given user. - * This method should only be used when we want to bucket based on a user - * other than the current logged in user, e.g. if we are bucketing different - * listings based on their owner. - */ - function variantFor (string $name, array $user) : string - { - $config = new Config(new User($user), $this->url, $this->source); - return $config->variant($this->features[$name]); - } - - /** - * Get the name of the A/B variant for the named feature, bucketing by the - * given bucketing ID. (For other checks such as admin, and user whitelists - * uses the current user which may or may not make sense. If it doesn't - * make sense, don't configure the feature to use those mechanisms.) - */ - function variantBucketingBy (string $name, string $id) : string - { - $config = new Config(new User([]), $this->url, $this->source); - return $config->variantBucketingBy( - $this->features[$name], - $id - ); - } - - function description (string $name) : string + public function variant(string $featureName, string $id = ''): string { - return $this->features[$name]->description(); + return $this->features + ->get(featureName: $featureName) + ->variantByPercentage(id: $id); } } diff --git a/src/Value/Admin.php b/src/Value/Admin.php deleted file mode 100644 index eea0990..0000000 --- a/src/Value/Admin.php +++ /dev/null @@ -1,20 +0,0 @@ -variant = $variant; - } - - function variant (User $user) : string - { - return $user->isAdmin() ? $this->variant : ''; - } -} diff --git a/src/Value/Enabled.php b/src/Value/Enabled.php deleted file mode 100644 index d189aed..0000000 --- a/src/Value/Enabled.php +++ /dev/null @@ -1,41 +0,0 @@ - $percent) { - $total += $this->percentage($percent); - $variant = is_int($variant) ? Variant::ON : $variant; - $this->percentages[$variant] = $total; - } - asort($this->percentages, SORT_NUMERIC); - } - - function variantByPercentage (float $number) : string - { - $threshHold = function (int $percent) use ($number) : bool { - return $number < $percent; - }; - - $variant = key(array_filter($this->percentages, $threshHold)); - - return (string) ($variant ?: ''); - } - - private function percentage (int $percent) : int - { - return ($percent >= 0 && $percent <= 100) ? $percent : 0; - } -} diff --git a/src/Value/ExcludeFrom.php b/src/Value/ExcludeFrom.php deleted file mode 100644 index c592860..0000000 --- a/src/Value/ExcludeFrom.php +++ /dev/null @@ -1,36 +0,0 @@ -zips = $zips ? $excludeFrom['zips'] : []; - $this->regions = $regions ? $excludeFrom['regions'] : []; - $this->countries = $countries ? $excludeFrom['countries'] : []; - } - - function variant (User $user) : string - { - $zips = \in_array($user->zipcode(), $this->zips, true); - $regions = \in_array($user->region(), $this->regions, true); - $countries = \in_array($user->country(), $this->countries, true); - - return $zips || $regions || $countries ? Variant::OFF : ''; - } -} diff --git a/src/Value/Feature.php b/src/Value/Feature.php deleted file mode 100644 index 9f50d71..0000000 --- a/src/Value/Feature.php +++ /dev/null @@ -1,123 +0,0 @@ -name = $name; - $this->description = $description; - $this->enabled = new Enabled($enabled); - $this->users = new Users($users); - $this->groups = new Groups($groups); - $this->sources = new Sources($sources); - $this->admin = new Admin($admin); - $this->internal = new Internal($internal); - $this->urlOverride = new UrlOverride($urlOverride); - $this->excludeFrom = new ExcludeFrom($excludeFrom); - $this->time = new Time($start, $end); - $this->bucketing = $this->bucketingClass($bucketing); - } - - function name () : string - { - return $this->name; - } - - function enabled () : Enabled - { - return $this->enabled; - } - - function description () : string - { - return $this->description; - } - - function users () : Users - { - return $this->users; - } - - function groups () : Groups - { - return $this->groups; - } - - function sources () : Sources - { - return $this->sources; - } - - function admin () : Admin - { - return $this->admin; - } - - function internal () : Internal - { - return $this->internal; - } - - function urlOverride () : UrlOverride - { - return $this->urlOverride; - } - - function excludeFrom () : ExcludeFrom - { - return $this->excludeFrom; - } - - function time () : Time - { - return $this->time; - } - - function bucketing () : BucketType - { - return $this->bucketing; - } - - private function bucketingClass (string $bucketing) : BucketType - { - $namespace = "PabloJoan\\Feature\\Bucketing\\"; - $bucketing = $namespace . ucfirst($bucketing); - return new $bucketing; - } -} diff --git a/src/Value/Features.php b/src/Value/Features.php deleted file mode 100644 index dc92aa3..0000000 --- a/src/Value/Features.php +++ /dev/null @@ -1,37 +0,0 @@ - $feature) { - $this->features[$name] = new Feature($name, $feature); - } - } - - function offsetSet ($name, $feature) - { - $this->features[$name] = new Feature($name, $feature); - } - - function offsetExists ($name) - { - return isset($this->features[$name]); - } - - function offsetUnset ($name) - { - unset($this->features[$name]); - } - - function offsetGet ($name) : Feature - { - return $this->features[$name] ?? new Feature($name, []); - } -} diff --git a/src/Value/Groups.php b/src/Value/Groups.php deleted file mode 100644 index f33ddb9..0000000 --- a/src/Value/Groups.php +++ /dev/null @@ -1,28 +0,0 @@ - $groups) { - foreach ((array) $groups as $group) { - $this->groups[$group] = $variant; - } - } - } - - function variant (User $user) : string - { - return $this->groups[$user->group()] ?? ''; - } -} diff --git a/src/Value/Internal.php b/src/Value/Internal.php deleted file mode 100644 index 0d91ed3..0000000 --- a/src/Value/Internal.php +++ /dev/null @@ -1,20 +0,0 @@ -variant = $variant; - } - - function variant (User $user) : string - { - return $user->internalIP() ? $this->variant : ''; - } -} diff --git a/src/Value/Sources.php b/src/Value/Sources.php deleted file mode 100644 index de2771e..0000000 --- a/src/Value/Sources.php +++ /dev/null @@ -1,28 +0,0 @@ - $sources) { - foreach ((array) $sources as $source) { - $this->sources[$source] = $variant; - } - } - } - - function variant (string $source) : string - { - return $this->sources[$source] ?? ''; - } -} diff --git a/src/Value/Time.php b/src/Value/Time.php deleted file mode 100644 index ba77aae..0000000 --- a/src/Value/Time.php +++ /dev/null @@ -1,30 +0,0 @@ -start = $start ? $start : 0; - - $end = strtotime($end); - $this->end = $end ? $end : 0; - } - - function variant () : string - { - $time = time(); - - $startNotValid = $this->start && $this->start > $time; - $endNotValid = $this->end && $this->end < $time; - - return $startNotValid || $endNotValid ? Variant::OFF : ''; - } -} diff --git a/src/Value/Url.php b/src/Value/Url.php deleted file mode 100644 index d17366e..0000000 --- a/src/Value/Url.php +++ /dev/null @@ -1,33 +0,0 @@ -features[$parts[0]] = $parts[1] ?? Variant::ON; - } - } - - function variant (string $name) : string - { - return $this->features[$name] ?? ''; - } -} diff --git a/src/Value/UrlOverride.php b/src/Value/UrlOverride.php deleted file mode 100644 index 996c0e8..0000000 --- a/src/Value/UrlOverride.php +++ /dev/null @@ -1,20 +0,0 @@ -on = $on; - } - - function variant (string $name, Url $url) : string - { - return $this->on ? $url->variant($name) : ''; - } -} diff --git a/src/Value/User.php b/src/Value/User.php deleted file mode 100644 index 573e558..0000000 --- a/src/Value/User.php +++ /dev/null @@ -1,69 +0,0 @@ -uaid = $user['uaid'] ?? ''; - $this->id = $user['id'] ?? ''; - $this->group = $user['group'] ?? ''; - $this->zipcode = $user['zipcode'] ?? ''; - $this->region = $user['region'] ?? ''; - $this->country = $user['country'] ?? ''; - $this->isAdmin = $user['is-admin'] ?? false; - $this->internalIP = $user['internal-ip'] ?? false; - } - - function uaid () : string - { - return $this->uaid; - } - - function id () : string - { - return $this->id; - } - - function country () : string - { - return $this->country; - } - - function zipcode () : string - { - return $this->zipcode; - } - - function region () : string - { - return $this->region; - } - - function isAdmin () : bool - { - return $this->isAdmin; - } - - function internalIP () : bool - { - return $this->internalIP; - } - - function group () : string - { - return $this->group; - } -} diff --git a/src/Value/Users.php b/src/Value/Users.php deleted file mode 100644 index e21d6a2..0000000 --- a/src/Value/Users.php +++ /dev/null @@ -1,28 +0,0 @@ - $users) { - foreach ((array) $users as $user) { - $this->users[$user] = $variant; - } - } - } - - function variant (User $user) : string - { - return $this->users[$user->id()] ?? ''; - } -} diff --git a/src/Value/Variant.php b/src/Value/Variant.php deleted file mode 100644 index 7654a41..0000000 --- a/src/Value/Variant.php +++ /dev/null @@ -1,11 +0,0 @@ - [ - 'testFeature' => ['enabled' => 100], - 'testFeature2' => ['enabled' => 0] - ] - ]); - - $this->assertEquals($feature->isEnabled('testFeature'), true); - $this->assertEquals($feature->isEnabled('testFeature2'), false); - - $this->assertEquals($feature->isEnabledFor('testFeature', []), true); - $this->assertEquals($feature->isEnabledFor('testFeature2', []), false); - - $this->assertEquals( - $feature->isEnabledBucketingBy('testFeature', 'testid1'), - true - ); - $this->assertEquals( - $feature->isEnabledBucketingBy('testFeature2', 'testid2'), - false - ); - - $this->assertEquals($feature->variant('testFeature'), 'on'); - $this->assertEquals($feature->variant('testFeature2'), ''); - - $this->assertEquals($feature->variantFor('testFeature', []), 'on'); - $this->assertEquals($feature->variantFor('testFeature2', []), ''); - - $this->assertEquals( - $feature->variantBucketingBy('testFeature', 'testid1'), - 'on' - ); - $this->assertEquals( - $feature->variantBucketingBy('testFeature2', 'testid2'), - '' - ); - - $feature->changeFeatures([ - 'testFeature' => ['enabled' => 0], - 'testFeature2' => ['enabled' => 100] - ]); - - $this->assertEquals($feature->isEnabled('testFeature'), false); - $this->assertEquals($feature->isEnabled('testFeature2'), true); - - $this->assertEquals($feature->isEnabledFor('testFeature', []), false); - $this->assertEquals($feature->isEnabledFor('testFeature2', []), true); - - $this->assertEquals( - $feature->isEnabledBucketingBy('testFeature', 'testid1'), - false - ); - $this->assertEquals( - $feature->isEnabledBucketingBy('testFeature2', 'testid2'), - true - ); - - $this->assertEquals($feature->variant('testFeature'), ''); - $this->assertEquals($feature->variant('testFeature2'), 'on'); - - $this->assertEquals($feature->variantFor('testFeature', []), ''); - $this->assertEquals($feature->variantFor('testFeature2', []), 'on'); - - $this->assertEquals( - $feature->variantBucketingBy('testFeature', 'testid1'), - '' - ); - $this->assertEquals( - $feature->variantBucketingBy('testFeature2', 'testid2'), - 'on' - ); - - $feature->setFeature('testFeature2', ['enabled' => 0]); - - $this->assertEquals($feature->isEnabled('testFeature'), false); - $this->assertEquals($feature->isEnabled('testFeature2'), false); - - $this->assertEquals($feature->isEnabledFor('testFeature', []), false); - $this->assertEquals($feature->isEnabledFor('testFeature2', []), false); - - $this->assertEquals( - $feature->isEnabledBucketingBy('testFeature', 'testid1'), - false - ); - $this->assertEquals( - $feature->isEnabledBucketingBy('testFeature2', 'testid2'), - false - ); - - $this->assertEquals($feature->variant('testFeature'), ''); - $this->assertEquals($feature->variant('testFeature2'), ''); - - $this->assertEquals($feature->variantFor('testFeature', []), ''); - $this->assertEquals($feature->variantFor('testFeature2', []), ''); - - $this->assertEquals( - $feature->variantBucketingBy('testFeature', 'testid1'), - '' - ); - $this->assertEquals( - $feature->variantBucketingBy('testFeature2', 'testid2'), - '' - ); - } - - function testDescription () - { - $feature = new Feature([ - 'features' => [ - 'testFeature' => [ - 'enabled' => 100, - 'description' => 'testFeature' - ], - 'testFeature2' => [ - 'enabled' => 0, - 'description' => 'testFeature2' - ] - ] - ]); - $this->assertEquals( - $feature->description('testFeature'), - 'testFeature' - ); - $this->assertEquals( - $feature->description('testFeature2'), - 'testFeature2' - ); - } - - function testVariant () - { - $feature = new Feature([ - 'features' => [ - 'testFeature' => [ - 'enabled' => ['variant1' => 50, 'variant2' => 50], - ], - 'testFeature2' => [ - 'enabled' => ['variant3' => 25, 'variant4' => 25], - ] - ] - ]); - - $this->assertEquals($feature->isEnabled('testFeature'), true); - $this->assertEquals($feature->isEnabledFor('testFeature', []), true); - $this->assertEquals( - $feature->isEnabledBucketingBy('testFeature', 'testid1'), - true - ); - - $variant = in_array( - $feature->variant('testFeature'), - ['variant1', 'variant2'], - true - ); - $this->assertEquals($variant, true); - $variant = in_array( - $feature->variant('testFeature2'), - ['variant3', 'variant4', ''], - true - ); - $this->assertEquals($variant, true); - - $variant = in_array( - $feature->variantFor('testFeature', []), - ['variant1', 'variant2'], - true - ); - $this->assertEquals($variant, true); - $variant = in_array( - $feature->variantFor('testFeature2', []), - ['variant3', 'variant4', ''], - true - ); - $this->assertEquals($variant, true); - - $variant = in_array( - $feature->variantBucketingBy('testFeature', 'testid1'), - ['variant1', 'variant2'], - true - ); - $this->assertEquals($variant, true); - $variant = in_array( - $feature->variantBucketingBy('testFeature2', 'testid2'), - ['variant3', 'variant4', ''], - true - ); - $this->assertEquals($variant, true); - } - - function testUsers () - { - $feature = new Feature([ - 'features' => [ - 'testFeature' => [ - 'enabled' => 0, - 'users' => ['test1' => '2', 'test4' => ['7', '8', '9']], - ], - 'testFeature2' => [ - 'enabled' => ['variant1' => 25, 'variant2' => 25], - 'users' => ['variant2' => '5'] - ] - ], - 'user' => ['id' => '5'] - ]); - - $this->assertEquals($feature->isEnabled('testFeature'), false); - $this->assertEquals($feature->isEnabled('testFeature2'), true); - - $this->assertEquals($feature->variant('testFeature'), ''); - $this->assertEquals($feature->variant('testFeature2'), 'variant2'); - - $feature->changeUser(['id' => '7']); - $this->assertEquals($feature->isEnabled('testFeature'), true); - $this->assertEquals($feature->variant('testFeature'), 'test4'); - - $this->assertEquals($feature->isEnabledFor('testFeature', []), false); - $this->assertEquals( - $feature->isEnabledFor('testFeature', ['id' => '9']), - true - ); - $this->assertEquals( - $feature->isEnabledFor('testFeature', ['id' => '8']), - true - ); - $this->assertEquals( - $feature->isEnabledFor('testFeature', ['id' => '7']), - true - ); - $this->assertEquals( - $feature->isEnabledFor('testFeature', ['id' => '2']), - true - ); - $this->assertEquals( - $feature->isEnabledFor('testFeature', ['id' => '5']), - false - ); - $this->assertEquals( - $feature->isEnabledFor('testFeature2', ['id' => '5']), - true - ); - - $this->assertEquals($feature->variantFor('testFeature', []), ''); - $this->assertEquals( - $feature->variantFor('testFeature', ['id' => '9']), - 'test4' - ); - $this->assertEquals( - $feature->variantFor('testFeature', ['id' => '8']), - 'test4' - ); - $this->assertEquals( - $feature->variantFor('testFeature', ['id' => '7']), - 'test4' - ); - $this->assertEquals( - $feature->variantFor('testFeature', ['id' => '2']), - 'test1' - ); - $this->assertEquals( - $feature->variantFor('testFeature', ['id' => '5']), - '' - ); - $this->assertEquals( - $feature->variantFor('testFeature2', ['id' => '5']), - 'variant2' - ); - } - - function testGroups () - { - $feature = new Feature([ - 'features' => [ - 'testFeature' => [ - 'enabled' => 0, - 'groups' => ['test1' => '2', 'test4' => ['7', '8', '9']], - ], - 'testFeature2' => [ - 'enabled' => ['variant1' => 25, 'variant2' => 25], - 'groups' => ['variant2' => '5'] - ] - ], - 'user' => ['group' => '5'] - ]); - - $this->assertEquals($feature->isEnabled('testFeature'), false); - $this->assertEquals($feature->isEnabled('testFeature2'), true); - - $this->assertEquals($feature->variant('testFeature'), ''); - $this->assertEquals($feature->variant('testFeature2'), 'variant2'); - - $feature->changeUser(['group' => '7']); - $this->assertEquals($feature->isEnabled('testFeature'), true); - $this->assertEquals($feature->variant('testFeature'), 'test4'); - - $this->assertEquals($feature->isEnabledFor('testFeature', []), false); - $this->assertEquals( - $feature->isEnabledFor('testFeature', ['group' => '9']), - true - ); - $this->assertEquals( - $feature->isEnabledFor('testFeature', ['group' => '8']), - true - ); - $this->assertEquals( - $feature->isEnabledFor('testFeature', ['group' => '7']), - true - ); - $this->assertEquals( - $feature->isEnabledFor('testFeature', ['group' => '2']), - true - ); - $this->assertEquals( - $feature->isEnabledFor('testFeature', ['group' => '5']), - false - ); - $this->assertEquals( - $feature->isEnabledFor('testFeature2', ['group' => '5']), - true - ); - - $this->assertEquals($feature->variantFor('testFeature', []), ''); - $this->assertEquals( - $feature->variantFor('testFeature', ['group' => '9']), - 'test4' - ); - $this->assertEquals( - $feature->variantFor('testFeature', ['group' => '8']), - 'test4' - ); - $this->assertEquals( - $feature->variantFor('testFeature', ['group' => '7']), - 'test4' - ); - $this->assertEquals( - $feature->variantFor('testFeature', ['group' => '2']), - 'test1' - ); - $this->assertEquals( - $feature->variantFor('testFeature', ['group' => '5']), - '' - ); - $this->assertEquals( - $feature->variantFor('testFeature2', ['group' => '5']), - 'variant2' - ); - } - - function testSources () - { - $feature = new Feature([ - 'features' => [ - 'testFeature' => [ - 'enabled' => 0, - 'sources' => ['on' => 'test', 'off' => 'test2'], - ], - 'testFeature2' => [ - 'enabled' => 100, - 'sources' => ['off' => 'test', 'on' => 'test2'] - ] - ], - 'source' => 'test' - ]); - - $this->assertEquals($feature->isEnabled('testFeature'), true); - $this->assertEquals($feature->isEnabled('testFeature2'), false); - - $this->assertEquals($feature->isEnabledFor('testFeature', []), true); - $this->assertEquals($feature->isEnabledFor('testFeature2', []), false); - - $this->assertEquals( - $feature->isEnabledBucketingBy('testFeature', 'testid1'), - true - ); - $this->assertEquals( - $feature->isEnabledBucketingBy('testFeature2', 'testid2'), - false - ); - - $this->assertEquals($feature->variant('testFeature'), 'on'); - $this->assertEquals($feature->variant('testFeature2'), ''); - - $this->assertEquals($feature->variantFor('testFeature', []), 'on'); - $this->assertEquals($feature->variantFor('testFeature2', []), ''); - - $this->assertEquals( - $feature->variantBucketingBy('testFeature', 'testid1'), - 'on' - ); - $this->assertEquals( - $feature->variantBucketingBy('testFeature2', 'testid2'), - '' - ); - - $feature->changeSource('test2'); - - $this->assertEquals($feature->isEnabled('testFeature'), false); - $this->assertEquals($feature->isEnabled('testFeature2'), true); - - $this->assertEquals($feature->isEnabledFor('testFeature', []), false); - $this->assertEquals($feature->isEnabledFor('testFeature2', []), true); - - $this->assertEquals( - $feature->isEnabledBucketingBy('testFeature', 'testid1'), - false - ); - $this->assertEquals( - $feature->isEnabledBucketingBy('testFeature2', 'testid2'), - true - ); - - $this->assertEquals($feature->variant('testFeature'), ''); - $this->assertEquals($feature->variant('testFeature2'), 'on'); - - $this->assertEquals($feature->variantFor('testFeature', []), ''); - $this->assertEquals($feature->variantFor('testFeature2', []), 'on'); - - $this->assertEquals( - $feature->variantBucketingBy('testFeature', 'testid1'), - '' - ); - $this->assertEquals( - $feature->variantBucketingBy('testFeature2', 'testid2'), - 'on' - ); - } - - function testAdmin () - { - $feature = new Feature([ - 'features' => [ - 'testFeature' => ['enabled' => 0, 'admin' => 'on'], - 'testFeature2' => [ - 'enabled' => ['test1' => 100, 'test2' => 0], - 'admin' => 'test2' - ] - ], - 'user' => ['is-admin' => true] - ]); - - $this->assertEquals($feature->isEnabled('testFeature'), true); - $this->assertEquals($feature->isEnabled('testFeature2'), true); - - $this->assertEquals($feature->variant('testFeature'), 'on'); - $this->assertEquals($feature->variant('testFeature2'), 'test2'); - - $this->assertEquals($feature->isEnabledFor('testFeature', []), false); - $this->assertEquals($feature->variantFor('testFeature', []), ''); - $this->assertEquals($feature->isEnabledFor('testFeature2', []), true); - $this->assertEquals($feature->variantFor('testFeature2', []), 'test1'); - } - - function testInternal () - { - $feature = new Feature([ - 'features' => [ - 'testFeature' => ['enabled' => 0, 'internal' => 'on'], - 'testFeature2' => [ - 'enabled' => ['test1' => 100, 'test2' => 0], - 'internal' => 'test2' - ] - ], - 'user' => ['internal-ip' => true] - ]); - - $this->assertEquals($feature->isEnabled('testFeature'), true); - $this->assertEquals($feature->isEnabled('testFeature2'), true); - - $this->assertEquals($feature->variant('testFeature'), 'on'); - $this->assertEquals($feature->variant('testFeature2'), 'test2'); - - $this->assertEquals($feature->isEnabledFor('testFeature', []), false); - $this->assertEquals($feature->variantFor('testFeature', []), ''); - $this->assertEquals($feature->isEnabledFor('testFeature2', []), true); - $this->assertEquals($feature->variantFor('testFeature2', []), 'test1'); - } - - function testStart () - { - $feature = new Feature([ - 'features' => [ - 'testFeature' => ['enabled' => 100, 'start' => 'today'], - 'testFeature2' => ['enabled' => 100, 'start' => 'tomorrow'] - ] - ]); - - $this->assertEquals($feature->isEnabled('testFeature'), true); - $this->assertEquals($feature->isEnabled('testFeature2'), false); - - $this->assertEquals($feature->isEnabledFor('testFeature', []), true); - $this->assertEquals($feature->isEnabledFor('testFeature2', []), false); - - $this->assertEquals( - $feature->isEnabledBucketingBy('testFeature', 'testid1'), - true - ); - $this->assertEquals( - $feature->isEnabledBucketingBy('testFeature2', 'testid2'), - false - ); - - $this->assertEquals($feature->variant('testFeature'), 'on'); - $this->assertEquals($feature->variant('testFeature2'), ''); - - $this->assertEquals($feature->variantFor('testFeature', []), 'on'); - $this->assertEquals($feature->variantFor('testFeature2', []), ''); - - $this->assertEquals( - $feature->variantBucketingBy('testFeature', 'testid1'), - 'on' - ); - $this->assertEquals( - $feature->variantBucketingBy('testFeature2', 'testid2'), - '' - ); - } - - function testEnd () - { - $feature = new Feature([ - 'features' => [ - 'testFeature' => ['enabled' => 100, 'end' => 'tomorrow'], - 'testFeature2' => ['enabled' => 100, 'end' => 'yesterday'] - ] - ]); - - $this->assertEquals($feature->isEnabled('testFeature'), true); - $this->assertEquals($feature->isEnabled('testFeature2'), false); - - $this->assertEquals($feature->isEnabledFor('testFeature', []), true); - $this->assertEquals($feature->isEnabledFor('testFeature2', []), false); - - $this->assertEquals( - $feature->isEnabledBucketingBy('testFeature', 'testid1'), - true - ); - $this->assertEquals( - $feature->isEnabledBucketingBy('testFeature2', 'testid2'), - false - ); - - $this->assertEquals($feature->variant('testFeature'), 'on'); - $this->assertEquals($feature->variant('testFeature2'), ''); - - $this->assertEquals($feature->variantFor('testFeature', []), 'on'); - $this->assertEquals($feature->variantFor('testFeature2', []), ''); - - $this->assertEquals( - $feature->variantBucketingBy('testFeature', 'testid1'), - 'on' - ); - $this->assertEquals( - $feature->variantBucketingBy('testFeature2', 'testid2'), - '' - ); - } - - function testExcludeFrom () - { - $feature = new Feature([ - 'features' => [ - 'testFeature' => [ - 'enabled' => 100, - 'exclude_from' => ['zips' => ['10014', '10023']], - ], - 'testFeature2' => [ - 'enabled' => 100, - 'exclude_from' => ['countries' => ['us', 'rd']], - ], - 'testFeature3' => [ - 'enabled' => 100, - 'exclude_from' => ['regions' => ['ny', 'nj', 'ca']], - ] - ], - 'user' => [ - 'country' => 'us', - 'zipcode' => '10014', - 'region' => 'ny' - ] - ]); - - $this->assertEquals($feature->isEnabled('testFeature'), false); - $this->assertEquals($feature->isEnabled('testFeature2'), false); - $this->assertEquals($feature->isEnabled('testFeature3'), false); - - $this->assertEquals($feature->isEnabledFor('testFeature', []), true); - $this->assertEquals($feature->isEnabledFor('testFeature2', []), true); - $this->assertEquals($feature->isEnabledFor('testFeature3', []), true); - - $this->assertEquals( - $feature->isEnabledBucketingBy('testFeature', 'testid1'), - true - ); - $this->assertEquals( - $feature->isEnabledBucketingBy('testFeature2', 'testid2'), - true - ); - $this->assertEquals( - $feature->isEnabledBucketingBy('testFeature3', 'testid3'), - true - ); - - $this->assertEquals($feature->variant('testFeature'), ''); - $this->assertEquals($feature->variant('testFeature2'), ''); - $this->assertEquals($feature->variant('testFeature3'), ''); - - $this->assertEquals($feature->variantFor('testFeature', []), 'on'); - $this->assertEquals($feature->variantFor('testFeature2', []), 'on'); - $this->assertEquals($feature->variantFor('testFeature2', []), 'on'); - - $this->assertEquals( - $feature->variantBucketingBy('testFeature', 'testid1'), - 'on' - ); - $this->assertEquals( - $feature->variantBucketingBy('testFeature2', 'testid2'), - 'on' - ); - $this->assertEquals( - $feature->variantBucketingBy('testFeature3', 'testid3'), - 'on' - ); - } - - function testUrlOverride () - { - $feature = new Feature([ - 'features' => [ - 'testFeature' => [ - 'enabled' => ['variant1' => 0, 'variant2' => 0], - 'url_override' => true - ], - 'testFeature2' => [ - 'enabled' => ['variant3' => 0, 'variant4' => 0], - 'url_override' => true - ] - ], - 'url' => 'http://www.testurl.com/?feature=testFeature:variant1,testFeature2:variant4' - ]); - - $this->assertEquals($feature->isEnabled('testFeature'), true); - $this->assertEquals($feature->isEnabled('testFeature2'), true); - - $this->assertEquals($feature->variant('testFeature'), 'variant1'); - $this->assertEquals($feature->variant('testFeature2'), 'variant4'); - - $feature->changeUrl( - 'http://www.testurl.com/?feature=testFeature:variant2,testFeature2:variant3' - ); - - $this->assertEquals($feature->isEnabled('testFeature'), true); - $this->assertEquals($feature->isEnabled('testFeature2'), true); - - $this->assertEquals($feature->variant('testFeature'), 'variant2'); - $this->assertEquals($feature->variant('testFeature2'), 'variant3'); - - $feature->changeUrl('http://www.testurl.com/'); - - $this->assertEquals($feature->isEnabled('testFeature'), false); - $this->assertEquals($feature->isEnabled('testFeature2'), false); - - $this->assertEquals($feature->variant('testFeature'), ''); - $this->assertEquals($feature->variant('testFeature2'), ''); - } - - function testBucketing () - { - $feature = new Feature([ - 'features' => [ - 'testFeature' => [ - 'enabled' => ['variant1' => 50, 'variant2' => 50], - 'bucketing' => 'random' - ], - 'testFeature2' => [ - 'enabled' => ['variant3' => 50, 'variant4' => 50], - 'bucketing' => 'uaid' - ], - 'testFeature3' => [ - 'enabled' => ['variant5' => 50, 'variant6' => 50], - 'bucketing' => 'user' - ] - ], - 'user' => ['id' => 'testid5', 'uaid' => 'randomteststring'] - ]); - - $variant = in_array( - $feature->variant('testFeature'), - ['variant1', 'variant2'], - true - ); - $this->assertEquals($variant, true); - $this->assertEquals($feature->variant('testFeature2'), 'variant4'); - $this->assertEquals($feature->variant('testFeature3'), 'variant6'); - - $this->assertEquals( - $feature->variantBucketingBy('testFeature2', 'testid1'), - 'variant3' - ); - $this->assertEquals( - $feature->variantBucketingBy('testFeature3', 'testid2'), - 'variant6' - ); - - $feature->changeUser(['id' => 'anotheruser', 'uaid' => 'string3']); - - $this->assertEquals($feature->variant('testFeature2'), 'variant4'); - $this->assertEquals($feature->variant('testFeature3'), 'variant6'); - } -} diff --git a/tests/FeatureTest.php b/tests/FeatureTest.php index 6a169dc..b9ca9f7 100644 --- a/tests/FeatureTest.php +++ b/tests/FeatureTest.php @@ -9,253 +9,91 @@ class FeatureTest extends TestCase { - private $feature; - - function setUp () + public function testFeatures(): void { - $this->feature = new Feature([ - 'features' => [ - 'testFeature' => [ - 'description' => 'this is the description', - 'enabled' => [ - 'test1' => 20, - 'test2' => 30, - 'test3' => 15, - 'test4' => 35 - ], - 'users' => ['test1' => '2', 'test4' => '7'], - 'groups' => ['test1' => 'group1', 'test2' => 'group2'], - 'sources' => ['test3' => 'source1', 'test4' => 'source2'], - 'admin' => 'test3', - 'internal' => 'test1', - 'url_override' => true, - 'exclude_from' => [ - 'zips' => ['10014', '10023'], - 'countries' => ['us', 'rd'], - 'regions' => ['ny', 'nj', 'ca'] - ], - 'start' => '20170214', - 'end' => '99990530' + $feature = new Feature([ + '1' => [ + 'enabled' => 100 + ], + '2' => [ + 'enabled' => 0 + ], + '3' => [ + 'enabled' => 50, + 'bucketing' => 'id' + ], + '4' => [ + 'enabled' => [ + 'test1' => 20, + 'test2' => 30, + 'test3' => 15, + 'test4' => 35 ], - 'testFeature2' => ['enabled' => 0, 'bucketing' => 'random'] + 'bucketing' => 'id' ], - 'url' => 'http://www.testurl.com/?feature=testFeature:test3', - 'source' => 'source2', - 'user' => [ - 'uaid' => 'as54gerfd', - 'id' => '5', - 'is-admin' => false, - 'group' => 'group3', - 'internal-ip' => false + '5' => [ + 'enabled' => 0, + 'bucketing' => 'random' + ], + '6' => [ + 'enabled' => 0, + 'bucketing' => 'id' + ], + '7' => [ + 'enabled' => 100, + 'bucketing' => 'random' + ], + '8' => [ + 'enabled' => 100, + 'bucketing' => 'id' ] ]); - } - function testIsEnabled () - { - $this->assertEquals($this->feature->isEnabled('testFeature'), true); - $this->assertEquals($this->feature->isEnabled('testFeature2'), false); - } - - function testIsEnabledFor () - { - $this->assertEquals( - $this->feature->isEnabledFor( - 'testFeature2', - [ - 'uaid' => 'as54gerfd', - 'id' => '5', - 'is-admin' => false, - 'group' => 'group', - 'internal-ip' => false + $this->assertEquals($feature->isEnabled('1'), true); + $this->assertEquals($feature->isEnabled('2'), false); + $this->assertEquals($feature->isEnabled('3'), false); + $this->assertEquals($feature->isEnabled('4'), true); + $this->assertEquals($feature->isEnabled('5'), false); + $this->assertEquals($feature->isEnabled('6'), false); + $this->assertEquals($feature->isEnabled('7'), true); + $this->assertEquals($feature->isEnabled('8'), true); + + $this->assertEquals($feature->isEnabled('1', 'test'), true); + $this->assertEquals($feature->isEnabled('2', 'test'), false); + $this->assertEquals($feature->isEnabled('3', 'test'), false); + $this->assertEquals($feature->isEnabled('4', 'test'), true); + $this->assertEquals($feature->isEnabled('5', 'test'), false); + $this->assertEquals($feature->isEnabled('6', 'test'), false); + $this->assertEquals($feature->isEnabled('7', 'test'), true); + $this->assertEquals($feature->isEnabled('8', 'test'), true); + + $this->assertEquals($feature->variant('1'), '0'); + $this->assertEquals($feature->variant('2'), ''); + $this->assertEquals($feature->variant('3'), ''); + $this->assertEquals($feature->variant('4'), 'test4'); + $this->assertEquals($feature->variant('5'), ''); + $this->assertEquals($feature->variant('6'), ''); + $this->assertEquals($feature->variant('7'), '0'); + $this->assertEquals($feature->variant('8'), '0'); + + $this->assertEquals($feature->variant('1', 'test'), '0'); + $this->assertEquals($feature->variant('2', 'test'), ''); + $this->assertEquals($feature->variant('3', 'test'), ''); + $this->assertEquals($feature->variant('4', 'test'), 'test4'); + $this->assertEquals($feature->variant('5', 'test'), ''); + $this->assertEquals($feature->variant('6', 'test'), ''); + $this->assertEquals($feature->variant('7', 'test'), '0'); + $this->assertEquals($feature->variant('8', 'test'), '0'); + + try { + $feature = new Feature([ + '3' => [ + 'enabled' => 50, + 'bucketing' => 'not supported bucketing' ] - ), - false - ); - - $this->assertEquals( - $this->feature->isEnabledFor( - 'testFeature', - [ - 'uaid' => 'kl23j4n5', - 'id' => '2', - 'is-admin' => false, - 'group' => 'group', - 'internal-ip' => false - ] - ), - true - ); - } - - function testIsEnabledBucketingBy () - { - $this->assertEquals( - $this->feature->isEnabledBucketingBy('testFeature', 'test'), - true - ); - } - - function testVariant () - { - $this->assertEquals($this->feature->variant('testFeature'), 'test3'); - $this->assertEquals($this->feature->variant('testFeature2'), ''); - } - - function testVariantFor () - { - $this->assertEquals( - $this->feature->variantFor( - 'testFeature', - [ - 'uaid' => 'as54gerfd', - 'id' => '7', - 'is-admin' => false, - 'group' => 'group', - 'internal-ip' => false - ] - ), - 'test3' - ); - } - - function testVariantBucketingBy () - { - $this->assertEquals( - $this->feature->variantBucketingBy('testFeature', 'test'), - 'test3' - ); - } - - function testDescription () - { - $this->assertEquals( - $this->feature->description('testFeature'), - 'this is the description' - ); - } - - function testAddFeature () - { - $this->assertEquals($this->feature->isEnabled('newFeature'), false); - $this->assertEquals($this->feature->variant('newFeature'), ''); - - $this->feature->setFeature('newFeature', ['enabled' => 100]); - $this->assertEquals($this->feature->isEnabled('newFeature'), true); - $this->assertEquals($this->feature->variant('newFeature'), 'on'); - } - - function testRemoveFeature () - { - $this->assertEquals($this->feature->isEnabled('newFeature2'), false); - $this->assertEquals($this->feature->variant('newFeature2'), ''); - - $this->feature->setFeature('newFeature2', ['enabled' => 100]); - $this->assertEquals($this->feature->isEnabled('newFeature2'), true); - $this->assertEquals($this->feature->variant('newFeature2'), 'on'); - - $this->feature->removeFeature('newFeature2'); - $this->assertEquals($this->feature->isEnabled('newFeature2'), false); - $this->assertEquals($this->feature->variant('newFeature2'), ''); - } - - function testChangeFeatures () - { - $this->feature->changeFeatures([ - 'testFeature' => [ - 'description' => 'different description', - 'enabled' => [ - 'test1' => 0, - 'test2' => 0, - 'test3' => 0, - 'test4' => 0 - ] - ], - 'testFeature2' => ['enabled' => 100] - ]); - - $this->assertEquals($this->feature->isEnabled('testFeature'), false); - $this->assertEquals($this->feature->isEnabled('testFeature2'), true); - - $this->assertEquals($this->feature->variant('testFeature'), ''); - $this->assertEquals($this->feature->variant('testFeature2'), 'on'); - - $this->assertEquals( - $this->feature->description('testFeature'), - 'different description' - ); - } - - function testSetFeature () - { - $this->feature->setFeature( - 'testFeature2', - [ - 'enabled' => ['test1' => 0, 'test4' => 0], - 'users' => ['test1' => '2', 'test4' => '7'], - 'sources' => ['test1' => 'source3'], - 'url_override' => true - ] - ); - $this->assertEquals($this->feature->isEnabled('testFeature2'), false); - $this->assertEquals($this->feature->variant('testFeature2'), ''); - } - - function testChangeUser () - { - $this->feature->setFeature( - 'testFeature2', - [ - 'enabled' => ['test1' => 0, 'test4' => 0], - 'users' => ['test1' => '2', 'test4' => '7'], - 'sources' => ['test1' => 'source3'], - 'url_override' => true - ] - ); - $this->feature->changeUser(['id' => '2']); - $this->assertEquals($this->feature->isEnabled('testFeature2'), true); - $this->assertEquals($this->feature->variant('testFeature2'), 'test1'); - } - - function testChangeUrl () - { - $this->feature->setFeature( - 'testFeature2', - [ - 'enabled' => ['test1' => 0, 'test4' => 0], - 'users' => ['test1' => '2', 'test4' => '7'], - 'sources' => ['test1' => 'source3'], - 'url_override' => true - ] - ); - $this->feature->changeUrl( - 'http://www.testurl.com/?feature=testFeature2:test4' - ); - $this->assertEquals($this->feature->isEnabled('testFeature2'), true); - $this->assertEquals($this->feature->variant('testFeature2'), 'test4'); - } - - function testChangeSource () - { - $this->feature->setFeature( - 'testFeature2', - [ - 'enabled' => ['test1' => 0, 'test4' => 0], - 'users' => ['test1' => '2', 'test4' => '7'], - 'sources' => ['test1' => 'source3'], - 'url_override' => true - ] - ); - $this->feature->changeSource('source3'); - $this->assertEquals($this->feature->isEnabled('testFeature2'), true); - $this->assertEquals($this->feature->variant('testFeature2'), 'test1'); - } - - function emptyTest () - { - $feature = new Feature(); - $this->assertEquals($feature->isEnabled('testFeature2'), false); - $this->assertEquals($feature->variant('testFeature2'), ''); - $this->assertEquals($feature->description('testFeature'), ''); + ]); + } catch (\Exception $e) { + $this->assertEquals($e->getMessage(), 'bucketing option: not supported bucketing not supported.'); + } } } From 137dc6cbdeb1c4e080da6797123d074327cefd1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Wed, 26 May 2021 22:14:39 -0400 Subject: [PATCH 74/92] remove travis --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 917c469..235f750 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,3 @@ -[![Build Status](https://travis-ci.org/PabloJoan/feature.svg?branch=master)](https://travis-ci.org/PabloJoan/feature) -[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/PabloJoan/feature/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/PabloJoan/feature/?branch=master) -[![Code Coverage](https://scrutinizer-ci.com/g/PabloJoan/feature/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/PabloJoan/feature/?branch=master) [![GitHub license](https://img.shields.io/github/license/PabloJoan/feature.svg)](https://github.com/PabloJoan/feature/blob/master/LICENSE) Requires PHP 8.0 and above. From 5b1e26d3f0bffe6738b8641b3f23dcee86490873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Wed, 26 May 2021 22:15:07 -0400 Subject: [PATCH 75/92] Delete .travis.yml --- .travis.yml | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index d0cad91..0000000 --- a/.travis.yml +++ /dev/null @@ -1,7 +0,0 @@ -language: php - -php: - - '8.0' - - nightly - -script: composer install --no-interaction && composer test \ No newline at end of file From 874e5b62708c47221a3b11383ba3b21f7e9bc2cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Wed, 26 May 2021 22:37:18 -0400 Subject: [PATCH 76/92] add github action --- .github/workflows/php.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/php.yml diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml new file mode 100644 index 0000000..bdd41f6 --- /dev/null +++ b/.github/workflows/php.yml @@ -0,0 +1,24 @@ +name: PHP Composer + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Install dependencies + run: composer install --no-interaction + + # Add a test script to composer.json, for instance: "test": "vendor/bin/phpunit" + # Docs: https://getcomposer.org/doc/articles/scripts.md + + - name: Run test suite + run: composer test From 58314c7e70642ab11018145926a83c296d2903ab Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Sat, 29 May 2021 19:15:05 -0400 Subject: [PATCH 77/92] bugfix where calling feature->variant() to a feature with no variant returns "0" --- composer.json | 2 +- src/Configurations/Collection.php | 4 +- src/Configurations/Config.php | 49 +++++++++++------- src/Feature.php | 4 +- tests/FeatureTest.php | 82 +++++++++++++++---------------- 5 files changed, 77 insertions(+), 64 deletions(-) diff --git a/composer.json b/composer.json index 1a7fefe..96e6bb5 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,7 @@ "squizlabs/php_codesniffer": "*" }, "scripts": { - "test": "./vendor/bin/phpcbf --standard=PSR12 src/ tests/ && ./vendor/bin/phpstan analyse --level=max --debug -vvv src/ tests/ && ./vendor/bin/psalm && php -d xdebug.mode=coverage ./vendor/bin/phpunit --stop-on-failure --fail-on-warning --fail-on-risky -v --debug --coverage-text --whitelist src/ tests/ && ./vendor/bin/phpcs --standard=PSR12 src/ tests/" + "test": "./vendor/bin/phpcbf --standard=PSR12 src/ tests/ && ./vendor/bin/phpstan analyse --level=max --debug -vvv src/ && ./vendor/bin/psalm && php -d xdebug.mode=coverage ./vendor/bin/phpunit --stop-on-failure --fail-on-warning --fail-on-risky -v --debug --coverage-text --whitelist src/ tests/ && ./vendor/bin/phpcs --standard=PSR12 src/ tests/" }, "autoload": { "psr-4": { diff --git a/src/Configurations/Collection.php b/src/Configurations/Collection.php index f26f009..e9d03bd 100644 --- a/src/Configurations/Collection.php +++ b/src/Configurations/Collection.php @@ -12,12 +12,12 @@ final class Collection private array $configurations; /** - * @param array $configurations + * @param array $configurations */ public function __construct(array $configurations) { foreach ($configurations as $featureName => $config) { - $this->configurations[(string)$featureName] = new Config(config: $config); + $this->configurations[$featureName] = new Config(featureName: $featureName, config: $config); } } diff --git a/src/Configurations/Config.php b/src/Configurations/Config.php index 0756b65..b4e4677 100644 --- a/src/Configurations/Config.php +++ b/src/Configurations/Config.php @@ -15,7 +15,7 @@ final class Config { /** - * @var int[] + * @var array */ private array $percentages; @@ -24,16 +24,10 @@ final class Config /** * @param array{enabled: int|array, bucketing?: string} $config */ - public function __construct(array $config = ['enabled' => 0]) + public function __construct(string $featureName, array $config = ['enabled' => 0]) { - $bucketing = $config['bucketing'] ?? 'random'; - $this->bucketing = match ($bucketing) { - 'random' => new BucketRandom(), - 'id' => new BucketId(), - default => throw new \Exception("bucketing option: $bucketing not supported.") - }; - - $this->parseEnabled(enabled: $config['enabled'] ?? 0); + $this->percentages = $this->parseEnabled(featureName: $featureName, enabled: $config['enabled']); + $this->bucketing = $this->parseBucketing(bucketing: $config['bucketing'] ?? 'random'); } /** @@ -43,12 +37,10 @@ public function __construct(array $config = ['enabled' => 0]) public function variantByPercentage(string $id): string { $number = $this->bucketing->randomIshNumber(idToHash: $id); - $percentRange = fn (int $percent): bool => $number < $percent; - $variant = (string) key(array_filter($this->percentages, $percentRange)); - - return ($variant || $variant === '0') ? $variant : ''; + $variant = key(array_filter($this->percentages, $percentRange)); + return $variant ? $variant : ''; } /** @@ -56,15 +48,36 @@ public function variantByPercentage(string $id): string * Returns the upper-boundary of the variants percentage. * * @param int|array $enabled + * @return array */ - private function parseEnabled(int|array $enabled): void + private function parseEnabled(string $featureName, int|array $enabled): array { $total = 0; - foreach ((array) $enabled as $variant => $percent) { + $percentages = []; + + $enabled = is_int($enabled) ? [$featureName => $enabled] : $enabled; + + foreach ($enabled as $variant => $percent) { $total += $this->percentage(percent: $percent); - $this->percentages[(string)$variant] = $total; + $percentages[$variant] = $total; } - asort($this->percentages, SORT_NUMERIC); + + asort($percentages, SORT_NUMERIC); + + return $percentages; + } + + /** + * Parse the 'bucketing' property of the feature's config stanza. + * Determines how the variants will be bucketed. + */ + private function parseBucketing(string $bucketing): BucketType + { + return match ($bucketing) { + 'random' => new BucketRandom(), + 'id' => new BucketId(), + default => throw new \Exception("bucketing option: $bucketing not supported.") + }; } private function percentage(int $percent): int diff --git a/src/Feature.php b/src/Feature.php index 8df30a0..91cb6ae 100644 --- a/src/Feature.php +++ b/src/Feature.php @@ -27,7 +27,7 @@ final class Feature private Collection $features; /** - * @param array $features + * @param array $features */ public function __construct(array $features) { @@ -40,7 +40,7 @@ public function __construct(array $features) */ public function isEnabled(string $featureName, string $id = ''): bool { - return '' !== $this->variant(featureName: $featureName, id: $id); + return (bool) $this->variant(featureName: $featureName, id: $id); } /** diff --git a/tests/FeatureTest.php b/tests/FeatureTest.php index b9ca9f7..c60534f 100644 --- a/tests/FeatureTest.php +++ b/tests/FeatureTest.php @@ -12,17 +12,17 @@ class FeatureTest extends TestCase public function testFeatures(): void { $feature = new Feature([ - '1' => [ + 'test_feature_1' => [ 'enabled' => 100 ], - '2' => [ + 'test_feature_2' => [ 'enabled' => 0 ], - '3' => [ + 'test_feature_3' => [ 'enabled' => 50, 'bucketing' => 'id' ], - '4' => [ + 'test_feature_4' => [ 'enabled' => [ 'test1' => 20, 'test2' => 30, @@ -31,63 +31,63 @@ public function testFeatures(): void ], 'bucketing' => 'id' ], - '5' => [ + 'test_feature_5' => [ 'enabled' => 0, 'bucketing' => 'random' ], - '6' => [ + 'test_feature_6' => [ 'enabled' => 0, 'bucketing' => 'id' ], - '7' => [ + 'test_feature_7' => [ 'enabled' => 100, 'bucketing' => 'random' ], - '8' => [ + 'test_feature_8' => [ 'enabled' => 100, 'bucketing' => 'id' ] ]); - $this->assertEquals($feature->isEnabled('1'), true); - $this->assertEquals($feature->isEnabled('2'), false); - $this->assertEquals($feature->isEnabled('3'), false); - $this->assertEquals($feature->isEnabled('4'), true); - $this->assertEquals($feature->isEnabled('5'), false); - $this->assertEquals($feature->isEnabled('6'), false); - $this->assertEquals($feature->isEnabled('7'), true); - $this->assertEquals($feature->isEnabled('8'), true); + $this->assertEquals($feature->isEnabled('test_feature_1'), true); + $this->assertEquals($feature->isEnabled('test_feature_2'), false); + $this->assertEquals($feature->isEnabled('test_feature_3'), false); + $this->assertEquals($feature->isEnabled('test_feature_4'), true); + $this->assertEquals($feature->isEnabled('test_feature_5'), false); + $this->assertEquals($feature->isEnabled('test_feature_6'), false); + $this->assertEquals($feature->isEnabled('test_feature_7'), true); + $this->assertEquals($feature->isEnabled('test_feature_8'), true); - $this->assertEquals($feature->isEnabled('1', 'test'), true); - $this->assertEquals($feature->isEnabled('2', 'test'), false); - $this->assertEquals($feature->isEnabled('3', 'test'), false); - $this->assertEquals($feature->isEnabled('4', 'test'), true); - $this->assertEquals($feature->isEnabled('5', 'test'), false); - $this->assertEquals($feature->isEnabled('6', 'test'), false); - $this->assertEquals($feature->isEnabled('7', 'test'), true); - $this->assertEquals($feature->isEnabled('8', 'test'), true); + $this->assertEquals($feature->isEnabled('test_feature_1', 'test'), true); + $this->assertEquals($feature->isEnabled('test_feature_2', 'test'), false); + $this->assertEquals($feature->isEnabled('test_feature_3', 'test'), false); + $this->assertEquals($feature->isEnabled('test_feature_4', 'test'), true); + $this->assertEquals($feature->isEnabled('test_feature_5', 'test'), false); + $this->assertEquals($feature->isEnabled('test_feature_6', 'test'), false); + $this->assertEquals($feature->isEnabled('test_feature_7', 'test'), true); + $this->assertEquals($feature->isEnabled('test_feature_8', 'test'), true); - $this->assertEquals($feature->variant('1'), '0'); - $this->assertEquals($feature->variant('2'), ''); - $this->assertEquals($feature->variant('3'), ''); - $this->assertEquals($feature->variant('4'), 'test4'); - $this->assertEquals($feature->variant('5'), ''); - $this->assertEquals($feature->variant('6'), ''); - $this->assertEquals($feature->variant('7'), '0'); - $this->assertEquals($feature->variant('8'), '0'); + $this->assertEquals($feature->variant('test_feature_1'), 'test_feature_1'); + $this->assertEquals($feature->variant('test_feature_2'), ''); + $this->assertEquals($feature->variant('test_feature_3'), ''); + $this->assertEquals($feature->variant('test_feature_4'), 'test4'); + $this->assertEquals($feature->variant('test_feature_5'), ''); + $this->assertEquals($feature->variant('test_feature_6'), ''); + $this->assertEquals($feature->variant('test_feature_7'), 'test_feature_7'); + $this->assertEquals($feature->variant('test_feature_8'), 'test_feature_8'); - $this->assertEquals($feature->variant('1', 'test'), '0'); - $this->assertEquals($feature->variant('2', 'test'), ''); - $this->assertEquals($feature->variant('3', 'test'), ''); - $this->assertEquals($feature->variant('4', 'test'), 'test4'); - $this->assertEquals($feature->variant('5', 'test'), ''); - $this->assertEquals($feature->variant('6', 'test'), ''); - $this->assertEquals($feature->variant('7', 'test'), '0'); - $this->assertEquals($feature->variant('8', 'test'), '0'); + $this->assertEquals($feature->variant('test_feature_1', 'test'), 'test_feature_1'); + $this->assertEquals($feature->variant('test_feature_2', 'test'), ''); + $this->assertEquals($feature->variant('test_feature_3', 'test'), ''); + $this->assertEquals($feature->variant('test_feature_4', 'test'), 'test4'); + $this->assertEquals($feature->variant('test_feature_5', 'test'), ''); + $this->assertEquals($feature->variant('test_feature_6', 'test'), ''); + $this->assertEquals($feature->variant('test_feature_7', 'test'), 'test_feature_7'); + $this->assertEquals($feature->variant('test_feature_8', 'test'), 'test_feature_8'); try { $feature = new Feature([ - '3' => [ + 'test_feature_3' => [ 'enabled' => 50, 'bucketing' => 'not supported bucketing' ] From dd3cca87be20f95ef6dd87f3550b364c4c72c9a8 Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Sun, 6 Jun 2021 20:13:52 -0400 Subject: [PATCH 78/92] improve performance by using faster hash algorithm --- src/Bucketing/Id.php | 9 +++------ src/Bucketing/Random.php | 2 +- tests/FeatureTest.php | 6 +++--- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/Bucketing/Id.php b/src/Bucketing/Id.php index 99c1520..73b41e0 100644 --- a/src/Bucketing/Id.php +++ b/src/Bucketing/Id.php @@ -12,17 +12,14 @@ final class Id implements Type */ public function randomIshNumber(string $idToHash = ''): float { - $hash = hash('haval192,3', $idToHash); - - $maxIterations = strlen($hash) - 1; - $maxValueOfX = 2 ** $maxIterations; + $hash = hash('ripemd256', $idToHash); $x = 0; - for ($i = 0; $i < $maxIterations; ++$i) { + for ($i = 0; $i < 63; ++$i) { $x = ($x * 2) + (hexdec($hash[$i]) < 8 ? 0 : 1); } - $x = $x / $maxValueOfX; + $x = $x / PHP_INT_MAX; return $x * 100; } diff --git a/src/Bucketing/Random.php b/src/Bucketing/Random.php index 99743ec..dcaf4b8 100644 --- a/src/Bucketing/Random.php +++ b/src/Bucketing/Random.php @@ -8,7 +8,7 @@ final class Random implements Type { public function randomIshNumber(string $idToHash = ''): float { - $x = random_int(0, PHP_INT_MAX - 1) / PHP_INT_MAX; + $x = random_int(0, PHP_INT_MAX) / PHP_INT_MAX; return $x * 100; } } diff --git a/tests/FeatureTest.php b/tests/FeatureTest.php index c60534f..1b8183b 100644 --- a/tests/FeatureTest.php +++ b/tests/FeatureTest.php @@ -51,7 +51,7 @@ public function testFeatures(): void $this->assertEquals($feature->isEnabled('test_feature_1'), true); $this->assertEquals($feature->isEnabled('test_feature_2'), false); - $this->assertEquals($feature->isEnabled('test_feature_3'), false); + $this->assertEquals($feature->isEnabled('test_feature_3'), true); $this->assertEquals($feature->isEnabled('test_feature_4'), true); $this->assertEquals($feature->isEnabled('test_feature_5'), false); $this->assertEquals($feature->isEnabled('test_feature_6'), false); @@ -69,8 +69,8 @@ public function testFeatures(): void $this->assertEquals($feature->variant('test_feature_1'), 'test_feature_1'); $this->assertEquals($feature->variant('test_feature_2'), ''); - $this->assertEquals($feature->variant('test_feature_3'), ''); - $this->assertEquals($feature->variant('test_feature_4'), 'test4'); + $this->assertEquals($feature->variant('test_feature_3'), 'test_feature_3'); + $this->assertEquals($feature->variant('test_feature_4'), 'test2'); $this->assertEquals($feature->variant('test_feature_5'), ''); $this->assertEquals($feature->variant('test_feature_6'), ''); $this->assertEquals($feature->variant('test_feature_7'), 'test_feature_7'); From 6b174e654d0f281c84bb17eb3e4a5e9c829aae11 Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Thu, 10 Jun 2021 13:23:29 -0400 Subject: [PATCH 79/92] use the crc32c hash to improve performance --- src/Bucketing/Id.php | 22 ++++++++++++---------- tests/FeatureTest.php | 4 ++-- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/Bucketing/Id.php b/src/Bucketing/Id.php index 73b41e0..ff64170 100644 --- a/src/Bucketing/Id.php +++ b/src/Bucketing/Id.php @@ -7,20 +7,22 @@ final class Id implements Type { /** - * Map a hex value to the half-open interval between 0 and 1 while - * preserving uniformity of the input distribution. + * hexdec('ffffffff') is the largest possible outcome + * of hash('crc32c', $idToHash) + */ + private const TOTAL = 4294967295; + + /** + * Convert Id string to a Hex + * Convert Hex to Dec int + * Get a percentage float */ public function randomIshNumber(string $idToHash = ''): float { - $hash = hash('ripemd256', $idToHash); - - $x = 0; - for ($i = 0; $i < 63; ++$i) { - $x = ($x * 2) + (hexdec($hash[$i]) < 8 ? 0 : 1); - } - - $x = $x / PHP_INT_MAX; + $hex = hash('crc32c', $idToHash); + $dec = hexdec($hex); + $x = $dec / self::TOTAL; return $x * 100; } } diff --git a/tests/FeatureTest.php b/tests/FeatureTest.php index 1b8183b..228a82a 100644 --- a/tests/FeatureTest.php +++ b/tests/FeatureTest.php @@ -70,7 +70,7 @@ public function testFeatures(): void $this->assertEquals($feature->variant('test_feature_1'), 'test_feature_1'); $this->assertEquals($feature->variant('test_feature_2'), ''); $this->assertEquals($feature->variant('test_feature_3'), 'test_feature_3'); - $this->assertEquals($feature->variant('test_feature_4'), 'test2'); + $this->assertEquals($feature->variant('test_feature_4'), 'test1'); $this->assertEquals($feature->variant('test_feature_5'), ''); $this->assertEquals($feature->variant('test_feature_6'), ''); $this->assertEquals($feature->variant('test_feature_7'), 'test_feature_7'); @@ -79,7 +79,7 @@ public function testFeatures(): void $this->assertEquals($feature->variant('test_feature_1', 'test'), 'test_feature_1'); $this->assertEquals($feature->variant('test_feature_2', 'test'), ''); $this->assertEquals($feature->variant('test_feature_3', 'test'), ''); - $this->assertEquals($feature->variant('test_feature_4', 'test'), 'test4'); + $this->assertEquals($feature->variant('test_feature_4', 'test'), 'test3'); $this->assertEquals($feature->variant('test_feature_5', 'test'), ''); $this->assertEquals($feature->variant('test_feature_6', 'test'), ''); $this->assertEquals($feature->variant('test_feature_7', 'test'), 'test_feature_7'); From ba58b7d43fd443fbcce86b714dacd849b5b3f6fc Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Wed, 12 Apr 2023 04:17:04 -0400 Subject: [PATCH 80/92] upgrade to php 8.2, improve random bucketing performance, BC changes --- README.md | 83 ++++++++-------- composer.json | 11 +-- psalm.xml | 29 ------ src/Bucketing/Enum.php | 19 ++++ src/Bucketing/Id.php | 9 +- src/Bucketing/Random.php | 18 +++- src/Bucketing/Type.php | 4 +- src/Configurations/Collection.php | 20 ++-- src/Configurations/Config.php | 80 ++++++---------- src/{Feature.php => Features.php} | 28 +++--- tests/FeatureTest.php | 151 +++++++++++++++++++++--------- 11 files changed, 248 insertions(+), 204 deletions(-) delete mode 100644 psalm.xml create mode 100644 src/Bucketing/Enum.php rename src/{Feature.php => Features.php} (57%) diff --git a/README.md b/README.md index 235f750..789562c 100644 --- a/README.md +++ b/README.md @@ -12,17 +12,17 @@ composer require pablojoan/feature ```php -use PabloJoan\Feature\Feature; // Import the namespace. +use PabloJoan\Feature\Features; // Import the namespace. -$config = [ +$featureConfigs = [ 'foo' => [ - 'enabled' => [ + 'variants' => [ 'variant1' => 100, //100% chance this variable will be chosen - 'variant2' => 0 //0% chance this variable will be chosen + 'variant2' => 0 //0% chance this variable will be chosen ] ], 'bar' => [ - 'enabled' => [ + 'variants' => [ 'variant1' => 25, //25% chance this variable will be chosen 'variant2' => 25, //25% chance this variable will be chosen 'variant3' => 50 //50% chance this variable will be chosen @@ -31,10 +31,10 @@ $config = [ ] ]; -$feature = new Feature($config); +$features = new Features($featureConfigs); -$feature->isEnabled('foo'); // true -$feature->variant('foo'); // 'variant1' +$features->isEnabled(featureName: 'foo'); // true +$features->enabledVariant(featureName: 'foo'); // 'variant1' ``` For a quick summary and common use cases, please read the rest of this README. @@ -51,28 +51,28 @@ between and can comprise a number of related variants. The two main API entry points are: ```php - $feature->isEnabled('my_feature') + $features->isEnabled(featureName: 'my_feature') ``` which returns true when `my_feature` is enabled and, for multi-variant features: ```php - $feature->variant('my_feature') + $features->getEnabledVariant(featureName: 'my_feature') ``` which returns the name of the particular variant which should be used. The single argument to each of these methods is the name of the feature to test. -A typical use of `$feature->isEnabled` for a single-variant feature +A typical use of `$features->isEnabled` for a single-variant feature would look something like this: ```php - if ($feature->isEnabled('my_feature')) { + if ($features->isEnabled(featureName: 'my_feature')) { // do stuff } ``` For a multi-variant feature, we can determine the appropriate code to run for each variant with something like this: ```php - switch ($feature->variant('my_feature')) { + switch ($features->getEnabledVariant(featureName: 'my_feature')) { case 'foo': // do stuff appropriate for the 'foo' variant break; @@ -81,6 +81,20 @@ each variant with something like this: break; } ``` +If a feature is bucketed by id, then we pass the id string to +`$features->isEnabled` and `$features->getEnabledVariant` as a second parameter +```php + $isMyFeatureEnabled = $features->isEnabled( + featureName: 'my_feature', + id: 'unique_id_string' + ); + + $variant = $features->getEnabledVariant( + featureName: 'my_feature', + id: 'unique_id_string' + ); +``` + ## Configuration cookbook @@ -90,24 +104,24 @@ cases along with the most concise way to write the configuration. ### A totally enabled feature: ```php - $server_config['foo'] = ['enabled' => 100]; + $server_config['foo'] = ['variants' => ['enabled' => 100]]; ``` ### A totally disabled feature: ```php - $server_config['foo'] = ['enabled' => 0]; + $server_config['foo'] = ['variants' => ['enabled' => 0]]; ``` ### Feature with winning variant turned on for everyone ```php - $server_config['foo'] = ['enabled' => ['blue_background' => 100]]; + $server_config['foo'] = ['variants' => ['blue_background' => 100]]; ``` ### Single-variant feature ramped up to 1% of users. ```php - $server_config['foo'] = ['enabled' => 1]; + $server_config['foo'] = ['variants' => ['enabled' => 1]]; ``` ### Multi-variant feature ramped up to 1% of users for each variant. ```php $server_config['foo'] = [ - 'enabled' => [ + 'variants' => [ 'blue_background' => 1, 'orange_background' => 1, 'pink_background' => 1, @@ -117,31 +131,31 @@ cases along with the most concise way to write the configuration. ### Enabled for 10% of regular users. ```php $server_config['foo'] = [ - 'enabled' => 10 + 'variants' => ['enabled' => 10] ]; ``` ### Feature ramped up to 1% of requests, bucketing at random rather than by id ```php $server_config['foo'] = [ - 'enabled' => 1, + 'variants' => ['enabled' => 1], 'bucketing' => 'random' ]; ``` ### Feature ramped up to 40% of requests, bucketing by id rather than at random ```php $server_config['foo'] = [ - 'enabled' => 40, + 'variants' => ['enabled' => 40], 'bucketing' => 'id' ]; ``` ### Single-variant feature in 50/50 A/B test ```php - $server_config['foo'] = ['enabled' => 50]; + $server_config['foo'] = ['variants' => ['enabled' => 50]]; ``` ### Multi-variant feature in A/B test with 20% of users seeing each variant (and 40% left in control group). ```php $server_config['foo'] = [ - 'enabled' => [ + 'variants' => [ 'blue_background' => 20, 'orange_background' => 20, 'pink_background' => 20 @@ -154,14 +168,10 @@ Each feature’s config stanza controls when the feature is enabled and what variant should be used when it is. The value of a feature config stanza is an array with a number of special -keys, the most important of which is `'enabled'`. - -In its full form, the value of the `'enabled'` property an array whose keys are -names of variants and whose values are the percentage of requests that should -see each variant. +keys, the most important of which is `'variants'`. -As a shorthand to support the common case of a feature with only one variant, -`'enabled'` can also be specified as a percentage from 0 to 100. +The value of the `'variants'` property an array whose keys are names of variants +and whose values are the percentage of requests that should see each variant. The remaining feature config property is `'bucketing'`. Bucketing specifies how users are bucketed when a feature is enabled for only a percentage of users. @@ -179,15 +189,12 @@ There are a few ways to misuse the Feature API or misconfigure a feature that may be detected. (Some of these are not currently detected but may be in the future.) - 1. Setting `'enabled'` to numeric value less than 0 or greater than 100. - - 2. Setting the percentage value of a variant in `'enabled'` to a value less + 1. Setting the percentage value of a variant in `'variants'` to a value less than 0 or greater than 100. - 3. Setting `'enabled'` such that the sum of the variant percentages is greater - than 100. + 2. Setting `'variants'` such that the sum of the variant percentages is + greater than 100. - 4. Setting `'enabled'` to a non-numeric, non-array value. + 3. Setting `'variants'` to a non-array value. - 5. Setting `'bucketing'` to `'id'` and not providing an id string to the - `$feature->variant` or the `$feature->isEnabled` function. + 4. Setting `'bucketing'` to any value that is not `'id'` or `'random'`. diff --git a/composer.json b/composer.json index 96e6bb5..1c1fe14 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "pablojoan/feature", - "description": "PSR-4 compliant Feature Flags library", + "description": "Feature flagging API used for operational rampups and A/B testing", "authors": [ { "name": "Pablo Iglesias", @@ -22,16 +22,13 @@ "toggle" ], "require": { - "php": ">=8.0" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "*", - "phpstan/phpstan": "*", - "vimeo/psalm": "*", - "squizlabs/php_codesniffer": "*" + "phpunit/phpunit": "*" }, "scripts": { - "test": "./vendor/bin/phpcbf --standard=PSR12 src/ tests/ && ./vendor/bin/phpstan analyse --level=max --debug -vvv src/ && ./vendor/bin/psalm && php -d xdebug.mode=coverage ./vendor/bin/phpunit --stop-on-failure --fail-on-warning --fail-on-risky -v --debug --coverage-text --whitelist src/ tests/ && ./vendor/bin/phpcs --standard=PSR12 src/ tests/" + "test": "php ./vendor/bin/phpunit --stop-on-failure --fail-on-warning --fail-on-risky -v tests/" }, "autoload": { "psr-4": { diff --git a/psalm.xml b/psalm.xml deleted file mode 100644 index 4ab8899..0000000 --- a/psalm.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - diff --git a/src/Bucketing/Enum.php b/src/Bucketing/Enum.php new file mode 100644 index 0000000..cca8d8d --- /dev/null +++ b/src/Bucketing/Enum.php @@ -0,0 +1,19 @@ + new Random(), + static::ID => new Id() + }; + } +} diff --git a/src/Bucketing/Id.php b/src/Bucketing/Id.php index ff64170..13ff889 100644 --- a/src/Bucketing/Id.php +++ b/src/Bucketing/Id.php @@ -4,22 +4,23 @@ namespace PabloJoan\Feature\Bucketing; -final class Id implements Type +final readonly class Id implements Type { /** * hexdec('ffffffff') is the largest possible outcome * of hash('crc32c', $idToHash) */ - private const TOTAL = 4294967295; + private const TOTAL = 4294967295; + private const HASH_ALGO = 'crc32c'; /** * Convert Id string to a Hex * Convert Hex to Dec int * Get a percentage float */ - public function randomIshNumber(string $idToHash = ''): float + public function strToIntHash(string $idToHash = ''): float { - $hex = hash('crc32c', $idToHash); + $hex = hash(self::HASH_ALGO, $idToHash); $dec = hexdec($hex); $x = $dec / self::TOTAL; diff --git a/src/Bucketing/Random.php b/src/Bucketing/Random.php index dcaf4b8..8611480 100644 --- a/src/Bucketing/Random.php +++ b/src/Bucketing/Random.php @@ -4,11 +4,21 @@ namespace PabloJoan\Feature\Bucketing; -final class Random implements Type +use Random\Randomizer; +use Random\Engine\Xoshiro256StarStar; + +final readonly class Random implements Type { - public function randomIshNumber(string $idToHash = ''): float + private Randomizer $randomizer; + + public function __construct() + { + $this->randomizer = new Randomizer(new Xoshiro256StarStar()); + } + + public function strToIntHash(string $idToHash = ''): float { - $x = random_int(0, PHP_INT_MAX) / PHP_INT_MAX; - return $x * 100; + $decimal = $this->randomizer->getInt(0, PHP_INT_MAX) / PHP_INT_MAX; + return $decimal * 100; } } diff --git a/src/Bucketing/Type.php b/src/Bucketing/Type.php index 7e78582..8206414 100644 --- a/src/Bucketing/Type.php +++ b/src/Bucketing/Type.php @@ -7,8 +7,8 @@ interface Type { /** - * A random-ish number between 0 and 100 based on the feature name and $id + * A hash number between 0 and 100 based on an id string * unless we are bucketing completely at random */ - public function randomIshNumber(string $idToHash = ''): float; + public function strToIntHash(string $idToHash = ''): float; } diff --git a/src/Configurations/Collection.php b/src/Configurations/Collection.php index e9d03bd..0d87021 100644 --- a/src/Configurations/Collection.php +++ b/src/Configurations/Collection.php @@ -4,25 +4,25 @@ namespace PabloJoan\Feature\Configurations; -final class Collection +final readonly class Collection { - /** - * @var Config[] - */ private array $configurations; - /** - * @param array $configurations - */ public function __construct(array $configurations) { - foreach ($configurations as $featureName => $config) { - $this->configurations[$featureName] = new Config(featureName: $featureName, config: $config); - } + $this->configurations = array_map( + $this->buildConfig(...), + $configurations + ); } public function get(string $featureName): Config { return $this->configurations[$featureName]; } + + private function buildConfig(array $config): Config + { + return new Config($config); + } } diff --git a/src/Configurations/Config.php b/src/Configurations/Config.php index b4e4677..4876b0b 100644 --- a/src/Configurations/Config.php +++ b/src/Configurations/Config.php @@ -4,61 +4,57 @@ namespace PabloJoan\Feature\Configurations; +use PabloJoan\Feature\Bucketing\Enum as BucketOptions; use PabloJoan\Feature\Bucketing\Type as BucketType; -use PabloJoan\Feature\Bucketing\Id as BucketId; -use PabloJoan\Feature\Bucketing\Random as BucketRandom; /** - * A feature that can be enabled, disabled, ramped up, and - * A/B tested. + * A feature that can be enabled, disabled, ramped up, and A/B tested. */ -final class Config +final readonly class Config { - /** - * @var array - */ - private array $percentages; - + private array $variantIntegerRanges; private BucketType $bucketing; - /** - * @param array{enabled: int|array, bucketing?: string} $config - */ - public function __construct(string $featureName, array $config = ['enabled' => 0]) + public function __construct(array $config = ['variants' => ['' => 100]]) { - $this->percentages = $this->parseEnabled(featureName: $featureName, enabled: $config['enabled']); - $this->bucketing = $this->parseBucketing(bucketing: $config['bucketing'] ?? 'random'); + $this->variantIntegerRanges = $this->calculateIntegerRangeFromVariants( + $config['variants'] ?? [] + ); + + $bucketingOption = BucketOptions::tryFrom($config['bucketing'] ?? ''); + $bucketingOption ??= BucketOptions::RANDOM; + $this->bucketing = $bucketingOption->getBucketingClass(); } /** - * The percentage of users who should see each variant to - * map a random-ish number to a particular variant. + * Using a random 0 - 100 number or a 0 - 100 number hashed from an id, + * Select the variant where this random or hashed integer falls within it's + * calculated integer range. */ - public function variantByPercentage(string $id): string + public function pickVariantOutOfHat(string $id): string { - $number = $this->bucketing->randomIshNumber(idToHash: $id); - $percentRange = fn (int $percent): bool => $number < $percent; + $hashOrRandomNumber = $this->bucketing->strToIntHash($id); - $variant = key(array_filter($this->percentages, $percentRange)); - return $variant ? $variant : ''; + $variant = key(array_filter( + $this->variantIntegerRanges, + fn (int $variantRange): bool => $hashOrRandomNumber < $variantRange + )); + + return $variant ?? ''; } /** - * Parse the 'enabled' property of the feature's config stanza. - * Returns the upper-boundary of the variants percentage. - * - * @param int|array $enabled - * @return array + * Parse the 'variants' property of the feature's config stanza. + * Returns the upper-boundary of the variants percentage and uses that + * upper-boundary integer as its range of integers. */ - private function parseEnabled(string $featureName, int|array $enabled): array + private function calculateIntegerRangeFromVariants(array $variants): array { $total = 0; $percentages = []; - $enabled = is_int($enabled) ? [$featureName => $enabled] : $enabled; - - foreach ($enabled as $variant => $percent) { - $total += $this->percentage(percent: $percent); + foreach ($variants as $variant => $percent) { + $total += $percent; $percentages[$variant] = $total; } @@ -66,22 +62,4 @@ private function parseEnabled(string $featureName, int|array $enabled): array return $percentages; } - - /** - * Parse the 'bucketing' property of the feature's config stanza. - * Determines how the variants will be bucketed. - */ - private function parseBucketing(string $bucketing): BucketType - { - return match ($bucketing) { - 'random' => new BucketRandom(), - 'id' => new BucketId(), - default => throw new \Exception("bucketing option: $bucketing not supported.") - }; - } - - private function percentage(int $percent): int - { - return ($percent >= 0 && $percent <= 100) ? $percent : 0; - } } diff --git a/src/Feature.php b/src/Features.php similarity index 57% rename from src/Feature.php rename to src/Features.php index 91cb6ae..c8ec869 100644 --- a/src/Feature.php +++ b/src/Features.php @@ -20,18 +20,15 @@ * string, pass the string value as a second parameter. * * Feature->isEnabled(featureName: 'foo', id: $id); - * Feature->variant(featureName: 'foo', id: $id); + * Feature->getEnabledVariant(featureName: 'foo', id: $id); */ -final class Feature +final readonly class Features { private Collection $features; - /** - * @param array $features - */ public function __construct(array $features) { - $this->features = new Collection(configurations: $features); + $this->features = new Collection($features); } /** @@ -40,18 +37,21 @@ public function __construct(array $features) */ public function isEnabled(string $featureName, string $id = ''): bool { - return (bool) $this->variant(featureName: $featureName, id: $id); + return (bool) $this->getEnabledVariant( + featureName: $featureName, + id: $id + ); } /** - * Get the name of the A/B variant for the named feature for - * the given user or arbitrary string. Returns an empty string - * if the feature is not enabled for $userId. + * Get the name of the enabled variant for the named feature for the given + * id. Returns an empty string if the feature is not enabled. */ - public function variant(string $featureName, string $id = ''): string + public function getEnabledVariant( + string $featureName, + string $id = '' + ): string { - return $this->features - ->get(featureName: $featureName) - ->variantByPercentage(id: $id); + return $this->features->get($featureName)->pickVariantOutOfHat($id); } } diff --git a/tests/FeatureTest.php b/tests/FeatureTest.php index 228a82a..75fbe90 100644 --- a/tests/FeatureTest.php +++ b/tests/FeatureTest.php @@ -4,26 +4,26 @@ namespace PabloJoan\Feature\Tests; -use PabloJoan\Feature\Feature; +use PabloJoan\Feature\Features; use PHPUnit\Framework\TestCase; class FeatureTest extends TestCase { public function testFeatures(): void { - $feature = new Feature([ + $feature = new Features([ 'test_feature_1' => [ - 'enabled' => 100 + 'variants' => ['enabled' => 100] ], 'test_feature_2' => [ - 'enabled' => 0 + 'variants' => ['enabled' => 0] ], 'test_feature_3' => [ - 'enabled' => 50, + 'variants' => ['enabled' => 50], 'bucketing' => 'id' ], 'test_feature_4' => [ - 'enabled' => [ + 'variants' => [ 'test1' => 20, 'test2' => 30, 'test3' => 15, @@ -32,19 +32,19 @@ public function testFeatures(): void 'bucketing' => 'id' ], 'test_feature_5' => [ - 'enabled' => 0, + 'variants' => ['enabled' => 0], 'bucketing' => 'random' ], 'test_feature_6' => [ - 'enabled' => 0, + 'variants' => ['enabled' => 0], 'bucketing' => 'id' ], 'test_feature_7' => [ - 'enabled' => 100, + 'variants' => ['enabled' => 100], 'bucketing' => 'random' ], 'test_feature_8' => [ - 'enabled' => 100, + 'variants' => ['enabled' => 100], 'bucketing' => 'id' ] ]); @@ -58,42 +58,103 @@ public function testFeatures(): void $this->assertEquals($feature->isEnabled('test_feature_7'), true); $this->assertEquals($feature->isEnabled('test_feature_8'), true); - $this->assertEquals($feature->isEnabled('test_feature_1', 'test'), true); - $this->assertEquals($feature->isEnabled('test_feature_2', 'test'), false); - $this->assertEquals($feature->isEnabled('test_feature_3', 'test'), false); - $this->assertEquals($feature->isEnabled('test_feature_4', 'test'), true); - $this->assertEquals($feature->isEnabled('test_feature_5', 'test'), false); - $this->assertEquals($feature->isEnabled('test_feature_6', 'test'), false); - $this->assertEquals($feature->isEnabled('test_feature_7', 'test'), true); - $this->assertEquals($feature->isEnabled('test_feature_8', 'test'), true); + $this->assertEquals( + $feature->isEnabled('test_feature_1', 'test'), + true + ); + $this->assertEquals( + $feature->isEnabled('test_feature_2', 'test'), + false + ); + $this->assertEquals( + $feature->isEnabled('test_feature_3', 'test'), + false + ); + $this->assertEquals( + $feature->isEnabled('test_feature_4', 'test'), + true + ); + $this->assertEquals( + $feature->isEnabled('test_feature_5', 'test'), + false + ); + $this->assertEquals( + $feature->isEnabled('test_feature_6', 'test'), + false + ); + $this->assertEquals( + $feature->isEnabled('test_feature_7', 'test'), + true + ); + $this->assertEquals( + $feature->isEnabled('test_feature_8', 'test'), + true + ); - $this->assertEquals($feature->variant('test_feature_1'), 'test_feature_1'); - $this->assertEquals($feature->variant('test_feature_2'), ''); - $this->assertEquals($feature->variant('test_feature_3'), 'test_feature_3'); - $this->assertEquals($feature->variant('test_feature_4'), 'test1'); - $this->assertEquals($feature->variant('test_feature_5'), ''); - $this->assertEquals($feature->variant('test_feature_6'), ''); - $this->assertEquals($feature->variant('test_feature_7'), 'test_feature_7'); - $this->assertEquals($feature->variant('test_feature_8'), 'test_feature_8'); + $this->assertEquals( + $feature->getEnabledVariant('test_feature_1'), + 'enabled' + ); + $this->assertEquals( + $feature->getEnabledVariant('test_feature_2'), + '' + ); + $this->assertEquals( + $feature->getEnabledVariant('test_feature_3'), + 'enabled' + ); + $this->assertEquals( + $feature->getEnabledVariant('test_feature_4'), + 'test1' + ); + $this->assertEquals( + $feature->getEnabledVariant('test_feature_5'), + '' + ); + $this->assertEquals( + $feature->getEnabledVariant('test_feature_6'), + '' + ); + $this->assertEquals( + $feature->getEnabledVariant('test_feature_7'), + 'enabled' + ); + $this->assertEquals( + $feature->getEnabledVariant('test_feature_8'), + 'enabled' + ); - $this->assertEquals($feature->variant('test_feature_1', 'test'), 'test_feature_1'); - $this->assertEquals($feature->variant('test_feature_2', 'test'), ''); - $this->assertEquals($feature->variant('test_feature_3', 'test'), ''); - $this->assertEquals($feature->variant('test_feature_4', 'test'), 'test3'); - $this->assertEquals($feature->variant('test_feature_5', 'test'), ''); - $this->assertEquals($feature->variant('test_feature_6', 'test'), ''); - $this->assertEquals($feature->variant('test_feature_7', 'test'), 'test_feature_7'); - $this->assertEquals($feature->variant('test_feature_8', 'test'), 'test_feature_8'); - - try { - $feature = new Feature([ - 'test_feature_3' => [ - 'enabled' => 50, - 'bucketing' => 'not supported bucketing' - ] - ]); - } catch (\Exception $e) { - $this->assertEquals($e->getMessage(), 'bucketing option: not supported bucketing not supported.'); - } + $this->assertEquals( + $feature->getEnabledVariant('test_feature_1', 'test'), + 'enabled' + ); + $this->assertEquals( + $feature->getEnabledVariant('test_feature_2', 'test'), + '' + ); + $this->assertEquals( + $feature->getEnabledVariant('test_feature_3', 'test'), + '' + ); + $this->assertEquals( + $feature->getEnabledVariant('test_feature_4', 'test'), + 'test3' + ); + $this->assertEquals( + $feature->getEnabledVariant('test_feature_5', 'test'), + '' + ); + $this->assertEquals( + $feature->getEnabledVariant('test_feature_6', 'test'), + '' + ); + $this->assertEquals( + $feature->getEnabledVariant('test_feature_7', 'test'), + 'enabled' + ); + $this->assertEquals( + $feature->getEnabledVariant('test_feature_8', 'test'), + 'enabled' + ); } } From 97213383cae5991008863767bceff92267b18047 Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Wed, 12 Apr 2023 04:32:46 -0400 Subject: [PATCH 81/92] update github workflow --- .github/workflows/php.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index bdd41f6..03acfbc 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -2,9 +2,12 @@ name: PHP Composer on: push: - branches: [ master ] + branches: [ "master" ] pull_request: - branches: [ master ] + branches: [ "master" ] + +permissions: + contents: read jobs: build: @@ -12,13 +15,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Install dependencies run: composer install --no-interaction - # Add a test script to composer.json, for instance: "test": "vendor/bin/phpunit" - # Docs: https://getcomposer.org/doc/articles/scripts.md - - name: Run test suite run: composer test From 25b0a6a5c01ec842352c175891dc99cf98ad7cec Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Wed, 12 Apr 2023 04:39:30 -0400 Subject: [PATCH 82/92] update github workflow --- .github/workflows/php.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 03acfbc..73e812d 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -16,6 +16,8 @@ jobs: steps: - uses: actions/checkout@v3 + with: + php-version: '8.2' - name: Install dependencies run: composer install --no-interaction From a3a83634210e51be9d46955d5f6b2cc32757336e Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Wed, 12 Apr 2023 04:57:38 -0400 Subject: [PATCH 83/92] update github workflow --- .github/workflows/php.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 73e812d..3dd803e 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -15,9 +15,14 @@ jobs: runs-on: ubuntu-latest steps: + - name: "Checkout" - uses: actions/checkout@v3 + + - name: "Install PHP" + - uses: shivammathur/setup-php@v2 with: php-version: '8.2' + tools: composer - name: Install dependencies run: composer install --no-interaction From 5ac19e064d61f2cb663e111f358bee9b178bb054 Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Wed, 12 Apr 2023 04:59:03 -0400 Subject: [PATCH 84/92] update github workflow --- .github/workflows/php.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 3dd803e..6bf1d2d 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -16,10 +16,10 @@ jobs: steps: - name: "Checkout" - - uses: actions/checkout@v3 + uses: actions/checkout@v3 - name: "Install PHP" - - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@v2 with: php-version: '8.2' tools: composer From b5d76d41d51926935adf44ba58bd325ddc27a767 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Wed, 12 Apr 2023 05:16:05 -0400 Subject: [PATCH 85/92] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 789562c..0c4692d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![GitHub license](https://img.shields.io/github/license/PabloJoan/feature.svg)](https://github.com/PabloJoan/feature/blob/master/LICENSE) -Requires PHP 8.0 and above. +Requires PHP 8.2 and above. # Installation From f27a295ecebcc9e6f73cdbdbfd69767f693d6fc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jo=C3=A1n=20Iglesias?= Date: Mon, 1 May 2023 11:40:45 -0400 Subject: [PATCH 86/92] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0c4692d..8483e96 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ $featureConfigs = [ $features = new Features($featureConfigs); $features->isEnabled(featureName: 'foo'); // true -$features->enabledVariant(featureName: 'foo'); // 'variant1' +$features->getEnabledVariant(featureName: 'foo'); // 'variant1' ``` For a quick summary and common use cases, please read the rest of this README. From a25342f63f0d174c73ad0528fa975800d1a190f9 Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Mon, 27 Nov 2023 20:56:02 -0500 Subject: [PATCH 87/92] require php 8.3 --- .github/workflows/php.yml | 39 ++++++++++++++++--------------- README.md | 48 +++++++++++++++++++++++++++++++-------- composer.json | 4 ++-- src/Bucketing/Id.php | 10 ++++---- src/Bucketing/Random.php | 5 ++-- src/Bucketing/Type.php | 4 ++-- 6 files changed, 68 insertions(+), 42 deletions(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 6bf1d2d..991bfff 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -1,31 +1,30 @@ name: PHP Composer on: - push: - branches: [ "master" ] - pull_request: - branches: [ "master" ] + push: + branches: ["master"] + pull_request: + branches: ["master"] permissions: - contents: read + contents: read jobs: - build: + build: + runs-on: ubuntu-latest - runs-on: ubuntu-latest + steps: + - name: "Checkout" + uses: actions/checkout@v3 - steps: - - name: "Checkout" - uses: actions/checkout@v3 + - name: "Install PHP" + uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + tools: composer - - name: "Install PHP" - uses: shivammathur/setup-php@v2 - with: - php-version: '8.2' - tools: composer + - name: Install dependencies + run: composer install --no-interaction - - name: Install dependencies - run: composer install --no-interaction - - - name: Run test suite - run: composer test + - name: Run test suite + run: composer test diff --git a/README.md b/README.md index 8483e96..6e85247 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![GitHub license](https://img.shields.io/github/license/PabloJoan/feature.svg)](https://github.com/PabloJoan/feature/blob/master/LICENSE) -Requires PHP 8.2 and above. +Requires PHP 8.3 and above. # Installation @@ -33,7 +33,7 @@ $featureConfigs = [ $features = new Features($featureConfigs); -$features->isEnabled(featureName: 'foo'); // true +$features->isEnabled(featureName: 'foo'); // true $features->getEnabledVariant(featureName: 'foo'); // 'variant1' ``` @@ -50,13 +50,17 @@ A feature can be completely enabled, completely disabled, or something in between and can comprise a number of related variants. The two main API entry points are: + ```php $features->isEnabled(featureName: 'my_feature') ``` + which returns true when `my_feature` is enabled and, for multi-variant features: + ```php $features->getEnabledVariant(featureName: 'my_feature') ``` + which returns the name of the particular variant which should be used. The single argument to each of these methods is the name of the @@ -64,13 +68,16 @@ feature to test. A typical use of `$features->isEnabled` for a single-variant feature would look something like this: + ```php if ($features->isEnabled(featureName: 'my_feature')) { // do stuff } ``` + For a multi-variant feature, we can determine the appropriate code to run for each variant with something like this: + ```php switch ($features->getEnabledVariant(featureName: 'my_feature')) { case 'foo': @@ -81,8 +88,10 @@ each variant with something like this: break; } ``` + If a feature is bucketed by id, then we pass the id string to `$features->isEnabled` and `$features->getEnabledVariant` as a second parameter + ```php $isMyFeatureEnabled = $features->isEnabled( featureName: 'my_feature', @@ -95,7 +104,6 @@ If a feature is bucketed by id, then we pass the id string to ); ``` - ## Configuration cookbook There are a number of common configurations so before I explain the complete @@ -103,22 +111,31 @@ syntax of the feature configuration stanzas, here are some of the more common cases along with the most concise way to write the configuration. ### A totally enabled feature: + ```php $server_config['foo'] = ['variants' => ['enabled' => 100]]; ``` + ### A totally disabled feature: + ```php $server_config['foo'] = ['variants' => ['enabled' => 0]]; ``` + ### Feature with winning variant turned on for everyone + ```php $server_config['foo'] = ['variants' => ['blue_background' => 100]]; ``` + ### Single-variant feature ramped up to 1% of users. + ```php $server_config['foo'] = ['variants' => ['enabled' => 1]]; ``` + ### Multi-variant feature ramped up to 1% of users for each variant. + ```php $server_config['foo'] = [ 'variants' => [ @@ -128,31 +145,41 @@ cases along with the most concise way to write the configuration. ], ]; ``` + ### Enabled for 10% of regular users. + ```php $server_config['foo'] = [ 'variants' => ['enabled' => 10] ]; ``` + ### Feature ramped up to 1% of requests, bucketing at random rather than by id + ```php $server_config['foo'] = [ 'variants' => ['enabled' => 1], 'bucketing' => 'random' ]; ``` + ### Feature ramped up to 40% of requests, bucketing by id rather than at random + ```php $server_config['foo'] = [ 'variants' => ['enabled' => 40], 'bucketing' => 'id' ]; ``` + ### Single-variant feature in 50/50 A/B test + ```php $server_config['foo'] = ['variants' => ['enabled' => 50]]; ``` + ### Multi-variant feature in A/B test with 20% of users seeing each variant (and 40% left in control group). + ```php $server_config['foo'] = [ 'variants' => [ @@ -162,6 +189,7 @@ cases along with the most concise way to write the configuration. ], ]; ``` + ## Configuration details Each feature’s config stanza controls when the feature is enabled and what @@ -173,7 +201,7 @@ keys, the most important of which is `'variants'`. The value of the `'variants'` property an array whose keys are names of variants and whose values are the percentage of requests that should see each variant. -The remaining feature config property is `'bucketing'`. Bucketing specifies +The remaining feature config property is `'bucketing'`. Bucketing specifies how users are bucketed when a feature is enabled for only a percentage of users. The default value, `'random'`, causes each request to be bucketed independently, meaning that the same user will be in different buckets on different requests. @@ -189,12 +217,12 @@ There are a few ways to misuse the Feature API or misconfigure a feature that may be detected. (Some of these are not currently detected but may be in the future.) - 1. Setting the percentage value of a variant in `'variants'` to a value less - than 0 or greater than 100. +1. Setting the percentage value of a variant in `'variants'` to a value less + than 0 or greater than 100. - 2. Setting `'variants'` such that the sum of the variant percentages is - greater than 100. +2. Setting `'variants'` such that the sum of the variant percentages is + greater than 100. - 3. Setting `'variants'` to a non-array value. +3. Setting `'variants'` to a non-array value. - 4. Setting `'bucketing'` to any value that is not `'id'` or `'random'`. +4. Setting `'bucketing'` to any value that is not `'id'` or `'random'`. diff --git a/composer.json b/composer.json index 1c1fe14..b1b5824 100644 --- a/composer.json +++ b/composer.json @@ -22,13 +22,13 @@ "toggle" ], "require": { - "php": ">=8.2" + "php": ">=8.3" }, "require-dev": { "phpunit/phpunit": "*" }, "scripts": { - "test": "php ./vendor/bin/phpunit --stop-on-failure --fail-on-warning --fail-on-risky -v tests/" + "test": "php ./vendor/bin/phpunit --stop-on-failure --fail-on-warning --fail-on-risky tests/" }, "autoload": { "psr-4": { diff --git a/src/Bucketing/Id.php b/src/Bucketing/Id.php index 13ff889..8a075fb 100644 --- a/src/Bucketing/Id.php +++ b/src/Bucketing/Id.php @@ -10,20 +10,20 @@ * hexdec('ffffffff') is the largest possible outcome * of hash('crc32c', $idToHash) */ - private const TOTAL = 4294967295; - private const HASH_ALGO = 'crc32c'; + private const int TOTAL = 4294967295; + private const string HASH_ALGO = 'crc32c'; /** * Convert Id string to a Hex * Convert Hex to Dec int - * Get a percentage float + * Get a percentage int */ - public function strToIntHash(string $idToHash = ''): float + public function strToIntHash(string $idToHash): int { $hex = hash(self::HASH_ALGO, $idToHash); $dec = hexdec($hex); $x = $dec / self::TOTAL; - return $x * 100; + return (int) round($x * 100); } } diff --git a/src/Bucketing/Random.php b/src/Bucketing/Random.php index 8611480..eef5b57 100644 --- a/src/Bucketing/Random.php +++ b/src/Bucketing/Random.php @@ -16,9 +16,8 @@ public function __construct() $this->randomizer = new Randomizer(new Xoshiro256StarStar()); } - public function strToIntHash(string $idToHash = ''): float + public function strToIntHash(string $idToHash): int { - $decimal = $this->randomizer->getInt(0, PHP_INT_MAX) / PHP_INT_MAX; - return $decimal * 100; + return $this->randomizer->getInt(0, 100); } } diff --git a/src/Bucketing/Type.php b/src/Bucketing/Type.php index 8206414..9c92d4a 100644 --- a/src/Bucketing/Type.php +++ b/src/Bucketing/Type.php @@ -7,8 +7,8 @@ interface Type { /** - * A hash number between 0 and 100 based on an id string + * A hash that maps the given string to a number between 0 and 100 * unless we are bucketing completely at random */ - public function strToIntHash(string $idToHash = ''): float; + public function strToIntHash(string $idToHash): int; } From b8d5558a89b947a946859d874a65c00bb8f57480 Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Mon, 27 Nov 2023 21:05:28 -0500 Subject: [PATCH 88/92] remove default value in Config class constructor --- src/Configurations/Config.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Configurations/Config.php b/src/Configurations/Config.php index 4876b0b..718d4a9 100644 --- a/src/Configurations/Config.php +++ b/src/Configurations/Config.php @@ -15,7 +15,7 @@ private array $variantIntegerRanges; private BucketType $bucketing; - public function __construct(array $config = ['variants' => ['' => 100]]) + public function __construct(array $config) { $this->variantIntegerRanges = $this->calculateIntegerRangeFromVariants( $config['variants'] ?? [] From 3ecbeb4c30f781b87014a9051a4e41a2031bc851 Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Tue, 28 Nov 2023 01:42:18 -0500 Subject: [PATCH 89/92] fix bug where if hash number is 100, fails to match variant that is 100 --- src/Configurations/Config.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Configurations/Config.php b/src/Configurations/Config.php index 718d4a9..f8af5a4 100644 --- a/src/Configurations/Config.php +++ b/src/Configurations/Config.php @@ -35,12 +35,16 @@ public function pickVariantOutOfHat(string $id): string { $hashOrRandomNumber = $this->bucketing->strToIntHash($id); - $variant = key(array_filter( - $this->variantIntegerRanges, - fn (int $variantRange): bool => $hashOrRandomNumber < $variantRange - )); + foreach ($this->variantIntegerRanges as $variant => $variantRange) { + if ($hashOrRandomNumber < $variantRange) { + return $variant; + } + if ($hashOrRandomNumber === 100 && $variantRange === 100) { + return $variant; + } + } - return $variant ?? ''; + return ''; } /** From 43f74fddea1692980ea7e61d256450c191082c31 Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Wed, 29 Nov 2023 12:10:38 -0500 Subject: [PATCH 90/92] refactor to remove one if statement in a loop --- src/Bucketing/Id.php | 4 ++-- src/Bucketing/Random.php | 2 +- src/Configurations/Config.php | 3 --- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Bucketing/Id.php b/src/Bucketing/Id.php index 8a075fb..a97f779 100644 --- a/src/Bucketing/Id.php +++ b/src/Bucketing/Id.php @@ -23,7 +23,7 @@ public function strToIntHash(string $idToHash): int $hex = hash(self::HASH_ALGO, $idToHash); $dec = hexdec($hex); - $x = $dec / self::TOTAL; - return (int) round($x * 100); + $x = (int) round($dec / self::TOTAL * 100); + return $x === 100 ? 99 : $x; } } diff --git a/src/Bucketing/Random.php b/src/Bucketing/Random.php index eef5b57..67c975c 100644 --- a/src/Bucketing/Random.php +++ b/src/Bucketing/Random.php @@ -18,6 +18,6 @@ public function __construct() public function strToIntHash(string $idToHash): int { - return $this->randomizer->getInt(0, 100); + return $this->randomizer->getInt(0, 99); } } diff --git a/src/Configurations/Config.php b/src/Configurations/Config.php index f8af5a4..5819aa7 100644 --- a/src/Configurations/Config.php +++ b/src/Configurations/Config.php @@ -39,9 +39,6 @@ public function pickVariantOutOfHat(string $id): string if ($hashOrRandomNumber < $variantRange) { return $variant; } - if ($hashOrRandomNumber === 100 && $variantRange === 100) { - return $variant; - } } return ''; From 1ba43eb1db48d13c3ceb897bffd2fe9450b7dadf Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Wed, 29 Nov 2023 12:13:52 -0500 Subject: [PATCH 91/92] rename badly named function --- src/Configurations/Config.php | 4 ++-- src/Features.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Configurations/Config.php b/src/Configurations/Config.php index 5819aa7..1c8ad15 100644 --- a/src/Configurations/Config.php +++ b/src/Configurations/Config.php @@ -27,11 +27,11 @@ public function __construct(array $config) } /** - * Using a random 0 - 100 number or a 0 - 100 number hashed from an id, + * Using a random 0 - 99 number or a 0 - 99 number hashed from an id, * Select the variant where this random or hashed integer falls within it's * calculated integer range. */ - public function pickVariantOutOfHat(string $id): string + public function getEnabledVariant(string $id): string { $hashOrRandomNumber = $this->bucketing->strToIntHash($id); diff --git a/src/Features.php b/src/Features.php index c8ec869..1f46798 100644 --- a/src/Features.php +++ b/src/Features.php @@ -52,6 +52,6 @@ public function getEnabledVariant( string $id = '' ): string { - return $this->features->get($featureName)->pickVariantOutOfHat($id); + return $this->features->get($featureName)->getEnabledVariant($id); } } From ba908b50b7ab84fc2edb568d455f8d3d9a75dad3 Mon Sep 17 00:00:00 2001 From: PabloJoan Date: Sat, 2 Dec 2023 21:42:53 -0500 Subject: [PATCH 92/92] fix outdated code comment --- src/Bucketing/Type.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bucketing/Type.php b/src/Bucketing/Type.php index 9c92d4a..53c7ece 100644 --- a/src/Bucketing/Type.php +++ b/src/Bucketing/Type.php @@ -7,7 +7,7 @@ interface Type { /** - * A hash that maps the given string to a number between 0 and 100 + * A hash that maps the given string to a number between 0 and 99 * unless we are bucketing completely at random */ public function strToIntHash(string $idToHash): int;