Skip to content

Commit

Permalink
Merge pull request #89 from worksome/feature/bucket
Browse files Browse the repository at this point in the history
feat: add support for Bucket.co driver
  • Loading branch information
owenvoke authored Feb 26, 2025
2 parents ae9678b + 81d266c commit f30703f
Show file tree
Hide file tree
Showing 8 changed files with 248 additions and 65 deletions.
66 changes: 1 addition & 65 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,71 +21,7 @@ You can publish the config file with:
php artisan vendor:publish --tag="feature-flags-config"
```

This is the contents of the published config file:

```php
declare(strict_types=1);

use Worksome\FeatureFlags\ModelFeatureFlagConvertor;

// config for Worksome/FeatureFlags
return [
'default' => env('FEATURE_FLAGS_PROVIDER', 'launchdarkly'),

'convertor' => ModelFeatureFlagConvertor::class,

'providers' => [
'launchdarkly' => [
'key' => env('LAUNCHDARKLY_SDK_KEY'),
'options' => [
/**
* https://docs.launchdarkly.com/sdk/features/offline-mode
*/
'offline' => env('LAUNCHDARKLY_OFFLINE', false)
],
/**
* @link https://docs.launchdarkly.com/home/account-security/api-access-tokens
*/
'access-token' => env('FEATURE_FLAGS_API_ACCESS_TOKEN', null),
]
],

/**
* List of available overriders.
* Key is to be used to specify which overrider should be active.
*/
'overriders' => [
'config' => [
/**
* Overrides all feature flags directly without hitting the provider.
* This is particularly useful for running things in the CI,
* e.g. Cypress tests.
*
* Be careful in setting a default value as said value will be applied to all flags.
* Use `null` value if needing the key to be present but act as if it was not
*/
'override-all' => null,

/**
* Override flags. If a feature flag is set inside an override,
* it will be used instead of the flag set in the provider.
*
* Usage: ['feature-flag-key' => true]
*
* Be careful in setting a default value as it will be applied.
* Use `null` value if needing the key to be present but act as if it was not
*
*/
'overrides' => [
// ...
],
],
'in-memory' => [
// ...
]
],
];
```
See the [config file](config/feature-flags.php) for more information.

### Creating Feature Flags

Expand Down
5 changes: 5 additions & 0 deletions config/feature-flags.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
'overrider' => 'config',

'providers' => [
'bucket' => [
'key' => env('BUCKET_SECRET_KEY'),
'host' => env('BUCKET_HOST', 'https://front-eu.bucket.co'),
'options' => [],
],
'launchdarkly' => [
'key' => env('LAUNCHDARKLY_SDK_KEY'),
'options' => [
Expand Down
17 changes: 17 additions & 0 deletions src/Exceptions/Bucket/BucketInvalidResponseException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Worksome\FeatureFlags\Exceptions\Bucket;

use Exception;

final class BucketInvalidResponseException extends Exception
{
public function __construct()
{
parent::__construct(
'Invalid response from Bucket.'
);
}
}
1 change: 1 addition & 0 deletions src/FeatureFlagUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

class FeatureFlagUser
{
/** @param array<string, mixed> $custom */
public function __construct(
public string|int $id,
public string $email,
Expand Down
14 changes: 14 additions & 0 deletions src/FeatureFlagsManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,25 @@

use Illuminate\Support\Manager;
use Psr\Log\LoggerInterface;
use Worksome\FeatureFlags\Providers\Bucket\BucketProvider;
use Worksome\FeatureFlags\Providers\FakeProvider;
use Worksome\FeatureFlags\Providers\LaunchDarkly\LaunchDarklyProvider;

class FeatureFlagsManager extends Manager
{
public function createBucketDriver(): BucketProvider
{
/** @var array $config */
$config = $this->config->get('feature-flags.providers.bucket');
/** @var LoggerInterface $logger */
$logger = $this->getContainer()->get(LoggerInterface::class);

return new BucketProvider(
$config,
$logger,
);
}

public function createLaunchDarklyDriver(): LaunchDarklyProvider
{
/** @var array $config */
Expand Down
94 changes: 94 additions & 0 deletions src/Providers/Bucket/BucketClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

declare(strict_types=1);

namespace Worksome\FeatureFlags\Providers\Bucket;

use GuzzleHttp\Client;
use GuzzleHttp\Exception\BadResponseException;
use GuzzleHttp\HandlerStack;
use Illuminate\Support\Arr;
use Psr\Log\LoggerInterface;
use SensitiveParameter;
use Worksome\FeatureFlags\Exceptions\Bucket\BucketInvalidResponseException;

class BucketClient
{
public const string DEFAULT_BASE_URI = 'https://front-eu.bucket.co';

private Client $client;

public function __construct(
private readonly string $baseUri,
#[SensitiveParameter]
private readonly string $sdkKey,
private readonly array $options,
private readonly LoggerInterface $logger,
) {
$stack = HandlerStack::create();

$defaults = [
'headers' => self::defaultHeaders($this->sdkKey, $this->options),
'timeout' => Arr::get($this->options, 'timeout'),
'connect_timeout' => Arr::get($this->options, 'connect_timeout'),
'handler' => $stack,
'debug' => Arr::get($this->options, 'debug', false),
'base_uri' => $this->baseUri,
];

$this->client = new Client($defaults);
}

public function getFeature(string $value, bool $defaultValue, BucketContext|null $context = null): bool
{
$features = $this->getAllFeatures($context);

if (! isset($features[$value])) {
$this->logger->warning("BucketClient::getFeature: Feature flag does not exist for key: {$value}");

return $defaultValue;
}

return $features[$value];
}

/** @return array<string, bool> */
public function getAllFeatures(BucketContext|null $context = null): array
{
$transformedContext = $context?->transform() ?? [];

try {
$response = $this->client->get('/features/enabled', [
'query' => $transformedContext,
]);

$body = $response->getBody();

/** @var array{success?: bool, features?: array<string, array{isEnabled: bool}>} $response */
$response = json_decode($body->getContents(), true);

throw_unless($response['success'] ?? false, BucketInvalidResponseException::class);

/** @phpstan-ignore return.type */
return Arr::mapWithKeys(
$response['features'] ?? [],
fn (array $value, string $key): array => [$key => $value['isEnabled']],
);
} catch (BadResponseException $e) {
$this->logger->warning(
"BucketClient::getAllFeatures: (code {$e->getResponse()->getStatusCode()}) {$e->getMessage()}"
);

return [];
}
}

private static function defaultHeaders(string $sdkKey, array $options): array
{
return [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
'Authorization' => "Bearer {$sdkKey}",
];
}
}
47 changes: 47 additions & 0 deletions src/Providers/Bucket/BucketContext.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace Worksome\FeatureFlags\Providers\Bucket;

use Illuminate\Support\Arr;

readonly class BucketContext
{
/** @param array<string, mixed> $context */
public function __construct(public string $id, public string|null $email = null, public array $context = [])
{
}

public static function anonymous(): self
{
return new self('anonymous');
}

public function transform(): array
{
$userContext = isset($this->context['user']) && is_array($this->context['user'])
? $this->context['user']
: [];

$companyContext = isset($this->context['company']) && is_array($this->context['company'])
? $this->context['company']
: [];

return Arr::dot([
'context' => [
'user' => [
... $userContext,
'id' => $this->id,
'email' => $this->email,
],
'company' => [
... $companyContext,
],
'other' => [
... Arr::except($this->context, ['user', 'company']),
],
],
]);
}
}
69 changes: 69 additions & 0 deletions src/Providers/Bucket/BucketProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

declare(strict_types=1);

namespace Worksome\FeatureFlags\Providers\Bucket;

use Illuminate\Support\Arr;
use Psr\Log\LoggerInterface;
use Worksome\FeatureFlags\Contracts\FeatureFlagEnum;
use Worksome\FeatureFlags\Contracts\FeatureFlagsProvider;
use Worksome\FeatureFlags\FeatureFlagUser;

class BucketProvider implements FeatureFlagsProvider
{
private BucketContext|null $context = null;

private BucketClient|null $client = null;

public function __construct(
public readonly array $config,
public readonly LoggerInterface $logger,
) {
/** @var string|null $key */
$key = Arr::get($config, 'key');
/** @var string $host */
$host = Arr::get($config, 'host', BucketClient::DEFAULT_BASE_URI);
/** @var array $options */
$options = Arr::get($config, 'options');

if ($key) {
$this->client = new BucketClient($host, $key, $options, $logger);
}
}

public function setUser(FeatureFlagUser $user): void
{
$id = (string) $user->id;

$this->context = new BucketContext(
id: $id,
email: $user->email,
context: $user->custom,
);
}

public function setAnonymousUser(): void
{
$this->context = BucketContext::anonymous();
}

public function flag(FeatureFlagEnum $flag): bool
{
assert(is_string($flag->value));

$client = $this->client;

if ($client === null) {
return false;
}

if ($this->context === null) {
$this->setAnonymousUser();
}

assert($this->context !== null);

return $client->getFeature($flag->value, false, context: $this->context);
}
}

0 comments on commit f30703f

Please sign in to comment.