Initial commit
This commit is contained in:
parent
759c893bfc
commit
b57ef58ea4
54
.github/workflows/ci.yml
vendored
Normal file
54
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: PHP ${{ matrix.php }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php: [ '8.1', '8.2', '8.3' ]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
coverage: none
|
||||
tools: composer:v2
|
||||
|
||||
- name: Get Composer Cache Directory
|
||||
id: composer-cache
|
||||
run: |
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
|
||||
- name: Validate composer.json
|
||||
run: composer validate --strict
|
||||
|
||||
- name: Install dependencies (libraries use update)
|
||||
run: composer update --no-interaction --no-progress --prefer-dist
|
||||
|
||||
- name: Static Analysis (PHPStan)
|
||||
run: vendor/bin/phpstan analyse
|
||||
|
||||
- name: Coding Standards (PHP-CS-Fixer dry run)
|
||||
run: vendor/bin/php-cs-fixer fix --dry-run --diff
|
||||
|
||||
- name: PHPUnit
|
||||
run: vendor/bin/phpunit -c phpunit.xml.dist
|
||||
93
.gitignore
vendored
93
.gitignore
vendored
|
|
@ -1,87 +1,8 @@
|
|||
# ---> Composer
|
||||
composer.phar
|
||||
/.idea/
|
||||
/vendor/
|
||||
|
||||
# Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control
|
||||
# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file
|
||||
# composer.lock
|
||||
|
||||
# ---> JetBrains
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
# User-specific stuff
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
|
||||
# AWS User-specific
|
||||
.idea/**/aws.xml
|
||||
|
||||
# Generated files
|
||||
.idea/**/contentModel.xml
|
||||
|
||||
# Sensitive or high-churn files
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dbnavigator.xml
|
||||
|
||||
# Gradle
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
|
||||
# Gradle and Maven with auto-import
|
||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||
# since they will be recreated, and may cause churn. Uncomment if using
|
||||
# auto-import.
|
||||
# .idea/artifacts
|
||||
# .idea/compiler.xml
|
||||
# .idea/jarRepositories.xml
|
||||
# .idea/modules.xml
|
||||
# .idea/*.iml
|
||||
# .idea/modules
|
||||
# *.iml
|
||||
# *.ipr
|
||||
|
||||
# CMake
|
||||
cmake-build-*/
|
||||
|
||||
# Mongo Explorer plugin
|
||||
.idea/**/mongoSettings.xml
|
||||
|
||||
# File-based project format
|
||||
*.iws
|
||||
|
||||
# IntelliJ
|
||||
out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Cursive Clojure plugin
|
||||
.idea/replstate.xml
|
||||
|
||||
# SonarLint plugin
|
||||
.idea/sonarlint/
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
# Editor-based Rest Client
|
||||
.idea/httpRequests
|
||||
|
||||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
|
||||
/.phpunit.cache/
|
||||
/.phpunit.result.cache
|
||||
/.phpunit.cache/
|
||||
composer.lock
|
||||
/.php-cs-fixer.cache
|
||||
/.phpstan/
|
||||
|
|
|
|||
26
.php-cs-fixer.dist.php
Normal file
26
.php-cs-fixer.dist.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PhpCsFixer\Config;
|
||||
use PhpCsFixer\Finder;
|
||||
|
||||
$finder = Finder::create()
|
||||
->in(__DIR__ . '/src')
|
||||
->name('*.php')
|
||||
->ignoreDotFiles(true)
|
||||
->ignoreVCS(true);
|
||||
|
||||
return (new Config())
|
||||
->setRiskyAllowed(true)
|
||||
->setFinder($finder)
|
||||
->setRules([
|
||||
'@PSR12' => true,
|
||||
'array_syntax' => ['syntax' => 'short'],
|
||||
'ordered_imports' => true,
|
||||
'no_unused_imports' => true,
|
||||
'declare_strict_types' => true,
|
||||
'no_trailing_whitespace' => true,
|
||||
'single_quote' => true,
|
||||
'no_superfluous_phpdoc_tags' => true,
|
||||
]);
|
||||
12
CHANGELOG.md
Normal file
12
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
Unreleased
|
||||
- CI: GitHub Actions for PHP 8.1–8.3 (composer validate, PHPStan, PHP-CS-Fixer dry-run, PHPUnit)
|
||||
- Tooling: PHPStan level 8; PHP-CS-Fixer with PSR-12 ruleset
|
||||
- Composer: metadata (authors, keywords, homepage, support), scripts (validate, analyse, lint, fix, test)
|
||||
- Docs: Documented hashing normalization and boundary behavior in README
|
||||
|
||||
0.1.0 - YYYY-MM-DD
|
||||
- Initial release with core components and tests
|
||||
21
CODE_OF_CONDUCT.md
Normal file
21
CODE_OF_CONDUCT.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
Contributor Covenant Code of Conduct
|
||||
|
||||
Our Pledge
|
||||
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone.
|
||||
|
||||
Our Standards
|
||||
- Be respectful and inclusive.
|
||||
- Accept constructive criticism.
|
||||
- Focus on what is best for the community.
|
||||
|
||||
Enforcement Responsibilities
|
||||
Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior.
|
||||
|
||||
Scope
|
||||
This Code of Conduct applies within all community spaces and when an individual is officially representing the project.
|
||||
|
||||
Enforcement
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the maintainers. See SECURITY.md for private contact guidance.
|
||||
|
||||
Attribution
|
||||
This Code of Conduct is adapted from the Contributor Covenant, version 2.1 (https://www.contributor-covenant.org/version/2/1/code_of_conduct.html).
|
||||
38
CONTRIBUTING.md
Normal file
38
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
Contributing
|
||||
|
||||
Thanks for considering a contribution to FlagPole!
|
||||
|
||||
Development setup
|
||||
1. Install PHP 8.1+ and Composer v2.
|
||||
2. Install dependencies:
|
||||
```
|
||||
composer install
|
||||
```
|
||||
3. Run the test suite:
|
||||
```
|
||||
composer test
|
||||
```
|
||||
4. Static analysis and coding standards:
|
||||
- Analyse (PHPStan level 8):
|
||||
```
|
||||
composer analyse
|
||||
```
|
||||
- Lint (PHP-CS-Fixer dry run):
|
||||
```
|
||||
composer lint
|
||||
```
|
||||
- Auto-fix style issues where possible:
|
||||
```
|
||||
composer fix
|
||||
```
|
||||
|
||||
Guidelines
|
||||
- Follow PSR-12 coding style (automated via PHP-CS-Fixer config).
|
||||
- Include tests for new features or bug fixes.
|
||||
- Keep public API changes minimal and document them in the README and CHANGELOG.
|
||||
- For large changes, please open an issue for discussion before investing significant effort.
|
||||
|
||||
Releasing (maintainers)
|
||||
- Ensure CI is green on main.
|
||||
- Update CHANGELOG.md.
|
||||
- Tag a release (e.g., `v0.1.0`) and push the tag.
|
||||
81
README.md
81
README.md
|
|
@ -1,3 +1,82 @@
|
|||
# FlagPole
|
||||
|
||||
Feature Flag handling
|
||||
Feature flag handling for PHP. Simple, framework-agnostic, and lightweight.
|
||||
|
||||

|
||||

|
||||
|
||||
## Installation
|
||||
|
||||
Install via Composer:
|
||||
|
||||
```
|
||||
composer require phred/flagpole
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```php
|
||||
use FlagPole\FeatureManager;
|
||||
use FlagPole\Context;
|
||||
use FlagPole\Repository\InMemoryFlagRepository;
|
||||
|
||||
require __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
$repo = InMemoryFlagRepository::fromArray([
|
||||
'new-dashboard' => [
|
||||
'enabled' => null, // not a hard on/off
|
||||
'rolloutPercentage' => 25, // 25% gradual rollout
|
||||
'allowList' => ['user_1'], // always on for specific users
|
||||
],
|
||||
'hard-off' => [ 'enabled' => false ],
|
||||
'hard-on' => [ 'enabled' => true ],
|
||||
]);
|
||||
|
||||
$flags = new FeatureManager($repo);
|
||||
|
||||
$context = Context::fromArray(['userId' => 'user_42']);
|
||||
|
||||
if ($flags->isEnabled('new-dashboard', $context, false)) {
|
||||
// show the new dashboard
|
||||
} else {
|
||||
// keep the old dashboard
|
||||
}
|
||||
```
|
||||
|
||||
## Concepts
|
||||
|
||||
- Flag: has a `name` and optional strategies:
|
||||
- `enabled`: explicit boolean on/off overrides everything.
|
||||
- `rolloutPercentage`: 0-100 gradual rollout based on a stable hash of the flag name + user key.
|
||||
- `allowList`: list of user keys that always get the flag enabled.
|
||||
- Context: attributes about the subject (e.g. `userId`, `email`) used for evaluation.
|
||||
- Repository: source of truth for flags. Provided: `InMemoryFlagRepository`. You can implement your own.
|
||||
|
||||
## Targeting key
|
||||
|
||||
Evaluator looks for a stable key in the context in this order: `key`, `userId`, `id`, `email`.
|
||||
|
||||
## Rollout hashing and boundary behavior
|
||||
|
||||
- Stable bucketing uses `crc32(flagName:key)` normalized to an unsigned 32-bit integer, then mapped to buckets 0..99.
|
||||
- This guarantees consistent behavior across 32-bit and 64-bit platforms.
|
||||
- Boundary rules:
|
||||
- 0% rollout always evaluates to `false` when a targeting key is present.
|
||||
- 100% rollout always evaluates to `true` when a targeting key is present.
|
||||
- If no targeting key is present in the `Context`, percentage rollout falls back to the `default` you pass to `isEnabled()`.
|
||||
|
||||
## Precedence semantics
|
||||
|
||||
When evaluating a flag, the following precedence applies:
|
||||
1. `allowList` — if the targeting key is in the allow-list, the flag is enabled.
|
||||
2. `enabled` — explicit on/off overrides percentage rollout and defaults.
|
||||
3. `rolloutPercentage` — uses stable bucketing over the targeting key.
|
||||
4. Fallback — returns the provided default when none of the above apply.
|
||||
|
||||
## Framework integration
|
||||
|
||||
FlagPole is framework-agnostic. Wrap `FeatureManager` in your framework's container and bind a repository suitable for your environment.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
7
SECURITY.md
Normal file
7
SECURITY.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
Security Policy
|
||||
|
||||
If you discover a security vulnerability, please do not open a public issue.
|
||||
|
||||
Instead, email the maintainer (Phred) privately to report the issue. If you do not have a direct email, open a GitHub issue stating only that you wish to disclose a security problem and request a contact channel.
|
||||
|
||||
We will confirm receipt within 72 hours and provide a timeline for a fix when possible.
|
||||
83
composer.json
Normal file
83
composer.json
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
{
|
||||
"name": "phred/flagpole",
|
||||
"description": "Feature flag handling library for PHP. Simple, fast, and framework-agnostic.",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Phred",
|
||||
"email": "phred@phred.com",
|
||||
"homepage": "https://phred.com",
|
||||
"role": "Owner"
|
||||
}
|
||||
],
|
||||
"keywords": [
|
||||
"feature-flags",
|
||||
"flags",
|
||||
"toggle",
|
||||
"rollout",
|
||||
"ab-testing",
|
||||
"php"
|
||||
],
|
||||
"homepage": "https://github.com/phred/flagpole",
|
||||
"support": {
|
||||
"issues": "https://github.com/phred/flagpole/issues",
|
||||
"source": "https://github.com/phred/flagpole"
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"FlagPole\\": "src/"
|
||||
}
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^10.5",
|
||||
"phpstan/phpstan": "^1.11",
|
||||
"friendsofphp/php-cs-fixer": "^3.64"
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"FlagPole\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"composer-validate": [
|
||||
"@composer validate --strict"
|
||||
],
|
||||
"analyse": [
|
||||
"@php -d memory_limit=-1 vendor/bin/phpstan analyse"
|
||||
],
|
||||
"lint": [
|
||||
"@php vendor/bin/php-cs-fixer fix --dry-run --diff"
|
||||
],
|
||||
"fix": [
|
||||
"@php vendor/bin/php-cs-fixer fix"
|
||||
],
|
||||
"test": [
|
||||
"@php vendor/bin/phpunit -c phpunit.xml.dist"
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
"sort-packages": true,
|
||||
"preferred-install": {
|
||||
"*": "dist"
|
||||
},
|
||||
"optimize-autoloader": true
|
||||
},
|
||||
"archive": {
|
||||
"exclude": [
|
||||
".github/",
|
||||
".idea/",
|
||||
"tests/",
|
||||
".phpunit.cache/",
|
||||
"phpunit.xml.dist",
|
||||
".php-cs-fixer.cache",
|
||||
"phpstan.neon",
|
||||
"phpstan.neon.dist"
|
||||
]
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
7
phpstan.neon.dist
Normal file
7
phpstan.neon.dist
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
parameters:
|
||||
level: 8
|
||||
paths:
|
||||
- src
|
||||
checkGenericClassInNonGenericObjectType: true
|
||||
checkMissingIterableValueType: true
|
||||
reportUnmatchedIgnoredErrors: true
|
||||
11
phpunit.xml.dist
Normal file
11
phpunit.xml.dist
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true">
|
||||
<testsuites>
|
||||
<testsuite name="FlagPole Test Suite">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
</phpunit>
|
||||
44
src/Context.php
Normal file
44
src/Context.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FlagPole;
|
||||
|
||||
/**
|
||||
* Evaluation context passed to the flag evaluator.
|
||||
* Wrapper around an associative array of attributes (e.g., userId, email, team).
|
||||
*/
|
||||
final class Context
|
||||
{
|
||||
/** @var array<string, scalar|null> */
|
||||
private array $attributes;
|
||||
|
||||
/**
|
||||
* @param array<string, scalar|null> $attributes
|
||||
*/
|
||||
public function __construct(array $attributes = [])
|
||||
{
|
||||
$this->attributes = $attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, scalar|null> $attributes
|
||||
*/
|
||||
public static function fromArray(array $attributes): self
|
||||
{
|
||||
return new self($attributes);
|
||||
}
|
||||
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->attributes[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, scalar|null>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return $this->attributes;
|
||||
}
|
||||
}
|
||||
60
src/Evaluator.php
Normal file
60
src/Evaluator.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FlagPole;
|
||||
|
||||
final class Evaluator
|
||||
{
|
||||
public function evaluate(Flag $flag, ?Context $context = null, bool $default = false): bool
|
||||
{
|
||||
$context ??= new Context();
|
||||
|
||||
// 1) Allow list wins if present
|
||||
if (!empty($flag->allowList)) {
|
||||
$key = $this->resolveTargetingKey($context);
|
||||
if ($key !== null && in_array((string)$key, $flag->allowList, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Explicit on/off
|
||||
if ($flag->enabled !== null) {
|
||||
return $flag->enabled;
|
||||
}
|
||||
|
||||
// 3) Percentage rollout if available
|
||||
if ($flag->rolloutPercentage !== null) {
|
||||
$key = $this->resolveTargetingKey($context);
|
||||
if ($key === null) {
|
||||
return $default;
|
||||
}
|
||||
$bucket = $this->computeBucket($flag->name, (string)$key);
|
||||
return $bucket < $flag->rolloutPercentage;
|
||||
}
|
||||
|
||||
// 4) Fallback
|
||||
return $default;
|
||||
}
|
||||
|
||||
private function resolveTargetingKey(Context $context): ?string
|
||||
{
|
||||
$candidates = ['key', 'userId', 'id', 'email'];
|
||||
foreach ($candidates as $attr) {
|
||||
$v = $context->get($attr);
|
||||
if ($v !== null && $v !== '') {
|
||||
return (string)$v;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function computeBucket(string $flagName, string $key): int
|
||||
{
|
||||
$hash = crc32($flagName . ':' . $key);
|
||||
// Normalize to unsigned 32-bit to avoid negative values on some platforms
|
||||
$unsigned = (int) sprintf('%u', $hash);
|
||||
// Map to 0..99
|
||||
return (int)($unsigned % 100);
|
||||
}
|
||||
}
|
||||
25
src/FeatureManager.php
Normal file
25
src/FeatureManager.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FlagPole;
|
||||
|
||||
use FlagPole\Repository\FlagRepositoryInterface;
|
||||
|
||||
final class FeatureManager
|
||||
{
|
||||
public function __construct(
|
||||
private FlagRepositoryInterface $repository,
|
||||
private Evaluator $evaluator = new Evaluator()
|
||||
) {
|
||||
}
|
||||
|
||||
public function isEnabled(string $flagName, ?Context $context = null, bool $default = false): bool
|
||||
{
|
||||
$flag = $this->repository->get($flagName);
|
||||
if ($flag === null) {
|
||||
return $default;
|
||||
}
|
||||
return $this->evaluator->evaluate($flag, $context, $default);
|
||||
}
|
||||
}
|
||||
25
src/Flag.php
Normal file
25
src/Flag.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FlagPole;
|
||||
|
||||
/**
|
||||
* Immutable definition of a feature flag.
|
||||
*/
|
||||
final class Flag
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $name,
|
||||
public readonly ?bool $enabled = null,
|
||||
public readonly ?int $rolloutPercentage = null,
|
||||
/** @var list<string> */
|
||||
public readonly array $allowList = [],
|
||||
) {
|
||||
if ($this->rolloutPercentage !== null) {
|
||||
if ($this->rolloutPercentage < 0 || $this->rolloutPercentage > 100) {
|
||||
throw new \InvalidArgumentException('rolloutPercentage must be between 0 and 100');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/Repository/FlagRepositoryInterface.php
Normal file
17
src/Repository/FlagRepositoryInterface.php
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FlagPole\Repository;
|
||||
|
||||
use FlagPole\Flag;
|
||||
|
||||
interface FlagRepositoryInterface
|
||||
{
|
||||
public function get(string $name): ?Flag;
|
||||
|
||||
/**
|
||||
* @return iterable<string, Flag>
|
||||
*/
|
||||
public function all(): iterable;
|
||||
}
|
||||
54
src/Repository/InMemoryFlagRepository.php
Normal file
54
src/Repository/InMemoryFlagRepository.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FlagPole\Repository;
|
||||
|
||||
use FlagPole\Flag;
|
||||
|
||||
/**
|
||||
* Simple in-memory repository. Useful for bootstrapping or tests.
|
||||
*/
|
||||
final class InMemoryFlagRepository implements FlagRepositoryInterface
|
||||
{
|
||||
/** @var array<string, Flag> */
|
||||
private array $flags = [];
|
||||
|
||||
/**
|
||||
* @param array<string, array{enabled?:bool|null, rolloutPercentage?:int|null, allowList?:list<string>}> $config
|
||||
*/
|
||||
public static function fromArray(array $config): self
|
||||
{
|
||||
$items = [];
|
||||
foreach ($config as $name => $def) {
|
||||
$items[$name] = new Flag(
|
||||
name: (string)$name,
|
||||
enabled: $def['enabled'] ?? null,
|
||||
rolloutPercentage: $def['rolloutPercentage'] ?? null,
|
||||
allowList: $def['allowList'] ?? []
|
||||
);
|
||||
}
|
||||
return new self($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, Flag> $flags
|
||||
*/
|
||||
public function __construct(array $flags = [])
|
||||
{
|
||||
$this->flags = $flags;
|
||||
}
|
||||
|
||||
public function get(string $name): ?Flag
|
||||
{
|
||||
return $this->flags[$name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, Flag>
|
||||
*/
|
||||
public function all(): iterable
|
||||
{
|
||||
return $this->flags;
|
||||
}
|
||||
}
|
||||
119
tests/FeatureManagerTest.php
Normal file
119
tests/FeatureManagerTest.php
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FlagPole\Tests;
|
||||
|
||||
use FlagPole\Context;
|
||||
use FlagPole\FeatureManager;
|
||||
use FlagPole\Repository\InMemoryFlagRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class FeatureManagerTest extends TestCase
|
||||
{
|
||||
public function testExplicitEnabledOverrides(): void
|
||||
{
|
||||
$repo = InMemoryFlagRepository::fromArray([
|
||||
'hard-on' => ['enabled' => true],
|
||||
'hard-off' => ['enabled' => false],
|
||||
]);
|
||||
$fm = new FeatureManager($repo);
|
||||
|
||||
$this->assertTrue($fm->isEnabled('hard-on'));
|
||||
$this->assertFalse($fm->isEnabled('hard-off', null, true));
|
||||
}
|
||||
|
||||
public function testAllowListWins(): void
|
||||
{
|
||||
$repo = InMemoryFlagRepository::fromArray([
|
||||
'flag' => [
|
||||
'enabled' => false,
|
||||
'allowList' => ['user_1'],
|
||||
],
|
||||
]);
|
||||
$fm = new FeatureManager($repo);
|
||||
$ctx1 = Context::fromArray(['userId' => 'user_1']);
|
||||
$ctx2 = Context::fromArray(['userId' => 'user_2']);
|
||||
|
||||
$this->assertTrue($fm->isEnabled('flag', $ctx1));
|
||||
$this->assertFalse($fm->isEnabled('flag', $ctx2));
|
||||
}
|
||||
|
||||
public function testPercentageRolloutIsStable(): void
|
||||
{
|
||||
$repo = InMemoryFlagRepository::fromArray([
|
||||
'gradual' => [
|
||||
'rolloutPercentage' => 10,
|
||||
],
|
||||
]);
|
||||
$fm = new FeatureManager($repo);
|
||||
$ctx = Context::fromArray(['userId' => 'user_42']);
|
||||
|
||||
$first = $fm->isEnabled('gradual', $ctx);
|
||||
$second = $fm->isEnabled('gradual', $ctx);
|
||||
|
||||
$this->assertSame($first, $second, 'Rollout decision should be stable for same user');
|
||||
}
|
||||
|
||||
public function testRolloutBoundaryPercentages(): void
|
||||
{
|
||||
$repo = InMemoryFlagRepository::fromArray([
|
||||
'zero' => [ 'rolloutPercentage' => 0 ],
|
||||
'hundred' => [ 'rolloutPercentage' => 100 ],
|
||||
]);
|
||||
$fm = new FeatureManager($repo);
|
||||
$ctx = Context::fromArray(['userId' => 'any_user']);
|
||||
|
||||
$this->assertFalse($fm->isEnabled('zero', $ctx), '0% rollout should always be false when key present');
|
||||
$this->assertTrue($fm->isEnabled('hundred', $ctx), '100% rollout should always be true when key present');
|
||||
}
|
||||
|
||||
public function testRolloutEdgeCasesOneAndNinetyNinePercent(): void
|
||||
{
|
||||
$repo = InMemoryFlagRepository::fromArray([
|
||||
'one' => [ 'rolloutPercentage' => 1 ],
|
||||
'ninetyNine' => [ 'rolloutPercentage' => 99 ],
|
||||
'hundred' => [ 'rolloutPercentage' => 100 ],
|
||||
]);
|
||||
$fm = new FeatureManager($repo);
|
||||
|
||||
// Find a user that gets bucket < 1 (i.e., enabled at 1%)
|
||||
$foundOne = null;
|
||||
for ($i = 0; $i < 5000; $i++) {
|
||||
$ctx = Context::fromArray(['userId' => 'u'.$i]);
|
||||
if ($fm->isEnabled('one', $ctx)) { // enabled under 1%
|
||||
$foundOne = $ctx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$this->assertNotNull($foundOne, 'Should find a user enabled at 1% within a reasonable search space');
|
||||
|
||||
// Find a user that is NOT enabled at 99% but is at 100%
|
||||
$foundFalseAt99 = null;
|
||||
for ($i = 0; $i < 5000; $i++) {
|
||||
$ctx = Context::fromArray(['userId' => 'v'.$i]);
|
||||
if (!$fm->isEnabled('ninetyNine', $ctx) && $fm->isEnabled('hundred', $ctx)) {
|
||||
$foundFalseAt99 = $ctx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$this->assertNotNull($foundFalseAt99, 'Should find a user disabled at 99% but enabled at 100%');
|
||||
}
|
||||
|
||||
public function testPercentageRolloutWithMissingTargetingKeyFallsBackToDefault(): void
|
||||
{
|
||||
$repo = InMemoryFlagRepository::fromArray([
|
||||
'gradual' => [ 'rolloutPercentage' => 50 ],
|
||||
]);
|
||||
$fm = new FeatureManager($repo);
|
||||
|
||||
// No context provided
|
||||
$this->assertFalse($fm->isEnabled('gradual', null, false));
|
||||
$this->assertTrue($fm->isEnabled('gradual', null, true));
|
||||
|
||||
// Empty context (no targeting attributes)
|
||||
$emptyCtx = Context::fromArray([]);
|
||||
$this->assertFalse($fm->isEnabled('gradual', $emptyCtx, false));
|
||||
$this->assertTrue($fm->isEnabled('gradual', $emptyCtx, true));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue