├── .gitignore ├── phpstan.neon ├── tests ├── bootstrap.php ├── Unit │ ├── Utilities │ │ ├── TimeTest.php │ │ ├── RandomizerTest.php │ │ └── RuntimeCacheTest.php │ ├── Features │ │ ├── SetFactoryTest.php │ │ ├── FactoryTest.php │ │ ├── FeatureTest.php │ │ ├── SetTest.php │ │ └── SetRepositoryTest.php │ ├── Rules │ │ ├── StringHashTest.php │ │ ├── IpAddressTest.php │ │ ├── RandomTest.php │ │ ├── AbstractTest.php │ │ ├── PipedCompositeTest.php │ │ ├── HttpHeaderTest.php │ │ ├── UserIdTest.php │ │ ├── RuntimeCallableTest.php │ │ ├── PercentageTest.php │ │ ├── TimeAfterTest.php │ │ ├── TimeBeforeTest.php │ │ ├── EnvironmentTest.php │ │ ├── FactoryTest.php │ │ └── TypeMapperTest.php │ ├── Identification │ │ ├── IntegerIdTest.php │ │ ├── StringHashTest.php │ │ ├── PipedCompositeTest.php │ │ ├── IpAddressTest.php │ │ ├── UserIdTest.php │ │ ├── EnvironmentTest.php │ │ ├── HttpHeaderTest.php │ │ └── CollectionTest.php │ ├── Logger │ │ └── ProcessorTest.php │ ├── DoorKeeperTest.php │ └── RequestorTest.php └── Stubs │ └── AbstractRule.php ├── src ├── Features │ ├── SetProviderInterface.php │ ├── SetFactory.php │ ├── Factory.php │ ├── SetRepository.php │ ├── Set.php │ └── Feature.php ├── Utilities │ ├── Time.php │ ├── Randomizer.php │ └── RuntimeCache.php ├── DoorkeeperInterface.php ├── Identification │ ├── Environment.php │ ├── StringHash.php │ ├── IdentificationInterface.php │ ├── IntegerId.php │ ├── PipedComposite.php │ ├── HttpHeader.php │ ├── IpAddress.php │ ├── UserId.php │ ├── AbstractIdentification.php │ └── Collection.php ├── RequestorInterface.php ├── Rules │ ├── RuntimeCallable.php │ ├── RuleInterface.php │ ├── Random.php │ ├── StringHash.php │ ├── IpAddress.php │ ├── Environment.php │ ├── UserId.php │ ├── HttpHeader.php │ ├── PipedComposite.php │ ├── TimeAfter.php │ ├── TimeBefore.php │ ├── Percentage.php │ ├── Factory.php │ ├── AbstractRule.php │ └── TypeMapper.php ├── Logger │ └── Processor.php ├── Requestor.php └── Doorkeeper.php ├── psalm.xml ├── .scrutinizer.yml ├── Makefile ├── phpunit.xml.dist ├── composer.json ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | composer.phar 2 | /vendor/ 3 | /coverage/ 4 | .idea 5 | 6 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: max 3 | paths: 4 | - src 5 | checkMissingIterableValueType: false 6 | treatPhpDocTypesAsCertain: false 7 | checkGenericClassInNonGenericObjectType: false -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | mtRand($lower, $upper); 12 | } 13 | 14 | protected function mtRand(int $lower, int $upper): int 15 | { 16 | return mt_rand($lower, $upper); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Identification/PipedComposite.php: -------------------------------------------------------------------------------- 1 | assertEquals( 15 | new \DateTimeImmutable('2012-12-12'), 16 | (new Utilities\Time())->getImmutableDateTime('2012-12-12') 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Identification/HttpHeader.php: -------------------------------------------------------------------------------- 1 | getHeaderLine(Rules\HttpHeader::HEADER_KEY)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/Identification/IpAddress.php: -------------------------------------------------------------------------------- 1 | generateRangedRandomInt(1, 100); 16 | 17 | $this->assertGreaterThanOrEqual(1, $result); 18 | $this->assertLessThanOrEqual(100, $result); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | build: 2 | environment: 3 | php: 4 | version: '7.4.5' 5 | nodes: 6 | my-tests: 7 | environment: 8 | php: 9 | version: '7.4.5' 10 | cache: 11 | disabled: true 12 | 13 | dependencies: 14 | override: 15 | - composer install --ignore-platform-reqs --no-interaction 16 | 17 | build_failure_conditions: 18 | - 'elements.rating(<= B).new.exists' # No new classes/methods with a rating of C or worse allowed 19 | - 'project.metric_change("scrutinizer.test_coverage", < 0)' # Code Coverage decreased from previous inspection -------------------------------------------------------------------------------- /src/Identification/UserId.php: -------------------------------------------------------------------------------- 1 | featureFactory = $factory ?? new Factory(); 14 | } 15 | 16 | public function createFromArray(array $featureSet): Set 17 | { 18 | $features = []; 19 | 20 | foreach ($featureSet as $feature) { 21 | $features[] = $this->featureFactory->createFromArray($feature); 22 | } 23 | 24 | return new Set($features); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Rules/RuntimeCallable.php: -------------------------------------------------------------------------------- 1 | runtimeCallable = $runtimeCallable; 19 | } 20 | 21 | protected function childCanBeSatisfied(RequestorInterface $requestor = null): bool 22 | { 23 | $localFn = $this->runtimeCallable; 24 | return (bool) $localFn($requestor); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Rules/RuleInterface.php: -------------------------------------------------------------------------------- 1 | randomizer = $randomizer ?? new Utilities\Randomizer(); 17 | } 18 | 19 | protected function childCanBeSatisfied(RequestorInterface $requestor = null): bool 20 | { 21 | return ($this->randomizer->generateRangedRandomInt(1, 100) 22 | === $this->randomizer->generateRangedRandomInt(1, 100)); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Rules/StringHash.php: -------------------------------------------------------------------------------- 1 | hash = new Identification\StringHash($hash); 17 | } 18 | 19 | public function getValue() 20 | { 21 | return $this->hash->getIdentifier(); 22 | } 23 | 24 | protected function childCanBeSatisfied(RequestorInterface $requestor = null): bool 25 | { 26 | return $this->requestorHasMatchingId($requestor, $this->hash); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Rules/IpAddress.php: -------------------------------------------------------------------------------- 1 | ipAddress = new Identification\IpAddress($ipAddress); 17 | } 18 | 19 | public function getValue() 20 | { 21 | return $this->ipAddress->getIdentifier(); 22 | } 23 | 24 | protected function childCanBeSatisfied(RequestorInterface $requestor = null): bool 25 | { 26 | return $this->requestorHasMatchingId($requestor, $this->ipAddress); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Rules/Environment.php: -------------------------------------------------------------------------------- 1 | environment = new Identification\Environment($environment); 17 | } 18 | 19 | public function getValue() 20 | { 21 | return $this->environment->getIdentifier(); 22 | } 23 | 24 | protected function childCanBeSatisfied(RequestorInterface $requestor = null): bool 25 | { 26 | return $this->requestorHasMatchingId($requestor, $this->environment); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Rules/UserId.php: -------------------------------------------------------------------------------- 1 | userId = new Identification\UserId($userId); 20 | } 21 | 22 | public function getValue() 23 | { 24 | return $this->userId->getIdentifier(); 25 | } 26 | 27 | protected function childCanBeSatisfied(RequestorInterface $requestor = null): bool 28 | { 29 | return $this->requestorHasMatchingId($requestor, $this->userId); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Features/Factory.php: -------------------------------------------------------------------------------- 1 | ruleFactory = $rulesFactory ?? new Rules\Factory(); 16 | } 17 | 18 | public function createFromArray(array $feature): Feature 19 | { 20 | $rules = []; 21 | 22 | if (isset($feature['rules'])) { 23 | foreach ($feature['rules'] as $rule) { 24 | $rules[] = $this->ruleFactory->createFromArray($rule); 25 | } 26 | } 27 | 28 | return new Feature($feature['name'], $feature['enabled'], $rules); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Rules/HttpHeader.php: -------------------------------------------------------------------------------- 1 | header = new Identification\HttpHeader($headerValue); 19 | } 20 | 21 | public function getValue() 22 | { 23 | return $this->header->getIdentifier(); 24 | } 25 | 26 | protected function childCanBeSatisfied(RequestorInterface $requestor = null): bool 27 | { 28 | return $this->requestorHasMatchingId($requestor, $this->header); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Rules/PipedComposite.php: -------------------------------------------------------------------------------- 1 | pipedComposite = new Identification\PipedComposite($pipedComposite); 17 | } 18 | 19 | public function getValue() 20 | { 21 | return $this->pipedComposite->getIdentifier(); 22 | } 23 | 24 | protected function childCanBeSatisfied(RequestorInterface $requestor = null): bool 25 | { 26 | return $this->requestorHasMatchingId($requestor, $this->pipedComposite); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Unit/Features/SetFactoryTest.php: -------------------------------------------------------------------------------- 1 | createFromArray([ 20 | ['name' => 'feature1', 'enabled' => false], 21 | ['name' => 'feature2', 'enabled' => true], 22 | ]); 23 | 24 | $this->assertEquals($expected, $actual); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/Unit/Rules/StringHashTest.php: -------------------------------------------------------------------------------- 1 | assertFalse($rule->canBeSatisfied()); 19 | $this->assertFalse($rule->canBeSatisfied($requestor)); 20 | $this->assertTrue($rule->canBeSatisfied($requestor->withStringHash('1lk2j34lk'))); 21 | } 22 | 23 | public function testGetValue(): void 24 | { 25 | $this->assertEquals('#hash', (new Rules\StringHash('#hash'))->getValue()); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/Unit/Rules/IpAddressTest.php: -------------------------------------------------------------------------------- 1 | assertFalse($rule->canBeSatisfied()); 19 | $this->assertFalse($rule->canBeSatisfied($requestor)); 20 | 21 | $this->assertTrue($rule->canBeSatisfied($requestor->withIpAddress('127.0.0.1'))); 22 | } 23 | 24 | public function testGetValue(): void 25 | { 26 | $this->assertEquals('0.0.0.0', (new Rules\IpAddress('0.0.0.0'))->getValue()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Unit/Rules/RandomTest.php: -------------------------------------------------------------------------------- 1 | getMockBuilder(Utilities\Randomizer::class) 16 | ->onlyMethods(['mtRand']) 17 | ->getMock(); 18 | 19 | $rule = new Rules\Random($randomizer); 20 | 21 | $randomizer->method('mtRand') 22 | ->with(1, 100) 23 | ->willReturn(10); 24 | 25 | $this->assertTrue($rule->canBeSatisfied()); 26 | } 27 | 28 | public function testGetValue(): void 29 | { 30 | $this->assertNull((new Rules\Random())->getValue()); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Rules/TimeAfter.php: -------------------------------------------------------------------------------- 1 | timeUtility = $timeUtility ?? new Utilities\Time(); 19 | $this->timeAfter = $this->timeUtility->getImmutableDateTime($timeAfter); 20 | } 21 | 22 | public function getValue() 23 | { 24 | return $this->timeAfter->format('Y-m-d H:i:s'); 25 | } 26 | 27 | protected function childCanBeSatisfied(RequestorInterface $requestor = null): bool 28 | { 29 | return $this->timeAfter < $this->timeUtility->getImmutableDateTime('now'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Rules/TimeBefore.php: -------------------------------------------------------------------------------- 1 | timeUtility = $timeUtility ?? new Utilities\Time(); 19 | $this->timeBefore = $this->timeUtility->getImmutableDateTime($timeBefore); 20 | } 21 | 22 | public function getValue() 23 | { 24 | return $this->timeBefore->format('Y-m-d H:i:s'); 25 | } 26 | 27 | protected function childCanBeSatisfied(RequestorInterface $requestor = null): bool 28 | { 29 | return $this->timeBefore > $this->timeUtility->getImmutableDateTime('now'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/Unit/Rules/AbstractTest.php: -------------------------------------------------------------------------------- 1 | abstractRule = new Stubs\AbstractRule(); 18 | } 19 | 20 | public function testJsonSerializes(): void 21 | { 22 | $this->abstractRule->addPrerequisite(new Rules\Random()); 23 | // phpcs:disable 24 | $expected = '{"type":"RemotelyLiving\\\Doorkeeper\\\Tests\\\Stubs\\\AbstractRule","value":"mockValue","prerequisites":[{"type":"RemotelyLiving\\\Doorkeeper\\\Rules\\\Random","value":null,"prerequisites":[]}]}'; 25 | // phpcs:enable 26 | 27 | $this->assertEquals($expected, json_encode($this->abstractRule)); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Unit/Identification/IntegerIdTest.php: -------------------------------------------------------------------------------- 1 | expectException(\InvalidArgumentException::class); 18 | new Identification\IntegerId($invalidId); 19 | } 20 | 21 | public function testValidateValid(): void 22 | { 23 | $this->assertInstanceOf(Identification\IntegerId::class, (new Identification\IntegerId(1))); 24 | } 25 | 26 | public function invalidIntegerIdProvider(): array 27 | { 28 | return [ 29 | ['1'], 30 | ['boop'], 31 | [(object)[]], 32 | [[]], 33 | [-1] 34 | ]; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/Unit/Rules/PipedCompositeTest.php: -------------------------------------------------------------------------------- 1 | assertFalse($rule->canBeSatisfied()); 21 | $this->assertFalse($rule->canBeSatisfied($requestor)); 22 | $requestor->registerIdentification($identification); 23 | 24 | $this->assertTrue($rule->canBeSatisfied($requestor)); 25 | } 26 | 27 | public function testGetValue(): void 28 | { 29 | $this->assertEquals('A|B', (new Rules\PipedComposite('A|B'))->getValue()); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/Unit/Identification/StringHashTest.php: -------------------------------------------------------------------------------- 1 | expectException(\InvalidArgumentException::class); 18 | new Identification\StringHash($invalidId); 19 | } 20 | 21 | public function testValidateValid(): void 22 | { 23 | $this->assertInstanceOf( 24 | Identification\StringHash::class, 25 | (new Identification\StringHash(md5('herpderp'))) 26 | ); 27 | } 28 | 29 | public function invalidStringHashProvider(): array 30 | { 31 | return [ 32 | [(object)[]], 33 | [[]], 34 | [-1], 35 | [1.1], 36 | ]; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 16 | 17 | 18 | 19 | ./tests/Unit 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ./src 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remotelyliving/doorkeeper", 3 | "description": "A Feature Toggle", 4 | "minimum-stability": "stable", 5 | "license": "MIT", 6 | "keywords": ["Doorkeeper", "Feature Flag"], 7 | "authors": [ 8 | { 9 | "name": "Christian Thomas", 10 | "email": "christian.h.thomas@me.com" 11 | } 12 | ], 13 | "require": { 14 | "php": ">=7.4", 15 | "psr/cache": "^1.0", 16 | "psr/log": "^1.0", 17 | "psr/http-message": "^1.0", 18 | "ramsey/uuid": "^3.8", 19 | "ext-json": "*", 20 | "ext-filter": "*", 21 | "ext-mbstring": "*" 22 | }, 23 | "require-dev": { 24 | "phpunit/phpunit": "^9.0", 25 | "squizlabs/php_codesniffer": "^3.0", 26 | "php-coveralls/php-coveralls": "^2.0", 27 | "phpstan/phpstan": "^0.12.19", 28 | "maglnet/composer-require-checker": "^2.1" 29 | }, 30 | "autoload": { 31 | "psr-4": { 32 | "RemotelyLiving\\Doorkeeper\\" : "src/" 33 | } 34 | }, 35 | "autoload-dev": { 36 | "psr-4": { 37 | "RemotelyLiving\\Doorkeeper\\Tests\\" : "tests/" 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/Unit/Identification/PipedCompositeTest.php: -------------------------------------------------------------------------------- 1 | expectException(\InvalidArgumentException::class); 18 | new Identification\PipedComposite($invalidId); 19 | } 20 | 21 | public function testValidateValid(): void 22 | { 23 | $this->assertInstanceOf( 24 | Identification\PipedComposite::class, 25 | (new Identification\PipedComposite('boop|beep')) 26 | ) 27 | ; 28 | } 29 | 30 | public function invalidIdProvider(): array 31 | { 32 | return [ 33 | ['1'], 34 | ['boop'], 35 | [(object)[]], 36 | [[]], 37 | [-1] 38 | ]; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/Unit/Rules/HttpHeaderTest.php: -------------------------------------------------------------------------------- 1 | createMock(Http\Message\RequestInterface::class); 17 | $request->method('getHeaderLine') 18 | ->with(Rules\HttpHeader::HEADER_KEY) 19 | ->willReturn('the header'); 20 | 21 | $rule = new Rules\HttpHeader('the header'); 22 | 23 | $requestor = new Requestor(); 24 | 25 | $this->assertFalse($rule->canBeSatisfied()); 26 | $this->assertFalse($rule->canBeSatisfied($requestor)); 27 | $this->assertTrue($rule->canBeSatisfied($requestor->withRequest($request))); 28 | } 29 | 30 | public function testGetValue(): void 31 | { 32 | $this->assertEquals('header', (new Rules\HttpHeader('header'))->getValue()); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Unit/Features/FactoryTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($factory->createFromArray($array), $expected); 20 | } 21 | 22 | public function createFromArrayDataProvider(): array 23 | { 24 | return [ 25 | 'feature 1' => [ 26 | ['name' => 'boop', 'enabled' => true, 'rules' => [['type' => Random::class]]], 27 | new Features\Feature('boop', true, [new Rules\Random()]), 28 | ], 29 | 'feature 2 no rules' => [ 30 | ['name' => 'boop', 'enabled' => true], 31 | new Features\Feature('boop', true), 32 | ], 33 | ]; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/Unit/Rules/UserIdTest.php: -------------------------------------------------------------------------------- 1 | assertFalse($rule->canBeSatisfied()); 22 | $this->assertFalse($rule->canBeSatisfied($requestor)); 23 | 24 | $this->assertTrue($rule->canBeSatisfied($requestor->withUserId($id))); 25 | } 26 | 27 | public function testGetValue(): void 28 | { 29 | $this->assertEquals(234, (new Rules\UserId(234))->getValue()); 30 | } 31 | 32 | public function idProvider(): array 33 | { 34 | return [ 35 | 'uuid' => ['4dd8ab53-162c-4681-930a-62879d9e4b5f'], 36 | 'integer id' => [234], 37 | 'string' => ['#hi'], 38 | ]; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Christian Thomas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /src/Rules/Percentage.php: -------------------------------------------------------------------------------- 1 | 100) { 19 | throw new \InvalidArgumentException("Percentage must be represented as a value from 1 to 100"); 20 | } 21 | 22 | $this->chances = $percentage; 23 | $this->randomizer = $randomizer ?? new Utilities\Randomizer(); 24 | } 25 | 26 | public function getValue() 27 | { 28 | return $this->chances; 29 | } 30 | 31 | protected function childCanBeSatisfied(RequestorInterface $requestor = null): bool 32 | { 33 | $lotteryNumber = $this->randomizer->generateRangedRandomInt(1, 100); 34 | 35 | if ($this->chances === 100) { 36 | return true; 37 | } 38 | 39 | return $lotteryNumber <= $this->chances; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/Unit/Identification/IpAddressTest.php: -------------------------------------------------------------------------------- 1 | expectException(\InvalidArgumentException::class); 18 | new Identification\IpAddress($invalidId); 19 | } 20 | 21 | public function testValidateValid(): void 22 | { 23 | $this->assertInstanceOf( 24 | Identification\IpAddress::class, 25 | (new Identification\IpAddress('127.0.0.1')) 26 | ); 27 | 28 | $this->assertInstanceOf( 29 | Identification\IpAddress::class, 30 | (new Identification\IpAddress('2001:0db8:85a3:0000:0000:8a2e:0370:7334')) 31 | ); 32 | } 33 | 34 | public function invalidIpAddressProvider(): array 35 | { 36 | return [ 37 | ['1'], 38 | ['boop'], 39 | [(object)[]], 40 | [[]], 41 | [-1] 42 | ]; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Unit/Rules/RuntimeCallableTest.php: -------------------------------------------------------------------------------- 1 | assertTrue($callableTrue->canBeSatisfied()); 28 | $this->assertFalse($callableFalse->canBeSatisfied()); 29 | $this->assertTrue($callableWithRequestor->canBeSatisfied(new Requestor())); 30 | $this->assertFalse($callableWithRequestor->canBeSatisfied()); 31 | } 32 | 33 | public function testGetValue(): void 34 | { 35 | $fn = fn() => null; 36 | $this->assertNull((new Rules\RuntimeCallable($fn))->getValue()); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Features/SetRepository.php: -------------------------------------------------------------------------------- 1 | cache = $cache; 16 | } 17 | 18 | public function saveFeatureSet(Set $set): void 19 | { 20 | $cacheItem = $this->cache->getItem(self::generateFeatureSetCacheKey()); 21 | $cacheItem->set($set); 22 | 23 | $this->cache->save($cacheItem); 24 | } 25 | 26 | public function getFeatureSet(SetProviderInterface $fallback = null): Set 27 | { 28 | $result = $this->cache->getItem(self::generateFeatureSetCacheKey())->get(); 29 | 30 | if (!$result && $fallback) { 31 | $result = $fallback->getFeatureSet(); 32 | $this->saveFeatureSet($fallback->getFeatureSet()); 33 | } 34 | 35 | return ($result) ?? new Set(); 36 | } 37 | 38 | public function deleteFeatureSet(): void 39 | { 40 | $this->cache->deleteItem(self::generateFeatureSetCacheKey()); 41 | } 42 | 43 | public static function generateFeatureSetCacheKey(): string 44 | { 45 | return md5(self::class); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/Unit/Identification/UserIdTest.php: -------------------------------------------------------------------------------- 1 | expectException(\InvalidArgumentException::class); 18 | new Identification\UserId($invalidId); 19 | } 20 | 21 | public function testValidateValid(): void 22 | { 23 | $this->assertInstanceOf( 24 | Identification\UserId::class, 25 | (new Identification\UserId('#string_id#')) 26 | ) 27 | ; 28 | $this->assertInstanceOf(Identification\UserId::class, (new Identification\UserId(4))); 29 | $this->assertInstanceOf( 30 | Identification\UserId::class, 31 | (new Identification\UserId('4dd8ab53-162c-4681-930a-62879d9e4b5f')) 32 | ); 33 | } 34 | 35 | public function invalidStringHashProvider(): array 36 | { 37 | return [ 38 | [(object)[]], 39 | [[]], 40 | [true], 41 | [false], 42 | [1.123] 43 | ]; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/Unit/Identification/EnvironmentTest.php: -------------------------------------------------------------------------------- 1 | expectException(\InvalidArgumentException::class); 18 | new Identification\Environment($invalidEnv); 19 | } 20 | 21 | public function testValidateValid(): void 22 | { 23 | $this->assertInstanceOf(Identification\Environment::class, new Identification\Environment('DEV')); 24 | } 25 | 26 | public function testEquals(): void 27 | { 28 | $devEnv = new Identification\Environment('dev'); 29 | $prodEnv = new Identification\Environment('prod'); 30 | $otherDev = new Identification\Environment('dev'); 31 | 32 | $this->assertTrue($devEnv->equals($otherDev)); 33 | $this->assertFalse($devEnv->equals($prodEnv)); 34 | } 35 | 36 | public function invalidEnvProvider(): array 37 | { 38 | return [ 39 | [(object)[]], 40 | [1], 41 | [true], 42 | [false] 43 | ]; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/Unit/Features/FeatureTest.php: -------------------------------------------------------------------------------- 1 | assertTrue($enabled->isEnabled()); 19 | $this->assertFalse($disabled->isEnabled()); 20 | } 21 | 22 | public function testGetsId(): void 23 | { 24 | $feature = new Features\Feature('someId', false); 25 | 26 | $this->assertSame('someId', $feature->getName()); 27 | } 28 | 29 | public function testGetsRules(): void 30 | { 31 | $feature = new Features\Feature('someId', true); 32 | $rule = new Rules\Random(); 33 | 34 | $this->assertEmpty($feature->getRules()); 35 | 36 | $feature->addRule($rule); 37 | 38 | $rules = $feature->getRules(); 39 | 40 | $this->assertSame($rule, array_shift($rules)); 41 | } 42 | 43 | public function testJsonSerializes(): void 44 | { 45 | $feature = new Features\Feature('someId', true); 46 | $this->assertEquals('{"name":"someId","enabled":true,"rules":[]}', json_encode($feature)); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/Unit/Identification/HttpHeaderTest.php: -------------------------------------------------------------------------------- 1 | expectException(\InvalidArgumentException::class); 20 | new Identification\HttpHeader($invalidId); 21 | } 22 | 23 | public function validateValid(): void 24 | { 25 | $this->assertInstanceOf( 26 | Identification\HttpHeader::class, 27 | (new Identification\HttpHeader('herpderp')) 28 | ); 29 | } 30 | 31 | public function testCreateFromRequest(): void 32 | { 33 | $request = $this->createMock(Http\Message\RequestInterface::class); 34 | $request->method('getHeaderLine') 35 | ->with(Rules\HttpHeader::HEADER_KEY) 36 | ->willReturn('boop'); 37 | 38 | $this->assertInstanceOf( 39 | Identification\HttpHeader::class, 40 | Identification\HttpHeader::createFromRequest($request) 41 | ); 42 | } 43 | 44 | public function invalidStringHashProvider(): array 45 | { 46 | return [ 47 | [(object)[]], 48 | ]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Utilities/RuntimeCache.php: -------------------------------------------------------------------------------- 1 | maxCacheItems = $maxCacheItems; 19 | } 20 | 21 | /** 22 | * @return mixed|null 23 | */ 24 | public function get(string $key, callable $fallback = null) 25 | { 26 | if ($this->has($key)) { 27 | return $this->cache[$key]; 28 | } 29 | 30 | if (!$fallback) { 31 | return null; 32 | } 33 | 34 | $result = $fallback(); 35 | 36 | $this->set($key, $result); 37 | 38 | return $result; 39 | } 40 | 41 | public function has(string $key): bool 42 | { 43 | return isset($this->cache[$key]); 44 | } 45 | 46 | /** 47 | * @param mixed $value 48 | */ 49 | public function set(string $key, $value): void 50 | { 51 | if ($this->maxCacheItems !== null && count($this->cache) >= $this->maxCacheItems) { 52 | array_shift($this->cache); 53 | } 54 | 55 | $this->cache[$key] = $value; 56 | } 57 | 58 | public function destroy(string $key): void 59 | { 60 | unset($this->cache[$key]); 61 | } 62 | 63 | public function flush(): void 64 | { 65 | $this->cache = []; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Features/Set.php: -------------------------------------------------------------------------------- 1 | pushFeature($feature); 21 | } 22 | } 23 | 24 | public function pushFeature(Feature $feature): void 25 | { 26 | $this->features[$feature->getName()] = $feature; 27 | } 28 | 29 | public function offsetExists(string $featureName): bool 30 | { 31 | return isset($this->features[$featureName]); 32 | } 33 | 34 | /** 35 | * @return \RemotelyLiving\Doorkeeper\Features\Feature[] 36 | */ 37 | public function getFeatures(): array 38 | { 39 | return $this->features; 40 | } 41 | 42 | public function getFeatureByName(string $featureName): Feature 43 | { 44 | if (!$this->offsetExists($featureName)) { 45 | throw new \OutOfBoundsException("Feature {$featureName} does not exist"); 46 | } 47 | 48 | return $this->features[$featureName]; 49 | } 50 | 51 | /** 52 | * @return array 53 | */ 54 | public function jsonSerialize(): array 55 | { 56 | return ['features' => $this->features]; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/Unit/Rules/PercentageTest.php: -------------------------------------------------------------------------------- 1 | getMockBuilder(Utilities\Randomizer::class) 16 | ->onlyMethods(['mtRand']) 17 | ->getMock(); 18 | 19 | $rule = new Rules\Percentage(5, $randomizer); 20 | 21 | $randomizer->method('mtRand') 22 | ->willReturnMap([ 23 | [1, 100, 3], 24 | [1, 5, 3] 25 | ]); 26 | 27 | $this->assertTrue($rule->canBeSatisfied()); 28 | } 29 | 30 | public function testCanBeSatisfied100PercentChance(): void 31 | { 32 | $rule = new Rules\Percentage(100); 33 | 34 | $this->assertTrue($rule->canBeSatisfied()); 35 | } 36 | 37 | public function testCanBeSatisfied0PercentChance(): void 38 | { 39 | $rule = new Rules\Percentage(0); 40 | 41 | $this->assertFalse($rule->canBeSatisfied()); 42 | } 43 | 44 | public function testInvalidPercentageOverOneHundred(): void 45 | { 46 | $this->expectException(\InvalidArgumentException::class); 47 | new Rules\Percentage(101); 48 | } 49 | 50 | public function testGetValue(): void 51 | { 52 | $this->assertEquals(4, (new Rules\Percentage(4))->getValue()); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/Unit/Rules/TimeAfterTest.php: -------------------------------------------------------------------------------- 1 | getMockBuilder(Utilities\Time::class) 16 | ->onlyMethods(['getImmutableDateTime']) 17 | ->getMock(); 18 | 19 | $time->method('getImmutableDateTime') 20 | ->willReturnMap([ 21 | ['boop', new \DateTimeImmutable('2011-11-12')], 22 | ['now', new \DateTimeImmutable('2011-12-12')], 23 | ]); 24 | 25 | $rule = new Rules\TimeAfter('boop', $time); 26 | 27 | $this->assertTrue($rule->canBeSatisfied()); 28 | } 29 | 30 | public function testCannotBeSatisfied(): void 31 | { 32 | $time = $this->getMockBuilder(Utilities\Time::class) 33 | ->onlyMethods(['getImmutableDateTime']) 34 | ->getMock(); 35 | 36 | $time->method('getImmutableDateTime') 37 | ->willReturnMap([ 38 | ['boop', new \DateTimeImmutable('2011-12-12')], 39 | ['now', new \DateTimeImmutable('2011-11-12')], 40 | ]); 41 | 42 | $rule = new Rules\TimeAfter('boop', $time); 43 | 44 | $this->assertFalse($rule->canBeSatisfied()); 45 | } 46 | 47 | public function testGetValue(): void 48 | { 49 | $this->assertEquals('2011-12-12 00:00:00', (new Rules\TimeAfter('2011-12-12'))->getValue()); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/Unit/Rules/TimeBeforeTest.php: -------------------------------------------------------------------------------- 1 | getMockBuilder(Utilities\Time::class) 16 | ->onlyMethods(['getImmutableDateTime']) 17 | ->getMock(); 18 | 19 | $time->method('getImmutableDateTime') 20 | ->willReturnMap([ 21 | ['boop', new \DateTimeImmutable('2011-11-12')], 22 | ['now', new \DateTimeImmutable('2011-12-12')], 23 | ]); 24 | 25 | $rule = new Rules\TimeBefore('boop', $time); 26 | 27 | $this->assertFalse($rule->canBeSatisfied()); 28 | } 29 | 30 | public function testCanBeSatisfied(): void 31 | { 32 | $time = $this->getMockBuilder(Utilities\Time::class) 33 | ->onlyMethods(['getImmutableDateTime']) 34 | ->getMock(); 35 | 36 | $time->method('getImmutableDateTime') 37 | ->willReturnMap([ 38 | ['boop', new \DateTimeImmutable('2011-12-12')], 39 | ['now', new \DateTimeImmutable('2011-11-12')], 40 | ]); 41 | 42 | $rule = new Rules\TimeBefore('boop', $time); 43 | 44 | $this->assertTrue($rule->canBeSatisfied()); 45 | } 46 | 47 | public function testGetValue(): void 48 | { 49 | $this->assertEquals('2011-12-12 00:00:00', (new Rules\TimeBefore('2011-12-12'))->getValue()); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Identification/AbstractIdentification.php: -------------------------------------------------------------------------------- 1 | validate($identifier); 28 | 29 | $this->identifier = $identifier; 30 | $this->type = get_class($this); 31 | $this->identityHash = self::createIdentityHash($this); 32 | } 33 | 34 | final public function getIdentifier() 35 | { 36 | return $this->identifier; 37 | } 38 | 39 | final public function getType(): string 40 | { 41 | return $this->type; 42 | } 43 | 44 | final public function equals(IdentificationInterface $identity): bool 45 | { 46 | return $this->identityHash === self::createIdentityHash($identity); 47 | } 48 | 49 | /** 50 | * @param string|int|float $value 51 | */ 52 | abstract protected function validate($value): void; 53 | 54 | private static function createIdentityHash(IdentificationInterface $identification): string 55 | { 56 | return md5($identification->getType() . (string)$identification->getIdentifier()); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/Unit/Features/SetTest.php: -------------------------------------------------------------------------------- 1 | assertEquals(['feature1Id' => $feature1, 'feature2Id' => $feature2], $set->getFeatures()); 20 | $this->assertEquals($feature1, $set->getFeatureByName($feature1->getName())); 21 | $this->assertFalse($set->offsetExists('boop')); 22 | $this->assertTrue($set->offsetExists($feature2->getName())); 23 | } 24 | 25 | public function testJsonSerializesFeatures(): void 26 | { 27 | $feature_1 = new Features\Feature('feature1Id', false); 28 | $feature_2 = new Features\Feature('feature2Id', true); 29 | 30 | $set = new Features\Set([$feature_1, $feature_2]); 31 | // phpcs:disable 32 | $expected_json = '{"features":{"feature1Id":{"name":"feature1Id","enabled":false,"rules":[]},"feature2Id":{"name":"feature2Id","enabled":true,"rules":[]}}}'; 33 | // phpcs:enable 34 | 35 | $this->assertEquals($expected_json, json_encode($set)); 36 | } 37 | 38 | public function testThrowsOutOfBoundsExceptionWhenFeatureNotFound(): void 39 | { 40 | $this->expectException(\OutOfBoundsException::class); 41 | (new Features\Set())->getFeatureByName('slkf'); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Features/Feature.php: -------------------------------------------------------------------------------- 1 | name = $name; 28 | $this->enabled = $enabled; 29 | 30 | foreach ($rules as $rule) { 31 | $this->addRule($rule); 32 | } 33 | } 34 | 35 | public function getName(): string 36 | { 37 | return $this->name; 38 | } 39 | 40 | public function isEnabled(): bool 41 | { 42 | return $this->enabled; 43 | } 44 | 45 | /** 46 | * @throws \DomainException 47 | */ 48 | public function addRule(Rules\RuleInterface $rule): void 49 | { 50 | $this->ruleSet[] = $rule; 51 | } 52 | 53 | /** 54 | * @return \RemotelyLiving\Doorkeeper\Rules\RuleInterface[] 55 | */ 56 | public function getRules(): array 57 | { 58 | return $this->ruleSet; 59 | } 60 | 61 | public function jsonSerialize(): array 62 | { 63 | return [ 64 | 'name' => $this->name, 65 | 'enabled' => $this->enabled, 66 | 'rules' => $this->ruleSet, 67 | ]; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tests/Unit/Identification/CollectionTest.php: -------------------------------------------------------------------------------- 1 | expectException(\InvalidArgumentException::class); 15 | $collection = new Identification\Collection( 16 | Identification\Environment::class, 17 | [new Identification\Environment('dev')] 18 | ) 19 | ; 20 | $collection->add(new Identification\HttpHeader('boop')); 21 | } 22 | 23 | public function testAddsAndGetsRemovesCountsAndHasAndIsIterableLol(): void 24 | { 25 | $prod = new Identification\Environment('prod'); 26 | $dev = new Identification\Environment('dev'); 27 | $collection = new Identification\Collection(Identification\Environment::class, [$dev]); 28 | $collection->add($prod); 29 | $collection->add($prod); // overwrites 30 | 31 | $this->assertEquals($dev, $collection->get('dev')); 32 | $this->assertEquals($prod, $collection->get('prod')); 33 | 34 | $this->assertTrue($collection->has($dev)); 35 | $this->assertTrue($collection->has($prod)); 36 | $this->assertFalse($collection->has(new Identification\Environment('derp'))); 37 | 38 | $this->assertEquals(2, $collection->count()); 39 | $collection->remove($dev); 40 | 41 | $this->assertFalse($collection->has($dev)); 42 | $this->assertEquals(1, $collection->count()); 43 | 44 | foreach ($collection as $id) { 45 | $this->assertEquals($prod, $id); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Identification/Collection.php: -------------------------------------------------------------------------------- 1 | classType = $classType; 19 | 20 | foreach ($identifications as $identification) { 21 | $this->add($identification); 22 | } 23 | } 24 | 25 | public function getIterator(): \ArrayIterator 26 | { 27 | return new \ArrayIterator($this->identifications); 28 | } 29 | 30 | public function count(): int 31 | { 32 | return count($this->identifications); 33 | } 34 | 35 | public function add(IdentificationInterface $identification): void 36 | { 37 | if (get_class($identification) !== $this->classType) { 38 | throw new \InvalidArgumentException("Identification must be a {$this->classType}"); 39 | } 40 | 41 | $this->identifications[$identification->getIdentifier()] = $identification; 42 | } 43 | 44 | public function remove(IdentificationInterface $identification): void 45 | { 46 | if ($this->has($identification)) { 47 | unset($this->identifications[$identification->getIdentifier()]); 48 | } 49 | } 50 | 51 | public function get(string $id): ?IdentificationInterface 52 | { 53 | return $this->identifications[$id] ?? null; 54 | } 55 | 56 | public function has(IdentificationInterface $identification): bool 57 | { 58 | return isset($this->identifications[$identification->getIdentifier()]); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Rules/Factory.php: -------------------------------------------------------------------------------- 1 | ruleTypeMapper = $typeMapper ?? new TypeMapper(); 14 | } 15 | 16 | public function createFromArray(array $fields): RuleInterface 17 | { 18 | $ruleType = $this->normalizeRuleType($fields['type']); 19 | 20 | /** @var \RemotelyLiving\Doorkeeper\Rules\RuleInterface $rule */ 21 | $rule = isset($fields['value']) ? new $ruleType($fields['value']) : new $ruleType(); 22 | 23 | return isset($fields['prerequisites']) ? $this->addPrerequisites($rule, $fields['prerequisites']) : $rule; 24 | } 25 | 26 | private function addPrerequisites(RuleInterface $rule, array $prequisites): RuleInterface 27 | { 28 | foreach ($prequisites as $prequisite) { 29 | $preReqType = $this->normalizeRuleType($prequisite['type']); 30 | $preReq = isset($prequisite['value']) ? new $preReqType($prequisite['value']) : new $preReqType(); 31 | 32 | $rule->addPrerequisite($preReq); 33 | } 34 | 35 | return $rule; 36 | } 37 | 38 | /** 39 | * @param string|int $type 40 | */ 41 | private function normalizeRuleType($type): string 42 | { 43 | if (is_numeric($type)) { 44 | return $this->ruleTypeMapper->getClassNameById((int) $type); 45 | } 46 | 47 | $fqcnSegments = explode('\\', $type); 48 | $className = array_pop($fqcnSegments); 49 | 50 | if (class_exists(__NAMESPACE__ . "\\{$className}")) { 51 | return __NAMESPACE__ . "\\{$className}"; 52 | } 53 | 54 | throw new \InvalidArgumentException("{$type} is not a valid rule type"); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /tests/Unit/Utilities/RuntimeCacheTest.php: -------------------------------------------------------------------------------- 1 | cache = new Utilities\RuntimeCache(); 19 | } 20 | 21 | public function testGetNotCached(): void 22 | { 23 | $this->assertNull($this->cache->get('boop')); 24 | } 25 | 26 | public function testGetNotCachedUsesCallback(): void 27 | { 28 | $fallback = function () { 29 | return 'beep'; 30 | }; 31 | 32 | $this->assertEquals('beep', $this->cache->get('boop', $fallback)); 33 | } 34 | 35 | public function testSetWorksAndGetIgnoresFallback(): void 36 | { 37 | $this->cache->set('herp', ['derp']); 38 | 39 | $this->assertEquals(['derp'], $this->cache->get( 40 | 'herp', 41 | function () { 42 | return 'wrong'; 43 | } 44 | )); 45 | } 46 | 47 | public function testDestroy(): void 48 | { 49 | $this->cache->set('boop', 'beep'); 50 | $this->cache->set('herp', 'derp'); 51 | $this->cache->destroy('boop'); 52 | 53 | $this->assertEquals('derp', $this->cache->get('herp')); 54 | $this->assertNull($this->cache->get('boop')); 55 | } 56 | 57 | public function testSetsCacheLimit(): void 58 | { 59 | $cache = new Utilities\RuntimeCache(2); 60 | 61 | $cache->set('first', 1); 62 | 63 | $this->assertEquals(1, $cache->get('first')); 64 | 65 | $cache->set('second', 2); 66 | $cache->set('third', 3); 67 | 68 | $this->assertNull($cache->get('first')); 69 | $this->assertEquals(3, $cache->get('third')); 70 | $this->assertEquals(2, $cache->get('second')); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Rules/AbstractRule.php: -------------------------------------------------------------------------------- 1 | prerequisites[] = $rule; 20 | } 21 | 22 | final public function hasPrerequisites(): bool 23 | { 24 | return (bool) $this->prerequisites; 25 | } 26 | 27 | final public function getPrerequisites(): iterable 28 | { 29 | return $this->prerequisites; 30 | } 31 | 32 | public function getValue() 33 | { 34 | return null; 35 | } 36 | 37 | final public function canBeSatisfied(RequestorInterface $requestor = null): bool 38 | { 39 | if ($this->hasPrerequisites()) { 40 | foreach ($this->prerequisites as $prerequisite) { 41 | if (!$prerequisite->canBeSatisfied($requestor)) { 42 | return false; 43 | } 44 | } 45 | } 46 | 47 | return $this->childCanBeSatisfied($requestor); 48 | } 49 | 50 | public function jsonSerialize(): array 51 | { 52 | return [ 53 | 'type' => static::class, 54 | 'value' => $this->getValue(), 55 | 'prerequisites' => $this->prerequisites, 56 | ]; 57 | } 58 | 59 | protected function requestorHasMatchingId( 60 | RequestorInterface $requestor = null, 61 | Identification\IdentificationInterface $identification 62 | ): bool { 63 | if (!$requestor) { 64 | return false; 65 | } 66 | 67 | return $requestor->hasIdentification($identification); 68 | } 69 | 70 | abstract protected function childCanBeSatisfied(RequestorInterface $requestor = null): bool; 71 | } 72 | -------------------------------------------------------------------------------- /tests/Unit/Rules/EnvironmentTest.php: -------------------------------------------------------------------------------- 1 | assertFalse($rule->canBeSatisfied()); 19 | $this->assertFalse($rule->canBeSatisfied($requestor)); 20 | $this->assertTrue($rule->canBeSatisfied($requestor->withEnvironment('DEV'))); 21 | $this->assertFalse($rule->canBeSatisfied($requestor->withEnvironment('PROD'))); 22 | } 23 | 24 | public function testCannotBeSatisfiedWithFalseyPrereq() 25 | { 26 | $rule = new Rules\Environment('DEV'); 27 | $prereq = new Rules\HttpHeader('headerValue'); 28 | 29 | $rule->addPrerequisite($prereq); 30 | 31 | $this->assertTrue($rule->hasPrerequisites()); 32 | 33 | $requestor = new Requestor(); 34 | 35 | $this->assertFalse($rule->canBeSatisfied()); 36 | $this->assertFalse($rule->canBeSatisfied($requestor)); 37 | $this->assertFalse($rule->canBeSatisfied($requestor->withEnvironment('DEV'))); 38 | } 39 | 40 | public function testCanBeSatisfiedWithTruthyPrereq(): void 41 | { 42 | $rule = new Rules\Environment('DEV'); 43 | $prereq = new Rules\UserId(321); 44 | 45 | $rule->addPrerequisite($prereq); 46 | 47 | $this->assertTrue($rule->hasPrerequisites()); 48 | 49 | $requestor = new Requestor(); 50 | 51 | $this->assertFalse($rule->canBeSatisfied()); 52 | $this->assertFalse($rule->canBeSatisfied($requestor)); 53 | $this->assertTrue($rule->canBeSatisfied($requestor->withEnvironment('DEV')->withUserId(321))); 54 | $this->assertEquals([$prereq], $rule->getPrerequisites()); 55 | } 56 | 57 | public function testGetValue() 58 | { 59 | $this->assertEquals('dev', (new Rules\Environment('dev'))->getValue()); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Rules/TypeMapper.php: -------------------------------------------------------------------------------- 1 | HttpHeader::class, 25 | self::RULE_TYPE_IP_ADDRESS => IpAddress::class, 26 | self::RULE_TYPE_PERCENTAGE => Percentage::class, 27 | self::RULE_TYPE_RANDOM => Random::class, 28 | self::RULE_TYPE_STRING_HASH => StringHash::class, 29 | self::RULE_TYPE_USER_ID => UserId::class, 30 | self::RULE_TYPE_ENVIRONMENT => Environment::class, 31 | self::RULE_TYPE_BEFORE => TimeBefore::class, 32 | self::RULE_TYPE_AFTER => TimeAfter::class, 33 | self::RULE_TYPE_PIPED_COMPOSITE => PipedComposite::class, 34 | ]; 35 | 36 | public function __construct(array $extraTypes = []) 37 | { 38 | foreach ($extraTypes as $id => $name) { 39 | $this->pushExtraType($id, $name); 40 | } 41 | } 42 | 43 | public function getClassNameById(int $integerId): string 44 | { 45 | return $this->typeMap[$integerId]; 46 | } 47 | 48 | public function getIdForClassName(string $className): int 49 | { 50 | return array_flip($this->typeMap)[$className]; 51 | } 52 | 53 | public function pushExtraType(int $id, string $className): void 54 | { 55 | 56 | if (isset($this->typeMap[$id])) { 57 | throw new \DomainException("Type {$id} already set by parent"); 58 | } 59 | 60 | if (!class_exists($className)) { 61 | throw new \InvalidArgumentException("{$className} is not a loaded class"); 62 | } 63 | 64 | $this->typeMap[$id] = $className; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Logger/Processor.php: -------------------------------------------------------------------------------- 1 | getIdentificationCollections() as $identificationCollection) { 35 | foreach ($identificationCollection as $id) { 36 | if (isset($this->filteredIdentities[get_class($id)])) { 37 | continue; 38 | } 39 | $key = $this->getKeyFromIdentification($id); 40 | $requestorContext[self::CONTEXT_KEY_IDENTIFIERS][$key][] = $id->getIdentifier(); 41 | } 42 | } 43 | 44 | $requestorContext = $this->flattenIdCollections($requestorContext); 45 | $record['context'][static::CONTEXT_KEY_REQUESTOR] = $requestorContext; 46 | 47 | return $record; 48 | } 49 | 50 | public function setFilteredIdentityTypes(array $idTypes): void 51 | { 52 | $this->filteredIdentities = array_flip($idTypes); 53 | } 54 | 55 | private function getKeyFromIdentification(Identification\IdentificationInterface $identity): string 56 | { 57 | $classPaths = explode('\\', $identity->getType()); 58 | 59 | return (string) array_pop($classPaths); 60 | } 61 | 62 | private function flattenIdCollections(array $requestorContext): array 63 | { 64 | if (!isset($requestorContext[self::CONTEXT_KEY_IDENTIFIERS])) { 65 | return $requestorContext; 66 | } 67 | 68 | $flattened = []; 69 | 70 | foreach ($requestorContext[self::CONTEXT_KEY_IDENTIFIERS] as $idName => $identifiers) { 71 | $flattened[$idName] = $identifiers; 72 | } 73 | 74 | $requestorContext[self::CONTEXT_KEY_IDENTIFIERS] = json_encode($flattened); 75 | 76 | return $requestorContext; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /tests/Unit/Features/SetRepositoryTest.php: -------------------------------------------------------------------------------- 1 | cache = $this->createMock(PSRCache\CacheItemPoolInterface::class); 23 | $this->sut = new Features\SetRepository($this->cache); 24 | } 25 | 26 | public function testSaveFeatureSet(): void 27 | { 28 | $cacheItem = $this->createMock(PSRCache\CacheItemInterface::class); 29 | $featureSet = new Features\Set(); 30 | 31 | $cacheItem->expects($this->once()) 32 | ->method('set') 33 | ->with($featureSet); 34 | 35 | $this->cache->method('getItem') 36 | ->with(Features\SetRepository::generateFeatureSetCacheKey()) 37 | ->willReturn($cacheItem); 38 | 39 | $this->cache->expects($this->once()) 40 | ->method('save') 41 | ->with($cacheItem); 42 | 43 | $this->sut->saveFeatureSet($featureSet); 44 | } 45 | 46 | public function testGetFeatureSet(): void 47 | { 48 | $cacheItem = $this->createMock(PSRCache\CacheItemInterface::class); 49 | $featureSet = new Features\Set(); 50 | 51 | $cacheItem->method('get') 52 | ->willReturn($featureSet); 53 | 54 | $this->cache->method('getItem') 55 | ->with(Features\SetRepository::generateFeatureSetCacheKey()) 56 | ->willReturn($cacheItem); 57 | 58 | $this->assertEquals($featureSet, $this->sut->getFeatureSet()); 59 | } 60 | 61 | public function testGetFeatureSetWithCallback(): void 62 | { 63 | $cacheItem = $this->createMock(PSRCache\CacheItemInterface::class); 64 | 65 | $cacheItem->method('get') 66 | ->willReturn(null); 67 | 68 | $this->cache->method('getItem') 69 | ->with(Features\SetRepository::generateFeatureSetCacheKey()) 70 | ->willReturn($cacheItem); 71 | 72 | $otherProvider = new class implements Features\SetProviderInterface { 73 | public function getFeatureSet(): Features\Set 74 | { 75 | return new Features\Set([new Features\Feature('lioi', true)]); 76 | } 77 | }; 78 | 79 | $this->assertEquals( 80 | new Features\Set([new Features\Feature('lioi', true)]), 81 | $this->sut->getFeatureSet($otherProvider) 82 | ); 83 | } 84 | 85 | public function testDeleteFeatureSet(): void 86 | { 87 | $this->cache->expects($this->once()) 88 | ->method('deleteItem') 89 | ->with(Features\SetRepository::generateFeatureSetCacheKey()); 90 | 91 | $this->sut->deleteFeatureSet(); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /tests/Unit/Logger/ProcessorTest.php: -------------------------------------------------------------------------------- 1 | [Logger\Processor::FEATURE_ID => 'oye', Logger\Processor::CONTEXT_KEY_REQUESTOR => null] 19 | ]; 20 | 21 | $record2 = ['context' => [Logger\Processor::FEATURE_ID => 'oye']]; 22 | 23 | $this->assertSame($record1, $processor($record1)); 24 | $this->assertSame($record2, $processor($record2)); 25 | } 26 | 27 | public function testInvokeWithRequestor(): void 28 | { 29 | $requestor = (new Requestor())->withIpAddress('127.0.0.1') 30 | ->withUserId(123) 31 | ->withStringHash('hashymcgee') 32 | ->withStringHash('otherhashymgee'); 33 | 34 | $processor = new Logger\Processor(); 35 | $record = [ 36 | 'context' => [Logger\Processor::FEATURE_ID => 'oye', Logger\Processor::CONTEXT_KEY_REQUESTOR => $requestor] 37 | ]; 38 | 39 | $expected_ids = '{"IpAddress":["127.0.0.1"],"UserId":[123],"StringHash":["hashymcgee","otherhashymgee"]}'; 40 | $expectedContext = [ 41 | 'context' => [ 42 | Logger\Processor::FEATURE_ID => 'oye', 43 | Logger\Processor::CONTEXT_KEY_REQUESTOR => [ 44 | 'identifiers' => $expected_ids, 45 | ], 46 | ] 47 | ]; 48 | 49 | $this->assertEquals($expectedContext, $processor($record)); 50 | } 51 | 52 | 53 | public function testInvokeWithNoIdCollection(): void 54 | { 55 | $requestor = new Requestor(); 56 | $processor = new Logger\Processor(); 57 | $record = [ 58 | 'context' => [Logger\Processor::FEATURE_ID => 'oye', Logger\Processor::CONTEXT_KEY_REQUESTOR => $requestor] 59 | ]; 60 | 61 | $expectedContext = [ 62 | 'context' => [ 63 | Logger\Processor::FEATURE_ID => 'oye', 64 | Logger\Processor::CONTEXT_KEY_REQUESTOR => [], 65 | ] 66 | ]; 67 | 68 | $this->assertEquals($expectedContext, $processor($record)); 69 | } 70 | 71 | public function testInvokeWithRequestorAndFilteredFields(): void 72 | { 73 | $requestor = (new Requestor())->withIpAddress('127.0.0.1') 74 | ->withUserId(123) 75 | ->withStringHash('hashymcgee'); 76 | 77 | $processor = new Logger\Processor(); 78 | $processor->setFilteredIdentityTypes( 79 | [Identification\IpAddress::class, 'arbitrary', Identification\UserId::class] 80 | ); 81 | 82 | $record = ['context' => 83 | [Logger\Processor::FEATURE_ID => 'oye', Logger\Processor::CONTEXT_KEY_REQUESTOR => $requestor] 84 | ]; 85 | 86 | $expectedContext = [ 87 | 'context' => [ 88 | Logger\Processor::FEATURE_ID => 'oye', 89 | Logger\Processor::CONTEXT_KEY_REQUESTOR => [ 90 | 'identifiers' => '{"StringHash":["hashymcgee"]}', 91 | ], 92 | ] 93 | ]; 94 | 95 | $this->assertEquals($expectedContext, $processor($record)); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Requestor.php: -------------------------------------------------------------------------------- 1 | registerIdentification($identification); 21 | } 22 | } 23 | 24 | public function getIdentityHash(): string 25 | { 26 | return md5(serialize($this->idCollections)); 27 | } 28 | 29 | public function registerIdentification(Identification\IdentificationInterface $identification): void 30 | { 31 | $type = $identification->getType(); 32 | 33 | if (!isset($this->idCollections[$type])) { 34 | $this->idCollections[$type] = new Identification\Collection($type, [$identification]); 35 | return; 36 | } 37 | 38 | $this->idCollections[$type]->add($identification); 39 | } 40 | 41 | public function getIdentificationCollections(): array 42 | { 43 | return $this->idCollections; 44 | } 45 | 46 | public function hasIdentification(Identification\IdentificationInterface $identification): bool 47 | { 48 | if (!isset($this->idCollections[$identification->getType()])) { 49 | return false; 50 | } 51 | 52 | return $this->idCollections[$identification->getType()]->has($identification); 53 | } 54 | 55 | /** 56 | * @param string|int $id 57 | */ 58 | public function withUserId($id): self 59 | { 60 | $mutee = clone $this; 61 | $mutee->registerIdentification(new Identification\UserId($id)); 62 | 63 | return $mutee; 64 | } 65 | 66 | public function withIpAddress(string $ipAddress): self 67 | { 68 | $mutee = clone $this; 69 | $mutee->registerIdentification(new Identification\IpAddress($ipAddress)); 70 | 71 | return $mutee; 72 | } 73 | 74 | public function withStringHash(string $hash): self 75 | { 76 | $mutee = clone $this; 77 | $mutee->registerIdentification(new Identification\StringHash($hash)); 78 | 79 | return $mutee; 80 | } 81 | 82 | public function withRequest(Http\Message\RequestInterface $request): self 83 | { 84 | $mutee = clone $this; 85 | $mutee->registerIdentification(Identification\HttpHeader::createFromRequest($request)); 86 | 87 | return $mutee; 88 | } 89 | 90 | public function withEnvironment(string $environment): self 91 | { 92 | $mutee = clone $this; 93 | $mutee->registerIdentification(new Identification\Environment($environment)); 94 | 95 | return $mutee; 96 | } 97 | 98 | public function withPipedComposite(string $pipedComposite): self 99 | { 100 | $mutee = clone $this; 101 | $mutee->registerIdentification(new Identification\PipedComposite($pipedComposite)); 102 | 103 | return $mutee; 104 | } 105 | 106 | public function withIntegerId(int $integerId): self 107 | { 108 | $mutee = clone $this; 109 | $mutee->registerIdentification(new Identification\IntegerId($integerId)); 110 | 111 | return $mutee; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Doorkeeper.php: -------------------------------------------------------------------------------- 1 | featureSet = $featureSet; 28 | $this->auditLog = $auditLog ?? new PSRLog\NullLogger(); 29 | $this->runtimeCache = $cache ?? new Utilities\RuntimeCache(); 30 | } 31 | 32 | /** 33 | * @throws \DomainException 34 | */ 35 | public function setRequestor(RequestorInterface $requestor): void 36 | { 37 | if ($this->requestor) { 38 | throw new \DomainException('Requestor already set'); 39 | } 40 | 41 | $this->requestor = $requestor; 42 | } 43 | 44 | public function getRequestor(): ?RequestorInterface 45 | { 46 | return $this->requestor; 47 | } 48 | 49 | public function grantsAccessTo(string $featureName): bool 50 | { 51 | return $this->grantsAccessToRequestor($featureName, $this->requestor); 52 | } 53 | 54 | public function grantsAccessToRequestor(string $featureName, RequestorInterface $requestor = null): bool 55 | { 56 | $logContext = [ 57 | Logger\Processor::CONTEXT_KEY_REQUESTOR => $requestor, 58 | Logger\Processor::FEATURE_ID => $featureName, 59 | ]; 60 | 61 | if (!$this->featureSet->offsetExists($featureName)) { 62 | $this->logAttempt('Access denied because feature does not exist.', $logContext); 63 | return false; 64 | } 65 | 66 | $cache_key = md5(sprintf('%s:%s', $featureName, ($requestor) ? $requestor->getIdentityHash() : '')); 67 | $fallback = function () use ($featureName, $requestor, $logContext): bool { 68 | $feature = $this->featureSet->getFeatureByName($featureName); 69 | 70 | if (!$feature->isEnabled()) { 71 | $this->logAttempt('Access denied because feature is disabled.', $logContext); 72 | return false; 73 | } 74 | 75 | if ($feature->isEnabled() && !$feature->getRules()) { 76 | return true; 77 | } 78 | 79 | foreach ($feature->getRules() as $rule) { 80 | if ($rule->canBeSatisfied($requestor)) { 81 | $this->logAttempt('Access granted to feature', $logContext); 82 | return true; 83 | } 84 | } 85 | 86 | $this->logAttempt('Access denied to feature', $logContext); 87 | return false; 88 | }; 89 | 90 | return (bool) $this->runtimeCache->get($cache_key, $fallback); 91 | } 92 | 93 | public function flushRuntimeCache(): void 94 | { 95 | $this->runtimeCache->flush(); 96 | } 97 | 98 | private function logAttempt(string $message, array $context): void 99 | { 100 | $this->auditLog->info($message, $context); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /tests/Unit/Rules/FactoryTest.php: -------------------------------------------------------------------------------- 1 | factory = new Rules\Factory(); 17 | } 18 | 19 | public function testCreateFromArray(): void 20 | { 21 | $random = new Rules\Random(); 22 | $percent = new Rules\Percentage(1); 23 | $stringHash = new Rules\StringHash('some hash'); 24 | $userId = new Rules\UserId(123); 25 | $ipAddress = new Rules\IpAddress('127.0.0.1'); 26 | $header = new Rules\HttpHeader('customHeader'); 27 | $env = new Rules\Environment('DEV'); 28 | $envWithPreReq = new Rules\Environment('DEV'); 29 | $envWithPreReq->addPrerequisite($header); 30 | $envWithPreReq->addPrerequisite($userId); 31 | 32 | $time_before = new Rules\TimeBefore('2013-12-12'); 33 | $time_after = new Rules\TimeAfter('2012-12-12'); 34 | $factory = new Rules\Factory(); 35 | 36 | $this->assertEquals($random, $factory->createFromArray([ 37 | 'type' => Rules\Random::class 38 | ])); 39 | 40 | $this->assertEquals($percent, $factory->createFromArray([ 41 | 'type' => Rules\Percentage::class, 'value' => 1, 42 | ])); 43 | 44 | $this->assertEquals($stringHash, $factory->createFromArray([ 45 | 'type' => Rules\StringHash::class, 'value' => 'some hash', 46 | ])); 47 | 48 | $this->assertEquals($userId, $factory->createFromArray([ 49 | 'type' => Rules\UserId::class, 'value' => 123, 50 | ])); 51 | 52 | $this->assertEquals($ipAddress, $factory->createFromArray([ 53 | 'type' => Rules\IpAddress::class, 'value' => '127.0.0.1', 54 | ])); 55 | 56 | $this->assertEquals($header, $factory->createFromArray([ 57 | 'type' => 'HttpHeader', 'value' => 'customHeader', 58 | ])); 59 | 60 | $this->assertEquals($env, $factory->createFromArray([ 61 | 'type' => Rules\TypeMapper::RULE_TYPE_ENVIRONMENT, 'value' => 'DEV', 62 | ])); 63 | 64 | $this->assertEquals($time_after, $factory->createFromArray([ 65 | 'type' => Rules\TypeMapper::RULE_TYPE_AFTER, 'value' => '2012-12-12', 66 | ])); 67 | 68 | $this->assertEquals($time_before, $factory->createFromArray([ 69 | 'type' => Rules\TypeMapper::RULE_TYPE_BEFORE, 'value' => '2013-12-12', 70 | ])); 71 | 72 | $this->assertEquals($envWithPreReq, $factory->createFromArray([ 73 | 'type' => Rules\TypeMapper::RULE_TYPE_ENVIRONMENT, 74 | 'value' => 'DEV', 75 | 'prerequisites' => [ 76 | ['type' => Rules\HttpHeader::class, 'value' => 'customHeader'], 77 | ['type' => 'UserId', 'value' => 123], 78 | ], 79 | ])); 80 | } 81 | 82 | /** 83 | * @dataProvider invalidRuleTypeProvider 84 | */ 85 | public function testThrowsOnInvalidRuleType($invalidType): void 86 | { 87 | $this->expectException(\InvalidArgumentException::class); 88 | $this->expectExceptionMessage("{$invalidType} is not a valid rule type"); 89 | $this->factory->createFromArray(['type' => $invalidType, 'value' => 123]); 90 | } 91 | 92 | public function invalidRuleTypeProvider(): array 93 | { 94 | return [ 95 | 'existing non lib class' => [\stdClass::class], 96 | 'prefixed existing non lib class' => ['\\stdClass'], 97 | 'non existent class' => ['boop'], 98 | ]; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /tests/Unit/DoorKeeperTest.php: -------------------------------------------------------------------------------- 1 | featureSet = new Features\Set([$feature1, $feature2, $feature3, $feature4, $feature5]); 46 | $this->logger = new Log\Test\TestLogger(); 47 | $this->doorkeeper = new Doorkeeper($this->featureSet, null, $this->logger); 48 | } 49 | 50 | public function testGetsAndSetsRequestor(): void 51 | { 52 | $this->assertNull($this->doorkeeper->getRequestor()); 53 | 54 | $this->doorkeeper->setRequestor(new Requestor()); 55 | 56 | $this->assertEquals(new Requestor(), $this->doorkeeper->getRequestor()); 57 | } 58 | 59 | public function testProtectsAgainstResettingInstanceRequestor(): void 60 | { 61 | $this->doorkeeper->setRequestor(new Requestor()); 62 | $this->expectException(\DomainException::class); 63 | $this->doorkeeper->setRequestor(new Requestor()); 64 | } 65 | 66 | public function testGrantsAccessToFeatureWithoutRules(): void 67 | { 68 | $this->assertTrue($this->doorkeeper->grantsAccessTo('no rules')); 69 | $this->assertFalse($this->doorkeeper->grantsAccessTo('no')); 70 | } 71 | 72 | public function testRequestor() 73 | { 74 | $requestor = (new Requestor()) 75 | ->withEnvironment('DEV') 76 | ->withUserId(9); 77 | 78 | $this->doorkeeper->setRequestor($requestor); 79 | $this->assertTrue($this->doorkeeper->grantsAccessTo('new shiny')); 80 | $this->assertFalse($this->doorkeeper->grantsAccessTo('disabled app')); 81 | $this->assertTrue($this->doorkeeper->grantsAccessTo('killer app')); 82 | $this->assertFalse($this->doorkeeper->grantsAccessTo('no')); 83 | $this->assertFalse($this->doorkeeper->grantsAccessTo('does not exist')); 84 | $this->assertTrue($this->doorkeeper->grantsAccessTo('new shiny')); 85 | } 86 | 87 | public function testFlushRuntimeCache(): void 88 | { 89 | $doorkeeper = new Doorkeeper($this->featureSet, null, $this->logger); 90 | 91 | $this->assertEquals($this->doorkeeper, $doorkeeper); 92 | 93 | $this->doorkeeper->grantsAccessTo('no'); 94 | 95 | $this->assertNotEquals($this->doorkeeper, $doorkeeper); 96 | 97 | $this->doorkeeper->flushRuntimeCache(); 98 | 99 | $this->assertEquals($this->doorkeeper, $doorkeeper); 100 | } 101 | 102 | public function testOperatesWithoutLogger(): void 103 | { 104 | $doorkeeper = new Doorkeeper($this->featureSet); 105 | $this->assertFalse($doorkeeper->grantsAccessTo('no')); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /tests/Unit/Rules/TypeMapperTest.php: -------------------------------------------------------------------------------- 1 | $extraTypeClass]); 24 | 25 | $this->assertEquals( 26 | Rules\TypeMapper::RULE_TYPE_ENVIRONMENT, 27 | $mapper->getIdForClassName(Rules\Environment::class) 28 | ); 29 | 30 | $this->assertEquals( 31 | Rules\TypeMapper::RULE_TYPE_USER_ID, 32 | $mapper->getIdForClassName(Rules\UserId::class) 33 | ) 34 | ; 35 | $this->assertEquals( 36 | Rules\TypeMapper::RULE_TYPE_STRING_HASH, 37 | $mapper->getIdForClassName(Rules\StringHash::class) 38 | ); 39 | 40 | $this->assertEquals( 41 | Rules\TypeMapper::RULE_TYPE_RANDOM, 42 | $mapper->getIdForClassName(Rules\Random::class) 43 | ); 44 | 45 | $this->assertEquals( 46 | Rules\TypeMapper::RULE_TYPE_PERCENTAGE, 47 | $mapper->getIdForClassName(Rules\Percentage::class) 48 | ); 49 | 50 | $this->assertEquals( 51 | Rules\TypeMapper::RULE_TYPE_IP_ADDRESS, 52 | $mapper->getIdForClassName(Rules\IpAddress::class) 53 | ); 54 | 55 | $this->assertEquals( 56 | Rules\TypeMapper::RULE_TYPE_HEADER, 57 | $mapper->getIdForClassName(Rules\HttpHeader::class) 58 | ); 59 | 60 | $this->assertEquals( 61 | 1001, 62 | $mapper->getIdForClassName($extraTypeClass) 63 | ); 64 | 65 | $this->assertEquals( 66 | Rules\Environment::class, 67 | $mapper->getClassNameById(Rules\TypeMapper::RULE_TYPE_ENVIRONMENT) 68 | ) 69 | ; 70 | $this->assertEquals( 71 | Rules\UserId::class, 72 | $mapper->getClassNameById(Rules\TypeMapper::RULE_TYPE_USER_ID) 73 | ); 74 | 75 | $this->assertEquals( 76 | Rules\StringHash::class, 77 | $mapper->getClassNameById(Rules\TypeMapper::RULE_TYPE_STRING_HASH) 78 | ); 79 | 80 | $this->assertEquals( 81 | Rules\Random::class, 82 | $mapper->getClassNameById(Rules\TypeMapper::RULE_TYPE_RANDOM) 83 | ); 84 | 85 | $this->assertEquals( 86 | Rules\Percentage::class, 87 | $mapper->getClassNameById(Rules\TypeMapper::RULE_TYPE_PERCENTAGE) 88 | ); 89 | 90 | $this->assertEquals( 91 | Rules\IpAddress::class, 92 | $mapper->getClassNameById(Rules\TypeMapper::RULE_TYPE_IP_ADDRESS) 93 | ); 94 | 95 | $this->assertEquals( 96 | Rules\HttpHeader::class, 97 | $mapper->getClassNameById(Rules\TypeMapper::RULE_TYPE_HEADER) 98 | ); 99 | 100 | $this->assertEquals( 101 | $extraTypeClass, 102 | $mapper->getClassNameById(1001) 103 | ); 104 | } 105 | 106 | public function testPushesCustomIdClassValues(): void 107 | { 108 | $mapper = new Rules\TypeMapper(); 109 | 110 | $mapper->pushExtraType(20002, \stdClass::class); 111 | 112 | $this->assertEquals(20002, $mapper->getIdForClassName(\stdClass::class)); 113 | } 114 | 115 | public function testDoesNotAllowOverridesForIds(): void 116 | { 117 | $this->expectException(\DomainException::class); 118 | $mapper = new Rules\TypeMapper(); 119 | 120 | $mapper->pushExtraType(1, \stdClass::class); 121 | } 122 | 123 | public function testDoesNotAllowForNonClassesToBePushed(): void 124 | { 125 | $this->expectException(\InvalidArgumentException::class); 126 | $mapper = new Rules\TypeMapper(); 127 | 128 | $mapper->pushExtraType(10002, 'boopClass'); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /tests/Unit/RequestorTest.php: -------------------------------------------------------------------------------- 1 | createMock(Http\Message\RequestInterface::class); 33 | $request->method('getHeaderLine') 34 | ->with(Rules\HttpHeader::HEADER_KEY) 35 | ->willReturn($header); 36 | 37 | // phpcs:disable 38 | $identifications_stored = [ 39 | Identification\UserId::class => new Identification\Collection(Identification\UserId::class, [$idIdentification]), 40 | Identification\StringHash::class => new Identification\Collection(Identification\StringHash::class, [$hashIdentification]), 41 | Identification\Environment::class => new Identification\Collection(Identification\Environment::class, [$envIdentification]), 42 | Identification\IpAddress::class => new Identification\Collection(Identification\IpAddress::class, [$ipIdentification]), 43 | Identification\HttpHeader::class => new Identification\Collection(Identification\HttpHeader::class, [$headerIdentification]), 44 | Identification\PipedComposite::class => new Identification\Collection(Identification\PipedComposite::class, [$pcIdentification]), 45 | Identification\IntegerId::class => new Identification\Collection(Identification\IntegerId::class, [$intIdentification]) 46 | ]; 47 | // phpcs:enabled 48 | 49 | $identificationsArgs = [ 50 | $idIdentification, 51 | $hashIdentification, 52 | $envIdentification, 53 | $ipIdentification, 54 | $headerIdentification, 55 | $pcIdentification, 56 | $intIdentification 57 | ]; 58 | 59 | $this->assertEquals( 60 | new Requestor($identificationsArgs), 61 | (new Requestor()) 62 | ->withStringHash($hash) 63 | ->withUserId($userId) 64 | ->withEnvironment($env) 65 | ->withIpAddress($ip) 66 | ->withRequest($request) 67 | ->withPipedComposite($pipedComposite) 68 | ->withPipedComposite($pipedComposite) // duplicate protection 69 | ->withIntegerId($intId) 70 | ); 71 | 72 | $this->assertEquals( 73 | (new Requestor($identificationsArgs))->getIdentificationCollections(), 74 | $identifications_stored 75 | ); 76 | } 77 | 78 | public function testGetIdentityHash(): void 79 | { 80 | $id = new Identification\IntegerId(434); 81 | $collection = new Identification\Collection(Identification\IntegerId::class, [$id]); 82 | $expected_hash = md5(serialize([Identification\IntegerId::class => $collection])); 83 | $requestor = new Requestor([$id]); 84 | 85 | $this->assertEquals($expected_hash, $requestor->getIdentityHash()); 86 | } 87 | 88 | public function testHasIdentification(): void 89 | { 90 | $identification1 = new Identification\PipedComposite('Bliz|2'); 91 | $identification2 = new Identification\PipedComposite('Blaz|1'); 92 | $requestor = new Requestor([$identification1, $identification2]); 93 | 94 | $this->assertTrue($requestor->hasIdentification($identification1)); 95 | $this->assertTrue($requestor->hasIdentification($identification2)); 96 | $this->assertFalse($requestor->hasIdentification(new Identification\HttpHeader('thing'))); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Total Downloads](https://poser.pugx.org/remotelyliving/doorkeeper/downloads)](https://packagist.org/packages/remotelyliving/doorkeeper) 2 | [![Coverage Status](https://coveralls.io/repos/github/remotelyliving/doorkeeper/badge.svg?branch=master)](https://coveralls.io/github/remotelyliving/doorkeeper?branch=master) 3 | [![License](https://poser.pugx.org/remotelyliving/doorkeeper/license)](https://packagist.org/packages/remotelyliving/doorkeeper) 4 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/remotelyliving/doorkeeper/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/remotelyliving/doorkeeper/?branch=master) 5 | 6 | # Doorkeeper: a dynamic feature toggle 7 | 8 | ### The Birth of a Feature Toggle 9 | >Picture the scene. You're on one of several teams working on a sophisticated town planning simulation game. Your team is responsible for the core simulation engine. You have been tasked with increasing the efficiency of the Spline Reticulation algorithm. You know this will require a fairly large overhaul of the implementation which will take several weeks. Meanwhile other members of your team will need to continue some ongoing work on related areas of the codebase. 10 | You want to avoid branching for this work if at all possible, based on previous painful experiences of merging long-lived branches in the past. Instead, you decide that the entire team will continue to work on trunk, but the developers working on the Spline Reticulation improvements will use a Feature Toggle to prevent their work from impacting the rest of the team or destabilizing the codebase. 11 | 12 | https://martinfowler.com/articles/feature-toggles.html 13 | 14 | ### Enter Doorkeeper (if you can) 15 | 16 | There are a few feature toggle frameworks and libraries out there already. And many of them are fine. 17 | Doorkeeper was born our of a previous experience with one and wishing what it could be. 18 | 19 | ### Dynamic Usage 20 | 21 | Doorkeeper is storage agnostic, and has a few helpers to help translate what you decide to persist 22 | and how you want to load it. But however you choose to setup its Feature Set (features + rules), you can toggle your feature on and off 23 | by changing that configuration. 24 | 25 | ### Installation 26 | 27 | `composer require remotelyliving/doorkeeper` 28 | 29 | ### Wiring it up 30 | 31 | ```php 32 | // Requestors are the actor requesting access to a new feature 33 | // They have several forms of identity you can initialize them 34 | $requestor = ( new Requestor() ) 35 | ->withUserId($user->getId()) 36 | ->withRequest($request) 37 | ->withIpAddress('127.0.0.1') 38 | ->withEnvironment('STAGE') 39 | ->withStringHash('someArbitraryThingMaybeFromTheQueryString'); 40 | 41 | // A Feature Set has Features that have Rules 42 | $featureSet = $reatureSetRepository->getSet(); 43 | 44 | // Doorkeper takes in a Feature Set and an audit log if you want to log access results 45 | $doorkeeper = new Doorkeeper($featureSet, $logger); 46 | 47 | // Set an app instance bound requestor here or pass one to Doorkeeper::grantsAccessToRequestor('feature', $requestor) later 48 | $doorkeeper->setRequestor($requestor); 49 | ``` 50 | 51 | ### Usage 52 | 53 | ```php 54 | if ($doorkeeper->grantsAccessTo('some.new.feature')) { 55 | return $this->doNewFeatureStuff(); 56 | } 57 | 58 | // If you want to bypass the instance Requestor that was set and create another use Doorkeeper::grantsAccessToRequestor() 59 | // This is useful for more stateful applications 60 | 61 | $otherRequestor = (new Requestor()))->withUserId(123); 62 | 63 | if ($doorkeeper->grantsAccessToRequestor('some.new.feature', $otherRequestor)) { 64 | return $this->doNewFeatureStuff(); 65 | } 66 | ``` 67 | 68 | ***Setting a Requestor is not neccessary. It is only needed if you want to use rules that are evaluated 69 | against a specific requestor*** 70 | 71 | ### Requestor 72 | 73 | A Requestor is the one asking to access the feature. They must pass the Doorkeeper's strict house rules to enter. 74 | To see if a requestor is allowed access, they must present Identifications. 75 | 76 | `RemotelyLiving\Doorkeeper\Identifications` 77 | 78 | - HttpHeader - based on a `doorkeeper` header present in a request. 79 | - IntegerId (user id) - Based on the logged in user id 80 | - IpAddress - based on the Requestor's ip address 81 | - StringHash - Some flexibility here. Set it to whatever you want. 82 | - Environment - Based on the app environment the Requestor is in. 83 | - PipedComposite = A string of pipe delimited values used to make a composite key id 84 | 85 | The Requestor is immutable. It should not be changed anywhere in the call stack. 86 | That would produce less than consistent results depending on where the query takes place. 87 | 88 | The Requestor is best wired up and set in a service container. There are several convenience methods 89 | to set identities. 90 | 91 | ### Rules 92 | 93 | `RemotelyLiving\Doorkeeper\Rules` 94 | 95 | There are several types of Rules to use when defining access to a feature 96 | 97 | - Environment: can be set with the environment name that the feature is available in 98 | 99 | - HttpHeader: matches a specific value from a custom `doorkeeper` header you can choose to send. 100 | *The request identification on a Requestor must be registered to have a positive match 101 | 102 | - IpAddress: this is a specific IP address the feature is available to. Helpful for in-office access. 103 | *It only works if the Requestor has an IpAddress identification registered 104 | 105 | - Percentage: a percentage of requests to allow through to the feature 106 | 107 | - Random: chaos monkey. This rule randomly allows access. 108 | 109 | - StringHash: this is an arbitrary string. It works well for request query params, username, etc. 110 | *This only works if a StringHash identification is set on the Requestor 111 | 112 | - TimeAfter: allows for access to a feature only *after* the set time on the rule. 113 | 114 | - TimeBefore: allows for access to a feature only *before* the set time of the rule. 115 | 116 | - UserId: this rule allows for specific user access to a feature. 117 | *User Id only works if the Request has a user id identification registered to them 118 | 119 | - PipedComposite: allows for a pipe delimited composite key value 120 | 121 | - RuntimeCallable: pass in a callable that returns true/false. You'll have access to the requestor as a parameter, but be forewarned that storing this one is not possible (i.e. to a database) 122 | 123 | ### Prerequisistes 124 | 125 | Rules can be dependant on other rules for any other feature. 126 | 127 | ```php 128 | // create a time range 129 | $timeBeforeRule->addPrerequisite($timeAfterRule); 130 | 131 | // create a user id rule only for prod 132 | $userIdRule->addPrerequisite($prodEnvironmentRule); 133 | 134 | // add another prereq 135 | $userIdRule->addPrerequisite($ipAddressRule); 136 | 137 | // etc. 138 | ``` 139 | 140 | That prerequisite must be satisfied before the other rule is evaluated. 141 | 142 | ### Feature 143 | 144 | `RemotelyLiving\Doorkeeper\Features` 145 | 146 | A Feature is what a Requestor is asking for by name. It can have 0-n Rules around it. 147 | A Feature has a top level on/off switch called `enabled` that can bypass any rules. 148 | 149 | ```php 150 | // $enabled (true/false), $rules (\RemotelyLiving\Doorkeeper\Rules\RuleInterface[]) 151 | $feature = new Feature('some.new.feature', $enabled, $rules); 152 | ``` 153 | 154 | Doorkeeper gets the rules from a feature and evaluates them. If they require a specific Identification 155 | Doorkeeper looks into the Requestor to see if they have the right Identifications required by a rule 156 | 157 | If nothing satisfies the feature rules the default is to deny access. 158 | 159 | ***The first rule to be satisfied grants access*** 160 | 161 | Keep that in mind when setting rules up on a feature. 162 | 163 | If you say "only this ip address is allowed, but also the DEV environment." 164 | 165 | All requests with that ip address OR in the DEV environment will be allowed. 166 | 167 | But that means that in ANY environment, that ip address will be allowed. 168 | 169 | The proper way to setup exclusions would be to set the ip address rule as a prerequisite rule to the environment one. 170 | That would then stipulate that only this ip address in this environment can access. 171 | 172 | A Feature with no rules simply relies on the `enabled` field to tell Doorkeeper if it's on or off 173 | 174 | ### Feature Set 175 | 176 | A feature set object is the bread and butter of Doorkeeper. It's a collection of Features and rules and makes for easy 177 | caching. It is the complete set of what defines access to features. 178 | 179 | ### Feature Set Repository and Caching 180 | 181 | `Remotelyliving\Doorkeeper\Features` 182 | 183 | Obviously something that fires up for every request that uses dynamic config data can be costly. 184 | 185 | If you're using memcached or redis and a PSR6 cache library. you can cache and retrieve the Feature Set in the `Features\SetRepository`. 186 | 187 | You can build the Feature Set from arrays using the `Features\Set::createFromArray()` 188 | 189 | That array can come from anywhere: relational database, cache, config, xml, whatever. 190 | 191 | Checkout that factory method to see the schema of the array that needs to be passed in. 192 | 193 | How you choose to persist Features is up to you. But there are two things you're responsible for if using the `Features\SetRepository` 194 | 195 | 1. Clearing the cache when any member of a Feature Set is changed via `SetRepository::deleteFeatureSet()` 196 | 2. Providing a service that can provide a hydrated Feature Set to the `get('some.new.feature', $featureSetProvider)` method 197 | 198 | Doorkeeper also has a runtime cache that caches answers in memory to help as well. 199 | For persistent applications you'll need to call `Doorkeeper::flushRuntimeCache()` 200 | any time a Rule or Feature is updated. 201 | 202 | ### Logging 203 | 204 | Doorkeeper comes with a friendly log processor that can pass on filtered or unfiltered info about a Requestor. 205 | This is incredibly helpful when debugging. 206 | When paired with a Request-Id (or something like that) in the log context debugging a user's code patch can be very easy. 207 | 208 | 209 | --------------------------------------------------------------------------------