feat: implement command bus with attribute-based handler resolution and add example command and handler #27

Merged
rrise merged 1 commits from feat/commmand-bus into master 2025-12-21 21:04:56 +00:00
15 changed files with 359 additions and 3 deletions

View File

@@ -27,7 +27,8 @@
"react/async": "^4",
"guzzlehttp/guzzle": "^7.10",
"zircote/swagger-php": "^5.7",
"spiral/roadrunner-grpc": "^3.5"
"spiral/roadrunner-grpc": "^3.5",
"league/tactician": "^1.1"
},
"require-dev": {
"phpunit/phpunit": "^12.4",

57
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "977f74570c671e4d59fd70d5e732c3d2",
"content-hash": "d027bee8e875c5542f7ff9612bfac4e2",
"packages": [
{
"name": "adhocore/cli",
@@ -1360,6 +1360,61 @@
],
"time": "2024-11-25T08:10:15+00:00"
},
{
"name": "league/tactician",
"version": "v1.1.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/tactician.git",
"reference": "e79f763170f3d5922ec29e85cffca0bac5cd8975"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/tactician/zipball/e79f763170f3d5922ec29e85cffca0bac5cd8975",
"reference": "e79f763170f3d5922ec29e85cffca0bac5cd8975",
"shasum": ""
},
"require": {
"php": ">=7.1"
},
"require-dev": {
"mockery/mockery": "^1.3",
"phpunit/phpunit": "^7.5.20 || ^9.3.8",
"squizlabs/php_codesniffer": "^3.5.8"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0-dev"
}
},
"autoload": {
"psr-4": {
"League\\Tactician\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ross Tuck",
"homepage": "http://tactician.thephpleague.com"
}
],
"description": "A small, flexible command bus. Handy for building service layers.",
"keywords": [
"command",
"command bus",
"service layer"
],
"support": {
"issues": "https://github.com/thephpleague/tactician/issues",
"source": "https://github.com/thephpleague/tactician/tree/v1.1.0"
},
"time": "2021-02-14T15:29:04+00:00"
},
{
"name": "monolog/monolog",
"version": "3.9.0",

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Attributes\CommandBus;
use Attribute;
/**
* Class HandlesCommand
* @package Siteworxpro\App\Attributes\CommandBus
*/
#[Attribute(Attribute::TARGET_CLASS)]
readonly class HandlesCommand
{
/**
* @param class-string $commandClass
*/
public function __construct(public string $commandClass)
{
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus;
use League\Tactician\Exception\CanNotInvokeHandlerException;
use League\Tactician\Handler\Locator\HandlerLocator;
use Siteworxpro\App\Attributes\CommandBus\HandlesCommand;
class AttributeLocator implements HandlerLocator
{
private const string HANDLER_NAMESPACE = 'Siteworxpro\\App\\CommandBus\\Handlers\\';
private array $handlers;
public function __construct()
{
$directory = __DIR__ . '/Handlers';
$files = scandir($directory);
foreach ($files as $file) {
if (pathinfo($file, PATHINFO_EXTENSION) === 'php') {
$className = pathinfo($file, PATHINFO_FILENAME);
$fullClassName = self::HANDLER_NAMESPACE . $className;
if (class_exists($fullClassName)) {
$reflectionClass = new \ReflectionClass($fullClassName);
$attributes = $reflectionClass->getAttributes(HandlesCommand::class);
foreach ($attributes as $attribute) {
$instance = $attribute->newInstance();
$commandClass = $instance->commandClass;
$this->handlers[$commandClass] = $fullClassName;
}
}
}
}
}
public function getHandlerForCommand($commandName)
{
if (isset($this->handlers[$commandName])) {
$handlerClass = $this->handlers[$commandName];
return new $handlerClass();
}
throw new CanNotInvokeHandlerException("No handler found for command: " . $commandName);
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Commands;
readonly abstract class Command
{
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Commands;
readonly class ExampleCommand extends Command
{
public function __construct(
private string $name
) {
}
public function getName(): string
{
return $this->name;
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Handlers;
abstract class CommandHandler implements CommandHandlerInterface
{
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Handlers;
use Siteworxpro\App\CommandBus\Commands\Command;
interface CommandHandlerInterface
{
public function __invoke(Command $command): mixed;
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Handlers;
use Siteworxpro\App\Attributes\CommandBus\HandlesCommand;
use Siteworxpro\App\CommandBus\Commands\Command;
use Siteworxpro\App\CommandBus\Commands\ExampleCommand;
use Siteworxpro\App\Services\Facades\Logger;
#[HandlesCommand(ExampleCommand::class)]
class ExampleHandler extends CommandHandler
{
/**
* @param Command|ExampleCommand $command
* @return string
*/
public function __invoke(Command|ExampleCommand $command): string
{
if (!method_exists($command, 'getName')) {
throw new \TypeError('Invalid command type provided to ExampleHandler.');
}
$name = $command->getName();
Logger::info('Handling ExampleCommand for name: ' . $name);
return 'Hello, ' . $name . '!';
}
}

View File

@@ -49,7 +49,7 @@ class HealthcheckController extends Controller
'Healthcheck failed: ' . $e->getMessage(),
['exception' => $e]
);
return JsonResponseFactory::createJsonResponse(
new ServerErrorResponse($e),
CodesEnum::SERVICE_UNAVAILABLE

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Services\Facades;
use Siteworxpro\App\CommandBus\Commands\Command;
use Siteworxpro\App\Services\Facade;
/**
* Broker Facade
*
* @package Siteworxpro\App\Services\Facades
* @method static mixed handle(Command $command)
*/
class CommandBus extends Facade
{
/**
* Get the registered name of the component.
*
* @return string The name of the component.
*/
protected static function getFacadeAccessor(): string
{
return \League\Tactician\CommandBus::class;
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Services\ServiceProviders;
use Illuminate\Support\ServiceProvider;
use League\Tactician\CommandBus;
use League\Tactician\Handler\CommandHandlerMiddleware;
use League\Tactician\Handler\CommandNameExtractor\ClassNameExtractor;
use League\Tactician\Handler\MethodNameInflector\InvokeInflector;
use Siteworxpro\App\CommandBus\AttributeLocator;
/**
* Class CommandBusProvider
*
* @package Siteworxpro\App\Services\ServiceProviders
*/
class CommandBusProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(CommandBus::class, function () {
return new CommandBus([
new CommandHandlerMiddleware(
new ClassNameExtractor(),
new AttributeLocator(),
new InvokeInflector()
),
]);
});
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\CommandBus;
use League\Tactician\Exception\CanNotInvokeHandlerException;
use Siteworxpro\App\CommandBus\AttributeLocator;
use Siteworxpro\App\CommandBus\Commands\ExampleCommand;
use Siteworxpro\App\CommandBus\Handlers\ExampleHandler;
use Siteworxpro\Tests\Unit;
class AttributeLocatorTest extends Unit
{
private const array HANDLERS = [
ExampleCommand::class => ExampleHandler::class,
];
public function testResolvesFiles(): void
{
$attributeLocator = new AttributeLocator();
foreach (self::HANDLERS as $command => $handler) {
$class = $attributeLocator->getHandlerForCommand($command);
$this->assertInstanceOf($handler, $class);
}
}
public function testThrowsOnCannotResolve(): void
{
$attributeLocator = new AttributeLocator();
$this->expectException(CanNotInvokeHandlerException::class);
$attributeLocator->getHandlerForCommand('NonExistentCommand');
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace Siteworxpro\Tests\CommandBus\Handlers;
use Siteworxpro\App\CommandBus\Commands\Command;
use Siteworxpro\App\CommandBus\Commands\ExampleCommand;
use Siteworxpro\App\CommandBus\Handlers\ExampleHandler;
use Siteworxpro\Tests\Unit;
class ExampleHandlerTest extends Unit
{
public function testExampleCommand(): void
{
$command = new ExampleCommand('test payload');
$this->assertEquals('test payload', $command->getName());
$handler = new ExampleHandler();
$result = $handler($command);
$this->assertEquals('Hello, test payload!', $result);
}
public function testThrowsException(): void
{
$class = new readonly class extends Command
{
};
$this->expectException(\TypeError::class);
$handler = new ExampleHandler();
$handler($class);
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\GrpcHandlers;
use Siteworxpro\Tests\Unit;
use Spiral\RoadRunner\GRPC\ContextInterface;
class GreeterHandlerTest extends Unit
{
public function testSayHello(): void
{
$request = new \GRPC\Greeter\HelloRequest();
$request->setName('World');
$context = \Mockery::mock(ContextInterface::class);
$handler = new \Siteworxpro\App\GrpcHandlers\GreeterHandler();
$response = $handler->SayHello($context, $request);
$this->assertEquals('Hello World', $response->getMessage());
}
}