Skip to content

Commit

Permalink
Define voters, handlers, and permissions using PHP 8 attributes
Browse files Browse the repository at this point in the history
  • Loading branch information
olvlvl committed Dec 18, 2022
1 parent d50034e commit a7dd32e
Show file tree
Hide file tree
Showing 24 changed files with 465 additions and 80 deletions.
79 changes: 78 additions & 1 deletion README.md
Expand Up @@ -212,7 +212,84 @@ $container->compile();



## Providing handlers using a chain of providers


#### Using PHP 8 attributes instead of YAML

With the [composer-attribute-collector][] [Composer][] plugin, PHP 8 attributes can be used instead
of YAML to define voters, handlers, and permissions.

For voters, the [Vote](lib/Attribute/Vote.php) attribute can be used to replace YAML:

```php
<?php

namespace Acme\MenuService\Presentation\Security\Voters;

use ICanBoogie\MessageBus\Attribute\Vote;

#[Vote('can_write_menu')]
final class CanWriteMenu
{
// ...
}
```

```yaml
Acme\MenuService\Presentation\Security\Voters\CanWriteMenu:
tags:
- { name: message_bus.voter, permission: can_write_menu }
```

For handlers and permissions, the [Handler](lib/Attribute/Handler.php) and
[Permission](lib/Attribute/Permission.php) attribute can be used to replace YAML:

```php

namespace Acme\MenuService\Application\MessageBus;

use ICanBoogie\MessageBus\Attribute\Handler;
use ICanBoogie\MessageBus\Attribute\Permission;

#[Permission('is_admin')]
#[Permission('can_write_menu')]
final class CreateMenu
{
// ...
}

#[Handler]
final class CreateMenuHandler
{
// ...
}
```

```yaml
Acme\MenuService\Application\MessageBus\CreateMenuHandler:
tags:
- { name: message_bus.handler, message: Acme\MenuService\Application\MessageBus\CreateMenu }
- { name: message_bus.permission, permission: is_admin }
- { name: message_bus.permission, permission: can_write_menu }
```

You just need to add the compiler pass [MessagePubPassWithAttributes](lib/Symfony/MessageBusPassWithAttributes.php)
before [MessageBusPass](lib/Symfony/MessageBusPass.php):

```php
<?php

// ...
$container->addCompilerPass(new MessageBusPassWithAttributes());
$container->addCompilerPass(new MessageBusPass());
// ...
```





### Providing handlers using a chain of providers

With `HandlerProviderWithChain` you can chain multiple handler providers together. They will be used
in sequence until a handler is found.
Expand Down
11 changes: 7 additions & 4 deletions composer.json
Expand Up @@ -24,7 +24,8 @@
"config": {
"sort-packages": true,
"allow-plugins": {
"phpstan/extension-installer": true
"phpstan/extension-installer": true,
"olvlvl/composer-attribute-collector": true
}
},
"prefer-stable": true,
Expand All @@ -34,9 +35,10 @@
},
"require-dev": {
"jangregor/phpstan-prophecy": "^1.0",
"olvlvl/composer-attribute-collector": "^1.0",
"phpspec/prophecy-phpunit": "^2.0",
"phpstan/extension-installer": "^1.1",
"phpstan/phpstan": "^1.5",
"phpstan/phpstan": "^1.9",
"phpunit/phpunit": "^9.5",
"psr/container": "^1.0|^2.0",
"symfony/config": "^6.0",
Expand All @@ -45,12 +47,13 @@
},
"autoload": {
"psr-4": {
"ICanBoogie\\MessageBus\\": "lib/"
"ICanBoogie\\MessageBus\\": "lib"
}
},
"autoload-dev": {
"psr-4": {
"ICanBoogie\\MessageBus\\": "tests/"
"ICanBoogie\\MessageBus\\": "tests",
"Acme\\": "tests/Acme"
}
}
}
26 changes: 26 additions & 0 deletions lib/Attribute/Handler.php
@@ -0,0 +1,26 @@
<?php

/*
* This file is part of the ICanBoogie package.
*
* (c) Olivier Laviale <olivier.laviale@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace ICanBoogie\MessageBus\Attribute;

use Attribute;

/**
* Identifies a message handler.
*
* **Note:** The message type supported by the handler is inferred from its `__invoke` method.
*
* @readonly
*/
#[Attribute(Attribute::TARGET_CLASS)]
final class Handler
{
}
30 changes: 30 additions & 0 deletions lib/Attribute/Permission.php
@@ -0,0 +1,30 @@
<?php

/*
* This file is part of the ICanBoogie package.
*
* (c) Olivier Laviale <olivier.laviale@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace ICanBoogie\MessageBus\Attribute;

use Attribute;

/**
* Identifies a permission required to dispatch a message.
*
* A message can have multiple {@link Permission}s.
*
* @readonly
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
final class Permission
{
public function __construct(
public string $permission
) {
}
}
19 changes: 19 additions & 0 deletions lib/Attribute/Vote.php
@@ -0,0 +1,19 @@
<?php

namespace ICanBoogie\MessageBus\Attribute;

use Attribute;

/**
* Identifies a voter and the permission it votes for.
*
* @readonly
*/
#[Attribute(Attribute::TARGET_CLASS)]
final class Vote
{
public function __construct(
public string $permission,
) {
}
}
105 changes: 105 additions & 0 deletions lib/Symfony/MessageBusPassWithAttributes.php
@@ -0,0 +1,105 @@
<?php

