From ea3e2bd269d48d95ffab44eda33fce011c1adbe7 Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Thu, 1 Jan 2026 10:57:27 +0000 Subject: [PATCH 1/8] feat: allow pipe container to fallback to psr container --- CHANGELOG.md | 5 ++++ composer.json | 1 + deptrac.yaml | 5 ++++ src/Toolkit/Pipeline/PipeContainer.php | 13 +++++++++- .../Toolkit/Pipeline/PipeContainerTest.php | 26 ++++++++++++++++++- 5 files changed, 48 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e6e5e8..cc4bd24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. This projec ## Unreleased +### Added + +- The `PipeContainer` class can now fallback to resolving pipes from a PSR service container. Inject the service + container via the pipe container's only constructor argument. + ## [5.0.0] - 2025-12-09 ### Added diff --git a/composer.json b/composer.json index caf5551..fe0faa0 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ "require": { "php": "^8.2", "ext-json": "*", + "psr/container": "^2.0", "psr/log": "^2.0 || ^3.0", "ramsey/uuid": "^4.7", "symfony/polyfill-php84": "^1.33" diff --git a/deptrac.yaml b/deptrac.yaml index 2287983..dbb689f 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -28,6 +28,10 @@ deptrac: value: CloudCreativity\\Modules\\Contracts\\Infrastructure\\* - type: classLike value: CloudCreativity\\Modules\\Infrastructure\\* + - name: PSR Container + collectors: + - type: classLike + value: Psr\\Container\\* - name: PSR Log collectors: - type: classLike @@ -39,6 +43,7 @@ deptrac: ruleset: Toolkit: - Attributes + - PSR Container Domain: - Toolkit - Attributes diff --git a/src/Toolkit/Pipeline/PipeContainer.php b/src/Toolkit/Pipeline/PipeContainer.php index eff2526..96d403a 100644 --- a/src/Toolkit/Pipeline/PipeContainer.php +++ b/src/Toolkit/Pipeline/PipeContainer.php @@ -14,6 +14,7 @@ use Closure; use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer as IPipeContainer; +use Psr\Container\ContainerInterface; use RuntimeException; final class PipeContainer implements IPipeContainer @@ -23,6 +24,10 @@ final class PipeContainer implements IPipeContainer */ private array $pipes = []; + public function __construct(private readonly ?ContainerInterface $container = null) + { + } + /** * Bind a pipe into the container. */ @@ -37,7 +42,13 @@ public function get(string $pipeName): callable if (is_callable($factory)) { $pipe = $factory(); - assert(is_callable($pipe), 'Expecting pipe from factory to be callable.'); + assert(is_callable($pipe), "Expecting pipe {$pipeName} from factory to be callable."); + return $pipe; + } + + if ($this->container) { + $pipe = $this->container->get($pipeName); + assert(is_callable($pipe), "Expecting pipe {$pipeName} from PSR container to be callable."); return $pipe; } diff --git a/tests/Unit/Toolkit/Pipeline/PipeContainerTest.php b/tests/Unit/Toolkit/Pipeline/PipeContainerTest.php index d2ca32a..6da2fea 100644 --- a/tests/Unit/Toolkit/Pipeline/PipeContainerTest.php +++ b/tests/Unit/Toolkit/Pipeline/PipeContainerTest.php @@ -14,10 +14,11 @@ use CloudCreativity\Modules\Toolkit\Pipeline\PipeContainer; use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; class PipeContainerTest extends TestCase { - public function test(): void + public function testItResolvesBoundPipes(): void { $a = fn () => 1; $b = fn () => 2; @@ -34,4 +35,27 @@ public function test(): void $container->get('PipeC'); } + + public function testItFallsBackToPsrContainer(): void + { + $psrContainer = $this->createMock(ContainerInterface::class); + + $a = fn () => 1; + $b = fn () => 2; + $c = fn () => 3; + + $container = new PipeContainer($psrContainer); + $container->bind('PipeA', fn () => $a); + $container->bind('PipeB', fn () => $b); + + $psrContainer + ->expects($this->once()) + ->method('get') + ->with('PipeC') + ->willReturn($c); + + $this->assertSame($a, $container->get('PipeA')); + $this->assertSame($b, $container->get('PipeB')); + $this->assertSame($c, $container->get('PipeC')); + } } From 53ec18d814ef8c8f83e5640bf3511a8e01474fb0 Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Thu, 1 Jan 2026 11:43:59 +0000 Subject: [PATCH 2/8] feat: allow command and query handler containers to use psr container --- CHANGELOG.md | 6 ++- deptrac.yaml | 1 + .../Bus/CommandHandlerContainer.php | 31 ++++++++++++---- src/Application/Bus/QueryHandlerContainer.php | 31 ++++++++++++---- .../Bus/CommandHandlerContainerTest.php | 37 ++++++++++++++++++- .../Bus/QueryHandlerContainerTest.php | 37 ++++++++++++++++++- 6 files changed, 121 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc4bd24..24f110a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,10 @@ All notable changes to this project will be documented in this file. This projec ### Added -- The `PipeContainer` class can now fallback to resolving pipes from a PSR service container. Inject the service - container via the pipe container's only constructor argument. +- The `QueryHandlerContainer` and `CommandHandlerContainer` classes can now fallback to resolving handlers from a PSR + service container. Inject the service container via the handler container's only constructor argument. +- The pipeline `PipeContainer` class can now fallback to resolving pipes from a PSR service container. Inject the + service container via the pipe container's only constructor argument. ## [5.0.0] - 2025-12-09 diff --git a/deptrac.yaml b/deptrac.yaml index dbb689f..5e37a68 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -50,6 +50,7 @@ deptrac: Application: - Toolkit - Domain + - PSR Container - PSR Log - Attributes Infrastructure: diff --git a/src/Application/Bus/CommandHandlerContainer.php b/src/Application/Bus/CommandHandlerContainer.php index 5c74c2b..ca7b505 100644 --- a/src/Application/Bus/CommandHandlerContainer.php +++ b/src/Application/Bus/CommandHandlerContainer.php @@ -16,33 +16,48 @@ use CloudCreativity\Modules\Application\ApplicationException; use CloudCreativity\Modules\Contracts\Application\Bus\CommandHandlerContainer as ICommandHandlerContainer; use CloudCreativity\Modules\Contracts\Toolkit\Messages\Command; +use Psr\Container\ContainerInterface; final class CommandHandlerContainer implements ICommandHandlerContainer { /** - * @var array, Closure> + * @var array, class-string|Closure> */ private array $bindings = []; + public function __construct(private readonly ?ContainerInterface $container = null) + { + } + /** * Bind a command handler into the container. * * @param class-string $commandClass - * @param Closure(): object $binding + * @param class-string|(Closure(): object) $binding */ - public function bind(string $commandClass, Closure $binding): void + public function bind(string $commandClass, Closure|string $binding): void { + if (is_string($binding) && $this->container === null) { + throw new ApplicationException('Cannot use a string command handler binding without a PSR container.'); + } + $this->bindings[$commandClass] = $binding; } public function get(string $commandClass): CommandHandler { - $factory = $this->bindings[$commandClass] ?? null; + $binding = $this->bindings[$commandClass] ?? null; + + if ($binding instanceof Closure) { + $instance = $binding(); + assert(is_object($instance), "Command handler binding for {$commandClass} must return an object."); + return new CommandHandler($instance); + } - if ($factory) { - $innerHandler = $factory(); - assert(is_object($innerHandler), "Command handler binding for {$commandClass} must return an object."); - return new CommandHandler($innerHandler); + if (is_string($binding)) { + $instance = $this->container?->get($binding); + assert(is_object($instance), "PSR container command handler binding {$binding} is not an object."); + return new CommandHandler($instance); } throw new ApplicationException('No command handler bound for command class: ' . $commandClass); diff --git a/src/Application/Bus/QueryHandlerContainer.php b/src/Application/Bus/QueryHandlerContainer.php index 4352fe5..892df79 100644 --- a/src/Application/Bus/QueryHandlerContainer.php +++ b/src/Application/Bus/QueryHandlerContainer.php @@ -16,33 +16,48 @@ use CloudCreativity\Modules\Application\ApplicationException; use CloudCreativity\Modules\Contracts\Application\Bus\QueryHandlerContainer as IQueryHandlerContainer; use CloudCreativity\Modules\Contracts\Toolkit\Messages\Query; +use Psr\Container\ContainerInterface; final class QueryHandlerContainer implements IQueryHandlerContainer { /** - * @var array,Closure> + * @var array, class-string|Closure> */ private array $bindings = []; + public function __construct(private readonly ?ContainerInterface $container = null) + { + } + /** * Bind a query handler into the container. * * @param class-string $queryClass - * @param Closure(): object $binding + * @param class-string|(Closure(): object) $binding */ - public function bind(string $queryClass, Closure $binding): void + public function bind(string $queryClass, Closure|string $binding): void { + if (is_string($binding) && !$this->container) { + throw new ApplicationException('Cannot use a string query handler binding without a PSR container.'); + } + $this->bindings[$queryClass] = $binding; } public function get(string $queryClass): QueryHandler { - $factory = $this->bindings[$queryClass] ?? null; + $binding = $this->bindings[$queryClass] ?? null; + + if ($binding instanceof Closure) { + $instance = $binding(); + assert(is_object($instance), "Query handler binding for {$queryClass} must return an object."); + return new QueryHandler($instance); + } - if ($factory) { - $innerHandler = $factory(); - assert(is_object($innerHandler), "Query handler binding for {$queryClass} must return an object."); - return new QueryHandler($innerHandler); + if (is_string($binding)) { + $instance = $this->container?->get($binding); + assert(is_object($instance), "PSR container query handler binding {$binding} is not an object."); + return new QueryHandler($instance); } throw new ApplicationException('No query handler bound for query class: ' . $queryClass); diff --git a/tests/Unit/Application/Bus/CommandHandlerContainerTest.php b/tests/Unit/Application/Bus/CommandHandlerContainerTest.php index d14d64a..7bacf1d 100644 --- a/tests/Unit/Application/Bus/CommandHandlerContainerTest.php +++ b/tests/Unit/Application/Bus/CommandHandlerContainerTest.php @@ -17,13 +17,14 @@ use CloudCreativity\Modules\Application\Bus\CommandHandlerContainer; use CloudCreativity\Modules\Contracts\Toolkit\Messages\Command; use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; class CommandHandlerContainerTest extends TestCase { - public function test(): void + public function testItResolvesUsingClosureBindings(): void { $a = new TestCommandHandler(); - $b = $this->createMock(TestCommandHandler::class); + $b = $this->createStub(TestCommandHandler::class); $command1 = new class () implements Command {}; $command2 = new class () implements Command {}; @@ -41,4 +42,36 @@ public function test(): void $container->get($command3::class); } + + public function testItResolvesViaPsrContainer(): void + { + $a = new TestCommandHandler(); + $b = $this->createStub(TestCommandHandler::class); + + $command1 = new class () implements Command {}; + $command2 = new class () implements Command {}; + $command3 = new class () implements Command {}; + + $psrContainer = $this->createMock(ContainerInterface::class); + $psrContainer + ->expects($this->exactly(2)) + ->method('get') + ->willReturnCallback(fn (string $id) => match ($id) { + $a::class => $a, + $b::class => $b, + default => $this->fail('Unexpected container id: ' . $id), + }); + + $container = new CommandHandlerContainer($psrContainer); + $container->bind($command1::class, $a::class); + $container->bind($command2::class, $b::class); + + $this->assertEquals(new CommandHandler($a), $container->get($command1::class)); + $this->assertEquals(new CommandHandler($b), $container->get($command2::class)); + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('No command handler bound for command class: ' . $command3::class); + + $container->get($command3::class); + } } diff --git a/tests/Unit/Application/Bus/QueryHandlerContainerTest.php b/tests/Unit/Application/Bus/QueryHandlerContainerTest.php index 2348927..23ac92b 100644 --- a/tests/Unit/Application/Bus/QueryHandlerContainerTest.php +++ b/tests/Unit/Application/Bus/QueryHandlerContainerTest.php @@ -17,13 +17,14 @@ use CloudCreativity\Modules\Application\Bus\QueryHandlerContainer; use CloudCreativity\Modules\Contracts\Toolkit\Messages\Query; use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; class QueryHandlerContainerTest extends TestCase { - public function test(): void + public function testItResolvesClosureBindings(): void { $a = new TestQueryHandler(); - $b = $this->createMock(TestQueryHandler::class); + $b = $this->createStub(TestQueryHandler::class); $query1 = new class () implements Query {}; $query2 = new class () implements Query {}; @@ -41,4 +42,36 @@ public function test(): void $container->get($query3::class); } + + public function testItResolvesViaPsrContainer(): void + { + $a = new TestQueryHandler(); + $b = $this->createStub(TestQueryHandler::class); + + $query1 = new class () implements Query {}; + $query2 = new class () implements Query {}; + $query3 = new class () implements Query {}; + + $psrContainer = $this->createMock(ContainerInterface::class); + $psrContainer + ->expects($this->exactly(2)) + ->method('get') + ->willReturnCallback(fn (string $id) => match ($id) { + $a::class => $a, + $b::class => $b, + default => $this->fail('Unexpected container id: ' . $id), + }); + + $container = new QueryHandlerContainer($psrContainer); + $container->bind($query1::class, $a::class); + $container->bind($query2::class, $b::class); + + $this->assertEquals(new QueryHandler($a), $container->get($query1::class)); + $this->assertEquals(new QueryHandler($b), $container->get($query2::class)); + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('No query handler bound for query class: ' . $query3::class); + + $container->get($query3::class); + } } From ef56b0e6ab5dd3db0a5c1f1262e4609a1564fe6b Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Thu, 1 Jan 2026 13:34:38 +0000 Subject: [PATCH 3/8] feat: add autowiring via attributes to command bus --- CHANGELOG.md | 7 ++ composer.json | 3 +- phpunit.xml | 3 + src/Application/Bus/CommandDispatcher.php | 41 ++++++++- src/Application/Bus/CommandHandler.php | 17 +++- src/Application/Bus/Through.php | 26 ++++++ src/Application/Bus/WithCommand.php | 28 +++++++ src/Testing/FakeContainer.php | 84 +++++++++++++++++++ src/Testing/FakeLogger.php | 53 ++++++++++++ src/Testing/FakeUnitOfWork.php | 9 ++ tests/Integration/Application/AddCommand.php | 22 +++++ .../Application/AddCommandHandler.php | 32 +++++++ .../Application/MathCommandBus.php | 25 ++++++ .../Application/MathCommandBusTest.php | 43 ++++++++++ .../Application/MultiplyCommand.php | 22 +++++ .../Application/MultiplyCommandHandler.php | 31 +++++++ tests/Unit/Testing/FakeUnitOfWorkTest.php | 12 +++ 17 files changed, 451 insertions(+), 7 deletions(-) create mode 100644 src/Application/Bus/Through.php create mode 100644 src/Application/Bus/WithCommand.php create mode 100644 src/Testing/FakeContainer.php create mode 100644 src/Testing/FakeLogger.php create mode 100644 tests/Integration/Application/AddCommand.php create mode 100644 tests/Integration/Application/AddCommandHandler.php create mode 100644 tests/Integration/Application/MathCommandBus.php create mode 100644 tests/Integration/Application/MathCommandBusTest.php create mode 100644 tests/Integration/Application/MultiplyCommand.php create mode 100644 tests/Integration/Application/MultiplyCommandHandler.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 24f110a..8a4478c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,17 @@ All notable changes to this project will be documented in this file. This projec ### Added +- New command bus features: + - Can now use a PSR container for the command bus to resolve both handlers and middleware. Inject the service + container via the first constructor argument. + - Commands can now be mapped to handlers on a command bus class via the `WithCommand` attribute. + - Middleware can now be added to a command bus via the `Through` attribute. - The `QueryHandlerContainer` and `CommandHandlerContainer` classes can now fallback to resolving handlers from a PSR service container. Inject the service container via the handler container's only constructor argument. - The pipeline `PipeContainer` class can now fallback to resolving pipes from a PSR service container. Inject the service container via the pipe container's only constructor argument. +- The `FakeUnitOfWork` class now has integer properties for the number of attempts, commits and rollbacks. +- New `FakeContainer` class for faking a PSR container in tests. ## [5.0.0] - 2025-12-09 diff --git a/composer.json b/composer.json index fe0faa0..87b1cfa 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,8 @@ "psr/container": "^2.0", "psr/log": "^2.0 || ^3.0", "ramsey/uuid": "^4.7", - "symfony/polyfill-php84": "^1.33" + "symfony/polyfill-php84": "^1.33", + "symfony/polyfill-php85": "^1.33" }, "require-dev": { "deptrac/deptrac": "^4.4", diff --git a/phpunit.xml b/phpunit.xml index 2968136..c830534 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -19,6 +19,9 @@ ./tests/Unit/ + + ./tests/Integration/ + diff --git a/src/Application/Bus/CommandDispatcher.php b/src/Application/Bus/CommandDispatcher.php index 2a33820..cabf32c 100644 --- a/src/Application/Bus/CommandDispatcher.php +++ b/src/Application/Bus/CommandDispatcher.php @@ -12,25 +12,41 @@ namespace CloudCreativity\Modules\Application\Bus; -use CloudCreativity\Modules\Contracts\Application\Bus\CommandHandlerContainer; +use CloudCreativity\Modules\Contracts\Application\Bus\CommandHandlerContainer as ICommandHandlerContainer; use CloudCreativity\Modules\Contracts\Application\Ports\Driving\CommandDispatcher as ICommandDispatcher; use CloudCreativity\Modules\Contracts\Toolkit\Messages\Command; -use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer; +use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer as IPipeContainer; use CloudCreativity\Modules\Contracts\Toolkit\Result\Result; use CloudCreativity\Modules\Toolkit\Pipeline\MiddlewareProcessor; +use CloudCreativity\Modules\Toolkit\Pipeline\PipeContainer; use CloudCreativity\Modules\Toolkit\Pipeline\PipelineBuilder; +use Psr\Container\ContainerInterface; +use ReflectionClass; class CommandDispatcher implements ICommandDispatcher { + private readonly ICommandHandlerContainer $handlers; + + private readonly ?IPipeContainer $middleware; + /** * @var array */ private array $pipes = []; public function __construct( - private readonly CommandHandlerContainer $handlers, - private readonly ?PipeContainer $middleware = null, + ContainerInterface|ICommandHandlerContainer $handlers, + ?IPipeContainer $middleware = null, ) { + $this->handlers = $handlers instanceof ContainerInterface ? + new CommandHandlerContainer($handlers) : + $handlers; + + $this->middleware = $middleware === null && $handlers instanceof ContainerInterface + ? new PipeContainer($handlers) + : $middleware; + + $this->autowire(); } /** @@ -77,4 +93,21 @@ private function execute(Command $command): Result return $result; } + + private function autowire(): void + { + $reflection = new ReflectionClass($this); + + if ($this->handlers instanceof CommandHandlerContainer) { + foreach ($reflection->getAttributes(WithCommand::class) as $attribute) { + $instance = $attribute->newInstance(); + $this->handlers->bind($instance->command, $instance->handler); + } + } + + foreach ($reflection->getAttributes(Through::class) as $attribute) { + $instance = $attribute->newInstance(); + $this->pipes[] = $instance->pipe; + } + } } diff --git a/src/Application/Bus/CommandHandler.php b/src/Application/Bus/CommandHandler.php index 5ce85a6..da899d5 100644 --- a/src/Application/Bus/CommandHandler.php +++ b/src/Application/Bus/CommandHandler.php @@ -16,6 +16,7 @@ use CloudCreativity\Modules\Contracts\Application\Messages\DispatchThroughMiddleware; use CloudCreativity\Modules\Contracts\Toolkit\Messages\Command; use CloudCreativity\Modules\Contracts\Toolkit\Result\Result; +use ReflectionClass; final readonly class CommandHandler implements ICommandHandler { @@ -40,10 +41,22 @@ public function __invoke(Command $command): Result public function middleware(): array { + $middleware = []; + + $reflection = new ReflectionClass($this->handler); + + foreach ($reflection->getAttributes(Through::class) as $attribute) { + $instance = $attribute->newInstance(); + $middleware[] = $instance->pipe; + } + if ($this->handler instanceof DispatchThroughMiddleware) { - return $this->handler->middleware(); + $middleware = [ + ...$middleware, + ...$this->handler->middleware(), + ]; } - return []; + return $middleware; } } diff --git a/src/Application/Bus/Through.php b/src/Application/Bus/Through.php new file mode 100644 index 0000000..b0929a5 --- /dev/null +++ b/src/Application/Bus/Through.php @@ -0,0 +1,26 @@ + $command + * @param class-string $handler + */ + public function __construct(public string $command, public string $handler) + { + } +} diff --git a/src/Testing/FakeContainer.php b/src/Testing/FakeContainer.php new file mode 100644 index 0000000..7c6564c --- /dev/null +++ b/src/Testing/FakeContainer.php @@ -0,0 +1,84 @@ +, 2025 + */ + +declare(strict_types=1); + +namespace CloudCreativity\Modules\Testing; + +use Closure; +use CloudCreativity\Modules\Contracts\Application\Ports\Driven\ExceptionReporter; +use CloudCreativity\Modules\Contracts\Application\Ports\Driven\UnitOfWork; +use Exception; +use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; +use Psr\Log\LoggerInterface; + +final class FakeContainer implements ContainerInterface +{ + public readonly FakeLogger $logger; + public readonly FakeExceptionReporter $reporter; + public readonly FakeUnitOfWork $unitOfWork; + + /** + * @var array + */ + private array $bindings = []; + + public function __construct() + { + $this->logger = new FakeLogger(); + $this->reporter = new FakeExceptionReporter(); + $this->unitOfWork = new FakeUnitOfWork($this->reporter); + } + + public function bind(string $id, Closure $binding): void + { + $this->bindings[$id] = $binding; + } + + public function instance(string $id, mixed $instance): void + { + $this->bindings[$id] = fn () => $instance; + } + + public function get(string $id) + { + $stub = match ($id) { + ExceptionReporter::class => $this->reporter, + LoggerInterface::class => $this->logger, + UnitOfWork::class => $this->unitOfWork, + default => null, + }; + + if ($stub === null) { + $binding = $this->bindings[$id] ?? null; + $stub = $binding ? $binding() : null; + } + + return $stub ?? $this->abort($id); + } + + public function has(string $id): bool + { + try { + $this->get($id); + return true; + } catch (NotFoundExceptionInterface) { + return false; + } + } + + protected function abort(string $id): never + { + $message = 'Test service not found: ' . $id; + + throw new class ($message) extends Exception implements NotFoundExceptionInterface {}; + } +} diff --git a/src/Testing/FakeLogger.php b/src/Testing/FakeLogger.php new file mode 100644 index 0000000..f46615b --- /dev/null +++ b/src/Testing/FakeLogger.php @@ -0,0 +1,53 @@ + + */ + public array $log = []; + + public function log($level, string|Stringable $message, array $context = []): void + { + $this->log[] = [ + 'level' => $level, + 'message' => (string) $message, + 'context' => $context, + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return array_map( + fn (array $entry): string => implode(': ', array_filter([ + is_scalar($entry['level']) ? strtoupper((string) $entry['level']) : null, + $entry['message'], + ])), + $this->log, + ); + } + + public function count(): int + { + return count($this->log); + } +} diff --git a/src/Testing/FakeUnitOfWork.php b/src/Testing/FakeUnitOfWork.php index a3ae33e..adbbbca 100644 --- a/src/Testing/FakeUnitOfWork.php +++ b/src/Testing/FakeUnitOfWork.php @@ -20,6 +20,12 @@ final class FakeUnitOfWork implements UnitOfWork { + public int $attempts = 0; + + public int $commits = 0; + + public int $rollbacks = 0; + /** * @var list */ @@ -38,11 +44,14 @@ public function execute(Closure $callback, int $attempts = 1): mixed for ($i = 1; $i <= $attempts; $i++) { try { $this->sequence[] = 'attempt:' . $i; + $this->attempts++; $result = $callback(); $this->sequence[] = 'commit:' . $i; + $this->commits++; return $result; } catch (Throwable $ex) { $this->sequence[] = 'rollback:' . $i; + $this->rollbacks++; $this->exceptions->report($ex); if ($i === $attempts) { diff --git a/tests/Integration/Application/AddCommand.php b/tests/Integration/Application/AddCommand.php new file mode 100644 index 0000000..e80e95d --- /dev/null +++ b/tests/Integration/Application/AddCommand.php @@ -0,0 +1,22 @@ + + */ + public function execute(AddCommand $command): Result + { + return Result::ok($command->a + $command->b + $this->c); + } +} diff --git a/tests/Integration/Application/MathCommandBus.php b/tests/Integration/Application/MathCommandBus.php new file mode 100644 index 0000000..7be9d9d --- /dev/null +++ b/tests/Integration/Application/MathCommandBus.php @@ -0,0 +1,25 @@ +bind(AddCommandHandler::class, fn () => new AddCommandHandler(3)); + $container->bind(MultiplyCommandHandler::class, fn () => new MultiplyCommandHandler()); + $container->bind(LogMessageDispatch::class, fn () => new LogMessageDispatch($container->logger)); + $container->bind(ExecuteInUnitOfWork::class, fn () => new ExecuteInUnitOfWork( + new UnitOfWorkManager($container->unitOfWork), + )); + + $bus = new MathCommandBus($container); + + $add = $bus->dispatch(new AddCommand(1, 2)); + $multiply = $bus->dispatch(new MultiplyCommand(10, 11)); + + $this->assertSame(6, $add->value()); + $this->assertSame(110, $multiply->value()); + $this->assertCount(4, $container->logger); + $this->assertSame(1, $container->unitOfWork->commits); + } +} diff --git a/tests/Integration/Application/MultiplyCommand.php b/tests/Integration/Application/MultiplyCommand.php new file mode 100644 index 0000000..098eac9 --- /dev/null +++ b/tests/Integration/Application/MultiplyCommand.php @@ -0,0 +1,22 @@ + + */ + public function execute(MultiplyCommand $command): Result + { + return Result::ok($command->a * $command->b); + } +} diff --git a/tests/Unit/Testing/FakeUnitOfWorkTest.php b/tests/Unit/Testing/FakeUnitOfWorkTest.php index 3889bf3..d5bceab 100644 --- a/tests/Unit/Testing/FakeUnitOfWorkTest.php +++ b/tests/Unit/Testing/FakeUnitOfWorkTest.php @@ -28,6 +28,9 @@ public function testItIsSuccessfulOnFirstAttempt(): void $this->assertSame('result', $result); $this->assertSame(['attempt:1', 'commit:1'], $unitOfWork->sequence); $this->assertEmpty($unitOfWork->exceptions->reported); + $this->assertSame(1, $unitOfWork->attempts); + $this->assertSame(1, $unitOfWork->commits); + $this->assertSame(0, $unitOfWork->rollbacks); } public function testItIsSuccessfulBeforeMaxAttempts(): void @@ -56,6 +59,9 @@ public function testItIsSuccessfulBeforeMaxAttempts(): void 'commit:3', ], $unitOfWork->sequence); $this->assertSame($unitOfWork->exceptions->reported, [$ex1, $ex2]); + $this->assertSame(3, $unitOfWork->attempts); + $this->assertSame(1, $unitOfWork->commits); + $this->assertSame(2, $unitOfWork->rollbacks); } public function testItIsSuccessfulOnMaxAttempts(): void @@ -84,6 +90,9 @@ public function testItIsSuccessfulOnMaxAttempts(): void 'commit:3', ], $unitOfWork->sequence); $this->assertSame($unitOfWork->exceptions->reported, [$ex1, $ex2]); + $this->assertSame(3, $unitOfWork->attempts); + $this->assertSame(1, $unitOfWork->commits); + $this->assertSame(2, $unitOfWork->rollbacks); } public function testItIsNotSuccessful(): void @@ -108,6 +117,9 @@ public function testItIsNotSuccessful(): void 'rollback:2', ], $unitOfWork->sequence); $this->assertSame($unitOfWork->exceptions->reported, [$ex1, $ex2]); + $this->assertSame(2, $unitOfWork->attempts); + $this->assertSame(0, $unitOfWork->commits); + $this->assertSame(2, $unitOfWork->rollbacks); } } } From 2aeff81463c3ad8002578f821db10adc4006858e Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Thu, 1 Jan 2026 14:09:52 +0000 Subject: [PATCH 4/8] feat: add autowiring via attributes to query bus --- CHANGELOG.md | 5 +++ src/Application/Bus/CommandDispatcher.php | 1 + src/Application/Bus/CommandHandler.php | 26 ++--------- src/Application/Bus/QueryDispatcher.php | 43 +++++++++++++++++-- src/Application/Bus/QueryHandler.php | 13 ++---- src/Application/Bus/WithQuery.php | 28 ++++++++++++ src/Application/Messages/HandlesMessages.php | 40 +++++++++++++++++ src/Application/{Bus => Messages}/Through.php | 2 +- .../Application/{ => Bus}/AddCommand.php | 2 +- .../{ => Bus}/AddCommandHandler.php | 2 +- .../Application/Bus/DivideQuery.php | 22 ++++++++++ .../Application/Bus/DivideQueryHandler.php | 30 +++++++++++++ .../Application/{ => Bus}/MathCommandBus.php | 4 +- .../{ => Bus}/MathCommandBusTest.php | 2 +- .../Application/Bus/MathQueryBus.php | 25 +++++++++++ .../Application/Bus/MathQueryBusTest.php | 42 ++++++++++++++++++ .../Application/{ => Bus}/MultiplyCommand.php | 2 +- .../{ => Bus}/MultiplyCommandHandler.php | 4 +- .../Application/Bus/SubtractQuery.php | 22 ++++++++++ .../Application/Bus/SubtractQueryHandler.php | 32 ++++++++++++++ 20 files changed, 301 insertions(+), 46 deletions(-) create mode 100644 src/Application/Bus/WithQuery.php create mode 100644 src/Application/Messages/HandlesMessages.php rename src/Application/{Bus => Messages}/Through.php (89%) rename tests/Integration/Application/{ => Bus}/AddCommand.php (86%) rename tests/Integration/Application/{ => Bus}/AddCommandHandler.php (89%) create mode 100644 tests/Integration/Application/Bus/DivideQuery.php create mode 100644 tests/Integration/Application/Bus/DivideQueryHandler.php rename tests/Integration/Application/{ => Bus}/MathCommandBus.php (83%) rename tests/Integration/Application/{ => Bus}/MathCommandBusTest.php (95%) create mode 100644 tests/Integration/Application/Bus/MathQueryBus.php create mode 100644 tests/Integration/Application/Bus/MathQueryBusTest.php rename tests/Integration/Application/{ => Bus}/MultiplyCommand.php (86%) rename tests/Integration/Application/{ => Bus}/MultiplyCommandHandler.php (83%) create mode 100644 tests/Integration/Application/Bus/SubtractQuery.php create mode 100644 tests/Integration/Application/Bus/SubtractQueryHandler.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a4478c..e2eb227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ All notable changes to this project will be documented in this file. This projec container via the first constructor argument. - Commands can now be mapped to handlers on a command bus class via the `WithCommand` attribute. - Middleware can now be added to a command bus via the `Through` attribute. +- New query bus features: + - Can now use a PSR container for the query bus to resolve both handlers and middleware. Inject the service + container via the first constructor argument. + - Queries can now be mapped to handlers on a query bus class via the `WithQuery` attribute. + - Middleware can now be added to a query bus via the `Through` attribute. - The `QueryHandlerContainer` and `CommandHandlerContainer` classes can now fallback to resolving handlers from a PSR service container. Inject the service container via the handler container's only constructor argument. - The pipeline `PipeContainer` class can now fallback to resolving pipes from a PSR service container. Inject the diff --git a/src/Application/Bus/CommandDispatcher.php b/src/Application/Bus/CommandDispatcher.php index cabf32c..96d4df2 100644 --- a/src/Application/Bus/CommandDispatcher.php +++ b/src/Application/Bus/CommandDispatcher.php @@ -12,6 +12,7 @@ namespace CloudCreativity\Modules\Application\Bus; +use CloudCreativity\Modules\Application\Messages\Through; use CloudCreativity\Modules\Contracts\Application\Bus\CommandHandlerContainer as ICommandHandlerContainer; use CloudCreativity\Modules\Contracts\Application\Ports\Driving\CommandDispatcher as ICommandDispatcher; use CloudCreativity\Modules\Contracts\Toolkit\Messages\Command; diff --git a/src/Application/Bus/CommandHandler.php b/src/Application/Bus/CommandHandler.php index da899d5..5853e6a 100644 --- a/src/Application/Bus/CommandHandler.php +++ b/src/Application/Bus/CommandHandler.php @@ -12,14 +12,15 @@ namespace CloudCreativity\Modules\Application\Bus; +use CloudCreativity\Modules\Application\Messages\HandlesMessages; use CloudCreativity\Modules\Contracts\Application\Bus\CommandHandler as ICommandHandler; -use CloudCreativity\Modules\Contracts\Application\Messages\DispatchThroughMiddleware; use CloudCreativity\Modules\Contracts\Toolkit\Messages\Command; use CloudCreativity\Modules\Contracts\Toolkit\Result\Result; -use ReflectionClass; final readonly class CommandHandler implements ICommandHandler { + use HandlesMessages; + public function __construct(private object $handler) { } @@ -38,25 +39,4 @@ public function __invoke(Command $command): Result return $result; } - - public function middleware(): array - { - $middleware = []; - - $reflection = new ReflectionClass($this->handler); - - foreach ($reflection->getAttributes(Through::class) as $attribute) { - $instance = $attribute->newInstance(); - $middleware[] = $instance->pipe; - } - - if ($this->handler instanceof DispatchThroughMiddleware) { - $middleware = [ - ...$middleware, - ...$this->handler->middleware(), - ]; - } - - return $middleware; - } } diff --git a/src/Application/Bus/QueryDispatcher.php b/src/Application/Bus/QueryDispatcher.php index a19ec4d..ff9ac0e 100644 --- a/src/Application/Bus/QueryDispatcher.php +++ b/src/Application/Bus/QueryDispatcher.php @@ -12,25 +12,42 @@ namespace CloudCreativity\Modules\Application\Bus; -use CloudCreativity\Modules\Contracts\Application\Bus\QueryHandlerContainer; +use CloudCreativity\Modules\Application\Messages\Through; +use CloudCreativity\Modules\Contracts\Application\Bus\QueryHandlerContainer as IQueryHandlerContainer; use CloudCreativity\Modules\Contracts\Application\Ports\Driving\QueryDispatcher as IQueryDispatcher; use CloudCreativity\Modules\Contracts\Toolkit\Messages\Query; -use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer; +use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer as IPipeContainer; use CloudCreativity\Modules\Contracts\Toolkit\Result\Result; use CloudCreativity\Modules\Toolkit\Pipeline\MiddlewareProcessor; +use CloudCreativity\Modules\Toolkit\Pipeline\PipeContainer; use CloudCreativity\Modules\Toolkit\Pipeline\PipelineBuilder; +use Psr\Container\ContainerInterface; +use ReflectionClass; class QueryDispatcher implements IQueryDispatcher { + private readonly IQueryHandlerContainer $handlers; + + private readonly ?IPipeContainer $middleware; + /** * @var array */ private array $pipes = []; public function __construct( - private readonly QueryHandlerContainer $handlers, - private readonly ?PipeContainer $middleware = null, + ContainerInterface|IQueryHandlerContainer $handlers, + ?IPipeContainer $middleware = null, ) { + $this->handlers = $handlers instanceof ContainerInterface ? + new QueryHandlerContainer($handlers) : + $handlers; + + $this->middleware = $middleware === null && $handlers instanceof ContainerInterface + ? new PipeContainer($handlers) + : $middleware; + + $this->autowire(); } /** @@ -77,4 +94,22 @@ private function execute(Query $query): Result return $result; } + + + private function autowire(): void + { + $reflection = new ReflectionClass($this); + + if ($this->handlers instanceof QueryHandlerContainer) { + foreach ($reflection->getAttributes(WithQuery::class) as $attribute) { + $instance = $attribute->newInstance(); + $this->handlers->bind($instance->query, $instance->handler); + } + } + + foreach ($reflection->getAttributes(Through::class) as $attribute) { + $instance = $attribute->newInstance(); + $this->pipes[] = $instance->pipe; + } + } } diff --git a/src/Application/Bus/QueryHandler.php b/src/Application/Bus/QueryHandler.php index 8b4470c..9d0dd50 100644 --- a/src/Application/Bus/QueryHandler.php +++ b/src/Application/Bus/QueryHandler.php @@ -12,13 +12,15 @@ namespace CloudCreativity\Modules\Application\Bus; +use CloudCreativity\Modules\Application\Messages\HandlesMessages; use CloudCreativity\Modules\Contracts\Application\Bus\QueryHandler as IQueryHandler; -use CloudCreativity\Modules\Contracts\Application\Messages\DispatchThroughMiddleware; use CloudCreativity\Modules\Contracts\Toolkit\Messages\Query; use CloudCreativity\Modules\Contracts\Toolkit\Result\Result; final readonly class QueryHandler implements IQueryHandler { + use HandlesMessages; + public function __construct(private object $handler) { } @@ -37,13 +39,4 @@ public function __invoke(Query $query): Result return $result; } - - public function middleware(): array - { - if ($this->handler instanceof DispatchThroughMiddleware) { - return $this->handler->middleware(); - } - - return []; - } } diff --git a/src/Application/Bus/WithQuery.php b/src/Application/Bus/WithQuery.php new file mode 100644 index 0000000..4dafba1 --- /dev/null +++ b/src/Application/Bus/WithQuery.php @@ -0,0 +1,28 @@ + $query + * @param class-string $handler + */ + public function __construct(public string $query, public string $handler) + { + } +} diff --git a/src/Application/Messages/HandlesMessages.php b/src/Application/Messages/HandlesMessages.php new file mode 100644 index 0000000..d26fb50 --- /dev/null +++ b/src/Application/Messages/HandlesMessages.php @@ -0,0 +1,40 @@ +handler); + + foreach ($reflection->getAttributes(Through::class) as $attribute) { + $instance = $attribute->newInstance(); + $middleware[] = $instance->pipe; + } + + if ($this->handler instanceof DispatchThroughMiddleware) { + $middleware = [ + ...$middleware, + ...$this->handler->middleware(), + ]; + } + + return $middleware; + } +} diff --git a/src/Application/Bus/Through.php b/src/Application/Messages/Through.php similarity index 89% rename from src/Application/Bus/Through.php rename to src/Application/Messages/Through.php index b0929a5..51003f5 100644 --- a/src/Application/Bus/Through.php +++ b/src/Application/Messages/Through.php @@ -10,7 +10,7 @@ declare(strict_types=1); -namespace CloudCreativity\Modules\Application\Bus; +namespace CloudCreativity\Modules\Application\Messages; use Attribute; diff --git a/tests/Integration/Application/AddCommand.php b/tests/Integration/Application/Bus/AddCommand.php similarity index 86% rename from tests/Integration/Application/AddCommand.php rename to tests/Integration/Application/Bus/AddCommand.php index e80e95d..2596e23 100644 --- a/tests/Integration/Application/AddCommand.php +++ b/tests/Integration/Application/Bus/AddCommand.php @@ -10,7 +10,7 @@ declare(strict_types=1); -namespace CloudCreativity\Modules\Tests\Integration\Application; +namespace CloudCreativity\Modules\Tests\Integration\Application\Bus; use CloudCreativity\Modules\Contracts\Toolkit\Messages\Command; diff --git a/tests/Integration/Application/AddCommandHandler.php b/tests/Integration/Application/Bus/AddCommandHandler.php similarity index 89% rename from tests/Integration/Application/AddCommandHandler.php rename to tests/Integration/Application/Bus/AddCommandHandler.php index f1b5ff2..b0aa53c 100644 --- a/tests/Integration/Application/AddCommandHandler.php +++ b/tests/Integration/Application/Bus/AddCommandHandler.php @@ -10,7 +10,7 @@ declare(strict_types=1); -namespace CloudCreativity\Modules\Tests\Integration\Application; +namespace CloudCreativity\Modules\Tests\Integration\Application\Bus; use CloudCreativity\Modules\Toolkit\Result\Result; diff --git a/tests/Integration/Application/Bus/DivideQuery.php b/tests/Integration/Application/Bus/DivideQuery.php new file mode 100644 index 0000000..e439eba --- /dev/null +++ b/tests/Integration/Application/Bus/DivideQuery.php @@ -0,0 +1,22 @@ + + */ + public function execute(DivideQuery $query): Result + { + return Result::ok($query->a / $query->b); + } +} diff --git a/tests/Integration/Application/MathCommandBus.php b/tests/Integration/Application/Bus/MathCommandBus.php similarity index 83% rename from tests/Integration/Application/MathCommandBus.php rename to tests/Integration/Application/Bus/MathCommandBus.php index 7be9d9d..e49e13e 100644 --- a/tests/Integration/Application/MathCommandBus.php +++ b/tests/Integration/Application/Bus/MathCommandBus.php @@ -10,12 +10,12 @@ declare(strict_types=1); -namespace CloudCreativity\Modules\Tests\Integration\Application; +namespace CloudCreativity\Modules\Tests\Integration\Application\Bus; use CloudCreativity\Modules\Application\Bus\CommandDispatcher; use CloudCreativity\Modules\Application\Bus\Middleware\LogMessageDispatch; -use CloudCreativity\Modules\Application\Bus\Through; use CloudCreativity\Modules\Application\Bus\WithCommand; +use CloudCreativity\Modules\Application\Messages\Through; #[Through(LogMessageDispatch::class)] #[WithCommand(AddCommand::class, AddCommandHandler::class)] diff --git a/tests/Integration/Application/MathCommandBusTest.php b/tests/Integration/Application/Bus/MathCommandBusTest.php similarity index 95% rename from tests/Integration/Application/MathCommandBusTest.php rename to tests/Integration/Application/Bus/MathCommandBusTest.php index 5f8bed6..6c0d46e 100644 --- a/tests/Integration/Application/MathCommandBusTest.php +++ b/tests/Integration/Application/Bus/MathCommandBusTest.php @@ -10,7 +10,7 @@ declare(strict_types=1); -namespace CloudCreativity\Modules\Tests\Integration\Application; +namespace CloudCreativity\Modules\Tests\Integration\Application\Bus; use CloudCreativity\Modules\Application\Bus\Middleware\ExecuteInUnitOfWork; use CloudCreativity\Modules\Application\Bus\Middleware\LogMessageDispatch; diff --git a/tests/Integration/Application/Bus/MathQueryBus.php b/tests/Integration/Application/Bus/MathQueryBus.php new file mode 100644 index 0000000..fda6c4a --- /dev/null +++ b/tests/Integration/Application/Bus/MathQueryBus.php @@ -0,0 +1,25 @@ +bind(SubtractQueryHandler::class, fn () => new SubtractQueryHandler(3)); + $container->bind(DivideQueryHandler::class, fn () => new DivideQueryHandler()); + $container->bind(LogMessageDispatch::class, fn () => new LogMessageDispatch($container->logger)); + $container->bind('division-modifier', fn () => function (DivideQuery $query, Closure $next): mixed { + $query = new DivideQuery($query->a * 10, $query->b); + return $next($query); + }); + + $bus = new MathQueryBus($container); + + $add = $bus->dispatch(new SubtractQuery(1, 2)); + $multiply = $bus->dispatch(new DivideQuery(12, 3)); + + $this->assertSame(1 - 2 - 3, $add->value()); + $this->assertEquals(120 / 3, $multiply->value()); + $this->assertCount(4, $container->logger); + } +} diff --git a/tests/Integration/Application/MultiplyCommand.php b/tests/Integration/Application/Bus/MultiplyCommand.php similarity index 86% rename from tests/Integration/Application/MultiplyCommand.php rename to tests/Integration/Application/Bus/MultiplyCommand.php index 098eac9..fe63747 100644 --- a/tests/Integration/Application/MultiplyCommand.php +++ b/tests/Integration/Application/Bus/MultiplyCommand.php @@ -10,7 +10,7 @@ declare(strict_types=1); -namespace CloudCreativity\Modules\Tests\Integration\Application; +namespace CloudCreativity\Modules\Tests\Integration\Application\Bus; use CloudCreativity\Modules\Contracts\Toolkit\Messages\Command; diff --git a/tests/Integration/Application/MultiplyCommandHandler.php b/tests/Integration/Application/Bus/MultiplyCommandHandler.php similarity index 83% rename from tests/Integration/Application/MultiplyCommandHandler.php rename to tests/Integration/Application/Bus/MultiplyCommandHandler.php index b715faf..4e67f9f 100644 --- a/tests/Integration/Application/MultiplyCommandHandler.php +++ b/tests/Integration/Application/Bus/MultiplyCommandHandler.php @@ -10,10 +10,10 @@ declare(strict_types=1); -namespace CloudCreativity\Modules\Tests\Integration\Application; +namespace CloudCreativity\Modules\Tests\Integration\Application\Bus; use CloudCreativity\Modules\Application\Bus\Middleware\ExecuteInUnitOfWork; -use CloudCreativity\Modules\Application\Bus\Through; +use CloudCreativity\Modules\Application\Messages\Through; use CloudCreativity\Modules\Toolkit\Result\Result; #[Through(ExecuteInUnitOfWork::class)] diff --git a/tests/Integration/Application/Bus/SubtractQuery.php b/tests/Integration/Application/Bus/SubtractQuery.php new file mode 100644 index 0000000..9983742 --- /dev/null +++ b/tests/Integration/Application/Bus/SubtractQuery.php @@ -0,0 +1,22 @@ + + */ + public function execute(SubtractQuery $query): Result + { + return Result::ok($query->a - $query->b - $this->c); + } +} From dc198f455bb0d66b60a5f82e89c051bb92999a6a Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Thu, 1 Jan 2026 15:16:43 +0000 Subject: [PATCH 5/8] feat: add autowiring via attributes to the inbound event bus --- CHANGELOG.md | 6 +++ src/Application/Bus/CommandDispatcher.php | 2 +- src/Application/Bus/QueryDispatcher.php | 2 +- .../InboundEventBus/EventHandler.php | 13 ++--- .../InboundEventBus/EventHandlerContainer.php | 50 +++++++++++++---- .../InboundEventDispatcher.php | 46 ++++++++++++++-- .../InboundEventBus/WithDefault.php | 26 +++++++++ src/Application/InboundEventBus/WithEvent.php | 28 ++++++++++ src/Application/Messages/HandlesMessages.php | 15 ++---- src/Application/Messages/Through.php | 12 +++-- .../InboundEventBus/DefaultHandler.php | 28 ++++++++++ .../InboundEventBus/MathEventBus.php | 27 ++++++++++ .../InboundEventBus/MathEventBusTest.php | 50 +++++++++++++++++ .../InboundEventBus/NumbersAdded.php | 43 +++++++++++++++ .../InboundEventBus/NumbersAddedHandler.php | 26 +++++++++ .../InboundEventBus/NumbersDivided.php | 43 +++++++++++++++ .../InboundEventBus/NumbersSubtracted.php | 43 +++++++++++++++ .../NumbersSubtractedHandler.php | 30 +++++++++++ .../EventHandlerContainerTest.php | 54 +++++++++++++++++-- 19 files changed, 499 insertions(+), 45 deletions(-) create mode 100644 src/Application/InboundEventBus/WithDefault.php create mode 100644 src/Application/InboundEventBus/WithEvent.php create mode 100644 tests/Integration/Application/InboundEventBus/DefaultHandler.php create mode 100644 tests/Integration/Application/InboundEventBus/MathEventBus.php create mode 100644 tests/Integration/Application/InboundEventBus/MathEventBusTest.php create mode 100644 tests/Integration/Application/InboundEventBus/NumbersAdded.php create mode 100644 tests/Integration/Application/InboundEventBus/NumbersAddedHandler.php create mode 100644 tests/Integration/Application/InboundEventBus/NumbersDivided.php create mode 100644 tests/Integration/Application/InboundEventBus/NumbersSubtracted.php create mode 100644 tests/Integration/Application/InboundEventBus/NumbersSubtractedHandler.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e2eb227..fc6055f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ All notable changes to this project will be documented in this file. This projec container via the first constructor argument. - Queries can now be mapped to handlers on a query bus class via the `WithQuery` attribute. - Middleware can now be added to a query bus via the `Through` attribute. +- New inbound event bus features: + - Can now use a PSR container for the inbound event bus to resolve both handlers and middleware. Inject the service + container via the first constructor argument. + - Integration events can now be mapped to handlers on an inbound event bus class via the `WithEvent` attribute. + - The default handler can be set on the inbound event bus via the `WithDefault` attribute. + - Middleware can now be added to an inbound event bus via the `Through` attribute. - The `QueryHandlerContainer` and `CommandHandlerContainer` classes can now fallback to resolving handlers from a PSR service container. Inject the service container via the handler container's only constructor argument. - The pipeline `PipeContainer` class can now fallback to resolving pipes from a PSR service container. Inject the diff --git a/src/Application/Bus/CommandDispatcher.php b/src/Application/Bus/CommandDispatcher.php index 96d4df2..c7319c1 100644 --- a/src/Application/Bus/CommandDispatcher.php +++ b/src/Application/Bus/CommandDispatcher.php @@ -108,7 +108,7 @@ private function autowire(): void foreach ($reflection->getAttributes(Through::class) as $attribute) { $instance = $attribute->newInstance(); - $this->pipes[] = $instance->pipe; + $this->pipes = $instance->pipes; } } } diff --git a/src/Application/Bus/QueryDispatcher.php b/src/Application/Bus/QueryDispatcher.php index ff9ac0e..bd15dd4 100644 --- a/src/Application/Bus/QueryDispatcher.php +++ b/src/Application/Bus/QueryDispatcher.php @@ -109,7 +109,7 @@ private function autowire(): void foreach ($reflection->getAttributes(Through::class) as $attribute) { $instance = $attribute->newInstance(); - $this->pipes[] = $instance->pipe; + $this->pipes = $instance->pipes; } } } diff --git a/src/Application/InboundEventBus/EventHandler.php b/src/Application/InboundEventBus/EventHandler.php index 1f86055..8e0def6 100644 --- a/src/Application/InboundEventBus/EventHandler.php +++ b/src/Application/InboundEventBus/EventHandler.php @@ -12,12 +12,14 @@ namespace CloudCreativity\Modules\Application\InboundEventBus; +use CloudCreativity\Modules\Application\Messages\HandlesMessages; use CloudCreativity\Modules\Contracts\Application\InboundEventBus\EventHandler as IEventHandler; -use CloudCreativity\Modules\Contracts\Application\Messages\DispatchThroughMiddleware; use CloudCreativity\Modules\Contracts\Toolkit\Messages\IntegrationEvent; final readonly class EventHandler implements IEventHandler { + use HandlesMessages; + public function __construct(private object $handler) { } @@ -32,13 +34,4 @@ public function __invoke(IntegrationEvent $event): void $this->handler->handle($event); } - - public function middleware(): array - { - if ($this->handler instanceof DispatchThroughMiddleware) { - return $this->handler->middleware(); - } - - return []; - } } diff --git a/src/Application/InboundEventBus/EventHandlerContainer.php b/src/Application/InboundEventBus/EventHandlerContainer.php index 5a9cef1..e5007bb 100644 --- a/src/Application/InboundEventBus/EventHandlerContainer.php +++ b/src/Application/InboundEventBus/EventHandlerContainer.php @@ -16,40 +16,68 @@ use CloudCreativity\Modules\Application\ApplicationException; use CloudCreativity\Modules\Contracts\Application\InboundEventBus\EventHandlerContainer as IEventHandlerContainer; use CloudCreativity\Modules\Contracts\Toolkit\Messages\IntegrationEvent; +use Psr\Container\ContainerInterface; final class EventHandlerContainer implements IEventHandlerContainer { /** - * @var array, Closure> + * @var array, Closure|non-empty-string> */ private array $bindings = []; /** - * @param ?Closure(): object $default + * @param (Closure(): object)|non-empty-string|null $default */ - public function __construct(private readonly ?Closure $default = null) - { + public function __construct( + private Closure|string|null $default = null, + private readonly ?ContainerInterface $container = null, + ) { } /** * Bind a handler factory into the container. * * @param class-string $eventName - * @param Closure(): object $binding + * @param (Closure(): object)|non-empty-string $binding */ - public function bind(string $eventName, Closure $binding): void + public function bind(string $eventName, Closure|string $binding): void { + if (is_string($binding) && $this->container === null) { + throw new ApplicationException('Cannot use a string event handler binding without a PSR container.'); + } + $this->bindings[$eventName] = $binding; } + /** + * Bind a default handler factory into the container. + * + * @param (Closure(): object)|non-empty-string $binding + */ + public function withDefault(Closure|string $binding): void + { + if ($this->default === null) { + $this->default = $binding; + return; + } + + throw new ApplicationException('Default event handler binding is already set.'); + } + public function get(string $eventName): EventHandler { - $factory = $this->bindings[$eventName] ?? $this->default; + $binding = $this->bindings[$eventName] ?? $this->default; + + if ($binding instanceof Closure) { + $instance = $binding(); + assert(is_object($instance), "Handler binding for integration event {$eventName} must return an object."); + return new EventHandler($instance); + } - if ($factory) { - $handler = $factory(); - assert(is_object($handler), "Handler binding for integration event {$eventName} must return an object."); - return new EventHandler($handler); + if (is_string($binding)) { + $instance = $this->container?->get($binding); + assert(is_object($instance), "PSR container event handler binding {$binding} is not an object."); + return new EventHandler($instance); } throw new ApplicationException('No handler bound for integration event: ' . $eventName); diff --git a/src/Application/InboundEventBus/InboundEventDispatcher.php b/src/Application/InboundEventBus/InboundEventDispatcher.php index 691905c..6b13393 100644 --- a/src/Application/InboundEventBus/InboundEventDispatcher.php +++ b/src/Application/InboundEventBus/InboundEventDispatcher.php @@ -12,24 +12,40 @@ namespace CloudCreativity\Modules\Application\InboundEventBus; -use CloudCreativity\Modules\Contracts\Application\InboundEventBus\EventHandlerContainer; +use CloudCreativity\Modules\Application\Messages\Through; +use CloudCreativity\Modules\Contracts\Application\InboundEventBus\EventHandlerContainer as IEventHandlerContainer; use CloudCreativity\Modules\Contracts\Application\Ports\Driving\InboundEventDispatcher as IInboundEventDispatcher; use CloudCreativity\Modules\Contracts\Toolkit\Messages\IntegrationEvent; -use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer; +use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer as IPipeContainer; use CloudCreativity\Modules\Toolkit\Pipeline\MiddlewareProcessor; +use CloudCreativity\Modules\Toolkit\Pipeline\PipeContainer; use CloudCreativity\Modules\Toolkit\Pipeline\PipelineBuilder; +use Psr\Container\ContainerInterface; class InboundEventDispatcher implements IInboundEventDispatcher { + private readonly IEventHandlerContainer $handlers; + + private readonly ?IPipeContainer $middleware; + /** * @var array */ private array $pipes = []; public function __construct( - private readonly EventHandlerContainer $handlers, - private readonly ?PipeContainer $middleware = null, + ContainerInterface|IEventHandlerContainer $handlers, + ?IPipeContainer $middleware = null, ) { + $this->handlers = $handlers instanceof ContainerInterface ? + new EventHandlerContainer(container: $handlers) : + $handlers; + + $this->middleware = $middleware === null && $handlers instanceof ContainerInterface ? + new PipeContainer(container: $handlers) : + $middleware; + + $this->autowire(); } /** @@ -65,4 +81,26 @@ private function execute(IntegrationEvent $event): void $pipeline->process($event); } + + private function autowire(): void + { + $reflection = new \ReflectionClass($this); + + if ($this->handlers instanceof EventHandlerContainer) { + foreach ($reflection->getAttributes(WithEvent::class) as $attribute) { + $instance = $attribute->newInstance(); + $this->handlers->bind($instance->event, $instance->handler); + } + + foreach ($reflection->getAttributes(WithDefault::class) as $attribute) { + $instance = $attribute->newInstance(); + $this->handlers->withDefault($instance->handler); + } + } + + foreach ($reflection->getAttributes(Through::class) as $attribute) { + $instance = $attribute->newInstance(); + $this->pipes = $instance->pipes; + } + } } diff --git a/src/Application/InboundEventBus/WithDefault.php b/src/Application/InboundEventBus/WithDefault.php new file mode 100644 index 0000000..2b91975 --- /dev/null +++ b/src/Application/InboundEventBus/WithDefault.php @@ -0,0 +1,26 @@ + $event + * @param class-string $handler + */ + public function __construct(public string $event, public string $handler) + { + } +} diff --git a/src/Application/Messages/HandlesMessages.php b/src/Application/Messages/HandlesMessages.php index d26fb50..a091724 100644 --- a/src/Application/Messages/HandlesMessages.php +++ b/src/Application/Messages/HandlesMessages.php @@ -19,22 +19,17 @@ trait HandlesMessages { public function middleware(): array { - $middleware = []; + if ($this->handler instanceof DispatchThroughMiddleware) { + return $this->handler->middleware(); + } $reflection = new ReflectionClass($this->handler); foreach ($reflection->getAttributes(Through::class) as $attribute) { $instance = $attribute->newInstance(); - $middleware[] = $instance->pipe; - } - - if ($this->handler instanceof DispatchThroughMiddleware) { - $middleware = [ - ...$middleware, - ...$this->handler->middleware(), - ]; + return $instance->pipes; } - return $middleware; + return []; } } diff --git a/src/Application/Messages/Through.php b/src/Application/Messages/Through.php index 51003f5..658d62a 100644 --- a/src/Application/Messages/Through.php +++ b/src/Application/Messages/Through.php @@ -14,13 +14,19 @@ use Attribute; -#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] +#[Attribute(Attribute::TARGET_CLASS)] final readonly class Through { /** - * @param non-empty-string $pipe + * @var list */ - public function __construct(public string $pipe) + public array $pipes; + + /** + * @param non-empty-string ...$pipes + */ + public function __construct(string ...$pipes) { + $this->pipes = array_values($pipes); } } diff --git a/tests/Integration/Application/InboundEventBus/DefaultHandler.php b/tests/Integration/Application/InboundEventBus/DefaultHandler.php new file mode 100644 index 0000000..73f6f21 --- /dev/null +++ b/tests/Integration/Application/InboundEventBus/DefaultHandler.php @@ -0,0 +1,28 @@ + + */ + public array $handled = []; + + public function handle(IntegrationEvent $event): void + { + $this->handled[] = $event; + } +} diff --git a/tests/Integration/Application/InboundEventBus/MathEventBus.php b/tests/Integration/Application/InboundEventBus/MathEventBus.php new file mode 100644 index 0000000..d3e275d --- /dev/null +++ b/tests/Integration/Application/InboundEventBus/MathEventBus.php @@ -0,0 +1,27 @@ +bind(NumbersAddedHandler::class, fn () => $a); + $container->bind(NumbersSubtractedHandler::class, fn () => $b); + $container->bind(DefaultHandler::class, fn () => $c); + $container->bind(LogInboundEvent::class, fn () => new LogInboundEvent($container->logger)); + $container->bind(HandleInUnitOfWork::class, fn () => new HandleInUnitOfWork( + new UnitOfWorkManager($container->unitOfWork), + )); + + $bus = new MathEventBus($container); + + $bus->dispatch($ev1 = new NumbersAdded(1, 2, 3)); + $bus->dispatch($ev2 = new NumbersSubtracted(10, 6, 4)); + $bus->dispatch($ev3 = new NumbersDivided(12, 3, 4)); + + $this->assertSame([$ev1], $a->handled); + $this->assertSame([$ev2], $b->handled); + $this->assertSame([$ev3], $c->handled); + $this->assertCount(6, $container->logger); + $this->assertSame(1, $container->unitOfWork->commits); + } +} diff --git a/tests/Integration/Application/InboundEventBus/NumbersAdded.php b/tests/Integration/Application/InboundEventBus/NumbersAdded.php new file mode 100644 index 0000000..271af6d --- /dev/null +++ b/tests/Integration/Application/InboundEventBus/NumbersAdded.php @@ -0,0 +1,43 @@ +uuid = Uuid::random(); + } + + public function getUuid(): Uuid + { + return $this->uuid; + } + + public function getOccurredAt(): DateTimeImmutable + { + return $this->calculatedAt; + } +} diff --git a/tests/Integration/Application/InboundEventBus/NumbersAddedHandler.php b/tests/Integration/Application/InboundEventBus/NumbersAddedHandler.php new file mode 100644 index 0000000..686dd94 --- /dev/null +++ b/tests/Integration/Application/InboundEventBus/NumbersAddedHandler.php @@ -0,0 +1,26 @@ + + */ + public array $handled = []; + + public function handle(NumbersAdded $event): void + { + $this->handled[] = $event; + } +} diff --git a/tests/Integration/Application/InboundEventBus/NumbersDivided.php b/tests/Integration/Application/InboundEventBus/NumbersDivided.php new file mode 100644 index 0000000..020aae9 --- /dev/null +++ b/tests/Integration/Application/InboundEventBus/NumbersDivided.php @@ -0,0 +1,43 @@ +uuid = Uuid::random(); + } + + public function getUuid(): Uuid + { + return $this->uuid; + } + + public function getOccurredAt(): DateTimeImmutable + { + return $this->calculatedAt; + } +} diff --git a/tests/Integration/Application/InboundEventBus/NumbersSubtracted.php b/tests/Integration/Application/InboundEventBus/NumbersSubtracted.php new file mode 100644 index 0000000..112fd56 --- /dev/null +++ b/tests/Integration/Application/InboundEventBus/NumbersSubtracted.php @@ -0,0 +1,43 @@ +uuid = Uuid::random(); + } + + public function getUuid(): Uuid + { + return $this->uuid; + } + + public function getOccurredAt(): DateTimeImmutable + { + return $this->calculatedAt; + } +} diff --git a/tests/Integration/Application/InboundEventBus/NumbersSubtractedHandler.php b/tests/Integration/Application/InboundEventBus/NumbersSubtractedHandler.php new file mode 100644 index 0000000..b4ec0a9 --- /dev/null +++ b/tests/Integration/Application/InboundEventBus/NumbersSubtractedHandler.php @@ -0,0 +1,30 @@ + + */ + public array $handled = []; + + public function handle(NumbersSubtracted $event): void + { + $this->handled[] = $event; + } +} diff --git a/tests/Unit/Application/InboundEventBus/EventHandlerContainerTest.php b/tests/Unit/Application/InboundEventBus/EventHandlerContainerTest.php index 335520e..f82069d 100644 --- a/tests/Unit/Application/InboundEventBus/EventHandlerContainerTest.php +++ b/tests/Unit/Application/InboundEventBus/EventHandlerContainerTest.php @@ -12,17 +12,19 @@ namespace CloudCreativity\Modules\Tests\Unit\Application\InboundEventBus; +use CloudCreativity\Modules\Application\ApplicationException; use CloudCreativity\Modules\Application\InboundEventBus\EventHandler; use CloudCreativity\Modules\Application\InboundEventBus\EventHandlerContainer; use CloudCreativity\Modules\Tests\Unit\Infrastructure\OutboundEventBus\TestOutboundEvent; use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; class EventHandlerContainerTest extends TestCase { - public function testItHasHandlers(): void + public function testItHasHandlerBindings(): void { $a = new TestEventHandler(); - $b = $this->createMock(TestEventHandler::class); + $b = $this->createStub(TestEventHandler::class); $container = new EventHandlerContainer(); $container->bind(TestInboundEvent::class, fn () => $a); @@ -32,10 +34,33 @@ public function testItHasHandlers(): void $this->assertEquals(new EventHandler($b), $container->get(TestOutboundEvent::class)); } - public function testItHasDefaultHandler(): void + public function testItUsesPsrContainer(): void { $a = new TestEventHandler(); - $b = $this->createMock(TestEventHandler::class); + $b = $this->createStub(TestEventHandler::class); + + $psrContainer = $this->createMock(ContainerInterface::class); + $psrContainer + ->expects($this->exactly(2)) + ->method('get') + ->willReturnCallback(fn (string $class) => match ($class) { + $a::class => $a, + $b::class => $b, + default => $this->fail('Unexpected class requested: ' . $class), + }); + + $container = new EventHandlerContainer(container: $psrContainer); + $container->bind(TestInboundEvent::class, $a::class); + $container->bind(TestOutboundEvent::class, $b::class); + + $this->assertEquals(new EventHandler($a), $container->get(TestInboundEvent::class)); + $this->assertEquals(new EventHandler($b), $container->get(TestOutboundEvent::class)); + } + + public function testItHasBoundDefaultHandler(): void + { + $a = new TestEventHandler(); + $b = $this->createStub(TestEventHandler::class); $container = new EventHandlerContainer(default: fn () => $b); $container->bind(TestInboundEvent::class, fn () => $a); @@ -44,13 +69,32 @@ public function testItHasDefaultHandler(): void $this->assertEquals(new EventHandler($b), $container->get(TestOutboundEvent::class)); } + public function testItHasDefaultHandlerInPsrContainer(): void + { + $a = new TestEventHandler(); + $b = $this->createStub(TestEventHandler::class); + + $psrContainer = $this->createMock(ContainerInterface::class); + $psrContainer + ->expects($this->once()) + ->method('get') + ->with('default-handler') + ->willReturn($b); + + $container = new EventHandlerContainer(default: 'default-handler', container: $psrContainer); + $container->bind(TestInboundEvent::class, fn () => $a); + + $this->assertEquals(new EventHandler($a), $container->get(TestInboundEvent::class)); + $this->assertEquals(new EventHandler($b), $container->get(TestOutboundEvent::class)); + } + public function testItDoesNotHaveHandler(): void { $container = new EventHandlerContainer(); $container->bind(TestInboundEvent::class, fn () => new TestEventHandler()); - $this->expectException(\RuntimeException::class); + $this->expectException(ApplicationException::class); $this->expectExceptionMessage( 'No handler bound for integration event: ' . TestOutboundEvent::class, ); From d7818d6e060174f8aeabd0c620ff089b3e03bc3c Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Thu, 1 Jan 2026 15:51:02 +0000 Subject: [PATCH 6/8] feat: support psr container and autowiring on closure publisher --- CHANGELOG.md | 2 + deptrac.yaml | 1 + src/Application/Bus/CommandDispatcher.php | 2 +- src/Application/Bus/QueryDispatcher.php | 2 +- .../InboundEventDispatcher.php | 2 +- src/Application/Messages/HandlesMessages.php | 1 + .../OutboundEventBus/ClosurePublisher.php | 16 ++- src/Toolkit/Pipeline/PipelineBuilder.php | 11 ++- .../Messages => Toolkit/Pipeline}/Through.php | 2 +- .../Application/Bus/DivideQueryHandler.php | 2 +- .../Application/Bus/MathCommandBus.php | 2 +- .../Application/Bus/MathQueryBus.php | 2 +- .../Bus/MultiplyCommandHandler.php | 2 +- .../InboundEventBus/MathEventBus.php | 2 +- .../NumbersSubtractedHandler.php | 2 +- .../OutboundEventBus/TestClosurePublisher.php | 22 +++++ .../TestClosurePublisherTest.php | 42 ++++++++ .../OutboundEventBus/ClosurePublisherTest.php | 97 +++++++++++++------ 18 files changed, 166 insertions(+), 46 deletions(-) rename src/{Application/Messages => Toolkit/Pipeline}/Through.php (90%) create mode 100644 tests/Integration/Infrastructure/OutboundEventBus/TestClosurePublisher.php create mode 100644 tests/Integration/Infrastructure/OutboundEventBus/TestClosurePublisherTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index fc6055f..953cfbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ All notable changes to this project will be documented in this file. This projec service container. Inject the service container via the handler container's only constructor argument. - The pipeline `PipeContainer` class can now fallback to resolving pipes from a PSR service container. Inject the service container via the pipe container's only constructor argument. +- The outbound event bus `ClosurePublisher` class now accepts a PSR container for its middleware. Additionally, + middleware can be set on instances of closure publishers via the `Through` attribute. - The `FakeUnitOfWork` class now has integer properties for the number of attempts, commits and rollbacks. - New `FakeContainer` class for faking a PSR container in tests. diff --git a/deptrac.yaml b/deptrac.yaml index 5e37a68..7ce16fe 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -57,5 +57,6 @@ deptrac: - Toolkit - Domain - Application + - PSR Container - PSR Log - Attributes diff --git a/src/Application/Bus/CommandDispatcher.php b/src/Application/Bus/CommandDispatcher.php index c7319c1..3d975b9 100644 --- a/src/Application/Bus/CommandDispatcher.php +++ b/src/Application/Bus/CommandDispatcher.php @@ -12,7 +12,6 @@ namespace CloudCreativity\Modules\Application\Bus; -use CloudCreativity\Modules\Application\Messages\Through; use CloudCreativity\Modules\Contracts\Application\Bus\CommandHandlerContainer as ICommandHandlerContainer; use CloudCreativity\Modules\Contracts\Application\Ports\Driving\CommandDispatcher as ICommandDispatcher; use CloudCreativity\Modules\Contracts\Toolkit\Messages\Command; @@ -21,6 +20,7 @@ use CloudCreativity\Modules\Toolkit\Pipeline\MiddlewareProcessor; use CloudCreativity\Modules\Toolkit\Pipeline\PipeContainer; use CloudCreativity\Modules\Toolkit\Pipeline\PipelineBuilder; +use CloudCreativity\Modules\Toolkit\Pipeline\Through; use Psr\Container\ContainerInterface; use ReflectionClass; diff --git a/src/Application/Bus/QueryDispatcher.php b/src/Application/Bus/QueryDispatcher.php index bd15dd4..dbc9e10 100644 --- a/src/Application/Bus/QueryDispatcher.php +++ b/src/Application/Bus/QueryDispatcher.php @@ -12,7 +12,6 @@ namespace CloudCreativity\Modules\Application\Bus; -use CloudCreativity\Modules\Application\Messages\Through; use CloudCreativity\Modules\Contracts\Application\Bus\QueryHandlerContainer as IQueryHandlerContainer; use CloudCreativity\Modules\Contracts\Application\Ports\Driving\QueryDispatcher as IQueryDispatcher; use CloudCreativity\Modules\Contracts\Toolkit\Messages\Query; @@ -21,6 +20,7 @@ use CloudCreativity\Modules\Toolkit\Pipeline\MiddlewareProcessor; use CloudCreativity\Modules\Toolkit\Pipeline\PipeContainer; use CloudCreativity\Modules\Toolkit\Pipeline\PipelineBuilder; +use CloudCreativity\Modules\Toolkit\Pipeline\Through; use Psr\Container\ContainerInterface; use ReflectionClass; diff --git a/src/Application/InboundEventBus/InboundEventDispatcher.php b/src/Application/InboundEventBus/InboundEventDispatcher.php index 6b13393..60f4da5 100644 --- a/src/Application/InboundEventBus/InboundEventDispatcher.php +++ b/src/Application/InboundEventBus/InboundEventDispatcher.php @@ -12,7 +12,6 @@ namespace CloudCreativity\Modules\Application\InboundEventBus; -use CloudCreativity\Modules\Application\Messages\Through; use CloudCreativity\Modules\Contracts\Application\InboundEventBus\EventHandlerContainer as IEventHandlerContainer; use CloudCreativity\Modules\Contracts\Application\Ports\Driving\InboundEventDispatcher as IInboundEventDispatcher; use CloudCreativity\Modules\Contracts\Toolkit\Messages\IntegrationEvent; @@ -20,6 +19,7 @@ use CloudCreativity\Modules\Toolkit\Pipeline\MiddlewareProcessor; use CloudCreativity\Modules\Toolkit\Pipeline\PipeContainer; use CloudCreativity\Modules\Toolkit\Pipeline\PipelineBuilder; +use CloudCreativity\Modules\Toolkit\Pipeline\Through; use Psr\Container\ContainerInterface; class InboundEventDispatcher implements IInboundEventDispatcher diff --git a/src/Application/Messages/HandlesMessages.php b/src/Application/Messages/HandlesMessages.php index a091724..67b3b72 100644 --- a/src/Application/Messages/HandlesMessages.php +++ b/src/Application/Messages/HandlesMessages.php @@ -13,6 +13,7 @@ namespace CloudCreativity\Modules\Application\Messages; use CloudCreativity\Modules\Contracts\Application\Messages\DispatchThroughMiddleware; +use CloudCreativity\Modules\Toolkit\Pipeline\Through; use ReflectionClass; trait HandlesMessages diff --git a/src/Infrastructure/OutboundEventBus/ClosurePublisher.php b/src/Infrastructure/OutboundEventBus/ClosurePublisher.php index a541298..3b661cd 100644 --- a/src/Infrastructure/OutboundEventBus/ClosurePublisher.php +++ b/src/Infrastructure/OutboundEventBus/ClosurePublisher.php @@ -18,6 +18,9 @@ use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer; use CloudCreativity\Modules\Toolkit\Pipeline\MiddlewareProcessor; use CloudCreativity\Modules\Toolkit\Pipeline\PipelineBuilder; +use CloudCreativity\Modules\Toolkit\Pipeline\Through; +use Psr\Container\ContainerInterface; +use ReflectionClass; class ClosurePublisher implements OutboundEventPublisher { @@ -33,8 +36,9 @@ class ClosurePublisher implements OutboundEventPublisher public function __construct( private readonly Closure $fn, - private readonly ?PipeContainer $middleware = null, + private readonly ContainerInterface|PipeContainer|null $middleware = null, ) { + $this->autowire(); } /** @@ -67,4 +71,14 @@ public function publish(IntegrationEvent $event): void $pipeline->process($event); } + + private function autowire(): void + { + $reflection = new ReflectionClass($this); + + foreach ($reflection->getAttributes(Through::class) as $attribute) { + $instance = $attribute->newInstance(); + $this->pipes = $instance->pipes; + } + } } diff --git a/src/Toolkit/Pipeline/PipelineBuilder.php b/src/Toolkit/Pipeline/PipelineBuilder.php index 7b901e4..15daa1b 100644 --- a/src/Toolkit/Pipeline/PipelineBuilder.php +++ b/src/Toolkit/Pipeline/PipelineBuilder.php @@ -12,9 +12,10 @@ namespace CloudCreativity\Modules\Toolkit\Pipeline; -use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer; +use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer as IPipeContainer; use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipelineBuilder as IPipelineBuilder; use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\Processor; +use Psr\Container\ContainerInterface; use RuntimeException; final class PipelineBuilder implements IPipelineBuilder @@ -24,12 +25,16 @@ final class PipelineBuilder implements IPipelineBuilder */ private array $stages = []; - public static function make(?PipeContainer $container = null): self + public static function make(ContainerInterface|IPipeContainer|null $container = null): self { + if ($container instanceof ContainerInterface) { + return new self(new PipeContainer($container)); + } + return new self($container); } - public function __construct(private readonly ?PipeContainer $container = null) + public function __construct(private readonly ?IPipeContainer $container = null) { } diff --git a/src/Application/Messages/Through.php b/src/Toolkit/Pipeline/Through.php similarity index 90% rename from src/Application/Messages/Through.php rename to src/Toolkit/Pipeline/Through.php index 658d62a..090a7dc 100644 --- a/src/Application/Messages/Through.php +++ b/src/Toolkit/Pipeline/Through.php @@ -10,7 +10,7 @@ declare(strict_types=1); -namespace CloudCreativity\Modules\Application\Messages; +namespace CloudCreativity\Modules\Toolkit\Pipeline; use Attribute; diff --git a/tests/Integration/Application/Bus/DivideQueryHandler.php b/tests/Integration/Application/Bus/DivideQueryHandler.php index b8aa168..d32520b 100644 --- a/tests/Integration/Application/Bus/DivideQueryHandler.php +++ b/tests/Integration/Application/Bus/DivideQueryHandler.php @@ -12,7 +12,7 @@ namespace CloudCreativity\Modules\Tests\Integration\Application\Bus; -use CloudCreativity\Modules\Application\Messages\Through; +use CloudCreativity\Modules\Toolkit\Pipeline\Through; use CloudCreativity\Modules\Toolkit\Result\Result; #[Through('division-modifier')] diff --git a/tests/Integration/Application/Bus/MathCommandBus.php b/tests/Integration/Application/Bus/MathCommandBus.php index e49e13e..dfad23f 100644 --- a/tests/Integration/Application/Bus/MathCommandBus.php +++ b/tests/Integration/Application/Bus/MathCommandBus.php @@ -15,7 +15,7 @@ use CloudCreativity\Modules\Application\Bus\CommandDispatcher; use CloudCreativity\Modules\Application\Bus\Middleware\LogMessageDispatch; use CloudCreativity\Modules\Application\Bus\WithCommand; -use CloudCreativity\Modules\Application\Messages\Through; +use CloudCreativity\Modules\Toolkit\Pipeline\Through; #[Through(LogMessageDispatch::class)] #[WithCommand(AddCommand::class, AddCommandHandler::class)] diff --git a/tests/Integration/Application/Bus/MathQueryBus.php b/tests/Integration/Application/Bus/MathQueryBus.php index fda6c4a..0cf831c 100644 --- a/tests/Integration/Application/Bus/MathQueryBus.php +++ b/tests/Integration/Application/Bus/MathQueryBus.php @@ -15,7 +15,7 @@ use CloudCreativity\Modules\Application\Bus\Middleware\LogMessageDispatch; use CloudCreativity\Modules\Application\Bus\QueryDispatcher; use CloudCreativity\Modules\Application\Bus\WithQuery; -use CloudCreativity\Modules\Application\Messages\Through; +use CloudCreativity\Modules\Toolkit\Pipeline\Through; #[Through(LogMessageDispatch::class)] #[WithQuery(DivideQuery::class, DivideQueryHandler::class)] diff --git a/tests/Integration/Application/Bus/MultiplyCommandHandler.php b/tests/Integration/Application/Bus/MultiplyCommandHandler.php index 4e67f9f..a76e4e8 100644 --- a/tests/Integration/Application/Bus/MultiplyCommandHandler.php +++ b/tests/Integration/Application/Bus/MultiplyCommandHandler.php @@ -13,7 +13,7 @@ namespace CloudCreativity\Modules\Tests\Integration\Application\Bus; use CloudCreativity\Modules\Application\Bus\Middleware\ExecuteInUnitOfWork; -use CloudCreativity\Modules\Application\Messages\Through; +use CloudCreativity\Modules\Toolkit\Pipeline\Through; use CloudCreativity\Modules\Toolkit\Result\Result; #[Through(ExecuteInUnitOfWork::class)] diff --git a/tests/Integration/Application/InboundEventBus/MathEventBus.php b/tests/Integration/Application/InboundEventBus/MathEventBus.php index d3e275d..cd56b7d 100644 --- a/tests/Integration/Application/InboundEventBus/MathEventBus.php +++ b/tests/Integration/Application/InboundEventBus/MathEventBus.php @@ -16,7 +16,7 @@ use CloudCreativity\Modules\Application\InboundEventBus\Middleware\LogInboundEvent; use CloudCreativity\Modules\Application\InboundEventBus\WithDefault; use CloudCreativity\Modules\Application\InboundEventBus\WithEvent; -use CloudCreativity\Modules\Application\Messages\Through; +use CloudCreativity\Modules\Toolkit\Pipeline\Through; #[Through(LogInboundEvent::class)] #[WithDefault(DefaultHandler::class)] diff --git a/tests/Integration/Application/InboundEventBus/NumbersSubtractedHandler.php b/tests/Integration/Application/InboundEventBus/NumbersSubtractedHandler.php index b4ec0a9..e298768 100644 --- a/tests/Integration/Application/InboundEventBus/NumbersSubtractedHandler.php +++ b/tests/Integration/Application/InboundEventBus/NumbersSubtractedHandler.php @@ -13,7 +13,7 @@ namespace CloudCreativity\Modules\Tests\Integration\Application\InboundEventBus; use CloudCreativity\Modules\Application\InboundEventBus\Middleware\HandleInUnitOfWork; -use CloudCreativity\Modules\Application\Messages\Through; +use CloudCreativity\Modules\Toolkit\Pipeline\Through; #[Through(HandleInUnitOfWork::class)] final class NumbersSubtractedHandler diff --git a/tests/Integration/Infrastructure/OutboundEventBus/TestClosurePublisher.php b/tests/Integration/Infrastructure/OutboundEventBus/TestClosurePublisher.php new file mode 100644 index 0000000..3497b9f --- /dev/null +++ b/tests/Integration/Infrastructure/OutboundEventBus/TestClosurePublisher.php @@ -0,0 +1,22 @@ +bind(LogOutboundEvent::class, fn () => new LogOutboundEvent($container->logger)); + + $publisher = new TestClosurePublisher( + fn: function ($event) use (&$published) { + $published[] = $event; + }, + middleware: $container, + ); + + $event = new NumbersAdded(1, 2, 3); + $publisher->publish($event); + + $this->assertSame([$event], $published); + $this->assertCount(2, $container->logger); + } +} diff --git a/tests/Unit/Infrastructure/OutboundEventBus/ClosurePublisherTest.php b/tests/Unit/Infrastructure/OutboundEventBus/ClosurePublisherTest.php index 338d799..bf8366c 100644 --- a/tests/Unit/Infrastructure/OutboundEventBus/ClosurePublisherTest.php +++ b/tests/Unit/Infrastructure/OutboundEventBus/ClosurePublisherTest.php @@ -13,55 +13,40 @@ namespace CloudCreativity\Modules\Tests\Unit\Infrastructure\OutboundEventBus; use CloudCreativity\Modules\Contracts\Toolkit\Messages\IntegrationEvent; -use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer; +use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer as IPipeContainer; use CloudCreativity\Modules\Infrastructure\OutboundEventBus\ClosurePublisher; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; class ClosurePublisherTest extends TestCase { - private MockObject&PipeContainer $middleware; - /** * @var array */ private array $actual = []; - private ClosurePublisher $publisher; - - protected function setUp(): void - { - parent::setUp(); - - $this->publisher = new ClosurePublisher( - function (IntegrationEvent $event): void { - $this->actual[] = $event; - }, - $this->middleware = $this->createMock(PipeContainer::class), - ); - } - protected function tearDown(): void { parent::tearDown(); - unset($this->publisher, $this->middleware, $this->actual); + unset($this->actual); } public function test(): void { - $event = $this->createMock(IntegrationEvent::class); + $event = $this->createStub(IntegrationEvent::class); - $this->publisher->publish($event); + $publisher = $this->createPublisher(); + $publisher->publish($event); $this->assertSame([$event], $this->actual); } public function testWithMiddleware(): void { - $event1 = $this->createMock(IntegrationEvent::class); - $event2 = $this->createMock(IntegrationEvent::class); - $event3 = $this->createMock(IntegrationEvent::class); - $event4 = $this->createMock(IntegrationEvent::class); + $event1 = $this->createStub(IntegrationEvent::class); + $event2 = $this->createStub(IntegrationEvent::class); + $event3 = $this->createStub(IntegrationEvent::class); + $event4 = $this->createStub(IntegrationEvent::class); $middleware1 = function ($event, \Closure $next) use ($event1, $event2) { $this->assertSame($event1, $event); @@ -78,44 +63,92 @@ public function testWithMiddleware(): void return $next($event4); }; - $this->middleware + $middleware = $this->createMock(IPipeContainer::class); + $middleware ->expects($this->once()) ->method('get') ->with('MySecondMiddleware') ->willReturn($middleware2); - $this->publisher->through([ + $publisher = $this->createPublisher($middleware); + $publisher->through([ $middleware1, 'MySecondMiddleware', $middleware3, ]); - $this->publisher->publish($event1); + $publisher->publish($event1); $this->assertSame([$event4], $this->actual); } + public function testWithMiddlewareViaPsrContainer(): void + { + $event1 = $this->createStub(IntegrationEvent::class); + $event2 = $this->createStub(IntegrationEvent::class); + $event3 = $this->createStub(IntegrationEvent::class); + + $middleware1 = function ($event, \Closure $next) use ($event1, $event2) { + $this->assertSame($event1, $event); + return $next($event2); + }; + + $middleware2 = function ($event, \Closure $next) use ($event2, $event3) { + $this->assertSame($event2, $event); + return $next($event3); + }; + + $psrContainer = $this->createMock(ContainerInterface::class); + $psrContainer + ->expects($this->once()) + ->method('get') + ->with('MySecondMiddleware') + ->willReturn($middleware2); + + $publisher = $this->createPublisher($psrContainer); + $publisher->through([ + $middleware1, + 'MySecondMiddleware', + ]); + + $publisher->publish($event1); + + $this->assertSame([$event3], $this->actual); + } + public function testWithAlternativeHandlers(): void { $expected = new TestOutboundEvent(); - $mock = $this->createMock(IntegrationEvent::class); + $stub = $this->createStub(IntegrationEvent::class); $actual = null; - $this->publisher->bind($mock::class, function (): never { + $publisher = $this->createPublisher(); + + $publisher->bind($stub::class, function (): never { $this->fail('Not expecting this closure to be called.'); }); - $this->publisher->bind( + $publisher->bind( TestOutboundEvent::class, function (TestOutboundEvent $in) use (&$actual) { $actual = $in; }, ); - $this->publisher->publish($expected); + $publisher->publish($expected); $this->assertEmpty($this->actual); $this->assertSame($expected, $actual); } + + private function createPublisher(ContainerInterface|IPipeContainer|null $middleware = null): ClosurePublisher + { + return new ClosurePublisher( + function (IntegrationEvent $event): void { + $this->actual[] = $event; + }, + $middleware, + ); + } } From d42e69c605b2b6d96a1c610e92538efae084a86e Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Thu, 1 Jan 2026 16:31:13 +0000 Subject: [PATCH 7/8] feat: add autowiring via attributes to the outbound event publisher --- CHANGELOG.md | 14 +++- .../OutboundEventBus/ComponentPublisher.php | 45 ++++++++++-- .../PublisherHandlerContainer.php | 53 ++++++++++++--- .../OutboundEventBus/Publishes.php | 28 ++++++++ .../OutboundEventBus/WithDefault.php | 26 +++++++ .../OutboundEventBus/DefaultPublisher.php | 28 ++++++++ .../OutboundEventBus/MathEventPublisher.php | 29 ++++++++ .../MathEventPublisherTest.php | 46 +++++++++++++ .../NumbersAddedPublisher.php | 28 ++++++++ .../NumbersSubtractedPublisher.php | 28 ++++++++ .../PublisherHandlerContainerTest.php | 68 +++++++++++++++++-- 11 files changed, 374 insertions(+), 19 deletions(-) create mode 100644 src/Infrastructure/OutboundEventBus/Publishes.php create mode 100644 src/Infrastructure/OutboundEventBus/WithDefault.php create mode 100644 tests/Integration/Infrastructure/OutboundEventBus/DefaultPublisher.php create mode 100644 tests/Integration/Infrastructure/OutboundEventBus/MathEventPublisher.php create mode 100644 tests/Integration/Infrastructure/OutboundEventBus/MathEventPublisherTest.php create mode 100644 tests/Integration/Infrastructure/OutboundEventBus/NumbersAddedPublisher.php create mode 100644 tests/Integration/Infrastructure/OutboundEventBus/NumbersSubtractedPublisher.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 953cfbb..3f10507 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,8 +23,18 @@ All notable changes to this project will be documented in this file. This projec - Integration events can now be mapped to handlers on an inbound event bus class via the `WithEvent` attribute. - The default handler can be set on the inbound event bus via the `WithDefault` attribute. - Middleware can now be added to an inbound event bus via the `Through` attribute. -- The `QueryHandlerContainer` and `CommandHandlerContainer` classes can now fallback to resolving handlers from a PSR - service container. Inject the service container via the handler container's only constructor argument. +- New outbound event bus features, when using the publisher handler container: + - Can now use a PSR container for the outbound event bus to resolve both handlers and middleware. Inject the service + container via the first constructor argument. + - Integration events can now be mapped to handlers on a publisher handler container class via the `Publishes` + attribute. + - The default handler can be set on the outbound event publisher via the `WithDefault` attribute. + - Middleware can now be added to a publisher handler container via the `Through` attribute. +- In the Application layer, the `QueryHandlerContainer`, `CommandHandlerContainer` and `EventHandlerContainer` classes + can now fallback to resolving handlers from a PSR service container. Inject the service container via their + constructors. +- In the Infrastructure layer, the `PublisherHandlerContainer` can now fallback to resolving handlers from a PSR service + container. Inject the service container via the constructor. - The pipeline `PipeContainer` class can now fallback to resolving pipes from a PSR service container. Inject the service container via the pipe container's only constructor argument. - The outbound event bus `ClosurePublisher` class now accepts a PSR container for its middleware. Additionally, diff --git a/src/Infrastructure/OutboundEventBus/ComponentPublisher.php b/src/Infrastructure/OutboundEventBus/ComponentPublisher.php index d876d2e..98619ae 100644 --- a/src/Infrastructure/OutboundEventBus/ComponentPublisher.php +++ b/src/Infrastructure/OutboundEventBus/ComponentPublisher.php @@ -13,23 +13,40 @@ namespace CloudCreativity\Modules\Infrastructure\OutboundEventBus; use CloudCreativity\Modules\Contracts\Application\Ports\Driven\OutboundEventPublisher; -use CloudCreativity\Modules\Contracts\Infrastructure\OutboundEventBus\PublisherHandlerContainer; +use CloudCreativity\Modules\Contracts\Infrastructure\OutboundEventBus\PublisherHandlerContainer as IPublisherHandlerContainer; use CloudCreativity\Modules\Contracts\Toolkit\Messages\IntegrationEvent; -use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer; +use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer as IPipeContainer; use CloudCreativity\Modules\Toolkit\Pipeline\MiddlewareProcessor; +use CloudCreativity\Modules\Toolkit\Pipeline\PipeContainer; use CloudCreativity\Modules\Toolkit\Pipeline\PipelineBuilder; +use CloudCreativity\Modules\Toolkit\Pipeline\Through; +use Psr\Container\ContainerInterface; +use ReflectionClass; class ComponentPublisher implements OutboundEventPublisher { + private readonly IPublisherHandlerContainer $handlers; + + private readonly ?IPipeContainer $middleware; + /** * @var array */ private array $pipes = []; public function __construct( - private readonly PublisherHandlerContainer $handlers, - private readonly ?PipeContainer $middleware = null, + ContainerInterface|IPublisherHandlerContainer $handlers, + ?IPipeContainer $middleware = null, ) { + $this->handlers = $handlers instanceof ContainerInterface ? + new PublisherHandlerContainer(container: $handlers) : + $handlers; + + $this->middleware = $middleware === null && $handlers instanceof ContainerInterface ? + new PipeContainer(container: $handlers) : + $middleware; + + $this->autowire(); } /** @@ -55,4 +72,24 @@ public function publish(IntegrationEvent $event): void $pipeline->process($event); } + + private function autowire(): void + { + $reflection = new ReflectionClass($this); + + if ($this->handlers instanceof PublisherHandlerContainer) { + foreach ($reflection->getAttributes(Publishes::class) as $attribute) { + $instance = $attribute->newInstance(); + $this->handlers->bind($instance->event, $instance->handler); + } + + foreach ($reflection->getAttributes(WithDefault::class) as $attribute) { + $this->handlers->withDefault($attribute->newInstance()->handler); + } + } + + foreach ($reflection->getAttributes(Through::class) as $attribute) { + $this->pipes = $attribute->newInstance()->pipes; + } + } } diff --git a/src/Infrastructure/OutboundEventBus/PublisherHandlerContainer.php b/src/Infrastructure/OutboundEventBus/PublisherHandlerContainer.php index e50ff6c..7034ff1 100644 --- a/src/Infrastructure/OutboundEventBus/PublisherHandlerContainer.php +++ b/src/Infrastructure/OutboundEventBus/PublisherHandlerContainer.php @@ -13,6 +13,7 @@ namespace CloudCreativity\Modules\Infrastructure\OutboundEventBus; use Closure; +use Psr\Container\ContainerInterface; use CloudCreativity\Modules\Contracts\Infrastructure\OutboundEventBus\{ PublisherHandlerContainer as IPublisherHandlerContainer}; use CloudCreativity\Modules\Contracts\Toolkit\Messages\IntegrationEvent; @@ -21,12 +22,19 @@ final class PublisherHandlerContainer implements IPublisherHandlerContainer { /** - * @var array, Closure> + * @var array, Closure|string> */ private array $bindings = []; - public function __construct(private readonly ?Closure $default = null) - { + public function __construct( + private Closure|string|null $default = null, + private readonly ?ContainerInterface $container = null, + ) { + if (is_string($this->default) && $this->container === null) { + throw new InfrastructureException( + 'Cannot bind default event publisher handler as a string without a PSR container.', + ); + } } /** @@ -34,19 +42,46 @@ public function __construct(private readonly ?Closure $default = null) * * @param class-string $eventName */ - public function bind(string $eventName, Closure $binding): void + public function bind(string $eventName, Closure|string $binding): void { + if (is_string($binding) && $this->container === null) { + throw new InfrastructureException( + 'Cannot bind event publisher handler as a string without a PSR container.', + ); + } + $this->bindings[$eventName] = $binding; } + public function withDefault(Closure|string $binding): void + { + if ($this->default !== null) { + throw new InfrastructureException('Default event publisher handler is already set.'); + } + + if (is_string($binding) && $this->container === null) { + throw new InfrastructureException( + 'Cannot bind default event publisher handler as a string without a PSR container.', + ); + } + + $this->default = $binding; + } + public function get(string $eventName): PublisherHandler { - $factory = $this->bindings[$eventName] ?? $this->default; + $binding = $this->bindings[$eventName] ?? $this->default; + + if ($binding instanceof Closure) { + $instance = $binding(); + assert(is_object($instance), "Handler binding for integration event {$eventName} must return an object."); + return new PublisherHandler($instance); + } - if ($factory) { - $handler = $factory(); - assert(is_object($handler), "Handler binding for integration event {$eventName} must return an object."); - return new PublisherHandler($handler); + if (is_string($binding)) { + $instance = $this->container?->get($binding); + assert(is_object($instance), "PSR container event publisher handler binding {$binding} is not an object."); + return new PublisherHandler($instance); } throw new InfrastructureException('No handler bound for integration event: ' . $eventName); diff --git a/src/Infrastructure/OutboundEventBus/Publishes.php b/src/Infrastructure/OutboundEventBus/Publishes.php new file mode 100644 index 0000000..8b8e766 --- /dev/null +++ b/src/Infrastructure/OutboundEventBus/Publishes.php @@ -0,0 +1,28 @@ + $event + * @param class-string $handler + */ + public function __construct(public string $event, public string $handler) + { + } +} diff --git a/src/Infrastructure/OutboundEventBus/WithDefault.php b/src/Infrastructure/OutboundEventBus/WithDefault.php new file mode 100644 index 0000000..3eb96c0 --- /dev/null +++ b/src/Infrastructure/OutboundEventBus/WithDefault.php @@ -0,0 +1,26 @@ + + */ + public array $published = []; + + public function publish(IntegrationEvent $event): void + { + $this->published[] = $event; + } +} diff --git a/tests/Integration/Infrastructure/OutboundEventBus/MathEventPublisher.php b/tests/Integration/Infrastructure/OutboundEventBus/MathEventPublisher.php new file mode 100644 index 0000000..15b236b --- /dev/null +++ b/tests/Integration/Infrastructure/OutboundEventBus/MathEventPublisher.php @@ -0,0 +1,29 @@ +bind(NumbersAddedPublisher::class, fn () => $a); + $container->bind(NumbersSubtractedPublisher::class, fn () => $b); + $container->bind(DefaultPublisher::class, fn () => $c); + $container->bind(LogOutboundEvent::class, fn () => new LogOutboundEvent($container->logger)); + + $publisher = new MathEventPublisher($container); + $publisher->publish($ev1 = new NumbersAdded(1, 2, 3)); + $publisher->publish($ev2 = new NumbersSubtracted(10, 6, 4)); + $publisher->publish($ev3 = new NumbersDivided(12, 3, 4)); + + $this->assertSame([$ev1], $a->published); + $this->assertSame([$ev2], $b->published); + $this->assertSame([$ev3], $c->published); + $this->assertCount(6, $container->logger); + } +} diff --git a/tests/Integration/Infrastructure/OutboundEventBus/NumbersAddedPublisher.php b/tests/Integration/Infrastructure/OutboundEventBus/NumbersAddedPublisher.php new file mode 100644 index 0000000..c3f97a8 --- /dev/null +++ b/tests/Integration/Infrastructure/OutboundEventBus/NumbersAddedPublisher.php @@ -0,0 +1,28 @@ + + */ + public array $published = []; + + public function publish(NumbersAdded $event): void + { + $this->published[] = $event; + } +} diff --git a/tests/Integration/Infrastructure/OutboundEventBus/NumbersSubtractedPublisher.php b/tests/Integration/Infrastructure/OutboundEventBus/NumbersSubtractedPublisher.php new file mode 100644 index 0000000..f9379a7 --- /dev/null +++ b/tests/Integration/Infrastructure/OutboundEventBus/NumbersSubtractedPublisher.php @@ -0,0 +1,28 @@ + + */ + public array $published = []; + + public function publish(NumbersSubtracted $event): void + { + $this->published[] = $event; + } +} diff --git a/tests/Unit/Infrastructure/OutboundEventBus/PublisherHandlerContainerTest.php b/tests/Unit/Infrastructure/OutboundEventBus/PublisherHandlerContainerTest.php index 009bc0d..3c07902 100644 --- a/tests/Unit/Infrastructure/OutboundEventBus/PublisherHandlerContainerTest.php +++ b/tests/Unit/Infrastructure/OutboundEventBus/PublisherHandlerContainerTest.php @@ -15,14 +15,15 @@ use CloudCreativity\Modules\Infrastructure\OutboundEventBus\PublisherHandler; use CloudCreativity\Modules\Infrastructure\OutboundEventBus\PublisherHandlerContainer; use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; use RuntimeException; class PublisherHandlerContainerTest extends TestCase { - public function testItDoesNotHaveDefaultHandler(): void + public function testItPublishesViaBindingsWithoutDefaultHandler(): void { $a = new TestPublisher(); - $b = $this->createMock(TestPublisher::class); + $b = $this->createStub(TestPublisher::class); $event1 = new class () extends TestOutboundEvent {}; $event2 = new class () extends TestOutboundEvent {}; @@ -41,10 +42,10 @@ public function testItDoesNotHaveDefaultHandler(): void $container->get($event3::class); } - public function testItHasDefaultHandler(): void + public function testItPublishesViaBindingsWithDefaultHandler(): void { $a = new TestPublisher(); - $b = $this->createMock(TestPublisher::class); + $b = $this->createStub(TestPublisher::class); $event1 = new class () extends TestOutboundEvent {}; $event2 = new class () extends TestOutboundEvent {}; @@ -57,4 +58,63 @@ public function testItHasDefaultHandler(): void $this->assertEquals($default = new PublisherHandler($b), $container->get($event2::class)); $this->assertEquals($default, $container->get($event3::class)); } + + public function testItPublishesViaPsrContainerWithoutDefaultHandler(): void + { + $a = new TestPublisher(); + $b = $this->createStub(TestPublisher::class); + + $event1 = new class () extends TestOutboundEvent {}; + $event2 = new class () extends TestOutboundEvent {}; + $event3 = new class () extends TestOutboundEvent {}; + + $psrContainer = $this->createMock(ContainerInterface::class); + $psrContainer + ->expects($this->exactly(2)) + ->method('get') + ->willReturnCallback(fn (string $id) => match ($id) { + $a::class => $a, + $b::class => $b, + default => $this->fail('Unexpected container id: ' . $id), + }); + + $container = new PublisherHandlerContainer(container: $psrContainer); + $container->bind($event1::class, $a::class); + $container->bind($event2::class, $b::class); + + $this->assertEquals(new PublisherHandler($a), $container->get($event1::class)); + $this->assertEquals(new PublisherHandler($b), $container->get($event2::class)); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No handler bound for integration event: ' . $event3::class); + + $container->get($event3::class); + } + + public function testItPublishesViaPsrContainerWithDefaultHandler(): void + { + $a = new TestPublisher(); + $b = $this->createStub(TestPublisher::class); + + $event1 = new class () extends TestOutboundEvent {}; + $event2 = new class () extends TestOutboundEvent {}; + $event3 = new class () extends TestOutboundEvent {}; + + $psrContainer = $this->createMock(ContainerInterface::class); + $psrContainer + ->expects($this->exactly(3)) + ->method('get') + ->willReturnCallback(fn (string $id) => match ($id) { + $a::class => $a, + $b::class => $b, + default => $this->fail('Unexpected container id: ' . $id), + }); + + $container = new PublisherHandlerContainer(default: $b::class, container: $psrContainer); + $container->bind($event1::class, $a::class); + + $this->assertEquals(new PublisherHandler($a), $container->get($event1::class)); + $this->assertEquals($default = new PublisherHandler($b), $container->get($event2::class)); + $this->assertEquals($default, $container->get($event3::class)); + } } From 40845cce56097187a2835fd6af88d1f8302607f0 Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Thu, 1 Jan 2026 17:11:27 +0000 Subject: [PATCH 8/8] feat: add autowiring via attributes to the command queue --- CHANGELOG.md | 26 ++++++---- .../OutboundEventBus/ComponentPublisher.php | 6 +-- .../{WithDefault.php => DefaultPublisher.php} | 6 +-- .../OutboundEventBus/Publishes.php | 4 +- src/Infrastructure/Queue/ClosureQueue.php | 15 +++++- src/Infrastructure/Queue/ComponentQueue.php | 45 ++++++++++++++-- src/Infrastructure/Queue/DefaultEnqueuer.php | 26 ++++++++++ .../Queue/EnqueuerContainer.php | 52 +++++++++++++++---- src/Infrastructure/Queue/Queues.php | 28 ++++++++++ .../Application/Bus/FloorCommand.php | 22 ++++++++ .../OutboundEventBus/MathEventPublisher.php | 4 +- .../MathEventPublisherTest.php | 4 +- ...Publisher.php => TestDefaultPublisher.php} | 2 +- .../Queue/AddCommandEnqueuer.php | 28 ++++++++++ .../Infrastructure/Queue/MathQueue.php | 29 +++++++++++ .../Infrastructure/Queue/MathQueueTest.php | 46 ++++++++++++++++ .../Queue/MultiplyCommandEnqueuer.php | 28 ++++++++++ .../Infrastructure/Queue/TestClosureQueue.php | 22 ++++++++ .../Queue/TestClosureQueueTest.php | 42 +++++++++++++++ .../Queue/TestDefaultEnqueuer.php | 28 ++++++++++ .../Queue/EnqueuerContainerTest.php | 36 +++++++++++-- 21 files changed, 458 insertions(+), 41 deletions(-) rename src/Infrastructure/OutboundEventBus/{WithDefault.php => DefaultPublisher.php} (73%) create mode 100644 src/Infrastructure/Queue/DefaultEnqueuer.php create mode 100644 src/Infrastructure/Queue/Queues.php create mode 100644 tests/Integration/Application/Bus/FloorCommand.php rename tests/Integration/Infrastructure/OutboundEventBus/{DefaultPublisher.php => TestDefaultPublisher.php} (94%) create mode 100644 tests/Integration/Infrastructure/Queue/AddCommandEnqueuer.php create mode 100644 tests/Integration/Infrastructure/Queue/MathQueue.php create mode 100644 tests/Integration/Infrastructure/Queue/MathQueueTest.php create mode 100644 tests/Integration/Infrastructure/Queue/MultiplyCommandEnqueuer.php create mode 100644 tests/Integration/Infrastructure/Queue/TestClosureQueue.php create mode 100644 tests/Integration/Infrastructure/Queue/TestClosureQueueTest.php create mode 100644 tests/Integration/Infrastructure/Queue/TestDefaultEnqueuer.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f10507..c4a99c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,22 +23,28 @@ All notable changes to this project will be documented in this file. This projec - Integration events can now be mapped to handlers on an inbound event bus class via the `WithEvent` attribute. - The default handler can be set on the inbound event bus via the `WithDefault` attribute. - Middleware can now be added to an inbound event bus via the `Through` attribute. -- New outbound event bus features, when using the publisher handler container: - - Can now use a PSR container for the outbound event bus to resolve both handlers and middleware. Inject the service - container via the first constructor argument. - - Integration events can now be mapped to handlers on a publisher handler container class via the `Publishes` +- New outbound event bus features, when using the component publisher: + - Can now use a PSR container for the outbound event bus to resolve both publishers and middleware. Inject the + service container via the constructor. + - Integration events can now be mapped to publishers on a publisher handler container class via the `Publishes` attribute. - - The default handler can be set on the outbound event publisher via the `WithDefault` attribute. - - Middleware can now be added to a publisher handler container via the `Through` attribute. + - The default publisher can be set on the outbound event publisher via the `DefaultPublisher` attribute. + - Middleware can now be added to an outbound event publisher via the `Through` attribute. +- New queue features, when using the component queue: + - Can now use a PSR container for the queue to resolve both enqueuers and middleware. Inject the service container + via the constructor. + - Commands can now be mapped to enqueuers on a publisher handler container class via the `Queues` attribute. + - The default enqueuer can be set on the outbound event publisher via the `DefaultEnqueuer` attribute. + - Middleware can now be added to the queue via the `Through` attribute. - In the Application layer, the `QueryHandlerContainer`, `CommandHandlerContainer` and `EventHandlerContainer` classes can now fallback to resolving handlers from a PSR service container. Inject the service container via their constructors. -- In the Infrastructure layer, the `PublisherHandlerContainer` can now fallback to resolving handlers from a PSR service - container. Inject the service container via the constructor. +- In the Infrastructure layer, the `PublisherHandlerContainer` and `EnqueuerContainer` can now fallback to resolving + handlers/enqueuers from a PSR service container. Inject the service container via the constructor. +- The outbound event bus `ClosurePublisher` and the `ClosureQueue` classes now both accept a PSR container for their + middleware. Additionally, middleware can be set on instances of closure publishers via the `Through` attribute. - The pipeline `PipeContainer` class can now fallback to resolving pipes from a PSR service container. Inject the service container via the pipe container's only constructor argument. -- The outbound event bus `ClosurePublisher` class now accepts a PSR container for its middleware. Additionally, - middleware can be set on instances of closure publishers via the `Through` attribute. - The `FakeUnitOfWork` class now has integer properties for the number of attempts, commits and rollbacks. - New `FakeContainer` class for faking a PSR container in tests. diff --git a/src/Infrastructure/OutboundEventBus/ComponentPublisher.php b/src/Infrastructure/OutboundEventBus/ComponentPublisher.php index 98619ae..4d9e715 100644 --- a/src/Infrastructure/OutboundEventBus/ComponentPublisher.php +++ b/src/Infrastructure/OutboundEventBus/ComponentPublisher.php @@ -80,11 +80,11 @@ private function autowire(): void if ($this->handlers instanceof PublisherHandlerContainer) { foreach ($reflection->getAttributes(Publishes::class) as $attribute) { $instance = $attribute->newInstance(); - $this->handlers->bind($instance->event, $instance->handler); + $this->handlers->bind($instance->event, $instance->publisher); } - foreach ($reflection->getAttributes(WithDefault::class) as $attribute) { - $this->handlers->withDefault($attribute->newInstance()->handler); + foreach ($reflection->getAttributes(DefaultPublisher::class) as $attribute) { + $this->handlers->withDefault($attribute->newInstance()->publisher); } } diff --git a/src/Infrastructure/OutboundEventBus/WithDefault.php b/src/Infrastructure/OutboundEventBus/DefaultPublisher.php similarity index 73% rename from src/Infrastructure/OutboundEventBus/WithDefault.php rename to src/Infrastructure/OutboundEventBus/DefaultPublisher.php index 3eb96c0..d9ff172 100644 --- a/src/Infrastructure/OutboundEventBus/WithDefault.php +++ b/src/Infrastructure/OutboundEventBus/DefaultPublisher.php @@ -15,12 +15,12 @@ use Attribute; #[Attribute(Attribute::TARGET_CLASS)] -final readonly class WithDefault +final readonly class DefaultPublisher { /** - * @param class-string $handler + * @param non-empty-string $publisher */ - public function __construct(public string $handler) + public function __construct(public string $publisher) { } } diff --git a/src/Infrastructure/OutboundEventBus/Publishes.php b/src/Infrastructure/OutboundEventBus/Publishes.php index 8b8e766..77d68c0 100644 --- a/src/Infrastructure/OutboundEventBus/Publishes.php +++ b/src/Infrastructure/OutboundEventBus/Publishes.php @@ -20,9 +20,9 @@ { /** * @param class-string $event - * @param class-string $handler + * @param class-string $publisher */ - public function __construct(public string $event, public string $handler) + public function __construct(public string $event, public string $publisher) { } } diff --git a/src/Infrastructure/Queue/ClosureQueue.php b/src/Infrastructure/Queue/ClosureQueue.php index 88a22aa..385dcc1 100644 --- a/src/Infrastructure/Queue/ClosureQueue.php +++ b/src/Infrastructure/Queue/ClosureQueue.php @@ -18,6 +18,9 @@ use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer; use CloudCreativity\Modules\Toolkit\Pipeline\MiddlewareProcessor; use CloudCreativity\Modules\Toolkit\Pipeline\PipelineBuilder; +use CloudCreativity\Modules\Toolkit\Pipeline\Through; +use Psr\Container\ContainerInterface; +use ReflectionClass; class ClosureQueue implements Queue { @@ -33,8 +36,9 @@ class ClosureQueue implements Queue public function __construct( private readonly Closure $fn, - private readonly ?PipeContainer $middleware = null, + private readonly ContainerInterface|PipeContainer|null $middleware = null, ) { + $this->autowire(); } /** @@ -67,4 +71,13 @@ public function push(Command $command): void $pipeline->process($command); } + + private function autowire(): void + { + $reflection = new ReflectionClass($this); + + foreach ($reflection->getAttributes(Through::class) as $attribute) { + $this->pipes = $attribute->newInstance()->pipes; + } + } } diff --git a/src/Infrastructure/Queue/ComponentQueue.php b/src/Infrastructure/Queue/ComponentQueue.php index 6bc4f18..dbc2938 100644 --- a/src/Infrastructure/Queue/ComponentQueue.php +++ b/src/Infrastructure/Queue/ComponentQueue.php @@ -13,23 +13,40 @@ namespace CloudCreativity\Modules\Infrastructure\Queue; use CloudCreativity\Modules\Contracts\Application\Ports\Driven\Queue; -use CloudCreativity\Modules\Contracts\Infrastructure\Queue\EnqueuerContainer; +use CloudCreativity\Modules\Contracts\Infrastructure\Queue\EnqueuerContainer as IEnqueuerContainer; use CloudCreativity\Modules\Contracts\Toolkit\Messages\Command; -use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer; +use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer as IPipeContainer; use CloudCreativity\Modules\Toolkit\Pipeline\MiddlewareProcessor; +use CloudCreativity\Modules\Toolkit\Pipeline\PipeContainer; use CloudCreativity\Modules\Toolkit\Pipeline\PipelineBuilder; +use CloudCreativity\Modules\Toolkit\Pipeline\Through; +use Psr\Container\ContainerInterface; +use ReflectionClass; class ComponentQueue implements Queue { + private readonly IEnqueuerContainer $enqueuers; + + private readonly ?IPipeContainer $middleware; + /** * @var list */ private array $pipes = []; public function __construct( - private readonly EnqueuerContainer $enqueuers, - private readonly ?PipeContainer $middleware = null, + ContainerInterface|IEnqueuerContainer $enqueuers, + ?IPipeContainer $middleware = null, ) { + $this->enqueuers = $enqueuers instanceof ContainerInterface ? + new EnqueuerContainer(container: $enqueuers) : + $enqueuers; + + $this->middleware = $middleware === null && $enqueuers instanceof ContainerInterface + ? new PipeContainer($enqueuers) + : $middleware; + + $this->autowire(); } /** @@ -53,4 +70,24 @@ public function push(Command $command): void $pipeline->process($command); } + + private function autowire(): void + { + $reflection = new ReflectionClass($this); + + if ($this->enqueuers instanceof EnqueuerContainer) { + foreach ($reflection->getAttributes(Queues::class) as $attribute) { + $instance = $attribute->newInstance(); + $this->enqueuers->bind($instance->command, $instance->enqueuer); + } + + foreach ($reflection->getAttributes(DefaultEnqueuer::class) as $attribute) { + $this->enqueuers->withDefault($attribute->newInstance()->enqueuer); + } + } + + foreach ($reflection->getAttributes(Through::class) as $attribute) { + $this->pipes = $attribute->newInstance()->pipes; + } + } } diff --git a/src/Infrastructure/Queue/DefaultEnqueuer.php b/src/Infrastructure/Queue/DefaultEnqueuer.php new file mode 100644 index 0000000..1ae67c2 --- /dev/null +++ b/src/Infrastructure/Queue/DefaultEnqueuer.php @@ -0,0 +1,26 @@ +, Closure> + * @var array, Closure|string> */ private array $bindings = []; /** - * @param Closure(): object $default + * @param (Closure(): object)|string|null $default */ - public function __construct(private readonly Closure $default) - { + public function __construct( + private Closure|string|null $default = null, + private readonly ?ContainerInterface $container = null, + ) { + if (is_string($this->default) && $this->container === null) { + throw new InfrastructureException( + 'Cannot bind default enqueuer as a string without a PSR container.', + ); + } } /** * Bind an enqueuer factory into the container. * * @param class-string $queueableName - * @param Closure(): object $binding + * @param (Closure(): object)|string $binding */ - public function bind(string $queueableName, Closure $binding): void + public function bind(string $queueableName, Closure|string $binding): void { $this->bindings[$queueableName] = $binding; } + public function withDefault(Closure|string $binding): void + { + if ($this->default !== null) { + throw new InfrastructureException('Default enqueuer is already set.'); + } + + if (is_string($binding) && $this->container === null) { + throw new InfrastructureException( + 'Cannot bind default enqueuer as a string without a PSR container.', + ); + } + + $this->default = $binding; + } + public function get(string $command): Enqueuer { - $factory = $this->bindings[$command] ?? $this->default; + $binding = $this->bindings[$command] ?? $this->default; - $enqueuer = $factory(); + if ($binding instanceof Closure) { + $instance = $binding(); + assert(is_object($instance), "Enqueuer binding for command {$command} must return an object."); + return new Enqueuer($instance); + } - assert(is_object($enqueuer), "Enqueuer binding for {$command} must return an object."); + if (is_string($binding)) { + $instance = $this->container?->get($binding); + assert(is_object($instance), "PSR container enqueuer binding {$binding} is not an object."); + return new Enqueuer($instance); + } - return new Enqueuer($enqueuer); + throw new InfrastructureException('No enqueuer bound for command: ' . $command); } } diff --git a/src/Infrastructure/Queue/Queues.php b/src/Infrastructure/Queue/Queues.php new file mode 100644 index 0000000..6977fa6 --- /dev/null +++ b/src/Infrastructure/Queue/Queues.php @@ -0,0 +1,28 @@ + $command + * @param class-string $enqueuer + */ + public function __construct(public string $command, public string $enqueuer) + { + } +} diff --git a/tests/Integration/Application/Bus/FloorCommand.php b/tests/Integration/Application/Bus/FloorCommand.php new file mode 100644 index 0000000..fc79999 --- /dev/null +++ b/tests/Integration/Application/Bus/FloorCommand.php @@ -0,0 +1,22 @@ +bind(NumbersAddedPublisher::class, fn () => $a); $container->bind(NumbersSubtractedPublisher::class, fn () => $b); - $container->bind(DefaultPublisher::class, fn () => $c); + $container->bind(TestDefaultPublisher::class, fn () => $c); $container->bind(LogOutboundEvent::class, fn () => new LogOutboundEvent($container->logger)); $publisher = new MathEventPublisher($container); diff --git a/tests/Integration/Infrastructure/OutboundEventBus/DefaultPublisher.php b/tests/Integration/Infrastructure/OutboundEventBus/TestDefaultPublisher.php similarity index 94% rename from tests/Integration/Infrastructure/OutboundEventBus/DefaultPublisher.php rename to tests/Integration/Infrastructure/OutboundEventBus/TestDefaultPublisher.php index 67dad4b..36b1a21 100644 --- a/tests/Integration/Infrastructure/OutboundEventBus/DefaultPublisher.php +++ b/tests/Integration/Infrastructure/OutboundEventBus/TestDefaultPublisher.php @@ -14,7 +14,7 @@ use CloudCreativity\Modules\Contracts\Toolkit\Messages\IntegrationEvent; -final class DefaultPublisher +final class TestDefaultPublisher { /** * @var array diff --git a/tests/Integration/Infrastructure/Queue/AddCommandEnqueuer.php b/tests/Integration/Infrastructure/Queue/AddCommandEnqueuer.php new file mode 100644 index 0000000..5bb7646 --- /dev/null +++ b/tests/Integration/Infrastructure/Queue/AddCommandEnqueuer.php @@ -0,0 +1,28 @@ + + */ + public array $queued = []; + + public function push(AddCommand $command): void + { + $this->queued[] = $command; + } +} diff --git a/tests/Integration/Infrastructure/Queue/MathQueue.php b/tests/Integration/Infrastructure/Queue/MathQueue.php new file mode 100644 index 0000000..4cd8fb0 --- /dev/null +++ b/tests/Integration/Infrastructure/Queue/MathQueue.php @@ -0,0 +1,29 @@ +bind(AddCommandEnqueuer::class, fn () => $a); + $container->bind(MultiplyCommandEnqueuer::class, fn () => $b); + $container->bind(TestDefaultEnqueuer::class, fn () => $c); + $container->bind(LogPushedToQueue::class, fn () => new LogPushedToQueue($container->logger)); + + $publisher = new MathQueue($container); + $publisher->push($command1 = new AddCommand(1, 2)); + $publisher->push($command2 = new MultiplyCommand(10, 6)); + $publisher->push($command3 = new FloorCommand(99.9)); + + $this->assertSame([$command1], $a->queued); + $this->assertSame([$command2], $b->queued); + $this->assertSame([$command3], $c->queued); + $this->assertCount(6, $container->logger); + } +} diff --git a/tests/Integration/Infrastructure/Queue/MultiplyCommandEnqueuer.php b/tests/Integration/Infrastructure/Queue/MultiplyCommandEnqueuer.php new file mode 100644 index 0000000..d2f9ee9 --- /dev/null +++ b/tests/Integration/Infrastructure/Queue/MultiplyCommandEnqueuer.php @@ -0,0 +1,28 @@ + + */ + public array $queued = []; + + public function push(MultiplyCommand $command): void + { + $this->queued[] = $command; + } +} diff --git a/tests/Integration/Infrastructure/Queue/TestClosureQueue.php b/tests/Integration/Infrastructure/Queue/TestClosureQueue.php new file mode 100644 index 0000000..d690220 --- /dev/null +++ b/tests/Integration/Infrastructure/Queue/TestClosureQueue.php @@ -0,0 +1,22 @@ +bind(LogPushedToQueue::class, fn () => new LogPushedToQueue($container->logger)); + + $publisher = new TestClosureQueue( + fn: function ($event) use (&$queued) { + $queued[] = $event; + }, + middleware: $container, + ); + + $command = new AddCommand(1, 2); + $publisher->push($command); + + $this->assertSame([$command], $queued); + $this->assertCount(2, $container->logger); + } +} diff --git a/tests/Integration/Infrastructure/Queue/TestDefaultEnqueuer.php b/tests/Integration/Infrastructure/Queue/TestDefaultEnqueuer.php new file mode 100644 index 0000000..6bec9c3 --- /dev/null +++ b/tests/Integration/Infrastructure/Queue/TestDefaultEnqueuer.php @@ -0,0 +1,28 @@ + + */ + public array $queued = []; + + public function push(Command $command): void + { + $this->queued[] = $command; + } +} diff --git a/tests/Unit/Infrastructure/Queue/EnqueuerContainerTest.php b/tests/Unit/Infrastructure/Queue/EnqueuerContainerTest.php index 6226e78..31757c4 100644 --- a/tests/Unit/Infrastructure/Queue/EnqueuerContainerTest.php +++ b/tests/Unit/Infrastructure/Queue/EnqueuerContainerTest.php @@ -17,17 +17,18 @@ use CloudCreativity\Modules\Infrastructure\Queue\EnqueuerContainer; use CloudCreativity\Modules\Tests\Unit\Application\Bus\TestCommand; use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; class EnqueuerContainerTest extends TestCase { - public function test(): void + public function testItUsesBindings(): void { $command1 = new class () implements Command {}; $command2 = new class () implements Command {}; $a = new TestEnqueuer(); - $b = $this->createMock(TestEnqueuer::class); - $default = $this->createMock(TestEnqueuer::class); + $b = $this->createStub(TestEnqueuer::class); + $default = $this->createStub(TestEnqueuer::class); $container = new EnqueuerContainer(fn () => $default); $container->bind($command1::class, fn () => $a); @@ -37,4 +38,33 @@ public function test(): void $this->assertEquals(new Enqueuer($b), $container->get($command2::class)); $this->assertEquals(new Enqueuer($default), $container->get(TestCommand::class)); } + + public function testItUsesPsrContainer(): void + { + $command1 = new class () implements Command {}; + $command2 = new class () implements Command {}; + + $a = new TestEnqueuer(); + $b = $this->createStub(TestEnqueuer::class); + $default = $this->createStub(TestEnqueuer::class); + + $psrContainer = $this->createMock(ContainerInterface::class); + $psrContainer + ->expects($this->exactly(3)) + ->method('get') + ->willReturnCallback(fn (string $id) => match ($id) { + $a::class => $a, + $b::class => $b, + $default::class => $default, + default => $this->fail('Unexpected binding: ' . $id), + }); + + $container = new EnqueuerContainer(default: $default::class, container: $psrContainer); + $container->bind($command1::class, $a::class); + $container->bind($command2::class, $b::class); + + $this->assertEquals(new Enqueuer($a), $container->get($command1::class)); + $this->assertEquals(new Enqueuer($b), $container->get($command2::class)); + $this->assertEquals(new Enqueuer($default), $container->get(TestCommand::class)); + } }