Skip to content

Commit

Permalink
Merge pull request #3 from open-solid/decorators
Browse files Browse the repository at this point in the history
add support for handler decorators
  • Loading branch information
yceruto authored Aug 21, 2024
2 parents 0566adb + 168ceb1 commit dacf825
Show file tree
Hide file tree
Showing 25 changed files with 491 additions and 111 deletions.
1 change: 1 addition & 0 deletions .php-cs-fixer.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
'@Symfony' => true,
'header_comment' => ['header' => $header],
'declare_strict_types' => true,
'native_function_invocation' => true,
])
->setFinder($finder)
;
96 changes: 89 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ composer require open-solid/bus

### Dispatching Message with the Bus

Think of the "bus" as a mail delivery system for your messages. It follows a specific path, decided
by some rules (middleware), to send your message and handle it.
Think of the "bus" as a mail delivery system for your messages. It follows a
specific path, decided by some rules (middleware), to send your message and
handle it.

Here's a snippet on how to set it up and dispatch a message:

Expand Down Expand Up @@ -44,8 +45,8 @@ $bus->dispatch(new MyMessage());

### Handling Messages

A "message handler" is what does the work when a message arrives. It can be a simple function or a method in a class.
Here's how you set one up:
A "message handler" is what does the work when a message arrives. It can be a simple
function or a method in a class. Here's how you set one up:

```php
use App\Message\MyMessage;
Expand All @@ -61,9 +62,11 @@ class MyMessageHandler

### Middleware

Middleware are helpers that do stuff before and after your message is handled. They can change the message or do other tasks.
Middleware are helpers that perform tasks before and after your message is handled. They
operate at the bus level, meaning they handle all messages dispatched through the message
bus they are linked to.

Heres how to create one:
Here's how to create one:

```php
use OpenSolid\Bus\Envelope\Envelope;
Expand All @@ -76,13 +79,92 @@ class MyMiddleware implements Middleware
{
// Do something before the message handler works.

$next->handle($envelope); // Pass the message to the next middleware
$next->handle($envelope); // Call the next middleware

// Do something after the message handler is done.
}
}
```

### Decorators

Decorators are helpers that perform tasks before and after your message is handled. Unlike
Middleware, decorators operate at the handler level, allowing you to modify or enhance the
handler behavior without changing their actual code.

Essentially, a decorator is a callable that takes another callable as an argument and extends
or alters its behavior. Let's see an example:

```php
use OpenSolid\Bus\Decorator\Decorator;

class StopwatchDecorator implements Decorator
{
public function decorate(\Closure $func): \Closure
{
return function (mixed ...$args) use ($func): mixed {
// do something before

$result = $func(...$args);

// do something after

return $result;
};
}
}
```

Then, use it wherever you want to decorate a message handler operation with
the `#[Decorate]` attribute, which configures the decorator that will wrap
the current `MyMessageHandler::__invoke()` method.

```php
use App\Decorator\StopwatchDecorator;
use OpenSolid\Bus\Decorator\Decorate;

class MyMessageHandler
{
#[Decorate(StopwatchDecorator::class)]
public function __invoke(MyMessage $message): void
{
// ...
}
}
```

If it's a frequently used decorator, you can create a pre-defined class
to avoid configuring the same decorator repeatedly:

```php
use App\Decorator\StopwatchDecorator;
use OpenSolid\Bus\Decorator\Decorate;

#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
readonly class Stopwatch extends Decorate
{
public function __construct()
{
parent::__construct(StopwatchDecorator::class);
}
}
```

Then, you can simply reference your `Stopwatch` attribute decorator as follows:

```php
use App\Decorator\Stopwatch;

class MyMessageHandler
{
#[Stopwatch]
public function __invoke(MyMessage $message): void
{
// ...
}
}
```

## Framework Integration

* [cqs-bundle](https://github.com/open-solid/cqs-bundle) - Symfony bundle for Command-Query buses.
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"vimeo/psalm": "^5.0",
"phpunit/phpunit": "^10.0",
"psalm/plugin-phpunit": "^0.18",
"symfony/dependency-injection": "^7.0",
"symfony/dependency-injection": "^7.1",
"doctrine/orm": "^3.1",
"friendsofphp/php-cs-fixer": "^3.54"
},
Expand Down
2 changes: 2 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Message Bus Component

46 changes: 46 additions & 0 deletions src/Bridge/Doctrine/Decorator/DoctrineTransactionDecorator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

