Download project

This commit is contained in:
Roman Pyrih
2024-11-20 09:09:44 +01:00
parent 547a138d6a
commit 5ff041757f
40737 changed files with 7766183 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace League\Tactician\Bundle\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class DebugCommand extends Command
{
/**
* @var array
*/
private $mappings;
public function __construct(array $mappings)
{
parent::__construct();
$this->mappings = $mappings;
}
protected function configure()
{
$this->setName('debug:tactician');
}
public function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
$io->title('Tactician routing');
$headers = ['Command', 'Handler Service'];
foreach ($this->mappings as $busId => $map) {
$io->section('Bus: ' . $busId);
if (count($map) > 0) {
$io->table($headers, $this->mappingToRows($map));
} else {
$io->warning("No registered commands for bus $busId");
}
}
}
private function mappingToRows(array $map)
{
$rows = [];
foreach ($map as $commandName => $handlerService) {
$rows[] = [$commandName, $handlerService];
}
return $rows;
}
}

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace League\Tactician\Bundle\DependencyInjection\Compiler\BusBuilder;
use League\Tactician\Bundle\Handler\ContainerBasedHandlerLocator;
use League\Tactician\CommandBus;
use League\Tactician\Container\ContainerLocator;
use League\Tactician\Handler\CommandHandlerMiddleware;
use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\DependencyInjection\ServiceLocator;
final class BusBuilder
{
/**
* @var string
*/
private $busId;
/**
* @var string[]
*/
private $middlewareIds = [];
/**
* @var string
*/
private $methodInflectorId;
public function __construct(string $busId, string $methodInflector, array $middlewareIds)
{
$this->busId = $busId;
$this->methodInflectorId = $methodInflector;
$this->middlewareIds = $middlewareIds;
}
public function id(): string
{
return $this->busId;
}
public function serviceId(): string
{
return "tactician.commandbus.$this->busId";
}
public function locatorServiceId()
{
return "tactician.commandbus.{$this->busId}.handler.locator";
}
public function commandHandlerMiddlewareId(): string
{
return "tactician.commandbus.{$this->busId}.middleware.command_handler";
}
public function registerInContainer(ContainerBuilder $container, array $commandsToAccept)
{
$this->registerLocatorService($container, $commandsToAccept);
$container->setDefinition(
$this->commandHandlerMiddlewareId(),
new Definition(
CommandHandlerMiddleware::class,
[
new Reference('tactician.handler.command_name_extractor.class_name'),
new Reference($this->locatorServiceId()),
new Reference($this->methodInflectorId),
]
)
);
$container->setDefinition(
$this->serviceId(),
new Definition(
CommandBus::class,
[
array_map(
function (string $id) { return new Reference($id); },
$this->middlewareIds
)
]
)
)->setPublic(true);
if (method_exists($container, 'registerAliasForArgument')) {
$container->registerAliasForArgument($this->serviceId(), CommandBus::class, "{$this->busId}Bus");
}
}
private function registerLocatorService(ContainerBuilder $container, $commandsToAccept)
{
// Leverage symfony/dependency-injection:^3.3 service locators
if (class_exists(ServiceLocator::class)) {
$definition = new Definition(
ContainerLocator::class,
[new Reference($this->registerHandlerServiceLocator($container, $commandsToAccept)), $commandsToAccept]
);
} else {
$definition = new Definition(
ContainerBasedHandlerLocator::class,
[new Reference('service_container'), $commandsToAccept]
);
}
$container->setDefinition($this->locatorServiceId(), $definition);
}
private function registerHandlerServiceLocator(ContainerBuilder $container, array $commandsToAccept): string
{
$handlers = [];
foreach ($commandsToAccept as $commandName => $handlerId) {
$handlers[$handlerId] = new ServiceClosureArgument(new Reference($handlerId));
}
$handlerServiceLocator = (new Definition(ServiceLocator::class, [$handlers]))
->setPublic(false)
->addTag('container.service_locator');
$container->setDefinition(
$handlerId = "tactician.commandbus.{$this->busId}.handler.service_locator",
$handlerServiceLocator
);
return $handlerId;
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace League\Tactician\Bundle\DependencyInjection\Compiler\BusBuilder;
use League\Tactician\Bundle\DependencyInjection\DuplicatedCommandBusId;
use League\Tactician\Bundle\DependencyInjection\HandlerMapping\Routing;
use League\Tactician\Bundle\DependencyInjection\InvalidCommandBusId;
use ArrayIterator;
final class BusBuilders implements \IteratorAggregate
{
/**
* @var BusBuilder[]
*/
private $busBuilders = [];
/**
* @var string
*/
private $defaultBusId;
public function __construct(array $busBuilders, string $defaultBusId)
{
foreach ($busBuilders as $builder) {
$this->add($builder);
}
$this->assertValidBusId($defaultBusId);
$this->defaultBusId = $defaultBusId;
}
public function createBlankRouting(): Routing
{
return new Routing(array_keys($this->busBuilders));
}
public function defaultBus(): BusBuilder
{
return $this->get($this->defaultBusId);
}
private function get(string $busId): BusBuilder
{
$this->assertValidBusId($busId);
return $this->busBuilders[$busId];
}
/**
* @return ArrayIterator|BusBuilder[]
*/
public function getIterator()
{
return new ArrayIterator($this->busBuilders);
}
private function assertValidBusId($busId)
{
if (!isset($this->busBuilders[$busId])) {
throw InvalidCommandBusId::ofName($busId, array_keys($this->busBuilders));
}
}
private function add(BusBuilder $builder)
{
$id = $builder->id();
if (isset($this->busBuilders[$id])) {
throw DuplicatedCommandBusId::withId($id);
}
$this->busBuilders[$id] = $builder;
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace League\Tactician\Bundle\DependencyInjection\Compiler\BusBuilder;
final class BusBuildersFromConfig
{
const DEFAULT_METHOD_INFLECTOR = 'tactician.handler.method_name_inflector.handle';
const DEFAULT_BUS_ID = 'default';
public static function convert(array $config): BusBuilders
{
$defaultInflector = $config['method_inflector'] ?? self::DEFAULT_METHOD_INFLECTOR;
$builders = [];
foreach ($config['commandbus'] ?? [] as $busId => $busConfig) {
$builders[] = new BusBuilder(
$busId,
$busConfig['method_inflector'] ?? $defaultInflector,
$busConfig['middleware']
);
}
return new BusBuilders($builders, $config['default_bus'] ?? self::DEFAULT_BUS_ID);
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace League\Tactician\Bundle\DependencyInjection\Compiler;
use League\Tactician\Bundle\DependencyInjection\Compiler\BusBuilder\BusBuildersFromConfig;
use League\Tactician\Bundle\DependencyInjection\HandlerMapping\HandlerMapping;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use League\Tactician\CommandBus;
/**
* This compiler pass maps Handler DI tags to specific commands.
*/
class CommandHandlerPass implements CompilerPassInterface
{
/**
* @var HandlerMapping
*/
private $handlerMapping;
public function __construct(HandlerMapping $mappingStrategy)
{
$this->handlerMapping = $mappingStrategy;
}
public function process(ContainerBuilder $container)
{
$builders = BusBuildersFromConfig::convert(
$this->readAndForgetParameter($container, 'tactician.merged_config')
);
$routing = $this->handlerMapping->build($container, $builders->createBlankRouting());
$mappings = [];
// Register the completed builders in our container
foreach ($builders as $builder) {
$commandToServiceMapping = $routing->commandToServiceMapping($builder->id());
$mappings[$builder->id()] = $commandToServiceMapping;
$builder->registerInContainer($container, $commandToServiceMapping);
}
// Setup default aliases
$container->setAlias('tactician.commandbus', $builders->defaultBus()->serviceId());
$container->setAlias(CommandBus::class, 'tactician.commandbus');
$container->setAlias('tactician.handler.locator.symfony', $builders->defaultBus()->locatorServiceId());
$container->setAlias('tactician.middleware.command_handler', $builders->defaultBus()->commandHandlerMiddlewareId());
// Wire debug command
if ($container->hasDefinition('tactician.command.debug')) {
$container->getDefinition('tactician.command.debug')->addArgument($mappings);
}
}
private function readAndForgetParameter(ContainerBuilder $container, $parameter)
{
$value = $container->getParameter($parameter);
$container->getParameterBag()->remove($parameter);
return $value;
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace League\Tactician\Bundle\DependencyInjection\Compiler;
use League\Tactician\Doctrine\ORM\RollbackOnlyTransactionMiddleware;
use League\Tactician\Doctrine\ORM\TransactionMiddleware;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
/**
* This compiler pass registers doctrine entity manager middleware
*/
class DoctrineMiddlewarePass implements CompilerPassInterface
{
/**
* {@inheritdoc}
*/
public function process(ContainerBuilder $container)
{
if (!class_exists(TransactionMiddleware::class) || !$container->hasParameter('doctrine.entity_managers')) {
return;
}
$entityManagers = $container->getParameter('doctrine.entity_managers');
if (empty($entityManagers)) {
return;
}
foreach ($entityManagers as $name => $serviceId) {
$container->setDefinition(
sprintf('tactician.middleware.doctrine.%s', $name),
new Definition(TransactionMiddleware::class, [ new Reference($serviceId) ])
);
$container->setDefinition(
sprintf('tactician.middleware.doctrine_rollback_only.%s', $name),
new Definition(RollbackOnlyTransactionMiddleware::class, [ new Reference($serviceId) ])
);
}
$defaultEntityManager = $container->getParameter('doctrine.default_entity_manager');
$container->setAlias('tactician.middleware.doctrine', sprintf('tactician.middleware.doctrine.%s', $defaultEntityManager));
$container->setAlias('tactician.middleware.doctrine_rollback_only', sprintf('tactician.middleware.doctrine_rollback_only.%s', $defaultEntityManager));
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace League\Tactician\Bundle\DependencyInjection\Compiler;
use League\Tactician\Bundle\Middleware\SecurityMiddleware;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
/**
* This compiler pass registers security middleware if possible
*/
class SecurityMiddlewarePass implements CompilerPassInterface
{
const SERVICE_ID = 'tactician.middleware.security';
/**
* {@inheritdoc}
*/
public function process(ContainerBuilder $container)
{
if (false === $container->hasDefinition('security.authorization_checker')) {
return;
}
$container->setDefinition(
static::SERVICE_ID,
new Definition(SecurityMiddleware::class, [ new Reference('security.authorization_checker') ])
);
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace League\Tactician\Bundle\DependencyInjection\Compiler;
use League\Tactician\Bundle\Middleware\ValidatorMiddleware;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
/**
* This compiler pass registers validator middleware if possible
*/
class ValidatorMiddlewarePass implements CompilerPassInterface
{
const SERVICE_ID = 'tactician.middleware.validator';
/**
* {@inheritdoc}
*/
public function process(ContainerBuilder $container)
{
if (false === $container->hasDefinition('validator')) {
return;
}
$container->setDefinition(
static::SERVICE_ID,
new Definition(ValidatorMiddleware::class, [ new Reference('validator') ])
);
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace League\Tactician\Bundle\DependencyInjection;
use League\Tactician\Bundle\DependencyInjection\Compiler\BusBuilder\BusBuildersFromConfig;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
/**
* This is the class that validates and merges configuration from your app/config files.
*
* To learn more see {@link http://symfony.com/doc/current/cookbook/bundles/extension.html#cookbook-bundles-extension-config-class}
*/
class Configuration implements ConfigurationInterface
{
/**
* Create a rootnode tree for configuration that can be injected into the DI container.
*
* @return TreeBuilder
*/
public function getConfigTreeBuilder()
{
$treeBuilder = new TreeBuilder('tactician');
if (\method_exists($treeBuilder, 'getRootNode')) {
$rootNode = $treeBuilder->getRootNode();
} else {
// BC layer for symfony/config 4.1 and older
$rootNode = $treeBuilder->root('tactician');
}
$rootNode
->children()
->arrayNode('commandbus')
->defaultValue(['default' => ['middleware' => ['tactician.middleware.command_handler']]])
->requiresAtLeastOneElement()
->useAttributeAsKey('name')
->prototype('array')
->children()
->arrayNode('middleware')
->requiresAtLeastOneElement()
->useAttributeAsKey('name')
->prototype('scalar')->end()
->validate()
->ifTrue(function ($config) {
$isPresent = in_array('tactician.middleware.command_handler', $config);
$isLast = end($config) == 'tactician.middleware.command_handler';
return $isPresent && !$isLast;
})
->thenInvalid(
'"tactician.middleware.command_handler" should be the last middleware loaded '.
'when it is used.'
)
->end()
->end()
->scalarNode('method_inflector')->end()
->end()
->end()
->end()
->scalarNode('default_bus')
->defaultValue(BusBuildersFromConfig::DEFAULT_BUS_ID)
->cannotBeEmpty()
->end()
->scalarNode('method_inflector')
->defaultValue(BusBuildersFromConfig::DEFAULT_METHOD_INFLECTOR)
->cannotBeEmpty()
->end()
->arrayNode('security')
->defaultValue([])
->useAttributeAsKey('name')
->prototype('array')
->prototype('scalar')->end()
->end()
->end()
->scalarNode('logger_formatter')
->defaultValue('tactician.logger.class_properties_formatter')
->cannotBeEmpty()
->end()
->end()
->validate()
->ifTrue(function ($config) {
return is_array($config) &&
array_key_exists('default_bus', $config) &&
array_key_exists('commandbus', $config)
;
})
->then(function ($config) {
$busNames = [];
foreach ($config['commandbus'] as $busName => $busConfig) {
$busNames[] = $busName;
}
if (!in_array($config['default_bus'], $busNames)) {
throw new InvalidConfigurationException(
sprintf(
'The default_bus "%s" was not defined as a command bus. Valid option(s): %s',
$config['default_bus'],
implode(', ', $busNames)
)
);
}
return $config;
})
->end()
;
return $treeBuilder;
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace League\Tactician\Bundle\DependencyInjection;
final class DuplicatedCommandBusId extends \Exception
{
public static function withId(string $id)
{
return new static("There are multiple command buses with the id '$id'. All bus ids must be unique.");
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace League\Tactician\Bundle\DependencyInjection\HandlerMapping;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
final class ClassNameMapping extends TagBasedMapping
{
protected function isSupported(ContainerBuilder $container, Definition $definition, array $tagAttributes): bool
{
return isset($tagAttributes['command']) && class_exists($container->getParameterBag()->resolveValue($tagAttributes['command']));
}
protected function findCommandsForService(ContainerBuilder $container, Definition $definition, array $tagAttributes): array
{
return [
$container->getParameterBag()->resolveValue($tagAttributes['command'])
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace League\Tactician\Bundle\DependencyInjection\HandlerMapping;
use Symfony\Component\DependencyInjection\ContainerBuilder;
final class CompositeMapping implements HandlerMapping
{
/**
* @var HandlerMapping[]
*/
private $strategies;
public function __construct(HandlerMapping ...$strategies)
{
$this->strategies = $strategies;
}
public function build(ContainerBuilder $container, Routing $routing): Routing
{
foreach ($this->strategies as $strategy) {
$routing = $strategy->build($container, $routing);
}
return $routing;
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace League\Tactician\Bundle\DependencyInjection\HandlerMapping;
use Symfony\Component\DependencyInjection\ContainerBuilder;
interface HandlerMapping
{
public function build(ContainerBuilder $container, Routing $routing): Routing;
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace League\Tactician\Bundle\DependencyInjection\HandlerMapping;
use League\Tactician\Bundle\DependencyInjection\InvalidCommandBusId;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
final class Routing
{
/**
* [
* 'busId_1' => [
* 'My\Command\Name1' => 'some.service.id',
* 'My\Other\Command' => 'some.service.id.or.same.one'
* ],
* 'busId_2' => [
* 'Legacy\App\Command1' => 'some.old.handler',
* ...
* ],
* ]
*
* @var array
*/
private $mapping = [];
public function __construct(array $validBusIds)
{
foreach ($validBusIds as $validBusId) {
$this->mapping[$validBusId] = [];
}
}
public function routeToBus($busId, $commandClassName, $serviceId)
{
$this->assertValidBusId($busId);
$this->assertValidCommandFQCN($commandClassName, $serviceId);
$this->mapping[$busId][$commandClassName] = $serviceId;
}
public function routeToAllBuses($commandClassName, $serviceId)
{
$this->assertValidCommandFQCN($commandClassName, $serviceId);
foreach($this->mapping as $busId => $mapping) {
$this->mapping[$busId][$commandClassName] = $serviceId;
}
}
public function commandToServiceMapping(string $busId): array
{
$this->assertValidBusId($busId);
return $this->mapping[$busId];
}
private function assertValidBusId(string $busId)
{
if (!isset($this->mapping[$busId])) {
throw InvalidCommandBusId::ofName($busId, array_keys($this->mapping));
}
}
/**
* @param $commandClassName
* @param $serviceId
*/
protected function assertValidCommandFQCN($commandClassName, $serviceId)
{
if (!class_exists($commandClassName)) {
throw new InvalidArgumentException("Can not route $commandClassName to $serviceId, class $commandClassName does not exist!");
}
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace League\Tactician\Bundle\DependencyInjection\HandlerMapping;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
abstract class TagBasedMapping implements HandlerMapping
{
const TAG_NAME = 'tactician.handler';
public function build(ContainerBuilder $container, Routing $routing): Routing
{
foreach ($container->findTaggedServiceIds(self::TAG_NAME) as $serviceId => $tags) {
foreach ($tags as $attributes) {
$this->mapServiceByTag($container, $routing, $serviceId, $attributes);
}
}
return $routing;
}
/**
* @param ContainerBuilder $container
* @param Routing $routing
* @param $serviceId
* @param $attributes
*/
private function mapServiceByTag(ContainerBuilder $container, Routing $routing, $serviceId, $attributes)
{
$definition = $container->getDefinition($serviceId);
if (!$this->isSupported($container, $definition, $attributes)) {
return;
}
foreach ($this->findCommandsForService($container, $definition, $attributes) as $commandClassName) {
if (isset($attributes['bus'])) {
$routing->routeToBus($attributes['bus'], $commandClassName, $serviceId);
} else {
$routing->routeToAllBuses($commandClassName, $serviceId);
}
}
}
abstract protected function isSupported(ContainerBuilder $container, Definition $definition, array $tagAttributes): bool;
abstract protected function findCommandsForService(ContainerBuilder $container, Definition $definition, array $tagAttributes): array;
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace League\Tactician\Bundle\DependencyInjection\HandlerMapping;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use ReflectionClass;
use function method_exists;
/**
* Routes commands based on typehints in the handler.
*
* If your handler has a public method with a single, non-scalar, no-interface type hinted
* parameter, we'll assume that typehint is a command and route it to this
* service definition as the handler.
*
* So, a class like this:
*
* class MyHandler
* {
* public function handle(RegisterUser $command) {...}
* private function foobar(SomeObject $obj) {...}
* public function checkThings(OtherObject $obj, WhatObject $obj2)
* public function setADependency(ManagerInterface $interface) {...}
* }
*
* would have RegisterUser routed to it, but not SomeObject (because it's
* used in a private method), not OtherObject or WhatObject (because they
* don't appear as the only parameter) and not setADependency (because it
* has an interface type hinted parameter).
*/
final class TypeHintMapping extends TagBasedMapping
{
protected function isSupported(ContainerBuilder $container, Definition $definition, array $tagAttributes): bool
{
return isset($tagAttributes['typehints']) && $tagAttributes['typehints'] === true;
}
protected function findCommandsForService(ContainerBuilder $container, Definition $definition, array $tagAttributes): array
{
$results = [];
$reflClass = new ReflectionClass($container->getParameterBag()->resolveValue($definition->getClass()));
foreach ($reflClass->getMethods() as $method) {
if (!$method->isPublic()
|| $method->isConstructor()
|| $method->isStatic()
|| $method->isAbstract()
|| $method->isVariadic()
|| $method->getNumberOfParameters() !== 1
) {
continue;
}
$parameter = $method->getParameters()[0];
if (!$parameter->hasType()
|| $parameter->getType()->isBuiltin()
|| $parameter->getClass()->isInterface()
) {
continue;
}
$type = $parameter->getType();
if (version_compare(PHP_VERSION, '7.1.0') >= 0) {
$results[] = $type->getName();
} else {
$results[] = (string)$type;
}
}
return $results;
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace League\Tactician\Bundle\DependencyInjection;
final class InvalidCommandBusId extends \Exception
{
public static function ofName(string $expectedId, array $validIds)
{
return new static(
"Could not find a command bus with id '$expectedId'. Valid buses are: " . implode(', ', $validIds)
);
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace League\Tactician\Bundle\DependencyInjection;
use League\Tactician\Bundle\Security\Voter\HandleCommandVoter;
use League\Tactician\Logger\Formatter\ClassNameFormatter;
use League\Tactician\Logger\Formatter\ClassPropertiesFormatter;
use League\Tactician\Logger\LoggerMiddleware;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Loader;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\HttpKernel\DependencyInjection\ConfigurableExtension;
class TacticianExtension extends ConfigurableExtension
{
/**
* Configures the passed container according to the merged configuration.
*
* @param array $mergedConfig
* @param ContainerBuilder $container
*/
protected function loadInternal(array $mergedConfig, ContainerBuilder $container)
{
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config/services'));
$loader->load('services.yml');
$container->setParameter('tactician.merged_config', $mergedConfig);
$this->configureSecurity($mergedConfig, $container);
$this->configureLogger($mergedConfig, $container);
}
public function getAlias()
{
return 'tactician';
}
/**
* Configure the security voter if the security middleware is loaded.
*
* @param array $mergedConfig
* @param ContainerBuilder $container
*/
private function configureSecurity(array $mergedConfig, ContainerBuilder $container)
{
foreach ($mergedConfig['commandbus'] as $commandBusConfig) {
if (in_array('tactician.middleware.security', $commandBusConfig['middleware'])) {
return $this->configureCommandSecurityVoter($mergedConfig, $container);
}
}
}
/**
* Configure the security voter.
*
* @param array $mergedConfig
* @param ContainerBuilder $container
*/
private function configureCommandSecurityVoter(array $mergedConfig, ContainerBuilder $container)
{
if (!$container->has('tactician.middleware.security_voter')) {
$definition = new Definition(
HandleCommandVoter::class,
[
new Reference('security.access.decision_manager'),
$mergedConfig['security']
]
);
$definition->addTag('security.voter');
$container->setDefinition('tactician.middleware.security_voter', $definition);
}
}
/**
* Configure the logger middleware.
*
* @param array $mergedConfig
* @param ContainerBuilder $container
*/
private function configureLogger(array $mergedConfig, ContainerBuilder $container)
{
$this->configureLoggerFormatters($container);
$loggerMiddleware = new Definition(LoggerMiddleware::class, [
new Reference($mergedConfig['logger_formatter']),
new Reference('logger')
]);
$loggerMiddleware->setPublic(false);
$loggerMiddleware->addTag('monolog.logger', ['channel' => 'command_bus']);
$container->setDefinition('tactician.middleware.logger', $loggerMiddleware);
}
private function configureLoggerFormatters(ContainerBuilder $container)
{
$container->setDefinition(
'tactician.logger.class_properties_formatter',
new Definition(ClassPropertiesFormatter::class)
)->setPublic(false);
$container->setDefinition(
'tactician.logger.class_name_formatter',
new Definition(ClassNameFormatter::class)
)->setPublic(false);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace League\Tactician\Bundle\Handler;
use League\Tactician\Exception\MissingHandlerException;
use League\Tactician\Handler\Locator\HandlerLocator;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Lazily loads Command Handlers from the Symfony DI container
*/
class ContainerBasedHandlerLocator implements HandlerLocator
{
/**
* @var ContainerInterface
*/
private $container;
/**
* @var array
*/
private $commandToServiceId = [];
/**
* @param ContainerInterface $container
* @param array $commandToServiceIdMapping
*/
public function __construct(ContainerInterface $container, array $commandToServiceIdMapping)
{
$this->container = $container;
$this->commandToServiceId = $commandToServiceIdMapping;
}
/**
* Retrieves the handler for a specified command
*
* @param string $commandName
* @return object
*
* @throws MissingHandlerException
*/
public function getHandlerForCommand($commandName)
{
if (!isset($this->commandToServiceId[$commandName])) {
throw MissingHandlerException::forCommand($commandName);
}
return $this->container->get($this->commandToServiceId[$commandName]);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace League\Tactician\Bundle\Middleware;
use League\Tactician\Exception\Exception;
use Symfony\Component\Validator\ConstraintViolationListInterface;
class InvalidCommandException extends \Exception implements Exception
{
/**
* @var object
*/
protected $command;
/**
* @var ConstraintViolationListInterface
*/
protected $violations;
/**
* @param object $command
* @param ConstraintViolationListInterface $violations
*
* @return static
*/
public static function onCommand($command, ConstraintViolationListInterface $violations)
{
$exception = new static(
'Validation failed for ' . get_class($command) .
' with ' . $violations->count() . ' violation(s).'
);
$exception->command = $command;
$exception->violations = $violations;
return $exception;
}
/**
* @return object
*/
public function getCommand()
{
return $this->command;
}
/**
* @return ConstraintViolationListInterface
*/
public function getViolations()
{
return $this->violations;
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace League\Tactician\Bundle\Middleware;
use League\Tactician\Middleware;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
class SecurityMiddleware implements Middleware
{
/**
* @var AuthorizationCheckerInterface
*/
private $authorizationChecker;
/**
* @param AuthorizationCheckerInterface $authorizationChecker
*/
public function __construct(AuthorizationCheckerInterface $authorizationChecker)
{
$this->authorizationChecker = $authorizationChecker;
}
/**
* @param object $command
* @param callable $next
*
* @return mixed
*/
public function execute($command, callable $next)
{
if ($this->authorizationChecker->isGranted('handle', $command)) {
return $next($command);
}
throw new AccessDeniedException(
sprintf('The current user is not allowed to handle command of type \'%s\'', get_class($command))
);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace League\Tactician\Bundle\Middleware;
use League\Tactician\Middleware;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class ValidatorMiddleware implements Middleware
{
/**
* @var ValidatorInterface
*/
protected $validator;
/**
* @param ValidatorInterface $validator
*/
public function __construct(ValidatorInterface $validator)
{
$this->validator = $validator;
}
/**
* @param object $command
* @param callable $next
*
* @return mixed
*
* @throws InvalidCommandException
*/
public function execute($command, callable $next)
{
$constraintViolations = $this->validator->validate($command);
if (count($constraintViolations) > 0) {
throw InvalidCommandException::onCommand($command, $constraintViolations);
}
return $next($command);
}
}

View File

@@ -0,0 +1,31 @@
services:
tactician.middleware.locking:
class: League\Tactician\Plugins\LockingMiddleware
# The standard Handler method name inflectors
tactician.handler.method_name_inflector.handle:
class: League\Tactician\Handler\MethodNameInflector\HandleInflector
tactician.handler.method_name_inflector.class_name:
class: League\Tactician\Handler\MethodNameInflector\ClassNameInflector
tactician.handler.method_name_inflector.handle_class_name:
class: League\Tactician\Handler\MethodNameInflector\HandleClassNameInflector
tactician.handler.method_name_inflector.handle_class_name_without_suffix:
class: League\Tactician\Handler\MethodNameInflector\HandleClassNameWithoutSuffixInflector
tactician.handler.method_name_inflector.invoke:
class: League\Tactician\Handler\MethodNameInflector\InvokeInflector
# The CommandNameExtractors in Tactician core
tactician.handler.command_name_extractor.class_name:
class: League\Tactician\Handler\CommandNameExtractor\ClassNameExtractor
tactician.plugins.named_command.extractor:
class: League\Tactician\Plugins\NamedCommand\NamedCommandExtractor
tactician.command.debug:
class: League\Tactician\Bundle\Command\DebugCommand
tags:
- { name: console.command }

View File

@@ -0,0 +1,91 @@
<?php
namespace League\Tactician\Bundle\Security\Voter;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* Voter for security checks on handling commands.
*
* @author Ron Rademaker
*/
class HandleCommandVoter extends Voter
{
/**
* The decision manager.
*
* @var AccessDecisionManagerInterface
*/
private $decisionManager;
/**
* Command - Require role mapping
*
* @var array
*/
private $commandRoleMapping = [];
/**
* Create a new HandleCommandVoter.
*
* @param AccessDecisionManagerInterface $decisionManager
* @param array $commandRoleMapping
*/
public function __construct(AccessDecisionManagerInterface $decisionManager, array $commandRoleMapping = [])
{
$this->decisionManager = $decisionManager;
$this->commandRoleMapping = $commandRoleMapping;
}
/**
* The voter supports checking handle commands
*
* @param string $attribute
* @param object $subject
*
* @return bool
*/
protected function supports($attribute, $subject): bool
{
return $attribute === 'handle' && is_object($subject);
}
/**
* Checks if the currently logged on user may handle $subject.
*
* @param string $attribute
* @param mixed $subject
* @param TokenInterface $token
*
* @return bool
*/
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
$allowedRoles = $this->getAllowedRoles(get_class($subject));
if (count($allowedRoles) > 0) {
return $this->decisionManager->decide($token, $allowedRoles);
}
// default conclusion is access denied
return false;
}
/**
* Gets the roles allowed to handle a command of $type
*
* @param string $type
*
* @return array
*/
private function getAllowedRoles(string $type)
{
if (array_key_exists($type, $this->commandRoleMapping)) {
return $this->commandRoleMapping[$type];
}
return [];
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace League\Tactician\Bundle;
use League\Tactician\Bundle\DependencyInjection\HandlerMapping\ClassNameMapping;
use League\Tactician\Bundle\DependencyInjection\HandlerMapping\CompositeMapping;
use League\Tactician\Bundle\DependencyInjection\HandlerMapping\HandlerMapping;
use League\Tactician\Bundle\DependencyInjection\HandlerMapping\TypeHintMapping;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use League\Tactician\Bundle\DependencyInjection\Compiler;
use League\Tactician\Bundle\DependencyInjection\TacticianExtension;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class TacticianBundle extends Bundle
{
/**
* @var HandlerMapping
*/
private $handlerMapping;
public function __construct(HandlerMapping $handlerMapping = null)
{
if ($handlerMapping === null) {
$handlerMapping = static::defaultMappingStrategy();
}
$this->handlerMapping = $handlerMapping;
}
public function build(ContainerBuilder $container)
{
parent::build($container);
$container->addCompilerPass(new Compiler\DoctrineMiddlewarePass());
$container->addCompilerPass(new Compiler\ValidatorMiddlewarePass());
$container->addCompilerPass(new Compiler\SecurityMiddlewarePass());
$container->addCompilerPass(new Compiler\CommandHandlerPass($this->handlerMapping));
}
public function getContainerExtension()
{
return new TacticianExtension();
}
public static function defaultMappingStrategy(): HandlerMapping
{
return new CompositeMapping(new TypeHintMapping(), new ClassNameMapping());
}
}

View File

@@ -0,0 +1,19 @@
Copyright (c) 2015 Ross Tuck
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,80 @@
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use League\Container\ReflectionContainer;
use League\Tactician\Container\ContainerLocator;
use League\Container\Container;
use League\Tactician\Handler\CommandHandlerMiddleware;
use League\Tactician\Handler\CommandNameExtractor\ClassNameExtractor;
use League\Tactician\Handler\MethodNameInflector\HandleClassNameInflector;
use League\Tactician\CommandBus;
$mapping = [
'MyCommand' => 'MyCommandHandler',
];
final class Mailer
{
}
final class MyCommand
{
public $name;
public $emailAddress;
public function __construct($name, $emailAddress)
{
$this->name = $name;
$this->emailAddress = $emailAddress;
}
}
final class MyCommandHandler
{
private $mailer;
public function __construct(Mailer $mailer)
{
$this->mailer = $mailer;
}
public function handleMyCommand($command)
{
$format = <<<MSG
Hi %s,
Your email address is %s.
--
Cheers
MSG;
echo sprintf($format, $command->name, $command->emailAddress);
}
}
$containerLocator = new ContainerLocator(
(new Container())->delegate(new ReflectionContainer()),
$mapping
);
$handlerMiddleware = new CommandHandlerMiddleware(
new ClassNameExtractor(),
$containerLocator,
new HandleClassNameInflector()
);
$commandBus = new CommandBus([$handlerMiddleware]);
$command = new MyCommand('Joe Bloggs', 'j.bloggs@theinternet.com');
echo '<pre>';
try {
$commandBus->handle($command);
} catch (\Exception $e) {
echo $e->getMessage();
echo '<pre>';
print_r($e->getTraceAsString());
echo '</pre>';
}

View File

@@ -0,0 +1,89 @@
<?php
namespace League\Tactician\Container;
use Psr\Container\ContainerInterface;
use League\Tactician\Exception\MissingHandlerException;
use League\Tactician\Handler\Locator\HandlerLocator;
/**
* Fetch handler instances from an in-memory collection.
*
* This locator allows you to bind a handler FQCN to receive commands of a
* certain command name.
*/
class ContainerLocator implements HandlerLocator
{
/**
* @var ContainerInterface
*/
protected $container;
/**
* The collection of Command/CommandHandler
*
* @var array
*/
protected $commandNameToHandlerMap = [];
/**
* @param ContainerInterface $container
* @param array $commandNameToHandlerMap
*/
public function __construct(
ContainerInterface $container,
array $commandNameToHandlerMap = []
) {
$this->container = $container;
$this->addHandlers($commandNameToHandlerMap);
}
/**
* Bind a handler instance to receive all commands with a certain class
*
* @param string $handler Handler to receive class
* @param string $commandName Can be a class name or name of a NamedCommand
*/
public function addHandler($handler, $commandName)
{
$this->commandNameToHandlerMap[$commandName] = $handler;
}
/**
* Allows you to add multiple handlers at once.
*
* The map should be an array in the format of:
* [
* 'AddTaskCommand' => 'AddTaskCommandHandler',
* 'CompleteTaskCommand' => 'CompleteTaskCommandHandler',
* ]
*
* @param array $commandNameToHandlerMap
*/
public function addHandlers(array $commandNameToHandlerMap)
{
foreach ($commandNameToHandlerMap as $commandName => $handler) {
$this->addHandler($handler, $commandName);
}
}
/**
* Retrieves the handler for a specified command
*
* @param string $commandName
*
* @return object
*
* @throws MissingHandlerException
*/
public function getHandlerForCommand($commandName)
{
if (!isset($this->commandNameToHandlerMap[$commandName])) {
throw MissingHandlerException::forCommand($commandName);
}
$serviceId = $this->commandNameToHandlerMap[$commandName];
return $this->container->get($serviceId);
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace League\Tactician\Logger\Formatter;
use Exception;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
/**
* Returns log messages only dump the Command & Exception's class names.
*/
class ClassNameFormatter implements Formatter
{
/**
* @var string
*/
private $commandReceivedLevel;
/**
* @var string
*/
private $commandSucceededLevel;
/**
* @var string
*/
private $commandFailedLevel;
/**
* @param string $commandReceivedLevel
* @param string $commandSucceededLevel
* @param string $commandFailedLevel
*/
public function __construct(
$commandReceivedLevel = LogLevel::DEBUG,
$commandSucceededLevel = LogLevel::DEBUG,
$commandFailedLevel = LogLevel::ERROR
) {
$this->commandReceivedLevel = $commandReceivedLevel;
$this->commandSucceededLevel = $commandSucceededLevel;
$this->commandFailedLevel = $commandFailedLevel;
}
/**
* {@inheritDoc}
*/
public function logCommandReceived(LoggerInterface $logger, $command)
{
$logger->log($this->commandReceivedLevel, 'Command received: ' . get_class($command), []);
}
/**
* {@inheritDoc}
*/
public function logCommandSucceeded(LoggerInterface $logger, $command, $returnValue)
{
$logger->log($this->commandSucceededLevel, 'Command succeeded: ' . get_class($command), []);
}
/**
* {@inheritDoc}
*/
public function logCommandFailed(LoggerInterface $logger, $command, Exception $e)
{
$logger->log(
$this->commandFailedLevel,
'Command failed: ' . get_class($command),
['exception' => $e]
);
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace League\Tactician\Logger\Formatter;
use League\Tactician\Logger\PropertyNormalizer\PropertyNormalizer;
use League\Tactician\Logger\PropertyNormalizer\SimplePropertyNormalizer;
use Exception;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
/**
* Formatter that includes the Command's name and properties for more detail
*/
class ClassPropertiesFormatter implements Formatter
{
/**
* @var PropertyNormalizer
*/
private $normalizer;
/**
* @var string
*/
private $commandReceivedLevel;
/**
* @var string
*/
private $commandSucceededLevel;
/**
* @var string
*/
private $commandFailedLevel;
/**
* @param PropertyNormalizer $normalizer
* @param string $commandReceivedLevel
* @param string $commandSucceededLevel
* @param string $commandFailedLevel
*/
public function __construct(
PropertyNormalizer $normalizer = null,
$commandReceivedLevel = LogLevel::DEBUG,
$commandSucceededLevel = LogLevel::DEBUG,
$commandFailedLevel = LogLevel::ERROR
) {
$this->normalizer = $normalizer ?: new SimplePropertyNormalizer();
$this->commandReceivedLevel = $commandReceivedLevel;
$this->commandSucceededLevel = $commandSucceededLevel;
$this->commandFailedLevel = $commandFailedLevel;
}
/**
* {@inheritDoc}
*/
public function logCommandReceived(LoggerInterface $logger, $command)
{
$logger->log(
$this->commandReceivedLevel,
'Command received: ' . get_class($command),
['command' => $this->normalizer->normalize($command)]
);
}
/**
* {@inheritDoc}
*/
public function logCommandSucceeded(LoggerInterface $logger, $command, $returnValue)
{
$logger->log(
$this->commandSucceededLevel,
'Command succeeded: ' . get_class($command),
[
'command' => $this->normalizer->normalize($command)
]
);
}
/**
* {@inheritDoc}
*/
public function logCommandFailed(LoggerInterface $logger, $command, Exception $e)
{
$logger->log(
$this->commandFailedLevel,
'Command failed: ' . get_class($command),
['exception' => $e]
);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace League\Tactician\Logger\Formatter;
use Exception;
use Psr\Log\LoggerInterface;
/**
* Converts incoming Commands into log messages.
*
* Each method is written for a particular command path. A formatter class
* should take the given command, format it to a message and pass it to the
* given logger (with the desired log level).
*
* For an example of what this all looks like, take a look at the
* ClassNameFormatter example bundled with this package.
*
* A formatter may also use PSR-3 log contexts to pass extra info to the logger
* about the commands, return values and errors it receives. For more
* information about log contexts, see the PSR-3 specification.
* @see https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md#13-context
*/
interface Formatter
{
/**
* @param LoggerInterface $logger
* @param object $command
* @return void
*/
public function logCommandReceived(LoggerInterface $logger, $command);
/**
* @param LoggerInterface $logger
* @param object $command
* @param mixed $returnValue
* @return void
*/
public function logCommandSucceeded(LoggerInterface $logger, $command, $returnValue);
/**
* @param LoggerInterface $logger
* @param object $command
* @param Exception $e
* @return void
*/
public function logCommandFailed(LoggerInterface $logger, $command, Exception $e);
}

View File

@@ -0,0 +1,53 @@
<?php
namespace League\Tactician\Logger;
use League\Tactician\Logger\Formatter\Formatter;
use League\Tactician\Middleware;
use Psr\Log\LoggerInterface;
use Exception;
/**
* Add support for writing a message to the log whenever a command is received,
* handled or failed.
*/
class LoggerMiddleware implements Middleware
{
/**
* @var LoggerInterface
*/
private $logger;
/**
* @var Formatter
*/
private $formatter;
/**
* @param Formatter $formatter
* @param LoggerInterface $logger
*/
public function __construct(Formatter $formatter, LoggerInterface $logger)
{
$this->formatter = $formatter;
$this->logger = $logger;
}
/**
* {@inheritdoc}
*/
public function execute($command, callable $next)
{
$this->formatter->logCommandReceived($this->logger, $command);
try {
$returnValue = $next($command);
} catch (Exception $e) {
$this->formatter->logCommandFailed($this->logger, $command, $e);
throw $e;
}
$this->formatter->logCommandSucceeded($this->logger, $command, $returnValue);
return $returnValue;
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace League\Tactician\Logger\PropertyNormalizer;
/**
* Normalize value into scalars, usually to put them in a log message's context
*
* If given an object, return an array of properties. If given scalars, just
* return them directly.
*
* Implementations should work on any value, not just commands or exceptions.
*/
interface PropertyNormalizer
{
/**
* @param mixed $value
* @return string
*/
public function normalize($value);
}

View File

@@ -0,0 +1,52 @@
<?php
namespace League\Tactician\Logger\PropertyNormalizer;
use ReflectionClass;
/**
* Quick'n'dirty property normalizer that logs the first level properties
*
* Does not recurse into sub-objects or arrays.
*
* This is done in an extremely inefficient manner, so please never use this in
* a production context, only for local debugging.
*/
class SimplePropertyNormalizer implements PropertyNormalizer
{
/**
* @param object $command
* @return array
*/
public function normalize($command)
{
$reflectionClass = new ReflectionClass(get_class($command));
$properties = [];
foreach ($reflectionClass->getProperties() as $property) {
$property->setAccessible(true);
$properties[$property->getName()] = $this->formatValue($property->getValue($command));
}
return $properties;
}
/**
* Return the given (property) value as a descriptive string
*
* @param mixed $value Can be literally anything
* @return string
*/
protected function formatValue($value)
{
switch (gettype($value)) {
case 'object':
return 'object(' . get_class($value) . ')';
case 'array':
return '*array*';
case 'resource':
return 'resource(' . get_resource_type($value) . ')';
default:
return $value;
}
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace League\Tactician;
use League\Tactician\Exception\InvalidCommandException;
use League\Tactician\Exception\InvalidMiddlewareException;
/**
* Receives a command and sends it through a chain of middleware for processing.
*
* @final
*/
class CommandBus
{
/**
* @var callable
*/
private $middlewareChain;
/**
* @param Middleware[] $middleware
*/
public function __construct(array $middleware)
{
$this->middlewareChain = $this->createExecutionChain($middleware);
}
/**
* Executes the given command and optionally returns a value
*
* @param object $command
*
* @return mixed
*/
public function handle($command)
{
if (!is_object($command)) {
throw InvalidCommandException::forUnknownValue($command);
}
$middlewareChain = $this->middlewareChain;
return $middlewareChain($command);
}
/**
* @param Middleware[] $middlewareList
*
* @return callable
*/
private function createExecutionChain($middlewareList)
{
$lastCallable = function () {
// the final callable is a no-op
};
while ($middleware = array_pop($middlewareList)) {
if (! $middleware instanceof Middleware) {
throw InvalidMiddlewareException::forMiddleware($middleware);
}
$lastCallable = function ($command) use ($middleware, $lastCallable) {
return $middleware->execute($command, $lastCallable);
};
}
return $lastCallable;
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace League\Tactician\Exception;
/**
* Thrown when a CommandNameExtractor cannot determine the command's name
*/
class CanNotDetermineCommandNameException extends \RuntimeException implements Exception
{
/**
* @var mixed
*/
private $command;
/**
* @param mixed $command
*
* @return static
*/
public static function forCommand($command)
{
$type = is_object($command) ? get_class($command) : gettype($command);
$exception = new static('Could not determine command name of ' . $type);
$exception->command = $command;
return $exception;
}
/**
* Returns the command that could not be invoked
*
* @return mixed
*/
public function getCommand()
{
return $this->command;
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace League\Tactician\Exception;
/**
* Thrown when a specific handler object can not be used on a command object.
*
* The most common reason is the receiving method is missing or incorrectly
* named.
*/
class CanNotInvokeHandlerException extends \BadMethodCallException implements Exception
{
/**
* @var mixed
*/
private $command;
/**
* @param mixed $command
* @param string $reason
*
* @return static
*/
public static function forCommand($command, $reason)
{
$type = is_object($command) ? get_class($command) : gettype($command);
$exception = new static(
'Could not invoke handler for command ' . $type .
' for reason: ' . $reason
);
$exception->command = $command;
return $exception;
}
/**
* Returns the command that could not be invoked
*
* @return mixed
*/
public function getCommand()
{
return $this->command;
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace League\Tactician\Exception;
/**
* Marker interface for all Tactician exceptions
*/
interface Exception
{
}

View File

@@ -0,0 +1,37 @@
<?php
namespace League\Tactician\Exception;
/**
* Thrown when the command bus is given an non-object to use as a command.
*/
class InvalidCommandException extends \RuntimeException implements Exception
{
/**
* @var mixed
*/
private $invalidCommand;
/**
* @param mixed $invalidCommand
*
* @return static
*/
public static function forUnknownValue($invalidCommand)
{
$exception = new static(
'Commands must be an object but the value given was of type: ' . gettype($invalidCommand)
);
$exception->invalidCommand = $invalidCommand;
return $exception;
}
/**
* @return mixed
*/
public function getInvalidCommand()
{
return $this->invalidCommand;
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace League\Tactician\Exception;
/**
* Thrown when the CommandBus was instantiated with an invalid middleware object
*/
class InvalidMiddlewareException extends \InvalidArgumentException implements Exception
{
public static function forMiddleware($middleware)
{
$name = is_object($middleware) ? get_class($middleware) : gettype($middleware);
$message = sprintf(
'Cannot add "%s" to middleware chain as it does not implement the Middleware interface.',
$name
);
return new static($message);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace League\Tactician\Exception;
/**
* No handler could be found for the given command.
*/
class MissingHandlerException extends \OutOfBoundsException implements Exception
{
/**
* @var string
*/
private $commandName;
/**
* @param string $commandName
*
* @return static
*/
public static function forCommand($commandName)
{
$exception = new static('Missing handler for command ' . $commandName);
$exception->commandName = $commandName;
return $exception;
}
/**
* @return string
*/
public function getCommandName()
{
return $this->commandName;
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace League\Tactician\Handler;
use League\Tactician\Middleware;
use League\Tactician\Exception\CanNotInvokeHandlerException;
use League\Tactician\Handler\CommandNameExtractor\CommandNameExtractor;
use League\Tactician\Handler\Locator\HandlerLocator;
use League\Tactician\Handler\MethodNameInflector\MethodNameInflector;
/**
* The "core" CommandBus. Locates the appropriate handler and executes command.
*/
class CommandHandlerMiddleware implements Middleware
{
/**
* @var CommandNameExtractor
*/
private $commandNameExtractor;
/**
* @var HandlerLocator
*/
private $handlerLocator;
/**
* @var MethodNameInflector
*/
private $methodNameInflector;
/**
* @param CommandNameExtractor $commandNameExtractor
* @param HandlerLocator $handlerLocator
* @param MethodNameInflector $methodNameInflector
*/
public function __construct(
CommandNameExtractor $commandNameExtractor,
HandlerLocator $handlerLocator,
MethodNameInflector $methodNameInflector
) {
$this->commandNameExtractor = $commandNameExtractor;
$this->handlerLocator = $handlerLocator;
$this->methodNameInflector = $methodNameInflector;
}
/**
* Executes a command and optionally returns a value
*
* @param object $command
* @param callable $next
*
* @return mixed
*
* @throws CanNotInvokeHandlerException
*/
public function execute($command, callable $next)
{
$commandName = $this->commandNameExtractor->extract($command);
$handler = $this->handlerLocator->getHandlerForCommand($commandName);
$methodName = $this->methodNameInflector->inflect($command, $handler);
// is_callable is used here instead of method_exists, as method_exists
// will fail when given a handler that relies on __call.
if (!is_callable([$handler, $methodName])) {
throw CanNotInvokeHandlerException::forCommand(
$command,
"Method '{$methodName}' does not exist on handler"
);
}
return $handler->{$methodName}($command);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace League\Tactician\Handler\CommandNameExtractor;
/**
* Extract the name from the class
*/
class ClassNameExtractor implements CommandNameExtractor
{
/**
* {@inheritdoc}
*/
public function extract($command)
{
return get_class($command);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace League\Tactician\Handler\CommandNameExtractor;
use League\Tactician\Exception\CanNotDetermineCommandNameException;
/**
* Extract the name from a command so that the name can be determined
* by the context better than simply the class name
*/
interface CommandNameExtractor
{
/**
* Extract the name from a command
*
* @param object $command
*
* @return string
*
* @throws CannotDetermineCommandNameException
*/
public function extract($command);
}

View File

@@ -0,0 +1,54 @@
<?php
namespace League\Tactician\Handler\Locator;
use League\Tactician\Exception\MissingHandlerException;
/**
* This locator loads Handlers from a provided callable.
*
* At first glance, this might seem fairly useless but it's actually very
* useful to encapsulate DI containers without having to write a custom adapter
* for each one.
*
* Let's say you have a Symfony container or similar that works via a 'get'
* method. You can pass in an array style callable such as:
*
* $locator = new CallableLocator([$container, 'get'])
*
* This is easy to set up and will now automatically pipe the command name
* straight through to the $container->get() method without having to write
* the custom locator.
*
* Naturally, you can also pass in closures for further behavior tweaks.
*/
class CallableLocator implements HandlerLocator
{
/**
* @var callable
*/
private $callable;
/**
* @param callable $callable
*/
public function __construct(callable $callable)
{
$this->callable = $callable;
}
/**
* {@inheritdoc}
*/
public function getHandlerForCommand($commandName)
{
$callable = $this->callable;
$handler = $callable($commandName);
// Odds are the callable threw an exception but it always pays to check
if ($handler === null) {
throw MissingHandlerException::forCommand($commandName);
}
return $handler;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace League\Tactician\Handler\Locator;
use League\Tactician\Exception\MissingHandlerException;
/**
* Service locator for handler objects
*
* This interface is often a wrapper around your frameworks dependency
* injection container or just maps command names to handler names on disk somehow.
*/
interface HandlerLocator
{
/**
* Retrieves the handler for a specified command
*
* @param string $commandName
*
* @return object
*
* @throws MissingHandlerException
*/
public function getHandlerForCommand($commandName);
}

View File

@@ -0,0 +1,79 @@
<?php
namespace League\Tactician\Handler\Locator;
use League\Tactician\Exception\MissingHandlerException;
/**
* Fetch handler instances from an in-memory collection.
*
* This locator allows you to bind a handler object to receive commands of a
* certain class name. For example:
*
* // Wire everything together
* $myHandler = new TaskAddedHandler($dependency1, $dependency2);
* $inMemoryLocator->addHandler($myHandler, 'My\TaskAddedCommand');
*
* // Returns $myHandler
* $inMemoryLocator->getHandlerForCommand('My\TaskAddedCommand');
*/
class InMemoryLocator implements HandlerLocator
{
/**
* @var object[]
*/
protected $handlers = [];
/**
* @param array $commandClassToHandlerMap
*/
public function __construct(array $commandClassToHandlerMap = [])
{
$this->addHandlers($commandClassToHandlerMap);
}
/**
* Bind a handler instance to receive all commands with a certain class
*
* @param object $handler Handler to receive class
* @param string $commandClassName Command class e.g. "My\TaskAddedCommand"
*/
public function addHandler($handler, $commandClassName)
{
$this->handlers[$commandClassName] = $handler;
}
/**
* Allows you to add multiple handlers at once.
*
* The map should be an array in the format of:
* [
* AddTaskCommand::class => $someHandlerInstance,
* CompleteTaskCommand::class => $someHandlerInstance,
* ]
*
* @param array $commandClassToHandlerMap
*/
protected function addHandlers(array $commandClassToHandlerMap)
{
foreach ($commandClassToHandlerMap as $commandClass => $handler) {
$this->addHandler($handler, $commandClass);
}
}
/**
* Returns the handler bound to the command's class name.
*
* @param string $commandName
*
* @return object
*/
public function getHandlerForCommand($commandName)
{
if (!isset($this->handlers[$commandName])) {
throw MissingHandlerException::forCommand($commandName);
}
return $this->handlers[$commandName];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace League\Tactician\Handler\MethodNameInflector;
/**
* Assumes the method is only the last portion of the class name.
*
* Examples:
* - \MyGlobalCommand => $handler->myGlobalCommand()
* - \My\App\CreateUser => $handler->createUser()
*/
class ClassNameInflector implements MethodNameInflector
{
/**
* {@inheritdoc}
*/
public function inflect($command, $commandHandler)
{
$commandName = get_class($command);
// If class name has a namespace separator, only take last portion
if (strpos($commandName, '\\') !== false) {
$commandName = substr($commandName, strrpos($commandName, '\\') + 1);
}
return strtolower($commandName[0]) . substr($commandName, 1);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace League\Tactician\Handler\MethodNameInflector;
/**
* Assumes the method is handle + the last portion of the class name.
*
* Examples:
* - \MyGlobalCommand => $handler->handleMyGlobalCommand()
* - \My\App\TaskCompletedCommand => $handler->handleTaskCompletedCommand()
*/
class HandleClassNameInflector extends ClassNameInflector
{
/**
* {@inheritdoc}
*/
public function inflect($command, $commandHandler)
{
$commandName = parent::inflect($command, $commandHandler);
return 'handle' . ucfirst($commandName);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace League\Tactician\Handler\MethodNameInflector;
/**
* Returns a method name that is handle + the last portion of the class name
* but also without a given suffix, typically "Command". This allows you to
* handle multiple commands on a single object but with slightly less annoying
* method names.
*
* The string removal is case sensitive.
*
* Examples:
* - \CompleteTaskCommand => $handler->handleCompleteTask()
* - \My\App\DoThingCommand => $handler->handleDoThing()
*/
class HandleClassNameWithoutSuffixInflector extends HandleClassNameInflector
{
/**
* @var string
*/
private $suffix;
/**
* @var int
*/
private $suffixLength;
/**
* @param string $suffix The string to remove from end of each class name
*/
public function __construct($suffix = 'Command')
{
$this->suffix = $suffix;
$this->suffixLength = strlen($suffix);
}
/**
* @param object $command
* @param object $commandHandler
* @return string
*/
public function inflect($command, $commandHandler)
{
$methodName = parent::inflect($command, $commandHandler);
if (substr($methodName, $this->suffixLength * -1) !== $this->suffix) {
return $methodName;
}
return substr($methodName, 0, strlen($methodName) - $this->suffixLength);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace League\Tactician\Handler\MethodNameInflector;
/**
* Handle command by calling the "handle" method.
*/
class HandleInflector implements MethodNameInflector
{
/**
* {@inheritdoc}
*/
public function inflect($command, $commandHandler)
{
return 'handle';
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace League\Tactician\Handler\MethodNameInflector;
/**
* Handle command by calling the __invoke magic method. Handy for single
* use classes or closures.
*/
class InvokeInflector implements MethodNameInflector
{
/**
* {@inheritdoc}
*/
public function inflect($command, $commandHandler)
{
return '__invoke';
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace League\Tactician\Handler\MethodNameInflector;
/**
* Deduce the method name to call on the command handler based on the command
* and handler instances.
*/
interface MethodNameInflector
{
/**
* Return the method name to call on the command handler and return it.
*
* @param object $command
* @param object $commandHandler
*
* @return string
*/
public function inflect($command, $commandHandler);
}

View File

@@ -0,0 +1,24 @@
<?php
namespace League\Tactician;
/**
* Middleware are the plugins of Tactician. They receive each command that's
* given to the CommandBus and can take any action they choose. Middleware can
* continue the Command processing by passing the command they receive to the
* $next callable, which is essentially the "next" Middleware in the chain.
*
* Depending on where they invoke the $next callable, Middleware can execute
* their custom logic before or after the Command is handled. They can also
* modify, log, or replace the command they receive. The sky's the limit.
*/
interface Middleware
{
/**
* @param object $command
* @param callable $next
*
* @return mixed
*/
public function execute($command, callable $next);
}

View File

@@ -0,0 +1,71 @@
<?php
namespace League\Tactician\Plugins;
use League\Tactician\Middleware;
/**
* If another command is already being executed, locks the command bus and
* queues the new incoming commands until the first has completed.
*/
class LockingMiddleware implements Middleware
{
/**
* @var bool
*/
private $isExecuting;
/**
* @var callable[]
*/
private $queue = [];
/**
* Execute the given command... after other running commands are complete.
*
* @param object $command
* @param callable $next
*
* @throws \Exception
*
* @return mixed|void
*/
public function execute($command, callable $next)
{
$this->queue[] = function () use ($command, $next) {
return $next($command);
};
if ($this->isExecuting) {
return;
}
$this->isExecuting = true;
try {
$returnValue = $this->executeQueuedJobs();
} catch (\Exception $e) {
$this->isExecuting = false;
$this->queue = [];
throw $e;
}
$this->isExecuting = false;
return $returnValue;
}
/**
* Process any pending commands in the queue. If multiple, jobs are in the
* queue, only the first return value is given back.
*
* @return mixed
*/
protected function executeQueuedJobs()
{
$returnValues = [];
while ($resumeCommand = array_shift($this->queue)) {
$returnValues[] = $resumeCommand();
}
return array_shift($returnValues);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace League\Tactician\Plugins\NamedCommand;
/**
* Exposes a name for a command
*/
interface NamedCommand
{
/**
* Returns the name of the command
*
* @return string
*/
public function getCommandName();
}

View File

@@ -0,0 +1,24 @@
<?php
namespace League\Tactician\Plugins\NamedCommand;
use League\Tactician\Exception\CanNotDetermineCommandNameException;
use League\Tactician\Handler\CommandNameExtractor\CommandNameExtractor;
/**
* Extract the name from a NamedCommand
*/
class NamedCommandExtractor implements CommandNameExtractor
{
/**
* {@inheritdoc}
*/
public function extract($command)
{
if ($command instanceof NamedCommand) {
return $command->getCommandName();
}
throw CanNotDetermineCommandNameException::forCommand($command);
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace League\Tactician\Setup;
use League\Tactician\CommandBus;
use League\Tactician\Handler\CommandNameExtractor\ClassNameExtractor;
use League\Tactician\Handler\Locator\InMemoryLocator;
use League\Tactician\Handler\MethodNameInflector\HandleInflector;
use League\Tactician\Handler\CommandHandlerMiddleware;
use League\Tactician\Plugins\LockingMiddleware;
/**
* Builds a working command bus with minimum fuss.
*
* Currently, the default setup is:
* - Handlers instances in memory
* - The expected handler method is always "handle"
* - And only one command at a time can be executed.
*
* This factory is a decent place to start trying out Tactician but you're
* better off moving to a custom setup or a framework bundle/module/provider in
* the long run. As you can see, it's not difficult. :)
*/
class QuickStart
{
/**
* Creates a default CommandBus that you can get started with.
*
* @param array $commandToHandlerMap
*
* @return CommandBus
*/
public static function create($commandToHandlerMap)
{
$handlerMiddleware = new CommandHandlerMiddleware(
new ClassNameExtractor(),
new InMemoryLocator($commandToHandlerMap),
new HandleInflector()
);
$lockingMiddleware = new LockingMiddleware();
return new CommandBus([$lockingMiddleware, $handlerMiddleware]);
}
}