/*
* This file is part of the ICanBoogie package.
*
* (c) Olivier Laviale <olivier.laviale@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace ICanBoogie\MessageBus\Symfony;

use ICanBoogie\MessageBus\Attribute;
use LogicException;
use olvlvl\ComposerAttributeCollector\Attributes;
use ReflectionException;
use ReflectionMethod;
use ReflectionNamedType;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;

use function assert;

/**
* Creates handler and voter services according to their attributes.
*
* This compiler pass is meant to run before {@link MessageBusPass}.
*/
final class MessageBusPassWithAttributes implements CompilerPassInterface
{
public function __construct(
private string $tagForHandler = MessageBusPass::DEFAULT_TAG_FOR_HANDLER,
private string $attributeForMessage = MessageBusPass::DEFAULT_ATTRIBUTE_FOR_MESSAGE,
private string $tagForPermission = MessageBusPass::DEFAULT_TAG_FOR_PERMISSION,
private string $attributeForPermission = MessageBusPass::DEFAULT_ATTRIBUTE_FOR_PERMISSION,
private string $tagForVoter = MessageBusPass::DEFAULT_TAG_FOR_VOTER,
) {
}

/**
* @throws ReflectionException
*/
public function process(ContainerBuilder $container): void
{
/** @var array<class-string, Definition> $definitions */
$definitions = [];

/**
* @var array<class-string, string[]> $permissions
* Where _key_ is a command and _value_ its associated permissions.
*/
$permissions = [];

foreach (Attributes::findTargetClasses(Attribute\Permission::class) as $targetClass) {
$permissions[$targetClass->name][] = $targetClass->attribute->permission;
}

foreach (Attributes::findTargetClasses(Attribute\Handler::class) as $targetClass) {
$handler = $targetClass->name;
$message = self::resolveMessage($handler);

$definition = new Definition($handler);
$definition->addTag($this->tagForHandler, [ $this->attributeForMessage => $message ]);

foreach ($permissions[$message] ?? [] as $permission) {
$definition->addTag($this->tagForPermission, [ $this->attributeForPermission => $permission ]);
}

$definitions[$handler] = $definition;
}

foreach (Attributes::findTargetClasses(Attribute\Vote::class) as $targetClass) {
$voter = $targetClass->name;
$permission = $targetClass->attribute->permission;

$definition = new Definition($voter);
$definition->addTag($this->tagForVoter, [ $this->attributeForPermission => $permission ]);

$definitions[$voter] = $definition;
}

$container->addDefinitions($definitions);
}

/**
* @param class-string $handler
*
* @return class-string
*
* @throws ReflectionException
*/
private static function resolveMessage(string $handler): string
{
$type = (new ReflectionMethod($handler, '__invoke'))
->getParameters()[0]
->getType() ?? throw new LogicException("Expected a type for the first argument");

assert($type instanceof ReflectionNamedType);

// @phpstan-ignore-next-line
return $type->getName();
}
}
2 changes: 1 addition & 1 deletion phpunit.xml
@@ -1,7 +1,7 @@
<?xml version="1.0"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.5/phpunit.xsd" colors="true"
bootstrap="./vendor/autoload.php">
bootstrap="tests/bootstrap.php">
<coverage>
<include>
<directory suffix=".php">./lib</directory>
Expand Down
15 changes: 15 additions & 0 deletions tests/Acme/MenuService/Application/MessageBus/CreateMenu.php
@@ -0,0 +1,15 @@
<?php

namespace Acme\MenuService\Application\MessageBus;

use ICanBoogie\MessageBus\Attribute\Permission;

#[Permission(Permissions::IS_ADMIN)]
#[Permission(Permissions::CAN_WRITE_MENU)]
final class CreateMenu
{
public function __construct(
public /*readonly*/ int $id
) {
}
}
@@ -0,0 +1,13 @@
<?php

namespace Acme\MenuService\Application\MessageBus;

use ICanBoogie\MessageBus\Attribute\Handler;

#[Handler]
final class CreateMenuHandler
{
public function __invoke(CreateMenu $message): void
{
}
}
15 changes: 15 additions & 0 deletions tests/Acme/MenuService/Application/MessageBus/DeleteMenu.php
@@ -0,0 +1,15 @@
<?php

namespace Acme\MenuService\Application\MessageBus;

use ICanBoogie\MessageBus\Attribute\Permission;

#[Permission(Permissions::IS_ADMIN)]
#[Permission(Permissions::CAN_MANAGE_MENU)]
final class DeleteMenu
{
public function __construct(
public /*readonly*/ int $id
) {
}
}
@@ -0,0 +1,13 @@
<?php

namespace Acme\MenuService\Application\MessageBus;

use ICanBoogie\MessageBus\Attribute\Handler;

#[Handler]
final class DeleteMenuHandler
{
public function __invoke(DeleteMenu $message): void
{
}
}
10 changes: 10 additions & 0 deletions tests/Acme/MenuService/Application/MessageBus/Permissions.php
@@ -0,0 +1,10 @@
<?php

namespace Acme\MenuService\Application\MessageBus;

final class Permissions
{
public const IS_ADMIN = 'is_admin';
public const CAN_WRITE_MENU = 'can_write_menu';
public const CAN_MANAGE_MENU = 'can_manage_menu';
}

0 comments on commit a7dd32e

Please sign in to comment.