Skip to content

Commit

Permalink
Introduce #[IsNotEnabled] attribute to complement #[IsEnabled]
Browse files Browse the repository at this point in the history
Signed-off-by: Quentin Devos <4972091+Okhoshi@users.noreply.github.com>
  • Loading branch information
Okhoshi committed Aug 17, 2023
1 parent 7034fc8 commit cd57d1e
Show file tree
Hide file tree
Showing 5 changed files with 105 additions and 15 deletions.
31 changes: 26 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,15 @@ class MyService

## Controller attribute

You can also check for feature flag using an `#[IsEnabled]` attribute on a controller. You can use it on the whole
controller class as well as on a concrete method.
You can also check for feature flag using `#[IsEnabled]` and `#[IsNotEnabled]` attributes on a controller. You can use
it on the whole controller class as well as on a concrete method.

```php
<?php
use Unleash\Client\Bundle\Attribute\IsEnabled;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\Response;
#[IsEnabled('my_awesome_feature')]
final class MyController
Expand All @@ -88,14 +87,36 @@ In the example above the user on `/my-route` needs both `my_awesome_feature` and
(because of one attribute on the class and another attribute on the method) while the `/other-route` needs only
`my_awesome_feature` enabled (because of class attribute).

```php
<?php
use Unleash\Client\Bundle\Attribute\IsNotEnabled;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
#[IsNotEnabled('kill_switch')]
final class MyHeavyController
{
#[Route('/my-route')]
public function myRoute(): Response
{
// todo
}
}
```

In the second example, `/my-route` route is only enabled if `kill_switch` is **not** enabled.

You can also notice that one of the attributes specifies a second optional parameter with status code. The supported
status codes are:
- `404` - `NotFoundHttpException`
- `403` - `AccessDeniedHttpException`
- `400` - `BadRequestHttpException`
- `401` - `UnauthorizedHttpException` with message "Unauthorized".
- `401` - `UnauthorizedHttpException` with message "Unauthorized".
- `503` - `ServiceUnavailableHttpException` only with `#[IsNotEnabled]`

The default status code is `404`. If you use an unsupported status code `InvalidValueException` will be thrown.
The default status code is `404` for `#[IsEnabled]` and `503` for `#[IsNotEnabled]`. If you use an unsupported status
code `InvalidValueException` will be thrown.

### Setting custom exception for attribute

Expand Down
12 changes: 12 additions & 0 deletions src/Attribute/ControllerAttribute.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

namespace Unleash\Client\Bundle\Attribute;

interface ControllerAttribute
{
public function getFeatureName(): string;

public function getErrorCode(): int;

public function shouldThrowException(bool $isFeatureEnabled): bool;
}
17 changes: 16 additions & 1 deletion src/Attribute/IsEnabled.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
use Symfony\Component\HttpFoundation\Response;

#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
final class IsEnabled
final class IsEnabled implements ControllerAttribute
{
public function __construct(
public string $featureName,
Expand All @@ -20,4 +20,19 @@ public function __construct(
public int $errorCode = Response::HTTP_NOT_FOUND,
) {
}

public function getFeatureName(): string
{
return $this->featureName;
}

public function getErrorCode(): int
{
return $this->errorCode;
}

public function shouldThrowException(bool $isFeatureEnabled): bool
{
return !$isFeatureEnabled;
}
}
39 changes: 39 additions & 0 deletions src/Attribute/IsNotEnabled.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

namespace Unleash\Client\Bundle\Attribute;

use Attribute;
use JetBrains\PhpStorm\ExpectedValues;
use Symfony\Component\HttpFoundation\Response;

#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
final class IsNotEnabled implements ControllerAttribute
{
public function __construct(
public string $featureName,
#[ExpectedValues([
Response::HTTP_NOT_FOUND,
Response::HTTP_FORBIDDEN,
Response::HTTP_BAD_REQUEST,
Response::HTTP_UNAUTHORIZED,
Response::HTTP_SERVICE_UNAVAILABLE,
])]
public int $errorCode = Response::HTTP_SERVICE_UNAVAILABLE,
) {
}

public function getFeatureName(): string
{
return $this->featureName;
}

public function getErrorCode(): int
{
return $this->errorCode;
}

public function shouldThrowException(bool $isFeatureEnabled): bool
{
return $isFeatureEnabled;
}
}
21 changes: 12 additions & 9 deletions src/Listener/ControllerAttributeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Throwable;
use Unleash\Client\Bundle\Attribute\IsEnabled;
use Unleash\Client\Bundle\Attribute\ControllerAttribute;
use Unleash\Client\Bundle\Event\BeforeExceptionThrownForAttributeEvent;
use Unleash\Client\Bundle\Event\UnleashEvents;
use Unleash\Client\Exception\InvalidValueException;
Expand Down Expand Up @@ -61,24 +62,25 @@ public function onControllerResolved(ControllerEvent $event): void
$reflectionClass = new ReflectionClass($class);
$reflectionMethod = $reflectionClass->getMethod($method);

/** @var array<ReflectionAttribute<IsEnabled>> $attributes */
/** @var array<ReflectionAttribute<ControllerAttribute>> $attributes */
$attributes = [
...$reflectionClass->getAttributes(IsEnabled::class),
...$reflectionMethod->getAttributes(IsEnabled::class),
...$reflectionClass->getAttributes(ControllerAttribute::class, ReflectionAttribute::IS_INSTANCEOF),
...$reflectionMethod->getAttributes(ControllerAttribute::class, ReflectionAttribute::IS_INSTANCEOF),
];

foreach ($attributes as $attribute) {
$attribute = $attribute->newInstance();
assert($attribute instanceof IsEnabled);
if (!$this->unleash->isEnabled($attribute->featureName)) {
assert($attribute instanceof ControllerAttribute);

if ($attribute->shouldThrowException($this->unleash->isEnabled($attribute->getFeatureName()))) {
throw $this->getException($attribute);
}
}
}

private function getException(IsEnabled $attribute): HttpException|Throwable
private function getException(ControllerAttribute $attribute): HttpException|Throwable
{
$event = new BeforeExceptionThrownForAttributeEvent($attribute->errorCode);
$event = new BeforeExceptionThrownForAttributeEvent($attribute->getErrorCode());
$this->eventDispatcher->dispatch($event, UnleashEvents::BEFORE_EXCEPTION_THROWN_FOR_ATTRIBUTE);
$exception = $event->getException();
if ($exception !== null) {
Expand All @@ -90,7 +92,8 @@ private function getException(IsEnabled $attribute): HttpException|Throwable
Response::HTTP_UNAUTHORIZED => new UnauthorizedHttpException('Unauthorized'),
Response::HTTP_FORBIDDEN => new AccessDeniedHttpException(),
Response::HTTP_NOT_FOUND => new NotFoundHttpException(),
default => throw new InvalidValueException("Unsupported status code: {$attribute->errorCode}"),
Response::HTTP_SERVICE_UNAVAILABLE => new ServiceUnavailableHttpException(),
default => throw new InvalidValueException("Unsupported status code: {$attribute->getErrorCode()}"),
};
}
}

0 comments on commit cd57d1e

Please sign in to comment.