src/Extensions/Symfony/WorkflowExtension.php
<?php declare(strict_types=1);
/*
* This file is part of Biurad opensource projects.
*
* @copyright 2019 Biurad Group (https://biurad.com/)
* @license https://opensource.org/licenses/BSD-3-Clause License
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Flange\Extensions\Symfony;
use Flange\Commands\Symfony\WorkflowDumpCommand;
use Rade\DI\Container;
use Rade\DI\Definitions\Reference;
use Rade\DI\Definitions\Statement;
use Rade\DI\Extensions\AliasedInterface;
use Rade\DI\Extensions\ExtensionInterface;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Workflow;
use Symfony\Component\Workflow\Registry;
use Symfony\Component\Workflow\WorkflowEvents;
use function Rade\DI\Loader\service;
/**
* Symfony component workflow extension.
*
* @author Divine Niiquaye Ibok <divineibok@gmail.com>
*/
class WorkflowExtension implements AliasedInterface, ConfigurationInterface, ExtensionInterface
{
/**
* {@inheritdoc}
*/
public function getAlias(): string
{
return 'workflows';
}
/**
* {@inheritdoc}
*/
public function getConfigTreeBuilder(): TreeBuilder
{
$treeBuilder = new TreeBuilder(__CLASS__);
$treeBuilder->getRootNode()
->canBeEnabled()
->beforeNormalization()
->always(function ($v) {
if (\is_array($v) && true === $v['enabled']) {
$workflows = $v;
unset($workflows['enabled']);
if (1 === \count($workflows) && isset($workflows[0]['enabled']) && 1 === \count($workflows[0])) {
$workflows = [];
}
if (1 === \count($workflows) && isset($workflows['workflows']) && !\array_is_list($workflows['workflows']) && !empty(\array_diff(\array_keys($workflows['workflows']), ['audit_trail', 'type', 'marking_store', 'supports', 'support_strategy', 'initial_marking', 'places', 'transitions']))) {
$workflows = $workflows['workflows'];
}
foreach ($workflows as $key => $workflow) {
if (isset($workflow['enabled']) && false === $workflow['enabled']) {
throw new \LogicException(\sprintf('Cannot disable a single workflow. Remove the configuration for the workflow "%s" instead.', $workflow['name']));
}
unset($workflows[$key]['enabled']);
}
$v = [
'enabled' => true,
'workflows' => $workflows,
];
}
return $v;
})
->end()
->children()
->arrayNode('workflows')
->useAttributeAsKey('name')
->arrayPrototype()
->fixXmlConfig('support')
->fixXmlConfig('place')
->fixXmlConfig('transition')
->fixXmlConfig('event_to_dispatch', 'events_to_dispatch')
->children()
->arrayNode('audit_trail')
->canBeEnabled()
->end()
->enumNode('type')
->values(['workflow', 'state_machine'])
->defaultValue('state_machine')
->end()
->arrayNode('marking_store')
->children()
->enumNode('type')
->values(['method'])
->end()
->scalarNode('property')
->defaultValue('marking')
->end()
->scalarNode('service')
->cannotBeEmpty()
->end()
->end()
->end()
->arrayNode('supports')
->beforeNormalization()
->ifString()
->then(fn ($v) => [$v])
->end()
->prototype('scalar')
->cannotBeEmpty()
->validate()
->ifTrue(fn ($v) => !\class_exists($v) && !\interface_exists($v, false))
->thenInvalid('The supported class or interface "%s" does not exist.')
->end()
->end()
->end()
->scalarNode('support_strategy')
->cannotBeEmpty()
->end()
->arrayNode('initial_marking')
->beforeNormalization()->castToArray()->end()
->defaultValue([])
->prototype('scalar')->end()
->end()
->variableNode('events_to_dispatch')
->defaultValue(null)
->validate()
->ifTrue(function ($v) {
if (null === $v) {
return false;
}
if (!\is_array($v)) {
return true;
}
foreach ($v as $value) {
if (!\is_string($value)) {
return true;
}
if (\class_exists(WorkflowEvents::class) && !\in_array($value, WorkflowEvents::ALIASES, true)) {
return true;
}
}
return false;
})
->thenInvalid('The value must be "null" or an array of workflow events (like ["workflow.enter"]).')
->end()
->info('Select which Transition events should be dispatched for this Workflow')
->example(['workflow.enter', 'workflow.transition'])
->end()
->arrayNode('places')
->beforeNormalization()
->always()
->then(function ($places) {
// It's an indexed array of shape ['place1', 'place2']
if (isset($places[0]) && \is_string($places[0])) {
return \array_map(fn (string $place) => ['name' => $place], $places);
}
// It's an indexed array, we let the validation occur
if (isset($places[0]) && \is_array($places[0])) {
return $places;
}
foreach ($places as $name => $place) {
if (\is_array($place) && \array_key_exists('name', $place)) {
continue;
}
$place['name'] = $name;
$places[$name] = $place;
}
return \array_values($places);
})
->end()
->isRequired()
->requiresAtLeastOneElement()
->arrayPrototype()
->children()
->scalarNode('name')
->isRequired()
->cannotBeEmpty()
->end()
->arrayNode('metadata')
->normalizeKeys(false)
->defaultValue([])
->example(['color' => 'blue', 'description' => 'Workflow to manage article.'])
->prototype('variable')
->end()
->end()
->end()
->end()
->end()
->arrayNode('transitions')
->beforeNormalization()
->always()
->then(function ($transitions) {
// It's an indexed array, we let the validation occur
if (isset($transitions[0]) && \is_array($transitions[0])) {
return $transitions;
}
foreach ($transitions as $name => $transition) {
if (\is_array($transition) && \array_key_exists('name', $transition)) {
continue;
}
$transition['name'] = $name;
$transitions[$name] = $transition;
}
return $transitions;
})
->end()
->isRequired()
->requiresAtLeastOneElement()
->arrayPrototype()
->children()
->scalarNode('name')
->isRequired()
->cannotBeEmpty()
->end()
->scalarNode('guard')
->cannotBeEmpty()
->info('An expression to block the transition')
->example('is_fully_authenticated() and is_granted(\'ROLE_JOURNALIST\') and subject.getTitle() == \'My first article\'')
->end()
->arrayNode('from')
->beforeNormalization()
->ifString()
->then(fn ($v) => [$v])
->end()
->requiresAtLeastOneElement()
->prototype('scalar')
->cannotBeEmpty()
->end()
->end()
->arrayNode('to')
->beforeNormalization()
->ifString()
->then(fn ($v) => [$v])
->end()
->requiresAtLeastOneElement()
->prototype('scalar')
->cannotBeEmpty()
->end()
->end()
->arrayNode('metadata')
->normalizeKeys(false)
->defaultValue([])
->example(['color' => 'blue', 'description' => 'Workflow to manage article.'])
->prototype('variable')
->end()
->end()
->end()
->end()
->end()
->arrayNode('metadata')
->normalizeKeys(false)
->defaultValue([])
->example(['color' => 'blue', 'description' => 'Workflow to manage article.'])
->prototype('variable')
->end()
->end()
->end()
->validate()
->ifTrue(fn ($v) => $v['supports'] && isset($v['support_strategy']))
->thenInvalid('"supports" and "support_strategy" cannot be used together.')
->end()
->validate()
->ifTrue(fn ($v) => !$v['supports'] && !isset($v['support_strategy']))
->thenInvalid('"supports" or "support_strategy" should be configured.')
->end()
->beforeNormalization()
->always()
->then(function ($values) {
// Special case to deal with XML when the user wants an empty array
if (\array_key_exists('event_to_dispatch', $values) && null === $values['event_to_dispatch']) {
$values['events_to_dispatch'] = [];
unset($values['event_to_dispatch']);
}
return $values;
})
->end()
->end()
->end()
->end()
;
return $treeBuilder;
}
/**
* {@inheritdoc}
*/
public function register(Container $container, array $configs = []): void
{
if (!$configs['enabled']) {
return;
}
if (!\class_exists(Workflow\Workflow::class)) {
throw new \LogicException('Workflow support cannot be enabled as the Workflow component is not installed. Try running "composer require symfony/workflow".');
}
$registryDefinition = $container->autowire('workflow.registry', service(Registry::class));
$workflows = [];
foreach ($configs['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 = service(Workflow\Metadata\InMemoryMetadataStore::class, [[], [], null]);
if ($workflow['metadata']) {
$metadataStoreDefinition->arg(0, $workflow['metadata']);
}
$placesMetadata = [];
foreach ($workflow['places'] as $place) {
if ($place['metadata']) {
$placesMetadata[$place['name']] = $place['metadata'];
}
}
if ($placesMetadata) {
$metadataStoreDefinition->arg(1, $placesMetadata);
}
// Create transitions
$transitions = [];
$guardsConfiguration = [];
$transitionsMetadataDefinition = service(\SplObjectStorage::class);
$transitionCounter = 0; // Global transition counter per workflow
foreach ($workflow['transitions'] as $transition) {
if ('workflow' === $type) {
$transitionDefinition = service(Workflow\Transition::class, [$transition['name'], $transition['from'], $transition['to']])->public(false);
$container->set($transitionId = \sprintf('.%s.transition.%s', $workflowId, $transitionCounter++), $transitionDefinition);
$transitions[] = new Reference($transitionId);
if (isset($transition['guard'])) {
$eventName = \sprintf('workflow.%s.guard.%s', $name, $transition['name']);
$guardsConfiguration[$eventName][] = new Statement(Workflow\EventListener\GuardExpression::class, [new Reference($transitionId), $transition['guard']]);
}
if ($transition['metadata']) {
$transitionsMetadataDefinition->bind('attach', [new Reference($transitionId), $transition['metadata']]);
}
} elseif ('state_machine' === $type) {
foreach ($transition['from'] as $from) {
foreach ($transition['to'] as $to) {
$transitionDefinition = service(Workflow\Transition::class, [$transition['name'], $from, $to])->public(false);
$container->set($transitionId = \sprintf('.%s.transition.%s', $workflowId, $transitionCounter++), $transitionDefinition);
$transitions[] = new Reference($transitionId);
if (isset($transition['guard'])) {
$eventName = \sprintf('workflow.%s.guard.%s', $name, $transition['name']);
$guardsConfiguration[$eventName][] = new Statement(Workflow\EventListener\GuardExpression::class, [new Reference($transitionId), $transition['guard']]);
}
if ($transition['metadata']) {
$transitionsMetadataDefinition->bind('attach', [new Reference($transitionId), $transition['metadata']]);
}
}
}
}
}
$container->set($transitionMetaId = \sprintf('%s.transition_meta', $workflowId), $transitionsMetadataDefinition)->public(false);
$metadataStoreDefinition->arg(2, new Reference($transitionMetaId))->public(false);
$container->set($metaStoreId = \sprintf('%s.metadata_store', $workflowId), $metadataStoreDefinition)->public(false);
// Create places
$places = \array_column($workflow['places'], 'name');
$initialMarking = $workflow['initial_marking'] ?? [];
// Create a Definition
$definitionDefinition = new Statement(Workflow\Definition::class, [$places, $transitions, $initialMarking, new Reference($metaStoreId)]);
$workflows[$workflowId] = $definitionDefinition;
// Create MarkingStore
if (isset($workflow['marking_store']['type'])) {
$markingStoreDefinition = new Statement(Workflow\MarkingStore\MethodMarkingStore::class, ['state_machine' === $type, $workflow['marking_store']['property']]);
} elseif (isset($workflow['marking_store']['service'])) {
$markingStoreDefinition = new Reference($workflow['marking_store']['service']);
}
// Create Workflow
$workflowDefinition = new Statement('workflow' === $type ? Workflow\Workflow::class : Workflow\StateMachine::class, [
0 => $definitionDefinition,
1 => $markingStoreDefinition ?? null,
3 => $name,
4 => $workflow['events_to_dispatch'],
]);
// Add workflow to Registry
if ($workflow['supports']) {
foreach ($workflow['supports'] as $supportedClassName) {
$strategyDefinition = new Statement(Workflow\SupportStrategy\InstanceOfSupportStrategy::class, [$supportedClassName]);
$registryDefinition->bind('addWorkflow', [$workflowDefinition, $strategyDefinition]);
}
} elseif (isset($workflow['support_strategy'])) {
$registryDefinition->bind('addWorkflow', [$workflowDefinition, new Reference($workflow['support_strategy'])]);
}
// Enable the AuditTrail
if ($workflow['audit_trail']['enabled'] && $container->has('logger')) {
$listener = service(Workflow\EventListener\AuditTrailListener::class, [new Reference('logger')]);
$listener->tags([
'event_listener' => ['event' => \sprintf('workflow.%s.leave', $name), 'method' => 'onLeave'],
'event_listener' => ['event' => \sprintf('workflow.%s.transition', $name), 'method' => 'onTransition'],
'event_listener' => ['event' => \sprintf('workflow.%s.enter', $name), 'method' => 'onEnter'],
]);
$container->set(\sprintf('.%s.listener.audit_trail', $workflowId), $listener)->public(false);
}
// 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(Security::class)) {
throw new \LogicException('Cannot guard workflows as the Security component is not installed. Try running "composer require symfony/security-core".');
}
$guard = service(Workflow\EventListener\GuardListener::class);
$guard->args([
$guardsConfiguration,
new Statement(Workflow\EventListener\ExpressionLanguage::class),
new Reference('security.token_storage'),
new Reference('security.authorization_checker'),
new Reference('security.authentication.trust_resolver'),
new Reference('security.role_hierarchy'),
new Reference('?validator'),
]);
foreach ($guardsConfiguration as $eventName => $config) {
$guard->tag('event_listener', ['event' => $eventName, 'method' => 'onTransition']);
}
$container->set(\sprintf('.%s.listener.guard', $workflowId), $guard);
$container->parameters['workflow.has_guard_listeners'] = true;
}
}
if ($container->has('console')) {
$container->set('console.command.workflow_dump', service(WorkflowDumpCommand::class, [$workflows]))->public(false)->tag('console.command');
}
}
}