-
-
Notifications
You must be signed in to change notification settings - Fork 9.7k
[Workflow] Allow to define workflow with PHP attributes #61935
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 7.4
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -17,11 +17,9 @@ | |
use Doctrine\ORM\Mapping\MappedSuperclass; | ||
use Http\Client\HttpAsyncClient; | ||
use Http\Client\HttpClient; | ||
use phpDocumentor\Reflection\DocBlockFactoryInterface; | ||
use phpDocumentor\Reflection\Types\ContextFactory; | ||
use PhpParser\Parser; | ||
use PHPStan\PhpDocParser\Parser\PhpDocParser; | ||
use PHPUnit\Framework\TestCase; | ||
use PhpParser\Parser; | ||
use Psr\Cache\CacheItemPoolInterface; | ||
use Psr\Clock\ClockInterface as PsrClockInterface; | ||
use Psr\Container\ContainerInterface as PsrContainerInterface; | ||
|
@@ -33,11 +31,11 @@ | |
use Symfony\Bundle\FrameworkBundle\Routing\RouteLoaderInterface; | ||
use Symfony\Bundle\FullStack; | ||
use Symfony\Bundle\MercureBundle\MercureBundle; | ||
use Symfony\Component\Asset\Package; | ||
use Symfony\Component\Asset\PackageInterface; | ||
use Symfony\Component\AssetMapper\AssetMapper; | ||
use Symfony\Component\AssetMapper\Compiler\AssetCompilerInterface; | ||
use Symfony\Component\AssetMapper\Compressor\CompressorInterface; | ||
use Symfony\Component\Asset\Package; | ||
use Symfony\Component\Asset\PackageInterface; | ||
use Symfony\Component\BrowserKit\AbstractBrowser; | ||
use Symfony\Component\Cache\Adapter\AdapterInterface; | ||
use Symfony\Component\Cache\Adapter\ArrayAdapter; | ||
|
@@ -50,9 +48,9 @@ | |
use Symfony\Component\Config\Definition\ConfigurationInterface; | ||
use Symfony\Component\Config\FileLocator; | ||
use Symfony\Component\Config\Loader\LoaderInterface; | ||
use Symfony\Component\Config\ResourceCheckerInterface; | ||
use Symfony\Component\Config\Resource\DirectoryResource; | ||
use Symfony\Component\Config\Resource\FileResource; | ||
use Symfony\Component\Config\ResourceCheckerInterface; | ||
use Symfony\Component\Console\Application; | ||
use Symfony\Component\Console\Attribute\AsCommand; | ||
use Symfony\Component\Console\Command\Command; | ||
|
@@ -185,7 +183,6 @@ | |
use Symfony\Component\Scheduler\Attribute\AsSchedule; | ||
use Symfony\Component\Scheduler\Messenger\SchedulerTransportFactory; | ||
use Symfony\Component\Scheduler\Messenger\Serializer\Normalizer\SchedulerTriggerNormalizer; | ||
use Symfony\Component\Security\Core\AuthenticationEvents; | ||
use Symfony\Component\Security\Core\Exception\AuthenticationException; | ||
use Symfony\Component\Security\Csrf\CsrfToken; | ||
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; | ||
|
@@ -224,20 +221,20 @@ | |
use Symfony\Component\Uid\UuidV4; | ||
use Symfony\Component\Validator\Attribute\ExtendsValidationFor; | ||
use Symfony\Component\Validator\Constraint; | ||
use Symfony\Component\Validator\ConstraintValidatorInterface; | ||
use Symfony\Component\Validator\Constraints\ExpressionLanguageProvider; | ||
use Symfony\Component\Validator\Constraints\Traverse; | ||
use Symfony\Component\Validator\ConstraintValidatorInterface; | ||
use Symfony\Component\Validator\DependencyInjection\AttributeMetadataPass as ValidatorAttributeMetadataPass; | ||
use Symfony\Component\Validator\GroupProviderInterface; | ||
use Symfony\Component\Validator\Mapping\Loader\PropertyInfoLoader; | ||
use Symfony\Component\Validator\ObjectInitializerInterface; | ||
use Symfony\Component\Validator\Validation; | ||
use Symfony\Component\Webhook\Controller\WebhookController; | ||
use Symfony\Component\WebLink\HttpHeaderParser; | ||
use Symfony\Component\WebLink\HttpHeaderSerializer; | ||
use Symfony\Component\Webhook\Controller\WebhookController; | ||
use Symfony\Component\Workflow; | ||
use Symfony\Component\Workflow\Arc; | ||
use Symfony\Component\Workflow\WorkflowInterface; | ||
use Symfony\Component\Workflow\Attribute\AsWorkflow; | ||
use Symfony\Component\Workflow\Configuration\AttributeReader; | ||
use Symfony\Component\Yaml\Command\LintCommand as BaseYamlLintCommand; | ||
use Symfony\Component\Yaml\Yaml; | ||
use Symfony\Contracts\Cache\CacheInterface; | ||
|
@@ -249,6 +246,8 @@ | |
use Symfony\Contracts\Service\ResetInterface; | ||
use Symfony\Contracts\Service\ServiceSubscriberInterface; | ||
use Symfony\Contracts\Translation\LocaleAwareInterface; | ||
use phpDocumentor\Reflection\DocBlockFactoryInterface; | ||
use phpDocumentor\Reflection\Types\ContextFactory; | ||
|
||
/** | ||
* Process the configuration and prepare the dependency injection container with | ||
|
@@ -829,6 +828,14 @@ static function (ChildDefinition $definition, AsPeriodicTask|AsCronTask $attribu | |
); | ||
} | ||
|
||
$attributeReader = new AttributeReader(); | ||
$container->registerAttributeForAutoconfiguration(AsWorkflow::class, static function (ChildDefinition $definition, AsWorkflow $attribute, \ReflectionClass $reflection) use ($attributeReader): void { | ||
$configuration = $attributeReader->extractConfiguration($attribute, $reflection); | ||
$definition->addTag('.workflow.attribute', [ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A way to solve my objection in https://github.com/symfony/symfony/pull/61935/files#r2399400826 would be to define the attribute as |
||
'configuration' => $configuration, | ||
]); | ||
}); | ||
|
||
$container->registerForAutoconfiguration(CompilerPassInterface::class) | ||
->addTag('container.excluded', ['source' => 'because it\'s a compiler pass']); | ||
$container->registerForAutoconfiguration(Constraint::class) | ||
|
@@ -1087,190 +1094,7 @@ private function registerWorkflowConfiguration(array $config, ContainerBuilder $ | |
|
||
$loader->load('workflow.php'); | ||
|
||
$registryDefinition = $container->getDefinition('workflow.registry'); | ||
|
||
foreach ($config['workflows'] as $name => $workflow) { | ||
$type = $workflow['type']; | ||
$workflowId = \sprintf('%s.%s', $type, $name); | ||
|
||
// Process Metadata (workflow + places (transition is done in the "create transition" block)) | ||
$metadataStoreDefinition = new Definition(Workflow\Metadata\InMemoryMetadataStore::class, [[], [], null]); | ||
if ($workflow['metadata']) { | ||
$metadataStoreDefinition->replaceArgument(0, $workflow['metadata']); | ||
} | ||
$placesMetadata = []; | ||
foreach ($workflow['places'] as $place) { | ||
if ($place['metadata']) { | ||
$placesMetadata[$place['name']] = $place['metadata']; | ||
} | ||
} | ||
if ($placesMetadata) { | ||
$metadataStoreDefinition->replaceArgument(1, $placesMetadata); | ||
} | ||
|
||
// Create transitions | ||
$transitions = []; | ||
$guardsConfiguration = []; | ||
$transitionsMetadataDefinition = new Definition(\SplObjectStorage::class); | ||
// Global transition counter per workflow | ||
$transitionCounter = 0; | ||
foreach ($workflow['transitions'] as $transition) { | ||
foreach (['from', 'to'] as $direction) { | ||
foreach ($transition[$direction] as $k => $arc) { | ||
$transition[$direction][$k] = new Definition(Arc::class, [$arc['place'], $arc['weight'] ?? 1]); | ||
} | ||
} | ||
if ('workflow' === $type) { | ||
$transitionId = \sprintf('.%s.transition.%s', $workflowId, $transitionCounter++); | ||
$container->register($transitionId, Workflow\Transition::class) | ||
->setArguments([$transition['name'], $transition['from'], $transition['to']]); | ||
$transitions[] = new Reference($transitionId); | ||
if (isset($transition['guard'])) { | ||
$eventName = \sprintf('workflow.%s.guard.%s', $name, $transition['name']); | ||
$guardsConfiguration[$eventName][] = new Definition( | ||
Workflow\EventListener\GuardExpression::class, | ||
[new Reference($transitionId), $transition['guard']] | ||
); | ||
} | ||
if ($transition['metadata']) { | ||
$transitionsMetadataDefinition->addMethodCall('offsetSet', [ | ||
new Reference($transitionId), | ||
$transition['metadata'], | ||
]); | ||
} | ||
} elseif ('state_machine' === $type) { | ||
foreach ($transition['from'] as $from) { | ||
foreach ($transition['to'] as $to) { | ||
$transitionId = \sprintf('.%s.transition.%s', $workflowId, $transitionCounter++); | ||
$container->register($transitionId, Workflow\Transition::class) | ||
->setArguments([$transition['name'], [$from], [$to]]); | ||
$transitions[] = new Reference($transitionId); | ||
if (isset($transition['guard'])) { | ||
$eventName = \sprintf('workflow.%s.guard.%s', $name, $transition['name']); | ||
$guardsConfiguration[$eventName][] = new Definition( | ||
Workflow\EventListener\GuardExpression::class, | ||
[new Reference($transitionId), $transition['guard']] | ||
); | ||
} | ||
if ($transition['metadata']) { | ||
$transitionsMetadataDefinition->addMethodCall('offsetSet', [ | ||
new Reference($transitionId), | ||
$transition['metadata'], | ||
]); | ||
} | ||
} | ||
} | ||
} | ||
} | ||
$metadataStoreDefinition->replaceArgument(2, $transitionsMetadataDefinition); | ||
$metadataStoreId = \sprintf('%s.metadata_store', $workflowId); | ||
$container->setDefinition($metadataStoreId, $metadataStoreDefinition); | ||
|
||
// Create places | ||
$places = array_column($workflow['places'], 'name'); | ||
$initialMarking = $workflow['initial_marking'] ?? []; | ||
|
||
// Create a Definition | ||
$definitionDefinition = new Definition(Workflow\Definition::class); | ||
$definitionDefinition->addArgument($places); | ||
$definitionDefinition->addArgument($transitions); | ||
$definitionDefinition->addArgument($initialMarking); | ||
$definitionDefinition->addArgument(new Reference($metadataStoreId)); | ||
$definitionDefinitionId = \sprintf('%s.definition', $workflowId); | ||
|
||
// Create MarkingStore | ||
$markingStoreDefinition = null; | ||
if (isset($workflow['marking_store']['type']) || isset($workflow['marking_store']['property'])) { | ||
$markingStoreDefinition = new ChildDefinition('workflow.marking_store.method'); | ||
$markingStoreDefinition->setArguments([ | ||
'state_machine' === $type, // single state | ||
$workflow['marking_store']['property'] ?? 'marking', | ||
]); | ||
} elseif (isset($workflow['marking_store']['service'])) { | ||
$markingStoreDefinition = new Reference($workflow['marking_store']['service']); | ||
} | ||
|
||
// Validation | ||
$workflow['definition_validators'][] = match ($workflow['type']) { | ||
'state_machine' => Workflow\Validator\StateMachineValidator::class, | ||
'workflow' => Workflow\Validator\WorkflowValidator::class, | ||
default => throw new \LogicException(\sprintf('Invalid workflow type "%s".', $workflow['type'])), | ||
}; | ||
|
||
// Create Workflow | ||
$workflowDefinition = new ChildDefinition(\sprintf('%s.abstract', $type)); | ||
$workflowDefinition->replaceArgument(0, new Reference($definitionDefinitionId)); | ||
$workflowDefinition->replaceArgument(1, $markingStoreDefinition); | ||
$workflowDefinition->replaceArgument(3, $name); | ||
$workflowDefinition->replaceArgument(4, $workflow['events_to_dispatch']); | ||
|
||
$workflowDefinition->addTag('workflow', [ | ||
'name' => $name, | ||
'metadata' => $workflow['metadata'], | ||
'definition_validators' => $workflow['definition_validators'], | ||
'definition_id' => $definitionDefinitionId, | ||
]); | ||
if ('workflow' === $type) { | ||
$workflowDefinition->addTag('workflow.workflow', ['name' => $name]); | ||
} elseif ('state_machine' === $type) { | ||
$workflowDefinition->addTag('workflow.state_machine', ['name' => $name]); | ||
} | ||
|
||
// Store to container | ||
$container->setDefinition($workflowId, $workflowDefinition); | ||
$container->setDefinition($definitionDefinitionId, $definitionDefinition); | ||
$container->registerAliasForArgument($workflowId, WorkflowInterface::class, $name.'.'.$type, $name); | ||
|
||
// Add workflow to Registry | ||
if ($workflow['supports']) { | ||
foreach ($workflow['supports'] as $supportedClassName) { | ||
$strategyDefinition = new Definition(Workflow\SupportStrategy\InstanceOfSupportStrategy::class, [$supportedClassName]); | ||
$registryDefinition->addMethodCall('addWorkflow', [new Reference($workflowId), $strategyDefinition]); | ||
} | ||
} elseif (isset($workflow['support_strategy'])) { | ||
$registryDefinition->addMethodCall('addWorkflow', [new Reference($workflowId), new Reference($workflow['support_strategy'])]); | ||
} | ||
|
||
// Enable the AuditTrail | ||
if ($workflow['audit_trail']['enabled']) { | ||
$listener = new Definition(Workflow\EventListener\AuditTrailListener::class); | ||
$listener->addTag('monolog.logger', ['channel' => 'workflow']); | ||
$listener->addTag('kernel.event_listener', ['event' => \sprintf('workflow.%s.leave', $name), 'method' => 'onLeave']); | ||
$listener->addTag('kernel.event_listener', ['event' => \sprintf('workflow.%s.transition', $name), 'method' => 'onTransition']); | ||
$listener->addTag('kernel.event_listener', ['event' => \sprintf('workflow.%s.enter', $name), 'method' => 'onEnter']); | ||
$listener->addArgument(new Reference('logger')); | ||
$container->setDefinition(\sprintf('.%s.listener.audit_trail', $workflowId), $listener); | ||
} | ||
|
||
// Add Guard Listener | ||
if ($guardsConfiguration) { | ||
if (!class_exists(ExpressionLanguage::class)) { | ||
throw new LogicException('Cannot guard workflows as the ExpressionLanguage component is not installed. Try running "composer require symfony/expression-language".'); | ||
} | ||
|
||
if (!class_exists(AuthenticationEvents::class)) { | ||
throw new LogicException('Cannot guard workflows as the Security component is not installed. Try running "composer require symfony/security-core".'); | ||
} | ||
|
||
$guard = new Definition(Workflow\EventListener\GuardListener::class); | ||
|
||
$guard->setArguments([ | ||
$guardsConfiguration, | ||
new Reference('workflow.security.expression_language'), | ||
new Reference('security.token_storage'), | ||
new Reference('security.authorization_checker'), | ||
new Reference('security.authentication.trust_resolver'), | ||
new Reference('security.role_hierarchy'), | ||
new Reference('validator', ContainerInterface::NULL_ON_INVALID_REFERENCE), | ||
]); | ||
foreach ($guardsConfiguration as $eventName => $config) { | ||
$guard->addTag('kernel.event_listener', ['event' => $eventName, 'method' => 'onTransition']); | ||
} | ||
|
||
$container->setDefinition(\sprintf('.%s.listener.guard', $workflowId), $guard); | ||
$container->setParameter('workflow.has_guard_listeners', true); | ||
} | ||
} | ||
$container->setParameter('.workflow.config', $config); | ||
} | ||
|
||
private function registerDebugConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the Symfony package. | ||
* | ||
* (c) Fabien Potencier <fabien@symfony.com> | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
|
||
namespace Symfony\Component\Workflow\Attribute; | ||
|
||
use Symfony\Component\Workflow\WorkflowType; | ||
|
||
/** | ||
* @author Grégoire Pineau <lyrixx@lyrixx.info> | ||
*/ | ||
#[\Attribute(\Attribute::TARGET_CLASS)] | ||
final class AsWorkflow | ||
{ | ||
public function __construct( | ||
public string $name, | ||
public string|array $places = [], | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. any array should have phpdoc describing the expected type of this array (otherwise, we risk having to cover |
||
public WorkflowType $type = WorkflowType::StateMachine, | ||
public array $supports = [], | ||
public array $markingStore = [], | ||
public bool $auditTrail = true, | ||
) { | ||
if (\is_string($this->places)) { | ||
if (!enum_exists($this->places)) { | ||
throw new \InvalidArgumentException(\sprintf('The "places" attribute of the "%s" workflow must be an array or a valid enum name, "%s" given.', self::class, $this->places)); | ||
} | ||
if (!is_a($this->places, \BackedEnum::class, true)) { | ||
throw new \InvalidArgumentException(\sprintf('The "places" attribute of the "%s" workflow must be a backed enum', self::class)); | ||
} | ||
$this->places = array_map(fn (\UnitEnum $case) => $case->value, $this->places::cases()); | ||
} | ||
foreach ($this->places as $k => $place) { | ||
if (is_string($place)) { | ||
$this->places[$k] = [ | ||
'name' => $place, | ||
'metadata' => [], | ||
]; | ||
} | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is broken if the workflow component is not installed.