/*
* This file is part of OpenSolid package.
*
* (c) Yonel Ceruto <open@yceruto.dev>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace OpenSolid\Bus\Bridge\Doctrine\Decorator;

use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use OpenSolid\Bus\Decorator\Decorator;

class DoctrineTransactionDecorator implements Decorator
{
private array $options = [];

public function __construct(
private readonly ManagerRegistry $registry,
) {
}

public function decorate(\Closure $func): \Closure
{
$manager = $this->registry->getManager($this->options['name'] ?? null);

if (!$manager instanceof EntityManagerInterface) {
throw new \LogicException('Doctrine ORM entity managers are only supported');
}

return static function (mixed ...$args) use ($manager, $func): mixed {
return $manager->wrapInTransaction(static fn (): mixed => $func(...$args));
};
}

public function setOptions(array $options): void
{
$this->options = $options;
}
}
31 changes: 31 additions & 0 deletions src/Bridge/Doctrine/Decorator/Transactional.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

/*
* This file is part of OpenSolid package.
*
* (c) Yonel Ceruto <open@yceruto.dev>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace OpenSolid\Bus\Bridge\Doctrine\Decorator;

use OpenSolid\Bus\Decorator\Decorate;

#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
readonly class Transactional extends Decorate
{
/**
* @param string|null $entityManagerName the entity manager name (null for the default one)
*/
public function __construct(
?string $entityManagerName = null,
) {
parent::__construct(DoctrineTransactionDecorator::class, [
'name' => $entityManagerName,
]);
}
}
12 changes: 10 additions & 2 deletions src/Bridge/Doctrine/Middleware/DoctrineTransactionMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,27 @@
namespace OpenSolid\Bus\Bridge\Doctrine\Middleware;

use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use OpenSolid\Bus\Envelope\Envelope;
use OpenSolid\Bus\Middleware\Middleware;
use OpenSolid\Bus\Middleware\NextMiddleware;

final readonly class DoctrineTransactionMiddleware implements Middleware
{
public function __construct(
private EntityManagerInterface $em,
private ManagerRegistry $registry,
private ?string $entityManagerName = null,
) {
}

public function handle(Envelope $envelope, NextMiddleware $next): void
{
$this->em->wrapInTransaction(fn () => $next->handle($envelope));
$manager = $this->registry->getManager($this->entityManagerName);

if (!$manager instanceof EntityManagerInterface) {
throw new \LogicException('Doctrine ORM entity managers are only supported');
}

$manager->wrapInTransaction(fn () => $next->handle($envelope));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

declare(strict_types=1);

/*
* This file is part of OpenSolid package.
*
* (c) Yonel Ceruto <open@yceruto.dev>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace OpenSolid\Bus\Bridge\Symfony\DependencyInjection\CompilerPass;

use OpenSolid\Bus\Decorator\Decorate;
use OpenSolid\Bus\Decorator\Decorator;
use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument;
use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\LogicException;
use Symfony\Component\DependencyInjection\Reference;

final readonly class HandlingMiddlewarePass implements CompilerPassInterface
{
use PriorityTaggedServiceTrait;

public function __construct(
private string $messageHandlerTagName,
private string $handlingMiddlewareId,
private array $exclude = [],
private bool $allowMultiple = false,
private string $topic = 'message',
) {
}

public function process(ContainerBuilder $container): void
{
if (!$container->has($this->handlingMiddlewareId)) {
return;
}

$handlers = $this->findAndSortTaggedServices(
tagName: new TaggedIteratorArgument($this->messageHandlerTagName, 'class'),
container: $container,
exclude: $this->exclude,
);

$decorators = [];
foreach ($handlers as $messageClass => $refs) {
if (!$this->allowMultiple && \count($refs) > 1) {
throw new LogicException(\sprintf('Only one handler is allowed for %s of type "%s". However, %d were found: %s', $this->topic, $messageClass, \count($refs), implode(', ', $refs)));
}

foreach ($refs as $ref) {
/** @var class-string $handlerClass */
$handlerClass = $container->getDefinition((string) $ref)->getClass();

if (null === $refHandlerClass = $container->getReflectionClass($handlerClass)) {
throw new LogicException('Missing reflection class.');
}

/** @var array<\ReflectionAttribute<Decorate>> $attributes */
$attributes = $refHandlerClass->getMethod('__invoke')->getAttributes(Decorate::class, \ReflectionAttribute::IS_INSTANCEOF);

foreach ($attributes as $attribute) {
$instance = $attribute->newInstance();
$decorator = $container->getDefinition($instance->id);
/** @var class-string $decoratorClass */
$decoratorClass = $decorator->getClass();

if (!is_subclass_of($decoratorClass, Decorator::class)) {
throw new LogicException(\sprintf('The handler decorator "%s" must implement the "%s" interface.', $decoratorClass, Decorator::class));
}

if (method_exists($decoratorClass, 'setOptions')) {
$decorator->addMethodCall('setOptions', [$instance->options]);
}

$decorators[$handlerClass][$instance->id] = new Reference($instance->id);
}
}
}

$middleware = $container->findDefinition($this->handlingMiddlewareId);
$middleware->replaceArgument(0, new ServiceLocatorArgument($handlers));
$middleware->replaceArgument(1, new ServiceLocatorArgument($decorators));
}
}
Loading

0 comments on commit dacf825

Please sign in to comment.